Compare commits
5 commits
33234f32ae
...
c6ba3e38a2
| Author | SHA1 | Date | |
|---|---|---|---|
| c6ba3e38a2 | |||
| 10fbeb2327 | |||
| 874911821f | |||
| aea08dc3fb | |||
| 46258a83eb |
12
.htaccess
|
|
@ -1,21 +1,21 @@
|
|||
<IfModule mod_expires.c>
|
||||
ExpiresActive On
|
||||
ExpiresByType text/html "access plus 0 seconds"
|
||||
ExpiresByType text/css "access plus 0 seconds"
|
||||
ExpiresByType application/javascript "access plus 0 seconds"
|
||||
ExpiresByType application/json "access plus 0 seconds"
|
||||
ExpiresByType text/css "access plus 30 days"
|
||||
ExpiresByType application/javascript "access plus 30 days"
|
||||
ExpiresByType application/json "access plus 30 days"
|
||||
ExpiresByType image/png "access plus 30 days"
|
||||
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 "\.(html?|css|js|json)$">
|
||||
Header set Cache-Control "no-cache, no-store, must-revalidate"
|
||||
<FilesMatch "\.html?$">
|
||||
Header set Cache-Control "no-cache, must-revalidate"
|
||||
Header set Pragma "no-cache"
|
||||
Header set Expires "0"
|
||||
</FilesMatch>
|
||||
<FilesMatch "\.(png|webp|mp3|wav)$">
|
||||
<FilesMatch "\.(css|js|json|png|webp|mp3|wav)$">
|
||||
Header set Cache-Control "public, max-age=2592000"
|
||||
</FilesMatch>
|
||||
</IfModule>
|
||||
|
|
|
|||
69
PATCH_NOTES.md
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
## 15.9.2 修正
|
||||
- 巣箱アイコンとゲーム内描画から、枠内の明色・透過っぽく見える装飾を除去。
|
||||
- アリのHPバーを、たりないと同様にダメージ直後だけ表示する方式へ変更。
|
||||
- 道具UIに「げんこつ」を追加。添付画像から正義げんこつ部分を切り抜き、上から落下してたりない・アリにダメージと吹っ飛びを与える効果を追加。
|
||||
- 初期ロード画面と読み込み進捗バーを追加。JS/CSSをdefer化し、CSS/JSのサーバーキャッシュを有効化して再訪問時の通信量を削減。
|
||||
|
||||
|
||||
## 15.9.1 修正
|
||||
- 草を地面レイヤーとして扱い、既存の草の上にもオブジェクトを配置できるようにしました。草同士・草と障害物の植え付け制限は維持しています。
|
||||
- けんか餅のゲーム内描画から星印を削除し、×印のみ表示するようにしました。
|
||||
- ダメージ後に出る「こわい…」吹き出しを削除しました。
|
||||
- 家系図に「更新」トグルを追加しました。デフォルトはOFFで、ONの間だけ家系図を描画・更新します。
|
||||
- 死亡した個体のライブIDを再利用しないようにし、友達・敵対関係が別個体へ引き継がれて見える問題を防止しました。死亡時の一時ターゲット参照も掃除します。
|
||||
- たりないデータUIをカテゴリ別の折り畳み表示に変更しました。
|
||||
|
||||
# Patch Notes
|
||||
|
||||
## Encoding cleanup
|
||||
- Normalized `index.html` back to UTF-8.
|
||||
- Replaced mojibake strings with the intended labels.
|
||||
- Escaped Japanese text in source files to avoid encoding-dependent corruption.
|
||||
|
||||
## Personality specification fixes
|
||||
- Personality tags and data UI now use Japanese labels from the four-axis personality model.
|
||||
- Threshold-crossing personality logs are now generated in Japanese and only when visible trait bands change.
|
||||
- Daily personality caps now track increase and decrease separately per parameter.
|
||||
- Current personality effects are neutral inside the -0.5 to +0.5 band.
|
||||
- Direct conversion to the legacy timid personality after damage was removed; cowardly behavior is derived from high neuroticism plus low aggression.
|
||||
- Corpse/splat contact now increases stress and slowly reduces neuroticism through habituation.
|
||||
|
||||
## Follow-up fixes
|
||||
- Fixed CSS emoji escapes for tool UI icons. CSS now uses code-point escapes such as `\\1F448` instead of invalid surrogate escapes.
|
||||
- Hid the detailed four-axis personality rows from the compact tarinai data UI.
|
||||
- Restored `bestBedFor()` fallback so tarinai can choose hay beds when no usable nest box is available.
|
||||
- Damage now wakes sleeping tarinai and forces tarinai hidden inside a nest box to leave the box.
|
||||
- Added a short minimum stay window after entering a nest box to avoid immediate exit loops.
|
||||
- Renamed the friend relation label from `\\u4ef2\\u826f\\u3057` to `\\u53cb\\u9054` in UI text and related logs.
|
||||
|
||||
|
||||
## Personality cleanup v3
|
||||
- Current live friend count now changes sociability drift. Family/friend proximity raises sociability more when the tarinai has more current friends, while a lonely tarinai with no current friends can slowly lose sociability.
|
||||
- Removed the legacy named personality route from constructor, serialization, child inheritance, selected data UI, selected-card overlay, and family-tree titles. The game now uses only birthPersonality/currentPersonality four-axis data.
|
||||
- Kept empty legacy stubs only for old-save compatibility so older saves cannot revive the pre-four-axis personality model.
|
||||
|
||||
## Follow-up fixes v4
|
||||
- Restored compact personality information in the tarinai data panel: visible tags, birth personality, and current personality.
|
||||
- Moved the focused-tarinai highlight outside the sprite rotation transform so the ring and label stay upright.
|
||||
- Shortened ecology cards and added the HP/stress meter placement note: upper meter is HP, lower meter is stress.
|
||||
- Raised the friendship threshold to 16 affinity and reused the same threshold for friend count, friend behavior, friend UI, and observe highlights.
|
||||
- Observe mode now highlights friends of the selected tarinai as well as parents, children, and enemies.
|
||||
|
||||
## Ant nest update
|
||||
- Added a new placeable `ant_nest` object and tool button.
|
||||
- Added ant sprites cropped from the provided source image: queen, worker variants, and upside-down worker corpse.
|
||||
- Each ant nest starts with 6 workers, caps at 10 workers, and sends at most 3 workers outside at once.
|
||||
- Worker ants spawn above their home nest, random-walk, search for tarinai, and drag found tarinai back to the nest. Multiple workers dragging the same tarinai increase carrying speed.
|
||||
- Dragged tarinai struggle automatically and can kill workers. Dead workers become edible `ant_corpse` items and slightly reduce hygiene.
|
||||
- If a nest below capacity brings back a tarinai, its worker count increases by 1.
|
||||
- If a full nest brings back another tarinai, a queen ant appears and creates a new ant nest in an open spot.
|
||||
- Ant health does not recover.
|
||||
- Added the requested Irasutoya credit entry.
|
||||
|
||||
## Ant nest follow-up v5
|
||||
- Increased ant movement and hauling constants by 1.5x from the previous slow tuning.
|
||||
- Kept queen spawning to at most one queen per nest per in-game day, while making spawned queens keep retrying until a new ant nest is built somewhere.
|
||||
- Reduced the ant nest object radius from 34 to 17.
|
||||
- Removed the separate ant corpse sprite asset. Ant corpses now render by flipping the worker ant sprite vertically and remain stationary while decaying.
|
||||
- Added shared ant damage handling for firecracker explosions and dropped-object impacts.
|
||||
- Kept ant worker HP in the nest worker pool so damaged workers remain damaged after returning to and leaving the nest.
|
||||
249
README.md
|
|
@ -1,210 +1,79 @@
|
|||
# たりないコロニー観察所
|
||||
# Tarinai Colony Observation Game
|
||||
|
||||
ブラウザで動く、観察主体の小さなコロニーゲームです。
|
||||
外部ライブラリやサーバーは不要です。
|
||||
Single-page, dependency-free browser simulation. Open `index.html`; no build step, package manager, server API, transpiler, or module loader is required. Runtime is plain HTML/CSS/JS loaded by ordered `<script>` tags.
|
||||
|
||||
## 起動方法
|
||||
## Codex operating model
|
||||
|
||||
1. ZIPを展開
|
||||
2. `index.html` をブラウザで開く
|
||||
Read only these first: `README.md`, `docs/AI_CONTEXT.md`, `docs/code_map.md`. Then open the smallest implementation file named by the map. Avoid broad grep unless a symbol is not mapped. Keep Japanese UI strings escaped in source (`\uXXXX` in JS, entities in HTML/Markdown/CSS where applicable). After edits run `node --check js/*.js` and verify `index.html` script order.
|
||||
|
||||
Chrome / Edge / Firefox / Safari の近年版を想定しています。
|
||||
## Core simulation
|
||||
|
||||
## ゲーム内容
|
||||
- Entities: `Tarinai` agents with hunger, sleep/energy, loneliness, stress, health, age, personality, family links, relationships, diseases, favorite flag, target/need AI, transient animation timers.
|
||||
- World: bounded field with weather, time, lighting, temperature, spatial-grid queries, items, effects, logs, archive/family data, placement and collision rules.
|
||||
- Observation loop: agents autonomously seek food/water/sleep/social targets, react to zunchi, tools, rain, cold, crowding, relationships, disease, damage, conflict, reproduction, and player input.
|
||||
- Records: observation log and family tree store births, deaths, parentage, cause-of-death labels, favorite state, and key state changes.
|
||||
|
||||
「たりない」たちが観察皿の中で勝手に暮らします。
|
||||
基本は眺めるだけですが、餌・水・草・石・寝床・などを配置して軽く介入できます。
|
||||
## Important mechanics
|
||||
|
||||
## 主な要素
|
||||
- Personality uses four axes: aggression, openness, sociability, and neuroticism. Children inherit from the parents' current four-axis personality average with variation.
|
||||
- Relationships track affinity/fear/fight outcomes and influence approach, avoidance, intimidation, conflict, co-parent behavior, and UI summaries.
|
||||
- Death causes are normalized by `js/health.js`: fight, hunger, disease, stress overload, over-poking, firecracker, accident, lifespan. Major damage within 10 seconds can become `<cause> de suyaku` in UI text (`...\u3067\u8870\u5f31`).
|
||||
- Water-curable diseases force high-priority water seeking. Zunda-curable diseases force high-priority zunda seeking.
|
||||
- Favorite agents show a star above the sprite and can be toggled from the observation panel.
|
||||
|
||||
- 9種類のたりない画像を使用
|
||||
- 空腹・孤独・眠気・不安・たりない度
|
||||
- 餌探し、睡眠、群れ寄り、パニック、繁殖、死亡
|
||||
- 死後の「たりない跡」と草の発生
|
||||
- 個体選択による詳細観察
|
||||
- 観察ログ
|
||||
- 一時停止 / 速度変更 / 保存 / 読込
|
||||
- localStorageによるブラウザ内セーブ
|
||||
## Diseases
|
||||
|
||||
## 操作
|
||||
- Zunchi disease: zunchi-derived contamination; slows movement, increases stress, emits miasma effect, cured by water.
|
||||
- Sleep disease: caused by sleep drug; stops all behavior and sleeps indefinitely; cured by damage.
|
||||
- Explosion disease: low-probability result of explosion knockback; explodes after half a day; cured by water or zunda mochi.
|
||||
- Fight-wound disease: low-probability result after bleeding for five in-game hours; spins and wanders aimlessly; cured by zunda mochi.
|
||||
|
||||
- 観察:個体クリックで詳細表示
|
||||
- ふつうの餌 / あまい餌 / 水滴 / 草 / ぬくい石 / ふわふわ:キャンバスクリックで配置
|
||||
- つつく:個体をクリックして一時的に不安を上げる
|
||||
- 迷い込み:指定位置に新しいたりないを追加
|
||||
## Tools and items
|
||||
|
||||
## 素材について
|
||||
- Direct tools: observe, delete, poke, pinch, spawn.
|
||||
- Food/medicine: zunchi, zunda mochi, heko mochi, kenka mochi, water, water bowl, sleep drug, grass.
|
||||
- Environment/objects: stone, hay bed, nest box, cleaning tool, ball, firecracker, vertical fence, horizontal fence.
|
||||
- Cleaning removes zunchi/blood/traces while pushing agents and applying water effects. Water bowl is persistent water. Nest box lowers stress nearby and improves sleep quality. Rain increases falling water items.
|
||||
|
||||
同梱画像は本プロトタイプ用に加工した「たりない」素材です。
|
||||
公開・配布・商用利用を行う場合は、元画像作成者・権利者の許諾範囲に従ってください。
|
||||
|
||||
## ファイル構成
|
||||
## File map
|
||||
|
||||
```text
|
||||
tarinai_colony_game/
|
||||
├─ index.html
|
||||
├─ css/
|
||||
│ └─ styles.css
|
||||
├─ js/
|
||||
│ └─ game.js
|
||||
├─ assets/
|
||||
│ └─ sprites/
|
||||
│ ├─ tarinai_01_smile.png
|
||||
│ ├─ ...
|
||||
│ └─ _contact_sheet.png
|
||||
└─ docs/
|
||||
└─ design_notes.md
|
||||
index.html Static DOM shell; ordered script tags; no long ecology/body text.
|
||||
css/styles.css Layout, HUD, dialogs, tooltips, responsive rules.
|
||||
js/data.js Sprite/object assets, metrics, config, field presets.
|
||||
js/text_catalog.js Escaped Japanese UI catalog: tooltips and ecology cards.
|
||||
js/math.js Generic math helpers.
|
||||
js/assets.js Lazy image loading, caches, fallback render images.
|
||||
js/audio.js WebAudio/sample effects.
|
||||
js/render.js Background, weather, lighting, HUD, global canvas render.
|
||||
js/sim_core.js Names, personalities, relationships, effects, spatial grid.
|
||||
js/health.js Damage memory, death-cause normalization, damage application.
|
||||
js/items.js Item state, lifecycle, drawing, grass/zunchi/ball/firecracker logic.
|
||||
js/tarinai.js Agent state, AI, disease state, eating, conflict, reproduction, draw.
|
||||
js/world.js World update, placement, weather, logs, global interactions.
|
||||
js/ui.js Input, tool selection, dialogs, observation panel.
|
||||
js/ui_charts.js Population/statistics charts.
|
||||
js/ui_family.js Family/archive UI and cause-of-death display.
|
||||
docs/AI_CONTEXT.md Minimal implementation context for AI edits.
|
||||
docs/code_map.md Symbol-to-file lookup table.
|
||||
```
|
||||
|
||||
## 今後の拡張候補
|
||||
## Edit rules
|
||||
|
||||
- 個体ごとの家系図
|
||||
- 図鑑・実績
|
||||
- より精密なコロニー分裂
|
||||
- 草・死跡・餌の生態循環
|
||||
- 音と環境変化
|
||||
- 画像素材の追加差分
|
||||
- Preserve zero-build script order. Add new JS files explicitly in `index.html` before dependents.
|
||||
- Keep global symbols intentional; this codebase is not ESM.
|
||||
- Prefer catalog/config/helper extraction over adding large inline blocks to `index.html` or `tarinai.js`.
|
||||
- Do not reintroduce removed compatibility files (`sim.js`, `ui_exports.js`) or broad JS/CSS caching for active code.
|
||||
- For public uploads, delete old server files before replacing the directory, then upload the full new ZIP contents.
|
||||
|
||||
|
||||
## 更新内容(最新版)
|
||||
## v15 notes
|
||||
- Colony chart tab `events` was removed. `environment` now reports security, hygiene, and happiness.
|
||||
- Selected Tarinai UI includes per-individual records attached by structured observation events.
|
||||
- Observation highlights selected relatives and major social relations: parent, child, friend, enemy.
|
||||
- Explosions impart velocity to nearby zunchi items; cursor contact reduces stress and emits dense heart effects.
|
||||
|
||||
- たりないの表示サイズを大幅に縮小
|
||||
- 右移動時に左右反転
|
||||
- 食事中の専用エフェクト
|
||||
- つつきすぎによる死亡演出(06→09→死亡)
|
||||
- 死亡時の緑のしぶき跡
|
||||
- 感情に応じたスプライト自動切替
|
||||
- 昼夜サイクルと時間HUD
|
||||
|
||||
|
||||
## 更新内容(v3)
|
||||
|
||||
- 出生・死亡バランスを再調整:飢餓速度を下げ、寿命と上限個体数を増やし、観察が長く続くように調整
|
||||
- 図鑑を追加:観測済みスプライト、イベント、死因を記録
|
||||
- 系譜を追加:繁殖時に親子関係・世代・出生時刻を保存
|
||||
- 選択個体に親・子数・気分・つつき耐久を表示
|
||||
- Web Audioによる簡易効果音を追加(音 ON ボタンで有効化)
|
||||
- 昼夜サイクルをログ・図鑑イベントにも反映
|
||||
- 食事、繁殖、死亡、つつき、時間帯切替のイベント記録を追加
|
||||
|
||||
|
||||
## 更新内容(v4)
|
||||
|
||||
- 選択個体パネルから「足りないもの」「考えていること」を削除
|
||||
- 系譜をリスト形式から樹状図形式へ変更
|
||||
- 子個体が成長するまで親に追従する挙動を追加
|
||||
- 食事エフェクト量を抑制し、餌・あまい餌・草の色を反映
|
||||
- 昼夜サイクルの光を地面・アイテム・たりない本体へ反映
|
||||
- 近距離・低機嫌・過密時に発生する喧嘩を追加
|
||||
- 喧嘩イベントを図鑑イベントへ記録
|
||||
- 保存キーは `tarinai-colony-save-v4`
|
||||
|
||||
|
||||
## 更新内容(v5)
|
||||
|
||||
- 草は自然発生するように変更
|
||||
- 草は自然には消えず、少しずつ伸びる
|
||||
- 草を食べると残量が減り、表示される本数も減る
|
||||
- 暗い時間帯にスプライトの透過部分へ矩形状の色が乗る問題を修正
|
||||
- HUDの観察日を「1日目」表記へ変更
|
||||
- HUDの「明るさ」表示を時刻表記へ変更
|
||||
- 図鑑UIを完全に削除
|
||||
- 系譜を右パネルからメイン画面下部へ移動
|
||||
- 保存キーは `tarinai-colony-save-v5`
|
||||
|
||||
|
||||
## 更新内容(v6)
|
||||
|
||||
- 喧嘩中に頭突き攻撃を行うように変更
|
||||
- 草と石が昼夜サイクルの光に反応するように変更
|
||||
- 睡眠中は完全に移動停止
|
||||
- 09_stretch が見えるよう、つつき耐久を 9 段階に調整
|
||||
- 3 回目以降: 06_cry
|
||||
- 6 回目以降: 09_stretch
|
||||
- 9 回目: 死亡
|
||||
- たりないがカーソルに反応
|
||||
- 好意的な個体は 01_smile で寄ってくる
|
||||
- 敵対的・不安定な個体は逃げる
|
||||
- 保存キーは `tarinai-colony-save-v6`
|
||||
|
||||
|
||||
## 更新内容(v7)
|
||||
|
||||
- メイン画面の外観を「庭の観察」テイストへ刷新
|
||||
- 空・日差し・庭枠・土の質感・周辺の草花を追加
|
||||
- たりないに接地影を追加
|
||||
- 石・草・各アイテムにも影と周囲とのなじみを追加
|
||||
- 選択リングを観察レンズ風の二重リングに変更
|
||||
- 子個体と親個体の関係が見えやすいよう、近距離時に薄い追従ガイド線を追加
|
||||
- 右上HUDと全体UIを庭の観察ノート風に調整
|
||||
- 保存キーは `tarinai-colony-save-v7`
|
||||
|
||||
|
||||
## 更新内容(v8)
|
||||
|
||||
- アニメーションを調整し、呼吸感・移動感・喧嘩時の勢いを強化
|
||||
- 庭素材を追加(小石・苔・クローバー・落ち葉・周辺の草花)
|
||||
- 太陽や月そのものは描かず、時間帯に応じた投影影を追加
|
||||
- ずんちを追加。食事を重ねると排泄し、庭に残る
|
||||
- 09_stretch が通常個体でもはっきり表示されるよう補強
|
||||
- 系譜の接続線を太く・連続的にして見やすく修正
|
||||
- 未使用だった図鑑系の状態管理や関連スタイルを削除
|
||||
- 保存キーは `tarinai-colony-save-v8`
|
||||
|
||||
|
||||
## 更新内容(v9)
|
||||
|
||||
- 投影影が見えにくい問題を修正
|
||||
- blur フィルタ依存をやめ、軽量な楕円影へ変更
|
||||
- たりない・石・草・アイテムの影を濃く調整
|
||||
- 重すぎる問題を軽量化
|
||||
- 庭背景の粒・草花・落ち葉描画数を削減
|
||||
- filter blur を廃止
|
||||
- 草の自動発生上限と発生頻度を抑制
|
||||
- アイテム上限を削減
|
||||
- 食事パーティクル頻度を抑制
|
||||
- 死亡後の緑のしぶきが時間経過でゆっくり薄く消えるように変更
|
||||
- 保存キーは `tarinai-colony-save-v9`
|
||||
|
||||
## 更新内容(v10)
|
||||
|
||||
- 新規スプライト5枚を追加
|
||||
- `10_hurt`: 痛い時・喧嘩中に打たれた時
|
||||
- `11_weak`: 低体力時
|
||||
- `12_back`: 奥方向へ進む時
|
||||
- `13_flee`: 喧嘩に負けて敗走する時
|
||||
- `14_zunda_eat`: ずんだ餅を食べる時
|
||||
- 追加画像はすべて透明PNG化し、`512x468` のキャンバスへ正規化
|
||||
- 追加スプライトは行動専用扱いにし、通常個体の基本タイプとしてはランダム出現しないように調整
|
||||
|
||||
|
||||
## 更新内容(v11)
|
||||
|
||||
- 個体ごとの性格を追加
|
||||
- 臆病、食いしん坊、眠たがり、寂しがり、怒りっぽい、マイペース
|
||||
- 性格は睡眠、喧嘩、ずんだ餅反応、恐怖、対個体行動へ影響
|
||||
- 選択パネルに観察理由を追加
|
||||
- 現在の行動理由
|
||||
- 対象
|
||||
- 仲の良い個体
|
||||
- 避けている個体
|
||||
- 喧嘩記憶
|
||||
- 関係性と喧嘩記憶を追加
|
||||
- 近くで過ごす個体同士は親和度が上がる
|
||||
- 喧嘩に負けた相手を記憶し、しばらく避けやすくなる
|
||||
- 勝敗はログと選択パネルに反映
|
||||
- hay bed に快適度・混雑ペナルティを追加
|
||||
- 空いているベッドを選びやすくなる
|
||||
- 混雑時は回復効率が下がり、ストレスが上がる
|
||||
- 草の生態系を強化
|
||||
- 雨、水、ずんち由来の肥沃地で育ちやすくなる
|
||||
- 強い晴天で水分がないと弱りやすくなる
|
||||
- 草の周辺ではストレス・恐怖が少し下がる
|
||||
- 表情スプライトの優先順位を整理
|
||||
- ずんだ餅、被ダメージ、敗走、恐怖、低体力、睡眠、喧嘩、奥移動、通常の順に競合解決
|
||||
- ログを種類別に色分け
|
||||
- 誕生、死亡、喧嘩、食事、草、環境、関係、観察
|
||||
- 喧嘩で転ぶ演出を追加
|
||||
- 打撃時に低確率で転ぶ
|
||||
- 敗北時に転倒し、土煙が出る
|
||||
- 保存キーは `tarinai-colony-save-v11`
|
||||
## v4 \u8ffd\u52a0\u4fee\u6b63
|
||||
- \u5de3\u7bb1\u5165\u9000\u5ba4\u3092\u5c02\u7528\u72b6\u614b\u5316\u3057\u3001\u900f\u660e\u5316\u30fb\u5f53\u305f\u308a\u5224\u5b9a\u6d88\u6ec5\u30fb\u500b\u4f53\u6570\u53cd\u6620\u30fb\u9000\u51fa\u5fa9\u5e30\u3092\u5b89\u5b9a\u5316\u3002
|
||||
- \u7761\u7720\u6642\u306e\u512a\u5148\u9806\u4f4d\u3092\u300c\u5de3\u7bb1 \u2192 \u5e72\u3057\u8349\u30d9\u30c3\u30c9 \u2192 \u305d\u306e\u5834\u300d\u306b\u5909\u66f4\u3002
|
||||
- \u305f\u308a\u306a\u3044\u30c7\u30fc\u30bfUI\u306e\u53cb\u9054/\u6575\u5bfe\u3092\u5168\u4ef6\u30c1\u30c3\u30d7\u8868\u793a\u306b\u3057\u3001\u30af\u30ea\u30c3\u30af\u3067\u500b\u4f53\u3078\u79fb\u52d5\u3067\u304d\u308b\u3088\u3046\u306b\u5909\u66f4\u3002
|
||||
|
|
|
|||
BIN
assets/objects/ant_corpse.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
assets/objects/ant_queen.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
assets/objects/ant_worker.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
assets/objects/ant_worker_mid.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
assets/objects/ant_worker_small.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
assets/objects/ant_worker_small_b.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
assets/objects/genkotsu.png
Normal file
|
After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 389 KiB |
|
|
@ -1,175 +0,0 @@
|
|||
[
|
||||
{
|
||||
"id": "smile",
|
||||
"file": "assets/sprites/tarinai_01_smile.png",
|
||||
"width": 512,
|
||||
"height": 468
|
||||
},
|
||||
{
|
||||
"id": "angry",
|
||||
"file": "assets/sprites/tarinai_02_angry.png",
|
||||
"width": 512,
|
||||
"height": 464
|
||||
},
|
||||
{
|
||||
"id": "teary",
|
||||
"file": "assets/sprites/tarinai_03_teary.png",
|
||||
"width": 512,
|
||||
"height": 472
|
||||
},
|
||||
{
|
||||
"id": "jito",
|
||||
"file": "assets/sprites/tarinai_04_jito.png",
|
||||
"width": 512,
|
||||
"height": 487
|
||||
},
|
||||
{
|
||||
"id": "drool",
|
||||
"file": "assets/sprites/tarinai_05_drool.png",
|
||||
"width": 512,
|
||||
"height": 461
|
||||
},
|
||||
{
|
||||
"id": "cry",
|
||||
"file": "assets/sprites/tarinai_06_cry.png",
|
||||
"width": 512,
|
||||
"height": 478
|
||||
},
|
||||
{
|
||||
"id": "sleep",
|
||||
"file": "assets/sprites/tarinai_07_sleep.png",
|
||||
"width": 511,
|
||||
"height": 475
|
||||
},
|
||||
{
|
||||
"id": "pokan",
|
||||
"file": "assets/sprites/tarinai_08_pokan.png",
|
||||
"width": 512,
|
||||
"height": 469
|
||||
},
|
||||
{
|
||||
"id": "stretch",
|
||||
"file": "assets/sprites/tarinai_09_stretch.png",
|
||||
"width": 512,
|
||||
"height": 384
|
||||
},
|
||||
{
|
||||
"id": "hurt",
|
||||
"file": "assets/sprites/tarinai_10_hurt.png",
|
||||
"width": 512,
|
||||
"height": 468,
|
||||
"actionOnly": true
|
||||
},
|
||||
{
|
||||
"id": "weak",
|
||||
"file": "assets/sprites/tarinai_11_weak.png",
|
||||
"width": 512,
|
||||
"height": 468,
|
||||
"actionOnly": true
|
||||
},
|
||||
{
|
||||
"id": "back",
|
||||
"file": "assets/sprites/tarinai_12_back.png",
|
||||
"width": 512,
|
||||
"height": 468,
|
||||
"actionOnly": true
|
||||
},
|
||||
{
|
||||
"id": "flee",
|
||||
"file": "assets/sprites/tarinai_13_flee.png",
|
||||
"width": 512,
|
||||
"height": 468,
|
||||
"actionOnly": true
|
||||
},
|
||||
{
|
||||
"id": "zunda_eat",
|
||||
"file": "assets/sprites/tarinai_14_zunda_eat.png",
|
||||
"width": 512,
|
||||
"height": 468,
|
||||
"actionOnly": true
|
||||
},
|
||||
{
|
||||
"id": "hurt2",
|
||||
"file": "assets/sprites/tarinai_15_hurt2.png",
|
||||
"width": 512,
|
||||
"height": 468,
|
||||
"actionOnly": true
|
||||
},
|
||||
{
|
||||
"id": "normal_smirk",
|
||||
"file": "assets/sprites/tarinai_16_normal_smirk.png",
|
||||
"width": 512,
|
||||
"height": 468,
|
||||
"displayOnly": true
|
||||
},
|
||||
{
|
||||
"id": "normal_tongue",
|
||||
"file": "assets/sprites/tarinai_17_normal_tongue.png",
|
||||
"width": 512,
|
||||
"height": 468,
|
||||
"displayOnly": true
|
||||
},
|
||||
{
|
||||
"id": "fear",
|
||||
"file": "assets/sprites/tarinai_18_fear.png",
|
||||
"width": 512,
|
||||
"height": 468,
|
||||
"actionOnly": true
|
||||
},
|
||||
{
|
||||
"id": "flee_fear2",
|
||||
"file": "assets/sprites/tarinai_19_flee_fear2.png",
|
||||
"width": 512,
|
||||
"height": 468,
|
||||
"actionOnly": true
|
||||
},
|
||||
{
|
||||
"id": "sleep2",
|
||||
"file": "assets/sprites/tarinai_20_sleep2.png",
|
||||
"width": 512,
|
||||
"height": 468,
|
||||
"actionOnly": true
|
||||
},
|
||||
{
|
||||
"id": "normal_happy",
|
||||
"file": "assets/sprites/tarinai_21_normal_happy.png",
|
||||
"width": 512,
|
||||
"height": 468,
|
||||
"displayOnly": true
|
||||
},
|
||||
{
|
||||
"id": "fear_blue",
|
||||
"file": "assets/sprites/tarinai_22_fear_blue.png",
|
||||
"width": 512,
|
||||
"height": 468,
|
||||
"actionOnly": true
|
||||
},
|
||||
{
|
||||
"id": "fear_cry",
|
||||
"file": "assets/sprites/tarinai_23_fear_cry.png",
|
||||
"width": 512,
|
||||
"height": 468,
|
||||
"actionOnly": true
|
||||
},
|
||||
{
|
||||
"id": "stress_dizzy",
|
||||
"file": "assets/sprites/tarinai_24_stress_dizzy.png",
|
||||
"width": 512,
|
||||
"height": 468,
|
||||
"actionOnly": true
|
||||
},
|
||||
{
|
||||
"id": "stress_sweat",
|
||||
"file": "assets/sprites/tarinai_25_stress_sweat.png",
|
||||
"width": 512,
|
||||
"height": 468,
|
||||
"actionOnly": true
|
||||
},
|
||||
{
|
||||
"id": "intimidate",
|
||||
"file": "assets/sprites/tarinai_26_intimidate.png",
|
||||
"width": 512,
|
||||
"height": 468,
|
||||
"actionOnly": true
|
||||
}
|
||||
]
|
||||
|
Before Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 109 KiB |
BIN
assets/ui/ecology_hp_stress.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
assets/ui/tool_ant_nest.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
assets/ui/tool_ball.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
assets/ui/tool_fight_mochi.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
assets/ui/tool_genkotsu.png
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
assets/ui/tool_love_mochi.png
Normal file
|
After Width: | Height: | Size: 1 KiB |
BIN
assets/ui/tool_nest_box.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
BIN
assets/ui/tool_sleep_drug.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
assets/ui/tool_water_bowl.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
assets/ui/tool_water_hose.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
424
css/styles.css
|
|
@ -35,6 +35,64 @@ button {
|
|||
color: inherit;
|
||||
}
|
||||
|
||||
.loading-screen {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background:
|
||||
radial-gradient(circle at 50% 30%, rgba(164, 225, 104, 0.22), transparent 32%),
|
||||
linear-gradient(180deg, rgba(239, 248, 255, 0.98), rgba(247, 245, 236, 0.98));
|
||||
transition: opacity 180ms ease, visibility 180ms ease;
|
||||
}
|
||||
.loading-screen.hidden {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
.loading-panel {
|
||||
width: min(420px, 92vw);
|
||||
padding: 24px 22px;
|
||||
border-radius: 24px;
|
||||
border: 1px solid rgba(84, 68, 48, 0.16);
|
||||
background: rgba(255, 252, 246, 0.92);
|
||||
box-shadow: 0 18px 48px rgba(64, 48, 32, 0.16);
|
||||
text-align: center;
|
||||
}
|
||||
.loading-title {
|
||||
font-weight: 700;
|
||||
font-size: 18px;
|
||||
letter-spacing: 0.04em;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.loading-text {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
min-height: 1.55em;
|
||||
}
|
||||
.loading-bar {
|
||||
height: 12px;
|
||||
margin: 18px 0 8px;
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
background: rgba(90, 70, 48, 0.12);
|
||||
}
|
||||
.loading-bar-fill {
|
||||
width: 0%;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, #9ce84b, #f0d35b);
|
||||
transition: width 160ms ease;
|
||||
}
|
||||
.loading-percent {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
#app {
|
||||
width: min(1480px, 100vw);
|
||||
margin: 0 auto;
|
||||
|
|
@ -155,6 +213,8 @@ h1 {
|
|||
.field-dialog.hidden { display: none; }
|
||||
.field-dialog-panel {
|
||||
width: min(440px, 100%);
|
||||
max-height: calc(100vh - 36px);
|
||||
overflow: auto;
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
background: rgba(255, 252, 245, 0.96);
|
||||
|
|
@ -188,23 +248,6 @@ h1 {
|
|||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
.save-text-dialog-panel {
|
||||
width: min(760px, calc(100vw - 30px));
|
||||
}
|
||||
.save-text-area {
|
||||
width: 100%;
|
||||
min-height: 180px;
|
||||
margin-top: 10px;
|
||||
padding: 10px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255,255,255,0.82);
|
||||
color: var(--ink);
|
||||
font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
resize: vertical;
|
||||
word-break: break-all;
|
||||
}
|
||||
.save-text-actions { flex-wrap: wrap; }
|
||||
.archive-reset-dialog-panel {
|
||||
width: min(420px, 100%);
|
||||
}
|
||||
|
|
@ -219,6 +262,9 @@ h1 {
|
|||
|
||||
.ecology-dialog-panel {
|
||||
width: min(760px, 100%);
|
||||
max-height: calc(100vh - 36px);
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
.ecology-grid {
|
||||
display: grid;
|
||||
|
|
@ -227,17 +273,17 @@ h1 {
|
|||
}
|
||||
.ecology-card {
|
||||
display: grid;
|
||||
grid-template-columns: 84px minmax(0, 1fr);
|
||||
grid-template-columns: 72px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
padding: 10px;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(180deg, rgba(255,255,255,0.88), rgba(243,237,225,0.76));
|
||||
border: 1px solid rgba(84,68,48,0.12);
|
||||
}
|
||||
.ecology-card img {
|
||||
width: 84px;
|
||||
height: 84px;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
object-fit: contain;
|
||||
filter: drop-shadow(0 8px 18px rgba(56, 44, 32, 0.10));
|
||||
}
|
||||
|
|
@ -250,7 +296,7 @@ h1 {
|
|||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
line-height: 1.40;
|
||||
}
|
||||
|
||||
.toast {
|
||||
|
|
@ -337,49 +383,34 @@ h1 {
|
|||
opacity: 0.10;
|
||||
pointer-events: none;
|
||||
}
|
||||
.tool span { min-width: 16px; text-align: center; color: rgba(72,58,43,0.64); font-size: 10px; }
|
||||
.tool > span:not(.tool-size-badge) { display: none; }
|
||||
.tool[data-tool="observe"] { --tool-icon: url("../assets/ui/tool_observe.png"); }
|
||||
.tool[data-tool="zunchi"] { --tool-icon: url("../assets/ui/tool_zunchi.png"); }
|
||||
.tool[data-tool="sweet"] { --tool-icon: url("../assets/ui/tool_sweet.png"); }
|
||||
.tool[data-tool="water"] { --tool-icon: url("../assets/ui/tool_water.png"); }
|
||||
.tool[data-tool="grass"] { --tool-icon: url("../assets/ui/tool_grass.png"); }
|
||||
.tool[data-tool="stone"] { --tool-icon: url("../assets/ui/tool_stone.png"); }
|
||||
.tool[data-tool="genkotsu"] { --tool-icon: url("../assets/ui/tool_genkotsu.png"); }
|
||||
.tool[data-tool="firecracker"] { --tool-icon: url("../assets/ui/tool_firecracker.png"); }
|
||||
.tool[data-tool="fence_v"] { --tool-icon: url("../assets/ui/tool_fence_v.png"); }
|
||||
.tool[data-tool="fence_h"] { --tool-icon: url("../assets/ui/tool_fence_h.png"); }
|
||||
.tool[data-tool="bed"] { --tool-icon: url("../assets/ui/tool_bed.png"); }
|
||||
.tool[data-tool="ball"] { --tool-icon: none; }
|
||||
.tool[data-tool="ball"]::before {
|
||||
content: "\26bd ";
|
||||
background: none;
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
filter: drop-shadow(0 2px 3px rgba(52,40,26,0.12));
|
||||
}
|
||||
.tool[data-tool="ball"]::after {
|
||||
content: "\26bd ";
|
||||
background: none;
|
||||
font-size: 48px;
|
||||
line-height: 1;
|
||||
opacity: 0.10;
|
||||
right: -6px;
|
||||
left: auto;
|
||||
top: 50%;
|
||||
bottom: auto;
|
||||
transform: translateY(-50%);
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
}
|
||||
.tool[data-tool="love_mochi"] { --tool-icon: url("../assets/ui/tool_love_mochi.png"); }
|
||||
.tool[data-tool="fight_mochi"] { --tool-icon: url("../assets/ui/tool_fight_mochi.png"); }
|
||||
.tool[data-tool="water_bowl"] { --tool-icon: url("../assets/ui/tool_water_bowl.png"); }
|
||||
.tool[data-tool="sleep_drug"] { --tool-icon: url("../assets/ui/tool_sleep_drug.png"); }
|
||||
.tool[data-tool="nest_box"] { --tool-icon: url("../assets/ui/tool_nest_box.png"); }
|
||||
.tool[data-tool="ant_nest"] { --tool-icon: url("../assets/ui/tool_ant_nest.png"); }
|
||||
.tool[data-tool="ball"] { --tool-icon: url("../assets/ui/tool_ball.png"); }
|
||||
.tool[data-tool="poke"] { --tool-icon: none; }
|
||||
.tool[data-tool="poke"]::before {
|
||||
content: "\1f448 ";
|
||||
content: "\1F448";
|
||||
background: none;
|
||||
font-size: 25px;
|
||||
line-height: 1;
|
||||
filter: drop-shadow(0 2px 3px rgba(52,40,26,0.12));
|
||||
}
|
||||
.tool[data-tool="poke"]::after {
|
||||
content: "\1f448 ";
|
||||
content: "\1F448";
|
||||
background: none;
|
||||
font-size: 48px;
|
||||
line-height: 1;
|
||||
|
|
@ -395,14 +426,14 @@ h1 {
|
|||
|
||||
.tool[data-tool="pinch"] { --tool-icon: none; }
|
||||
.tool[data-tool="pinch"]::before {
|
||||
content: "\1f90f ";
|
||||
content: "\1F90F";
|
||||
background: none;
|
||||
font-size: 25px;
|
||||
line-height: 1;
|
||||
filter: drop-shadow(0 2px 3px rgba(52,40,26,0.12));
|
||||
}
|
||||
.tool[data-tool="pinch"]::after {
|
||||
content: "\1f90f ";
|
||||
content: "\1F90F";
|
||||
background: none;
|
||||
font-size: 48px;
|
||||
line-height: 1;
|
||||
|
|
@ -415,39 +446,19 @@ h1 {
|
|||
width: 54px;
|
||||
height: 54px;
|
||||
}
|
||||
.tool[data-tool="love_mochi"] { --tool-icon: none; }
|
||||
.tool[data-tool="love_mochi"]::before {
|
||||
content: "\1f495 ";
|
||||
.tool[data-tool="water_hose"] { --tool-icon: none; }
|
||||
.tool[data-tool="water_hose"]::before {
|
||||
content: "\1F6BF";
|
||||
background: none;
|
||||
font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif;
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
filter: drop-shadow(0 2px 3px rgba(52,40,26,0.12));
|
||||
}
|
||||
.tool[data-tool="love_mochi"]::after {
|
||||
content: "\1f495 ";
|
||||
background: none;
|
||||
font-size: 46px;
|
||||
line-height: 1;
|
||||
opacity: 0.10;
|
||||
right: -6px;
|
||||
left: auto;
|
||||
top: 50%;
|
||||
bottom: auto;
|
||||
transform: translateY(-50%);
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
}
|
||||
.tool[data-tool="fight_mochi"] { --tool-icon: none; }
|
||||
.tool[data-tool="fight_mochi"]::before {
|
||||
content: "\1f4a2 ";
|
||||
background: none;
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
filter: drop-shadow(0 2px 3px rgba(52,40,26,0.12));
|
||||
}
|
||||
.tool[data-tool="fight_mochi"]::after {
|
||||
content: "\1f4a2 ";
|
||||
.tool[data-tool="water_hose"]::after {
|
||||
content: "\1F6BF";
|
||||
background: none;
|
||||
font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif;
|
||||
font-size: 46px;
|
||||
line-height: 1;
|
||||
opacity: 0.10;
|
||||
|
|
@ -471,7 +482,7 @@ h1 {
|
|||
.hint {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
line-height: 1.40;
|
||||
margin: 10px 0 0;
|
||||
}
|
||||
|
||||
|
|
@ -501,6 +512,36 @@ h1 {
|
|||
.tool-category.collapsed .tool-category-toggle::before { content: "\25b8 "; }
|
||||
.tool-category-body { padding: 8px; }
|
||||
.tool-category.collapsed .tool-category-body { display: none; }
|
||||
|
||||
.selected-data-groups {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.selected-category {
|
||||
border: 1px solid rgba(84,68,48,0.10);
|
||||
border-radius: 14px;
|
||||
background: rgba(255,255,255,0.30);
|
||||
overflow: hidden;
|
||||
}
|
||||
.selected-category-toggle {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
border-bottom: 1px solid rgba(84,68,48,0.08);
|
||||
background: rgba(255,252,244,0.66);
|
||||
padding: 8px 10px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
color: var(--ink);
|
||||
}
|
||||
.selected-category-toggle::before { content: "\25be "; display: inline-block; width: 16px; color: var(--muted); }
|
||||
.selected-category.collapsed .selected-category-toggle::before { content: "\25b8 "; }
|
||||
.selected-category-body { padding: 7px 9px; }
|
||||
.selected-category.collapsed .selected-category-body { display: none; }
|
||||
.selected-category-body .info-row:last-child { border-bottom: 0; }
|
||||
.tool-size-badge {
|
||||
position: absolute;
|
||||
right: 7px;
|
||||
|
|
@ -553,13 +594,64 @@ meter {
|
|||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
|
||||
.selected-card.hidden { display: none; }
|
||||
.selected-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.selected-head h2 { margin-bottom: 0; }
|
||||
.selected-close {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 1px solid rgba(84,68,48,0.16);
|
||||
border-radius: 999px;
|
||||
background: rgba(255,255,255,0.76);
|
||||
color: rgba(76,58,42,0.78);
|
||||
font-size: 20px;
|
||||
line-height: 24px;
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
}
|
||||
.selected-close:hover { background: #fff; }
|
||||
|
||||
.selected-info {
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
line-height: 1.40;
|
||||
}
|
||||
.selected-info.empty {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.selected-name-editor {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.selected-name-input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid rgba(103,178,44,0.34);
|
||||
border-radius: 13px;
|
||||
background: rgba(246,252,238,0.96);
|
||||
color: var(--ink);
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
padding: 8px 10px;
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.82);
|
||||
}
|
||||
.selected-name-input:focus {
|
||||
outline: 2px solid rgba(103,178,44,0.24);
|
||||
border-color: rgba(103,178,44,0.62);
|
||||
background: #fff;
|
||||
}
|
||||
.selected-effects {
|
||||
margin: 2px 0 6px;
|
||||
}
|
||||
.badge {
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
|
|
@ -578,6 +670,29 @@ meter {
|
|||
padding: 5px 0;
|
||||
}
|
||||
.info-row span:first-child { color: var(--muted); }
|
||||
.personality-values strong {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 2px 8px;
|
||||
max-width: 68%;
|
||||
text-align: right;
|
||||
}
|
||||
.personality-values strong span {
|
||||
color: var(--ink);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.personality-tag {
|
||||
display: inline-block;
|
||||
border-radius: 999px;
|
||||
padding: 2px 7px;
|
||||
background: rgba(103,178,44,0.10);
|
||||
border: 1px solid rgba(103,178,44,0.22);
|
||||
}
|
||||
.personality-tag.neutral {
|
||||
background: rgba(120,110,90,0.08);
|
||||
border-color: rgba(120,110,90,0.16);
|
||||
}
|
||||
|
||||
.log-card { min-height: 260px; flex: 0 0 auto; display: flex; flex-direction: column; }
|
||||
.log {
|
||||
|
|
@ -914,6 +1029,20 @@ body::before {
|
|||
z-index: 1;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.lineage-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.btn.active {
|
||||
background: #efffde;
|
||||
border-color: rgba(103,178,44,0.46);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.lineage-render-status {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
|
|
@ -1025,3 +1154,140 @@ body::before {
|
|||
max-height: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.tool[data-tip] { position: relative; }
|
||||
.tool-tip-popover {
|
||||
position: fixed;
|
||||
z-index: 2147483000;
|
||||
width: 220px;
|
||||
max-width: min(220px, calc(100vw - 16px));
|
||||
padding: 7px 9px;
|
||||
border-radius: 10px;
|
||||
background: rgba(46, 38, 30, 0.78);
|
||||
color: #fff8e6;
|
||||
font-family: "Yomogi", "Hiragino Maru Gothic ProN", "Yu Gothic", sans-serif;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
box-shadow: 0 10px 28px rgba(40, 32, 22, 0.18);
|
||||
backdrop-filter: blur(1px);
|
||||
text-shadow: 0 1px 3px rgba(25, 18, 12, 0.55);
|
||||
pointer-events: none;
|
||||
}
|
||||
.tool-tip-popover.hidden { display: none; }
|
||||
.selected-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin: 8px 0 4px;
|
||||
}
|
||||
.favorite-toggle {
|
||||
border: 1px solid rgba(128, 102, 56, 0.32);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 248, 221, 0.92);
|
||||
color: #5a4630;
|
||||
font-weight: 700;
|
||||
padding: 5px 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.favorite-toggle.active {
|
||||
background: rgba(255, 229, 122, 0.95);
|
||||
border-color: rgba(180, 132, 34, 0.55);
|
||||
}
|
||||
|
||||
.selected-records {
|
||||
margin-top: 8px;
|
||||
padding-top: 7px;
|
||||
border-top: 1px dashed rgba(126, 96, 64, 0.24);
|
||||
}
|
||||
.selected-records h3 {
|
||||
margin: 0 0 5px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.selected-records ol {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
.selected-records li {
|
||||
display: grid;
|
||||
grid-template-columns: 38px 1fr;
|
||||
gap: 6px;
|
||||
align-items: start;
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.selected-records li span {
|
||||
color: var(--muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.selected-records li strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.relation-list-row {
|
||||
align-items: start;
|
||||
}
|
||||
.relation-list-row > strong {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.relation-chip-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 5px;
|
||||
max-width: 100%;
|
||||
}
|
||||
.relation-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
max-width: 138px;
|
||||
min-width: 0;
|
||||
border: 1px solid rgba(104, 150, 84, 0.24);
|
||||
border-radius: 999px;
|
||||
background: rgba(250, 255, 242, 0.88);
|
||||
color: #4d6140;
|
||||
padding: 2px 6px 2px 2px;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
line-height: 1.2;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 1px 0 rgba(255,255,255,0.74);
|
||||
}
|
||||
.relation-chip.enemy {
|
||||
border-color: rgba(178, 92, 74, 0.28);
|
||||
background: rgba(255, 246, 240, 0.92);
|
||||
color: #754739;
|
||||
}
|
||||
.relation-chip:hover,
|
||||
.relation-chip:focus-visible {
|
||||
transform: translateY(-1px);
|
||||
background: rgba(255,255,255,0.98);
|
||||
outline: 2px solid rgba(118, 186, 72, 0.18);
|
||||
}
|
||||
.relation-chip.enemy:hover,
|
||||
.relation-chip.enemy:focus-visible {
|
||||
outline-color: rgba(190, 92, 72, 0.18);
|
||||
}
|
||||
.relation-chip .relation-face {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
.relation-chip-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
}
|
||||
.relation-chip em {
|
||||
font-style: normal;
|
||||
font-size: 10px;
|
||||
opacity: 0.72;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
|
|
|||
21
docs/AI_CONTEXT.md
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# AI_CONTEXT
|
||||
|
||||
Purpose: dense orientation for code-editing agents. Browser-only simulation; no build system. Source uses globals and strict script order, not ESM.
|
||||
|
||||
Load order from `index.html`: `data -> math -> assets -> audio -> render -> sim_core -> items -> health -> tarinai -> world -> text_catalog -> ui -> ui_charts -> ui_family -> main`.
|
||||
|
||||
Primary objects:
|
||||
- `World` (`js/world.js`): owns field size, time/weather, tarinai array, items, effects, logs, archive, placement, collision, interaction dispatch.
|
||||
- `Tarinai` (`js/tarinai.js`): agent state and AI. Use helper files for shared rules; avoid making this file larger.
|
||||
- `Item` (`js/items.js`): item state, draw, lifecycle.
|
||||
- `HEALTH` (`js/health.js`): damage memory, major-damage window, death-cause classification, damage application.
|
||||
- `TEXT_CATALOG` (`js/text_catalog.js`): Japanese tooltips and ecology cards. Keep Japanese escaped.
|
||||
|
||||
Behavior hotspots:
|
||||
- Need selection / target priority: `Tarinai.resolveNeeds` and related target helpers.
|
||||
- Disease state: constructor fields plus `infect*`, `recover*`, `updateZunchiDisease`, `updateSpecialDiseases`.
|
||||
- Conflict: intimidation/fight fields in `Tarinai`, global fight resolution in `World`.
|
||||
- Cause of death: never duplicate regexes in `tarinai.js`; update `HEALTH`.
|
||||
- Public load performance: avoid eager image loads; use `assets.js` lazy/fallback path.
|
||||
|
||||
Validation checklist: `node --check js/*.js`; search for accidental raw Japanese in `.js/.html/.css/.md/.json`; load `index.html` after cache-busting version changes.
|
||||
|
|
@ -1,27 +1,25 @@
|
|||
# Code Map
|
||||
# Code map
|
||||
|
||||
Use this map to open the smallest relevant file first.
|
||||
Use this as the first lookup table before opening large files.
|
||||
|
||||
## Simulation
|
||||
- `js/sim_core.js`: shared simulation helpers, personality definitions, `Effect`, `SpatialGrid`, and small drawing helpers used by simulation classes.
|
||||
- `js/items.js`: `Item` lifecycle, item save/load restore, grass and zunchi behavior, and item drawing.
|
||||
- `js/tarinai.js`: `Tarinai` state, AI, movement, eating, fighting, panic, reproduction-adjacent behavior, and drawing.
|
||||
- `js/world.js`: `World` state, save/load, spawning, family registry normalization, spatial queries, tools, weather, and update loop coordination.
|
||||
| Need | File / symbol |
|
||||
|---|---|
|
||||
| Script order / DOM shell | `index.html` |
|
||||
| Global config, sprites, object metrics | `js/data.js` / `CONFIG`, `SPRITES`, `IMAGE_METRICS` |
|
||||
| Lazy image loading | `js/assets.js` / `loadInitialImages`, `loadDeferredImages`, `getRenderableImage` |
|
||||
| UI Japanese text catalog | `js/text_catalog.js` / `TEXT_CATALOG` |
|
||||
| Core agent state | `js/tarinai.js` / `class Tarinai` constructor |
|
||||
| Agent AI target choice | `js/tarinai.js` / `resolveNeeds`, target helpers |
|
||||
| Disease state transitions | `js/tarinai.js` / `infect*`, `recover*`, `update*Disease*` |
|
||||
| Damage/death cause rules | `js/health.js` / `HEALTH` |
|
||||
| Item creation/lifecycle/draw | `js/items.js` / `class Item` |
|
||||
| Cleaning, rain, placement | `js/world.js` / placement/update interaction helpers |
|
||||
| Fence collision | `js/world.js` / `resolveFenceCollision` |
|
||||
| Logs/archive | `js/world.js` |
|
||||
| Main renderer | `js/render.js` / `render` helpers |
|
||||
| Agent drawing | `js/tarinai.js` / `draw` |
|
||||
| Input/tools/dialogs | `js/ui.js` |
|
||||
| Statistics UI | `js/ui_charts.js` |
|
||||
| Family tree UI | `js/ui_family.js` |
|
||||
|
||||
## UI And Rendering
|
||||
- `js/render.js`: canvas scene rendering, lighting, weather visuals, HUD, and selected-card overlay.
|
||||
- `js/ui.js`: DOM references, selected/log panels, labels, save/load buttons, tool controls, canvas input, and UI event binding.
|
||||
- `js/ui_charts.js`: colony history aggregation and chart rendering.
|
||||
- `js/ui_family.js`: family tree components, layout, archive rendering, and validation helper.
|
||||
|
||||
## Common Tasks
|
||||
- Change hunger, sleep, fighting, panic, eating, or movement: start with `js/tarinai.js`.
|
||||
- Change item growth, zunchi lifecycle, grass spread, or item visuals: start with `js/items.js`.
|
||||
- Change save/load, population, spawning, weather, tools, or family data correctness: start with `js/world.js`.
|
||||
- Change family tree display only: start with `js/ui_family.js`.
|
||||
- Change colony charts: start with `js/ui_charts.js`.
|
||||
- Change buttons, selected info, logs, or input handling: start with `js/ui.js`.
|
||||
- Change the garden canvas visuals: start with `js/render.js`.
|
||||
|
||||
## Loading Order
|
||||
`index.html` loads plain browser scripts in dependency order. Do not convert to ES modules or add a build step unless the project intentionally adopts one later.
|
||||
Refactor guidance: extract isolated rule sets into small helper files, then delegate from `Tarinai`/`World`. Keep shared domain terms centralized (`HEALTH`, `TEXT_CATALOG`, `CONFIG`) to reduce repeated prompt context.
|
||||
|
|
|
|||
910
docs/prompt.txt
Normal file
|
|
@ -0,0 +1,910 @@
|
|||
You are working on the latest Tarinai observation game build.
|
||||
|
||||
Base input:
|
||||
|
||||
* Start from `tarinai_colony_game_ant_nest_v5_fixed(1).zip`.
|
||||
* The extracted project folder may be named `tarinai_colony_game_personality_fixed`.
|
||||
* Modify the project files directly.
|
||||
* Return a new zip named something like `tarinai_colony_game_ant_nest_v6_fixed.zip`.
|
||||
* Preserve the current Japanese UI tone and naming style.
|
||||
* After changes, run syntax checks on all JavaScript files and verify zip integrity.
|
||||
* Bump all cache-busting versions in `index.html` consistently so browsers do not keep loading old JS/CSS.
|
||||
|
||||
Important context:
|
||||
|
||||
* This latest build already includes the four-axis personality system.
|
||||
* This latest build already includes the ant nest feature.
|
||||
* Do not revert either of those systems.
|
||||
* Do not reintroduce the old legacy named personality system.
|
||||
* Do not preserve old save compatibility.
|
||||
* Old local save data is irrelevant.
|
||||
|
||||
Important intentional behaviors to preserve:
|
||||
|
||||
* The family tree reset feature is intentional. It exists so the player can delete an extremely large/heavy family tree. Do not remove it.
|
||||
* The family tree reset may clear family tree data/cache and related heavy relationship graph data according to the current intended behavior. Do not “fix” it as a bug.
|
||||
* Item placement drop-impact damage is intentional. Do not remove it.
|
||||
* Keep item placement drop damage behavior as-is unless it directly causes a runtime error.
|
||||
* Grass planting checks may remain mostly as-is.
|
||||
* Tool-size inheritance may remain as-is.
|
||||
* Screen resizing may reset the field. Do not add complicated resize preservation logic.
|
||||
|
||||
---
|
||||
|
||||
# 1. Completely remove save/load functionality
|
||||
|
||||
Remove the save/load feature entirely.
|
||||
|
||||
Requirements:
|
||||
|
||||
* Remove manual save buttons, load buttons, autosave controls, import/export controls, or any equivalent UI if present.
|
||||
* Remove localStorage save/load usage.
|
||||
* Remove autosave timers.
|
||||
* Remove save key constants such as `tarinai-colony-save-*`.
|
||||
* Remove save migration logic.
|
||||
* Remove backward compatibility code for old saves.
|
||||
* Remove serialization/deserialization code that exists only for save/load.
|
||||
* Remove comments and fallback code related to old save formats.
|
||||
* Do not preserve compatibility with previous versions.
|
||||
* Do not add new save/load functionality.
|
||||
* Do not remove normal image/audio asset loading functions. Only remove game-state save/load.
|
||||
|
||||
Current-code hints:
|
||||
|
||||
* `CONFIG.saveKey` still exists and should be removed.
|
||||
* `serialize()` / `load(data)` style world-state code still exists and should be removed if it is only used for save/load.
|
||||
* CSS classes for old save text dialogs should be removed if unused.
|
||||
* Legacy personality stubs that exist only for old-save compatibility should be removed if they are not used by the current four-axis personality system.
|
||||
|
||||
Expected result:
|
||||
|
||||
* The game starts fresh.
|
||||
* No save/load UI is visible.
|
||||
* No old localStorage data is read.
|
||||
* No migration or compatibility logic remains.
|
||||
|
||||
---
|
||||
|
||||
# 2. Living Tarinai internal IDs
|
||||
|
||||
Introduce or refactor to a living-only internal ID system for Tarinai.
|
||||
|
||||
Important:
|
||||
|
||||
* The existing code uses `id` widely for Tarinai identity, family tree, ants, relationships, and logs.
|
||||
* Do not blindly make historical family-tree identity reusable if that would corrupt the family tree.
|
||||
* The requested reusable ID is the active living individual ID.
|
||||
* If persistent historical/family-tree identity is still needed, introduce a separate internal snapshot/archive key such as `familyKey` or `archiveKey`.
|
||||
* Do not display either ID in the UI.
|
||||
|
||||
Requirements:
|
||||
|
||||
* Every living Tarinai must have an internal living ID.
|
||||
* The living ID must not be displayed.
|
||||
* Use the living ID internally for:
|
||||
|
||||
* observation event ownership
|
||||
* relationship highlights
|
||||
* fight participants
|
||||
* damage attribution where needed
|
||||
* current ant targeting/dragging
|
||||
* current selection state
|
||||
* avoiding name-based ambiguity
|
||||
* Do not use display name text as an internal identifier.
|
||||
* Death must completely remove/release the living ID from the active living registry.
|
||||
* Released living IDs may be reused by later Tarinai.
|
||||
* Reusing a living ID must not attach new Tarinai events to an old dead Tarinai’s logs, observation history, family tree entry, or ant target.
|
||||
* Any historical display that survives death must store a display snapshot separately:
|
||||
|
||||
* name snapshot
|
||||
* sprite/type snapshot
|
||||
* death cause
|
||||
* family links if needed
|
||||
* any required personality display snapshot
|
||||
* Ants that target or drag a Tarinai must not accidentally switch to a new Tarinai after a released living ID is reused. Use direct object references, validation tokens, or live ID plus generation token if necessary.
|
||||
|
||||
Expected result:
|
||||
|
||||
* Living Tarinai are internally identifiable without relying on names.
|
||||
* Dead Tarinai living IDs are released and reusable.
|
||||
* Historical displays remain stable.
|
||||
* ID reuse does not corrupt logs, family tree, relationships, selection, or ant behavior.
|
||||
|
||||
---
|
||||
|
||||
# 3. Four-axis personality compatibility
|
||||
|
||||
The latest build uses four continuous personality axes:
|
||||
|
||||
* aggression
|
||||
* openness
|
||||
* sociability
|
||||
* neuroticism
|
||||
|
||||
Do not reintroduce legacy named personality as a main data model.
|
||||
|
||||
Coward/timid behavior:
|
||||
|
||||
* Do not set a legacy `currentPersonality = "臆病"` style value.
|
||||
* Do not add a separate cowardice parameter.
|
||||
* `臆病` should remain a derived tag/state based on the four-axis personality model.
|
||||
* The existing intended derivation is high neuroticism plus low aggression.
|
||||
* When a Tarinai takes damage and its total fight losses are greater than its total fight wins:
|
||||
|
||||
* with 5% chance, shift its current personality slightly toward cowardly behavior,
|
||||
* for example increase neuroticism and decrease aggression,
|
||||
* use only total fight wins/losses,
|
||||
* do not include per-relationship win/loss counts,
|
||||
* do not double count.
|
||||
* If this behavior is already implemented correctly, keep it and only clean up naming/comments so it does not imply a legacy named-personality route.
|
||||
|
||||
Expected result:
|
||||
|
||||
* The four-axis personality system remains intact.
|
||||
* `臆病` remains a derived display/behavior tag, not a separate old personality.
|
||||
* The damage/loss condition uses only aggregate lifetime fight result counters.
|
||||
|
||||
---
|
||||
|
||||
# 4. Observation logs, event ownership, filtering, and statistics
|
||||
|
||||
Current issues:
|
||||
|
||||
* Observation logs may still be assigned by checking whether event text includes a Tarinai’s name.
|
||||
* Hidden observation events may still count in event statistics.
|
||||
* `つつく` / poke may still be classified as fight by text inference.
|
||||
|
||||
Implement:
|
||||
|
||||
* Observation events must store structured participant references.
|
||||
* Use living Tarinai IDs while participants are alive.
|
||||
* Also store display-name snapshots so old logs remain correct after death or ID reuse.
|
||||
* Do not assign observation events by `text.includes(name)` or similar name substring matching.
|
||||
* Event objects should have structured fields such as:
|
||||
|
||||
* `type`
|
||||
* `actorLiveId`
|
||||
* `targetLiveId`
|
||||
* `participantLiveIds`
|
||||
* `participantSnapshots`
|
||||
* `hiddenFromObservation`
|
||||
* `countInStats`
|
||||
* Exact field names are flexible, but the data must be structured.
|
||||
* Hidden observation events must not be counted in statistics.
|
||||
* Do not merely hide them from the UI while still incrementing counters.
|
||||
* Events hidden from observation UI should either not be logged as visible observation events or should be marked with `countInStats: false`.
|
||||
|
||||
Remove from the observation UI and statistics:
|
||||
|
||||
* events about poking a ball
|
||||
* events of the form “X avoided Y” / `○○が××を避けた`
|
||||
* events that merely indicate selecting a Tarinai for observation, such as `○○を選択した。`
|
||||
|
||||
Important classification rule:
|
||||
|
||||
* `つつく` / poke is not a fight.
|
||||
* Do not classify poke events as `fight`.
|
||||
* If there is an `inferLogKind()` or equivalent text-based classifier, fix or replace it so poke does not become fight.
|
||||
* Prefer structured event types over Japanese text matching.
|
||||
|
||||
Log display formatting:
|
||||
|
||||
* Remove name-substring based log decoration where practical.
|
||||
* Render participant names from structured event snapshots.
|
||||
* If a participant has died and its living ID was released, use the display-name snapshot stored in the event.
|
||||
* Do not allow reused living IDs to rewrite old log display names.
|
||||
|
||||
Expected result:
|
||||
|
||||
* Observation logs attach to the correct individuals.
|
||||
* Hidden events are not shown and not counted.
|
||||
* Poke is not treated as fight.
|
||||
* Similar Tarinai names no longer corrupt logs.
|
||||
|
||||
---
|
||||
|
||||
# 5. Family tree update performance and scheduling
|
||||
|
||||
The latest build already has some idle/chunked family tree rendering. Preserve it and finish the scheduling/consistency work.
|
||||
|
||||
Requirements:
|
||||
|
||||
* Keep heavy preprocessing split into small chunks.
|
||||
* Use `requestIdleCallback` when available, with a safe `setTimeout` fallback.
|
||||
* Avoid doing large graph construction, signature generation, relationship counting, or DOM creation in one blocking pass.
|
||||
* The game loop must remain responsive while family tree data is prepared.
|
||||
|
||||
Update timing rules:
|
||||
|
||||
* When the family tree panel is hidden or offscreen:
|
||||
|
||||
* Do not update every 5 seconds.
|
||||
* Check/update at most once every 30 seconds.
|
||||
* When the family tree panel is visible:
|
||||
|
||||
* Do not update on a fixed 5-second timer.
|
||||
* Update only when the family tree is dirty, meaning a relevant change occurred.
|
||||
* Relevant dirty events include at least:
|
||||
|
||||
* birth
|
||||
* death
|
||||
* parent/child relationship creation
|
||||
* mate/partner relationship creation or change
|
||||
* name/identity data change that affects the family tree display
|
||||
* family tree reset
|
||||
* Mark dirty from the source of the change, not by repeatedly recomputing expensive signatures.
|
||||
* If a background update is already running, do not start another one.
|
||||
* If a newer dirty event occurs while an update is running, schedule one additional update after the current update completes.
|
||||
* If the family tree reset button is used, cancel stale pending render tokens/timers so old async work cannot reinsert deleted tree HTML afterward.
|
||||
|
||||
Important preservation rule:
|
||||
|
||||
* Do not remove the family tree reset feature.
|
||||
* It is intentional and may clear family tree data/cache and current heavy relationship graph data.
|
||||
* Only fix reset-related runtime errors or stale async render problems.
|
||||
|
||||
Expected result:
|
||||
|
||||
* Opening or updating the family tree should not freeze the main simulation.
|
||||
* Hidden family tree work should be rare.
|
||||
* Visible family tree should refresh promptly after actual changes.
|
||||
* Reset remains available and does not allow stale async renders to restore old content.
|
||||
|
||||
---
|
||||
|
||||
# 6. Disease fight initiation and targeting rules
|
||||
|
||||
Implement the following disease fight rules exactly.
|
||||
|
||||
Fight initiation:
|
||||
|
||||
* Tarinai with `爆発病` can initiate fights.
|
||||
* Tarinai with `ずんち病` can initiate fights.
|
||||
* Tarinai with any other disease must not initiate fights.
|
||||
* Healthy Tarinai may initiate fights according to the existing rules.
|
||||
|
||||
Being targeted / challenged:
|
||||
|
||||
* Tarinai with `ねむり病` must not be targeted for fights.
|
||||
* Tarinai with any other disease may still be targeted/challenged by another Tarinai.
|
||||
* This means:
|
||||
|
||||
* `爆発病` and `ずんち病`: can initiate and can be targeted.
|
||||
* `きずつき病` and other non-sleep diseases: cannot initiate, but can be targeted.
|
||||
* `ねむり病`: cannot initiate and cannot be targeted.
|
||||
|
||||
Apply consistently to:
|
||||
|
||||
* Normal fight selection.
|
||||
* Forced fight logic.
|
||||
* Intimidation logic if it can lead to fights.
|
||||
* Fight-food effects such as `けんか餅`.
|
||||
* Any helper such as `canStartFight`, `canBeFightTarget`, or equivalent.
|
||||
|
||||
Expected result:
|
||||
|
||||
* Disease rules are handled by clear helper functions.
|
||||
* No scattered ad-hoc disease checks disagree.
|
||||
|
||||
---
|
||||
|
||||
# 7. Collision and external-force unification
|
||||
|
||||
The latest build already has `applyImpulse()` and some collision refactoring. Preserve and finish the design.
|
||||
|
||||
Requirements:
|
||||
|
||||
* Normal self-propelled movement and external impulse/knockback must remain separate.
|
||||
* External impulses must not be clamped by the normal Tarinai walking speed cap.
|
||||
* If helper functions already exist, reuse and tighten them instead of adding duplicates.
|
||||
* Useful helpers may include:
|
||||
|
||||
* `applyImpulse(entity, vx, vy, options)`
|
||||
* `applyKnockback(entity, sourceX, sourceY, strength, options)`
|
||||
* `applyImpactDamage(target, amount, cause)`
|
||||
* `resolveCircleCircleCollision(...)`
|
||||
* `resolveCircleRectCollision(...)`
|
||||
|
||||
Unlimited ball speed:
|
||||
|
||||
* Do not reintroduce a ball speed cap.
|
||||
* Collision detection must handle high-speed balls using swept movement based on actual frame movement distance.
|
||||
* Remove fixed low `Math.min(...)` collision-search caps that contradict unlimited ball speed.
|
||||
* Ball vs wall, ball vs fence, ball vs nest box, ball vs ball, and ball vs Tarinai should remain reliable at high speed.
|
||||
|
||||
High-speed ball vs Tarinai:
|
||||
|
||||
* When a sufficiently fast ball hits a Tarinai:
|
||||
|
||||
* Knock the Tarinai away using velocity equal to 1/5 of the ball velocity.
|
||||
* This knockback must be an external impulse, not normal walking movement.
|
||||
* Do not let normal “play with ball” behavior also run for that same dangerous impact if it would conflict.
|
||||
* If the damage from this impact kills the Tarinai, death cause must be `衝突`.
|
||||
|
||||
High-speed Tarinai vs Tarinai:
|
||||
|
||||
* When a sufficiently fast Tarinai hits another Tarinai:
|
||||
|
||||
* The hit Tarinai should be knocked away.
|
||||
* Use the attacker’s actual movement/impulse direction and speed.
|
||||
* If collision damage kills a Tarinai, death cause must be `衝突`.
|
||||
|
||||
Explosion knockback:
|
||||
|
||||
* Keep explosion knockback strong.
|
||||
* Use external impulse, not normal movement velocity.
|
||||
* Knockback should be partly random.
|
||||
* Tarinai closer to the blast center should be thrown faster and farther.
|
||||
* Tarinai farther from the blast center should be thrown less strongly.
|
||||
* Preserve or improve existing explosion damage behavior.
|
||||
* If an explosion kills a Tarinai, keep the appropriate explosion-related death cause, not `衝突`.
|
||||
|
||||
Ant interaction preservation:
|
||||
|
||||
* Preserve existing ant damage handling from firecracker explosions and dropped-object impacts.
|
||||
* Do not break worker ants, queen ants, ant corpses, or ant nest reproduction behavior while refactoring forces.
|
||||
|
||||
Expected result:
|
||||
|
||||
* Normal walking speed remains bounded.
|
||||
* Knockback from explosions and collisions can exceed walking speed.
|
||||
* High-speed objects do not frequently tunnel through collisions.
|
||||
* Collision behavior is handled by shared primitives rather than scattered custom math.
|
||||
|
||||
---
|
||||
|
||||
# 8. Ball-to-ball collision
|
||||
|
||||
Ensure balls collide with each other robustly.
|
||||
|
||||
Requirements:
|
||||
|
||||
* Ball vs ball collision must use circle-circle collision.
|
||||
* Resolve overlap.
|
||||
* Exchange or reflect velocity in a physically plausible way.
|
||||
* Work with high-speed balls, not only overlapping balls.
|
||||
* Avoid infinite acceleration or NaN velocity.
|
||||
* Do not apply arbitrary maximum speed caps.
|
||||
|
||||
If this is already implemented, verify it and remove any remaining contradictory caps or stale-grid issues.
|
||||
|
||||
---
|
||||
|
||||
# 9. Item falling speed
|
||||
|
||||
Current issue:
|
||||
Item falling motion can look like it changes speed unnaturally.
|
||||
|
||||
Implement:
|
||||
|
||||
* Make the visible item falling motion constant speed.
|
||||
* Replace nonlinear visual curves such as `Math.pow(dropT, ...)` with linear progress unless a specific item intentionally has a special animation.
|
||||
* The item should fall at a stable fixed speed from its start height to the ground.
|
||||
* Keep the existing item placement drop-impact damage behavior.
|
||||
* Do not remove drop-impact damage.
|
||||
* Do not make harmless-looking items non-damaging merely because they look harmless. Current placement drop-impact behavior is intentional.
|
||||
|
||||
Expected result:
|
||||
|
||||
* Falling items move at a visually consistent constant speed.
|
||||
* Existing drop impact behavior remains.
|
||||
|
||||
---
|
||||
|
||||
# 10. Solid obstacle and placement logic unification
|
||||
|
||||
Current issues:
|
||||
Fence, nest box, water dish, grass, and placement preview logic are partly fragmented.
|
||||
|
||||
Implement:
|
||||
|
||||
* Unify placement validation for the object types where overlap matters.
|
||||
* Overlap is important for:
|
||||
|
||||
* grass
|
||||
* fence
|
||||
* nest box
|
||||
* water dish
|
||||
* For other items, overlap does not need to be strict unless an existing rule already requires it.
|
||||
* Do not over-engineer overlap rules for stones, balls, food items, firecrackers, poop, ant nests, ant corpses, or similar objects.
|
||||
|
||||
Solid obstacles:
|
||||
|
||||
* Unify all solid rectangular obstacle collision logic.
|
||||
* Fence and nest box should use the same obstacle system where possible.
|
||||
* Rename misleading helpers if needed:
|
||||
|
||||
* For example, functions named only for “fence” but now handling nest boxes too should be renamed or wrapped with neutral names such as `solidObstacle`.
|
||||
* Ball movement, Tarinai movement, path blocking, and placement checks should reference the same obstacle data.
|
||||
|
||||
Fence collision:
|
||||
|
||||
* Fence collision bounds must match the visible fence appearance.
|
||||
* If the visual fence is not the same size as the previous hitbox, update the hitbox to match the visual footprint.
|
||||
* Avoid separate visual size and collision size unless explicitly documented in one definition table.
|
||||
|
||||
Nest box collision:
|
||||
|
||||
* Preserve the existing 3×3 nest box hitbox rule:
|
||||
|
||||
* If the nest box is divided into a 3×3 grid, the collidable cells are:
|
||||
|
||||
* top-left
|
||||
* top-center
|
||||
* top-right
|
||||
* middle-left
|
||||
* middle-right
|
||||
* The middle-center, bottom-left, bottom-center, and bottom-right cells are passable/enterable according to the current nest behavior.
|
||||
* Ball must bounce off these nest box solid cells.
|
||||
* Tarinai must not path through these solid cells.
|
||||
|
||||
Placement preview:
|
||||
|
||||
* The preview highlight must use the exact same placement validation as actual placement.
|
||||
* If the preview is red, clicking must not place the object.
|
||||
* If the preview is green, clicking should place the object at the previewed position.
|
||||
* Do not silently clamp or auto-correct an out-of-bounds placement to an in-bounds location.
|
||||
* If the planned placement would extend outside the field, the highlight must turn red.
|
||||
* Fix any case where a fence preview extending outside the field does not turn red.
|
||||
* The highlighted preview position should show the true placement footprint, not a different corrected footprint.
|
||||
|
||||
Grass placement:
|
||||
|
||||
* Keep the existing grass planting spot search/check behavior unless it directly conflicts with the unified placement preview/actual placement contract.
|
||||
* Do not spend time expanding grass checks to every object type.
|
||||
|
||||
Screen resize:
|
||||
|
||||
* Do not implement special preservation behavior for screen resizing.
|
||||
* Screen size change is allowed to reset the field.
|
||||
* Do not add complicated coordinate scaling or migration logic for resize.
|
||||
|
||||
Expected result:
|
||||
|
||||
* Placement UI, actual placement, and collision behavior agree.
|
||||
* No “red but placeable” or “green but placed elsewhere” behavior.
|
||||
* Grass, fence, nest box, and water dish avoid important overlap.
|
||||
* Other item overlap can remain permissive.
|
||||
|
||||
---
|
||||
|
||||
# 11. Spatial query scratch arrays
|
||||
|
||||
Current issue:
|
||||
`nearbyItems()` and `nearbyTarinai()` may share the same mutable scratch array. Nested spatial queries can overwrite an outer loop’s result.
|
||||
|
||||
Implement:
|
||||
|
||||
* `nearbyItems()` and `nearbyTarinai()` must not share the same mutable scratch array.
|
||||
* Nested spatial queries must not overwrite an outer loop’s result.
|
||||
* Use separate scratch arrays, return fresh arrays, or explicitly copy results where nested queries are possible.
|
||||
* Verify:
|
||||
|
||||
* item interaction loops
|
||||
* bed/nest occupancy checks
|
||||
* best bed search
|
||||
* ball interactions
|
||||
* Tarinai interaction loops
|
||||
* ant prey search if it uses nearby queries
|
||||
|
||||
Expected result:
|
||||
|
||||
* Spatial queries are safe even when nested.
|
||||
* Loops over nearby entities do not mutate unexpectedly.
|
||||
|
||||
---
|
||||
|
||||
# 12. Spatial rebuild/update phase
|
||||
|
||||
Current issue:
|
||||
The spatial grid may be rebuilt before entities move, so later collision and interaction passes can use stale positions.
|
||||
|
||||
Implement:
|
||||
|
||||
* Ensure spatial grid data is not stale during collision and interaction passes.
|
||||
* Prefer phase separation:
|
||||
|
||||
* update items
|
||||
* update ants if their movement affects targeting/collision
|
||||
* update Tarinai movement
|
||||
* rebuild spatial grid
|
||||
* resolve cross-entity interactions/collisions
|
||||
* If full phase separation is too invasive, at minimum rebuild the spatial grid after Tarinai movement and before any interaction/collision logic that relies on it.
|
||||
* Avoid relying on grid data from before entities moved.
|
||||
|
||||
Expected result:
|
||||
|
||||
* Nearby queries use current positions.
|
||||
* Collision and interaction behavior is more stable.
|
||||
|
||||
---
|
||||
|
||||
# 13. Duplicate weather log
|
||||
|
||||
Current issue:
|
||||
Weather changes may create duplicate identical log entries in some builds.
|
||||
|
||||
Implement:
|
||||
|
||||
* Verify `updateWeather()`.
|
||||
* A weather change must create only one weather log entry.
|
||||
* Do not log the same weather text twice.
|
||||
* Preserve the actual weather behavior.
|
||||
|
||||
Expected result:
|
||||
|
||||
* One weather change produces one visible log entry.
|
||||
|
||||
---
|
||||
|
||||
# 14. Tool/item definition and CSS icon cleanup
|
||||
|
||||
The latest build already has a `TOOL_DEFINITIONS`-style structure. Finish consolidating and remove conflicts.
|
||||
|
||||
Requirements:
|
||||
|
||||
* Use a single current source of truth for tool/item definitions where practical.
|
||||
* It should include at least:
|
||||
|
||||
* internal id
|
||||
* Japanese label
|
||||
* tooltip text
|
||||
* UI icon or icon renderer
|
||||
* game appearance reference where applicable
|
||||
* placement size / footprint
|
||||
* whether it is placeable
|
||||
* whether it is scalable
|
||||
* collision shape if applicable
|
||||
* simulation-only status if applicable
|
||||
* Use this definition in the tool UI, placement preview, placement logic, and logs where appropriate.
|
||||
* Do not leave conflicting duplicate label/icon/size definitions in separate files unless unavoidable.
|
||||
|
||||
Specific icon requirements:
|
||||
|
||||
* `つつく` must use `👈`.
|
||||
* `つまむ` must use `🤏`.
|
||||
* `洗浄` must use the shower emoji `🚿`.
|
||||
* Remove or stop referencing old PNG icons for `つつく` and `つまむ`.
|
||||
* If `洗浄` has both an emoji and a PNG icon, ensure the displayed tool UI follows the current requested shower emoji specification.
|
||||
* Fix the nest box tool UI icon so it matches the actual in-game nest box appearance.
|
||||
* Preserve the ant nest tool icon and ensure it remains visible.
|
||||
* Ensure every tool that appears in the tool UI has an icon.
|
||||
* Avoid mojibake/garbled icon rendering.
|
||||
* CSS icon definitions must be unified to the current specification.
|
||||
* Remove old CSS generations and conflicting icon overrides.
|
||||
* Do not rely on CSS order to accidentally choose the correct icon.
|
||||
|
||||
Water tool:
|
||||
|
||||
* `水` must remain removed from the tool UI.
|
||||
* If water still exists as a simulation object produced by rain or other systems, keep that object behavior.
|
||||
* `水の皿` remains a valid tool/item.
|
||||
* Keep `water` as simulation-only if needed.
|
||||
* Remove leftover UI-only water tool entries, labels, tooltips, and placement handlers unless required by non-UI simulation.
|
||||
|
||||
Static tool buttons:
|
||||
|
||||
* If `index.html` still hardcodes tool buttons, ensure it does not conflict with `TOOL_DEFINITIONS`.
|
||||
* Prefer generating or validating tool metadata from the definition table if practical.
|
||||
* At minimum, make sure static buttons and definition data agree.
|
||||
|
||||
Expected result:
|
||||
|
||||
* Tool UI no longer has missing icons, old icons, or mismatched nest box visuals.
|
||||
* Current icon definitions are clean and non-duplicated.
|
||||
* Ant nest remains integrated.
|
||||
|
||||
---
|
||||
|
||||
# 15. Death cause normalization
|
||||
|
||||
Ensure these death causes are applied consistently:
|
||||
|
||||
* Death by old age / lifespan:
|
||||
|
||||
* `天寿を全うした`
|
||||
* Tarinai deleted because it went too far outside the field:
|
||||
|
||||
* `仕様により画面外で削除`
|
||||
* Death caused by ball collision or Tarinai collision damage:
|
||||
|
||||
* `衝突`
|
||||
* Explosion death:
|
||||
|
||||
* Keep explosion-related cause.
|
||||
* Existing disease-related deaths:
|
||||
|
||||
* Preserve current intended disease death causes unless they conflict with the above.
|
||||
|
||||
Implementation:
|
||||
|
||||
* Centralize death cause normalization if possible.
|
||||
* Avoid scattered caller-side strings that can drift.
|
||||
* Ensure observation UI, family tree, memorial/death display, and any other death display show the normalized cause.
|
||||
* Since save/load is removed, do not implement migration for old death-cause strings.
|
||||
|
||||
Expected result:
|
||||
|
||||
* Death cause strings are consistent.
|
||||
* Collision deaths do not appear as generic accidents.
|
||||
* Lifespan deaths show `天寿を全うした`.
|
||||
|
||||
---
|
||||
|
||||
# 16. Out-of-bounds object policy
|
||||
|
||||
Implement or verify a unified out-of-bounds policy:
|
||||
|
||||
* For objects or entities slightly outside the field:
|
||||
|
||||
* Return them to the nearest valid in-field position.
|
||||
* For objects or entities far outside the field, or with invalid coordinates such as NaN/Infinity:
|
||||
|
||||
* Delete them.
|
||||
* For Tarinai deleted by this system:
|
||||
|
||||
* Death cause must be `仕様により画面外で削除`.
|
||||
* Release its living ID so it may be reused.
|
||||
* For non-living objects:
|
||||
|
||||
* Delete without a death cause.
|
||||
* Keep wall bounce behavior for balls at normal field boundaries, but still sanitize balls that somehow get far outside or invalid coordinates.
|
||||
* Ant workers/queens should not remain permanently invisible offscreen. If they go far out or become invalid, delete or safely return them according to the least disruptive current ant behavior.
|
||||
* Do not add resize-specific preservation logic; field reset on screen size change is acceptable.
|
||||
|
||||
Expected result:
|
||||
|
||||
* No permanent invisible offscreen objects or actors.
|
||||
* No NaN entities poison the simulation.
|
||||
* Far-outside Tarinai deletion has the correct death cause.
|
||||
|
||||
---
|
||||
|
||||
# 17. Observation relationship highlight
|
||||
|
||||
Current issue:
|
||||
Relationship highlight during observation can be affected by sprite rotation in some builds.
|
||||
|
||||
Implement or verify:
|
||||
|
||||
* Relationship highlights must be drawn in world/screen space independent of Tarinai sprite rotation.
|
||||
* Do not draw the highlight inside a rotated sprite transform.
|
||||
* Ensure highlight position follows the Tarinai but does not rotate with the Tarinai body.
|
||||
* Use living IDs or stable current object references for relationship lookup where appropriate, not name matching.
|
||||
* Include the latest intended relationship types:
|
||||
|
||||
* parents
|
||||
* children
|
||||
* enemies
|
||||
* friends, using the current friendship threshold
|
||||
|
||||
Expected result:
|
||||
|
||||
* Highlight remains visually stable regardless of sprite rotation.
|
||||
* Highlight targets the correct individuals.
|
||||
|
||||
---
|
||||
|
||||
# 18. Nest box behavior preservation
|
||||
|
||||
Preserve and verify previous nest box fixes:
|
||||
|
||||
* Tarinai inside the nest box should be fully transparent in the main field.
|
||||
* Nest box can contain up to 5 Tarinai.
|
||||
* Nest box occupant popup should show each individual on a separate line.
|
||||
* The nest box solid hitbox must follow the 3×3 rule described above.
|
||||
* Ball should bounce off nest box solid hitbox cells.
|
||||
* Living Tarinai IDs must not break nest box occupancy.
|
||||
* Since save/load is removed, do not add save compatibility for nest box IDs.
|
||||
|
||||
Expected result:
|
||||
|
||||
* Nest box behavior remains stable after obstacle and ID refactoring.
|
||||
|
||||
---
|
||||
|
||||
# 19. Ant nest behavior preservation
|
||||
|
||||
Preserve the current ant nest feature.
|
||||
|
||||
Do not regress:
|
||||
|
||||
* `ant_nest` remains placeable.
|
||||
* Each ant nest starts with the intended worker count.
|
||||
* Worker ants spawn from their home nest.
|
||||
* Worker ants search for Tarinai and drag them back to the nest.
|
||||
* Multiple workers dragging the same Tarinai increase carrying speed.
|
||||
* Dragged Tarinai can struggle and kill workers.
|
||||
* Dead workers become edible `ant_corpse` items.
|
||||
* Damaged workers keep their HP when returning to and leaving the nest.
|
||||
* A full nest can spawn a queen.
|
||||
* A queen can create a new ant nest in an open spot.
|
||||
* Queen spawning remains limited according to the current intended daily rule.
|
||||
* Ant health does not recover.
|
||||
* Ant sprites and Irasutoya credit remain.
|
||||
|
||||
ID-related requirements:
|
||||
|
||||
* Ants must not use display names to target Tarinai.
|
||||
* Ant target references must not break after living ID reuse.
|
||||
* If a target Tarinai dies, is deleted, or its living ID is released, worker ants should safely abandon or retarget according to current behavior.
|
||||
* Removing save/load must not remove active runtime ant behavior.
|
||||
|
||||
Expected result:
|
||||
|
||||
* Ant nest behavior remains intact after ID, save/load, and spatial refactoring.
|
||||
|
||||
---
|
||||
|
||||
# 20. Encyclopedia / ecology page
|
||||
|
||||
Preserve and verify the `たりないの生態` addition:
|
||||
|
||||
* It must include an explanation of health.
|
||||
* It must include an explanation of stress.
|
||||
* It must use a Tarinai sprite/image with both the health bar and stress bar visible.
|
||||
* Keep the latest shortened ecology cards and HP/stress meter placement note if present.
|
||||
* Do not break existing encyclopedia entries.
|
||||
* Preserve the Irasutoya credit entry added for ant assets.
|
||||
|
||||
---
|
||||
|
||||
# 21. Naming and Japanese text preservation
|
||||
|
||||
Preserve previous Japanese terminology changes:
|
||||
|
||||
* `喧嘩傷病` must remain renamed to `きずつき病`.
|
||||
* Lifespan death text must display as `天寿を全うした`.
|
||||
* Friend relation label should remain `友達` if that is the current intended label.
|
||||
* Use the existing tone/style for Japanese labels and descriptions.
|
||||
* Do not reintroduce removed or old names.
|
||||
|
||||
---
|
||||
|
||||
# 22. Remove obsolete or dead code where safe
|
||||
|
||||
Clean up old or dead specifications, but do not remove active gameplay.
|
||||
|
||||
Remove or simplify where safe:
|
||||
|
||||
* old save/load compatibility code
|
||||
* old save migration code
|
||||
* old CSS icon generations
|
||||
* obsolete tool icon definitions
|
||||
* UI-only `water` tool leftovers
|
||||
* unused save-only item serialization fields
|
||||
* comments describing removed old-save behavior
|
||||
* old code paths for removed tools such as UI `水`
|
||||
* old legacy named-personality compatibility stubs if they exist only for old saves
|
||||
* old `food`, `mirror`, `cardboard`, or similar legacy remnants only if they are truly unreachable and not part of current gameplay
|
||||
|
||||
Keep:
|
||||
|
||||
* Current rain/simulation water behavior if it exists.
|
||||
* Current grass planting behavior.
|
||||
* Current tool-size inheritance behavior.
|
||||
* Current field reset behavior on screen resize.
|
||||
* Family tree reset feature.
|
||||
* Item placement drop-impact damage.
|
||||
* Four-axis personality system.
|
||||
* Ant nest feature.
|
||||
|
||||
---
|
||||
|
||||
# 23. Input cleanup
|
||||
|
||||
If practical without destabilizing gameplay, clean up mouse/touch input handling.
|
||||
|
||||
Requirements:
|
||||
|
||||
* Consider converting canvas input to `PointerEvent` handling.
|
||||
* Keep behavior equivalent on desktop.
|
||||
* Ensure dragging, pinching, hosing, placing, panning, and clicking remain stable.
|
||||
* Do not make this a large risky rewrite if the existing input system is stable.
|
||||
* This is lower priority than save/load removal, IDs, logging, collision, family tree performance, spatial safety, and item falling speed.
|
||||
|
||||
Expected result:
|
||||
|
||||
* Input behavior remains stable.
|
||||
* Touch and pointer handling do not regress.
|
||||
|
||||
---
|
||||
|
||||
# 24. Code quality expectations
|
||||
|
||||
Refactor carefully:
|
||||
|
||||
* Prefer small, well-named helper functions.
|
||||
* Avoid adding more scattered special cases.
|
||||
* Avoid duplicating collision math.
|
||||
* Avoid duplicating item/tool metadata.
|
||||
* Avoid string-matching for gameplay logic where structured data is possible.
|
||||
* Do not add save compatibility or migration code.
|
||||
* Do not over-engineer screen-resize preservation.
|
||||
* Keep intentional odd behaviors when explicitly listed above.
|
||||
|
||||
Add comments only where they clarify non-obvious game rules, especially:
|
||||
|
||||
* disease fight rules
|
||||
* nest box 3×3 collision cells
|
||||
* external impulse vs normal movement
|
||||
* family tree dirty/update scheduling
|
||||
* reusable living Tarinai IDs
|
||||
* hidden observation events not counting in statistics
|
||||
* four-axis personality compatibility
|
||||
* constant item falling speed
|
||||
* intentional family tree reset behavior
|
||||
* intentional item placement drop-impact damage
|
||||
* ant target safety with reusable living IDs
|
||||
|
||||
---
|
||||
|
||||
# 25. Validation checklist
|
||||
|
||||
Before returning the zip, verify at minimum:
|
||||
|
||||
Static checks:
|
||||
|
||||
* Run `node --check` on every JavaScript file.
|
||||
* Ensure there are no `ReferenceError` risks from renamed helpers.
|
||||
* Ensure all cache version references are updated consistently.
|
||||
* Ensure save/load functions and UI are removed.
|
||||
* Ensure no localStorage game-state save/load code remains.
|
||||
* Ensure old save migration and old-save compatibility comments are removed.
|
||||
|
||||
Manual or scripted behavior checks where practical:
|
||||
|
||||
* The game starts fresh without reading old saves.
|
||||
* No save/load UI is visible.
|
||||
* Living Tarinai have internal living IDs.
|
||||
* Living IDs are not displayed.
|
||||
* Dead Tarinai living IDs are released and can be reused without corrupting active references.
|
||||
* Historical family tree/log display snapshots remain stable after death and ID reuse.
|
||||
* Ant targeting does not break when Tarinai die or living IDs are released.
|
||||
* Observation events attach by structured IDs/snapshots, not by name substring.
|
||||
* Hidden observation events do not appear and do not count in statistics.
|
||||
* `つつく` is not classified as fight.
|
||||
* Selecting a Tarinai for observation is not counted as a gameplay event/stat.
|
||||
* Ball vs ball collision works.
|
||||
* Fast ball hitting Tarinai knocks it by ball velocity / 5.
|
||||
* Fast collision death cause is `衝突`.
|
||||
* Explosion throws nearby Tarinai farther than distant Tarinai and is not clamped by walking speed.
|
||||
* Falling items use visually constant falling speed.
|
||||
* Existing item placement drop-impact damage still exists.
|
||||
* Fence preview turns red when the footprint leaves the field.
|
||||
* Red placement preview cannot place an object.
|
||||
* Green placement preview places at the same footprint shown.
|
||||
* Grass, fence, nest box, and water dish respect important overlap checks.
|
||||
* Other item overlap remains permissive unless already constrained.
|
||||
* Nest box collision uses the required 3×3 cells.
|
||||
* Ball bounces off nest box solid cells.
|
||||
* Spatial query results are safe from nested-query overwrites.
|
||||
* Spatial grid is not stale during interaction/collision passes.
|
||||
* A weather change creates only one log entry.
|
||||
* Family tree hidden/offscreen update is no more frequent than every 30 seconds.
|
||||
* Family tree visible update happens when dirty, not every 5 seconds.
|
||||
* Family tree processing does not freeze the game.
|
||||
* Family tree reset feature still exists and can clear heavy family tree data/cache.
|
||||
* Family tree reset does not allow stale async renders to restore old tree content.
|
||||
* Four-axis personality UI still works.
|
||||
* Legacy named personality is not reintroduced.
|
||||
* Damage plus total losses greater than total wins can shift personality toward derived `臆病` behavior without creating a legacy personality field.
|
||||
* `爆発病` and `ずんち病` can initiate fights.
|
||||
* Other diseases cannot initiate fights.
|
||||
* `ねむり病` cannot initiate and cannot be targeted.
|
||||
* Other non-sleep diseases can be targeted.
|
||||
* Observation UI does not show ball-poke events or avoid events.
|
||||
* Tool icons are correct: `👈`, `🤏`, `🚿`, nest box matching in-game appearance, and ant nest visible.
|
||||
* CSS icon definitions are unified to current specs.
|
||||
* `水` is absent from the tool UI.
|
||||
* `水の皿` remains available.
|
||||
* Simulation-only rain water still works if present.
|
||||
* Lifespan death cause is `天寿を全うした`.
|
||||
* Far-outside Tarinai deletion cause is `仕様により画面外で削除`.
|
||||
* Ant nest behavior still works.
|
||||
* Screen resize may reset the field; no special preservation behavior is required.
|
||||
|
||||
Deliverable:
|
||||
|
||||
* Return a zip named something like `tarinai_colony_game_ant_nest_v6_fixed.zip`.
|
||||
* Include a concise summary of changed areas and validation results.
|
||||
128
index.html
|
|
@ -3,10 +3,20 @@
|
|||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<title>たりない観察</title>
|
||||
<link rel="stylesheet" href="css/styles.css?v=9.0.0" />
|
||||
<link rel="stylesheet" href="css/styles.css?v=15.9.2" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="loadingScreen" class="loading-screen" role="status" aria-live="polite">
|
||||
<div class="loading-panel">
|
||||
<div class="loading-title">たりない観察を読み込み中</div>
|
||||
<div id="loadingText" class="loading-text">通信量を抑えて初期化しています…</div>
|
||||
<div class="loading-bar" aria-hidden="true"><div id="loadingBar" class="loading-bar-fill"></div></div>
|
||||
<div id="loadingPercent" class="loading-percent">0%</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="app">
|
||||
<header class="topbar">
|
||||
<div class="top-left">
|
||||
|
|
@ -32,6 +42,14 @@
|
|||
</section>
|
||||
|
||||
<aside class="panel">
|
||||
<section id="selectedCard" class="card selected-card hidden">
|
||||
<div class="selected-head">
|
||||
<h2>たりないデータ</h2>
|
||||
<button id="selectedCloseBtn" class="selected-close" type="button" aria-label="観察をやめる">×</button>
|
||||
</div>
|
||||
<div id="selectedInfo" class="selected-info empty">未選択</div>
|
||||
</section>
|
||||
|
||||
<section id="toolCard" class="card tool-card">
|
||||
<div class="tool-head"><h2>道具</h2></div>
|
||||
<div class="tool-body">
|
||||
|
|
@ -39,33 +57,38 @@
|
|||
<section class="tool-category" data-tool-category="observe">
|
||||
<button class="tool-category-toggle" type="button" aria-expanded="true">観察</button>
|
||||
<div class="tool-grid tool-category-body">
|
||||
<button class="tool selected" data-tool="observe"><span>O</span>観察</button>
|
||||
<button class="tool" data-tool="delete"><span>D</span>削除</button>
|
||||
<button class="tool" data-tool="poke"><span>P</span>つつく</button>
|
||||
<button class="tool" data-tool="pinch"><span>🤏</span>つまむ</button>
|
||||
<button class="tool" data-tool="new"><span>N</span>追加</button>
|
||||
<button class="tool selected" data-tool="observe">観察</button>
|
||||
<button class="tool" data-tool="delete">削除</button>
|
||||
<button class="tool" data-tool="poke">つつく</button>
|
||||
<button class="tool" data-tool="pinch">つまむ</button>
|
||||
<button class="tool" data-tool="new">追加</button>
|
||||
<button class="tool" data-tool="water_hose">洗浄</button>
|
||||
</div>
|
||||
</section>
|
||||
<section class="tool-category" data-tool-category="food">
|
||||
<button class="tool-category-toggle" type="button" aria-expanded="true">食べ物・自然</button>
|
||||
<div class="tool-grid tool-category-body">
|
||||
<button class="tool" data-tool="zunchi"><span>Z</span>ずんち</button>
|
||||
<button class="tool" data-tool="sweet"><span>S</span>ずんだ餅</button>
|
||||
<button class="tool" data-tool="love_mochi"><span>H</span>へこ餅</button>
|
||||
<button class="tool" data-tool="fight_mochi"><span>K</span>けんか餅</button>
|
||||
<button class="tool" data-tool="water"><span>W</span>水</button>
|
||||
<button class="tool" data-tool="grass"><span>G</span>草</button>
|
||||
<button class="tool" data-tool="zunchi">ずんち</button>
|
||||
<button class="tool" data-tool="sweet">ずんだ餅</button>
|
||||
<button class="tool" data-tool="love_mochi">へこ餅</button>
|
||||
<button class="tool" data-tool="fight_mochi">けんか餅</button>
|
||||
<button class="tool" data-tool="water_bowl">水の皿</button>
|
||||
<button class="tool" data-tool="sleep_drug">ねむり薬</button>
|
||||
<button class="tool" data-tool="grass">草</button>
|
||||
</div>
|
||||
</section>
|
||||
<section class="tool-category" data-tool-category="object">
|
||||
<button class="tool-category-toggle" type="button" aria-expanded="true">オブジェクト</button>
|
||||
<div class="tool-grid tool-category-body">
|
||||
<button class="tool" data-tool="stone"><span>R</span>石</button>
|
||||
<button class="tool" data-tool="bed"><span>B</span>干草寝床</button>
|
||||
<button class="tool" data-tool="ball"><span>L</span>ボール</button>
|
||||
<button class="tool" data-tool="firecracker"><span>F</span>爆竹</button>
|
||||
<button class="tool" data-tool="fence_v"><span>V</span>縦の柵</button>
|
||||
<button class="tool" data-tool="fence_h"><span>H</span>横の柵</button>
|
||||
<button class="tool" data-tool="stone">石</button>
|
||||
<button class="tool" data-tool="genkotsu">げんこつ</button>
|
||||
<button class="tool" data-tool="bed">干草寝床</button>
|
||||
<button class="tool" data-tool="nest_box">巣箱</button>
|
||||
<button class="tool" data-tool="ant_nest">アリの巣</button>
|
||||
<button class="tool" data-tool="ball">ボール</button>
|
||||
<button class="tool" data-tool="firecracker">爆竹</button>
|
||||
<button class="tool" data-tool="fence_v">縦の柵</button>
|
||||
<button class="tool" data-tool="fence_h">横の柵</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
|
@ -79,16 +102,11 @@
|
|||
<div class="stat"><span>個体数</span><strong id="statPop">0</strong></div>
|
||||
<div class="stat"><span>累計死亡数</span><strong id="statDead">0</strong></div>
|
||||
</div>
|
||||
<div id="colonyChartTabs" class="chart-tabs"><button data-chart="population" class="active">人口</button><button data-chart="life">生活</button><button data-chart="events">イベント</button><button data-chart="objects">オブジェクト</button></div>
|
||||
<div id="colonyChartTabs" class="chart-tabs"><button data-chart="population" class="active">人口</button><button data-chart="life">生活</button><button data-chart="environment">環境</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>
|
||||
</section>
|
||||
|
||||
<section class="card selected-card">
|
||||
<h2>選択中</h2>
|
||||
<div id="selectedInfo" class="selected-info empty">未選択</div>
|
||||
</section>
|
||||
|
||||
<section class="card log-card">
|
||||
<h2>イベント</h2>
|
||||
<div id="log" class="log"></div>
|
||||
|
|
@ -99,7 +117,10 @@
|
|||
<section class="card lineage-main-card">
|
||||
<div class="lineage-head">
|
||||
<h2>家系図</h2>
|
||||
<button id="archiveResetBtn" class="btn danger" type="button">家系図をリセット</button>
|
||||
<div class="lineage-actions">
|
||||
<button id="archiveUpdateToggleBtn" class="btn" type="button" aria-pressed="false">更新 OFF</button>
|
||||
<button id="archiveResetBtn" class="btn danger" type="button">家系図をリセット</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="archiveContent" class="archive-content lineage-main-content"></div>
|
||||
</section>
|
||||
|
|
@ -128,6 +149,7 @@
|
|||
<p><strong>製作:</strong> 333</p>
|
||||
<p><strong>たりない:</strong> むらさき氏</p>
|
||||
<p><strong>効果音:</strong> VOICEVOX: ずんだもん 効果音ラボ: <a href="https://soundeffect-lab.info/" target="_blank" rel="noopener">https://soundeffect-lab.info/</a></p>
|
||||
<p><strong>アリ素材:</strong> いらすとや: <a href="https://www.irasutoya.com/" target="_blank" rel="noopener">https://www.irasutoya.com/</a></p>
|
||||
</div>
|
||||
<div class="field-dialog-actions credits-actions">
|
||||
<button id="creditsCloseBtn" class="btn" type="button">閉じる</button>
|
||||
|
|
@ -149,50 +171,28 @@
|
|||
<div id="ecologyDialog" class="field-dialog hidden" role="dialog" aria-modal="true" aria-labelledby="ecologyDialogTitle">
|
||||
<div class="field-dialog-panel ecology-dialog-panel">
|
||||
<h2 id="ecologyDialogTitle">たりないの生態</h2>
|
||||
<div class="ecology-grid">
|
||||
<section class="ecology-card">
|
||||
<img data-src="assets/sprites/tarinai_05_drool.png" loading="lazy" decoding="async" alt="">
|
||||
<div><strong>食べる</strong><p>お腹がすくと食べ物や草を探します。食べられない時間が長いと弱っていきます。</p></div>
|
||||
</section>
|
||||
<section class="ecology-card">
|
||||
<img data-src="assets/sprites/tarinai_07_sleep.png" loading="lazy" decoding="async" alt="">
|
||||
<div><strong>眠る</strong><p>疲れるとその場や寝床で眠ります。休めると元気を取り戻します。</p></div>
|
||||
</section>
|
||||
<section class="ecology-card">
|
||||
<img data-src="assets/sprites/tarinai_21_normal_happy.png" loading="lazy" decoding="async" alt="">
|
||||
<div><strong>なつく</strong><p>気分がよい個体はカーソルや仲間に寄ってきます。やさしく構うと落ち着きやすくなります。</p></div>
|
||||
</section>
|
||||
<section class="ecology-card">
|
||||
<img data-src="assets/sprites/tarinai_24_stress_dizzy.png" loading="lazy" decoding="async" alt="">
|
||||
<div><strong>関係が変わる</strong><p>近くで過ごすうちに仲良しや苦手が生まれます。強いストレスは行動や表情に出ます。</p></div>
|
||||
</section>
|
||||
<section class="ecology-card">
|
||||
<img data-src="assets/sprites/tarinai_27_birth_ritual.png" loading="lazy" decoding="async" alt="">
|
||||
<div><strong>増える</strong><p>落ち着いた相手どうしから新しい個体が生まれ、家系図に親子関係が残ります。</p></div>
|
||||
</section>
|
||||
<section class="ecology-card">
|
||||
<img data-src="assets/sprites/tarinai_18_fear.png" loading="lazy" decoding="async" alt="">
|
||||
<div><strong>環境の影響</strong><p>雨や食べ物不足、つつき、喧嘩などで暮らしぶりが変わります。庭の配置も大切です。</p></div>
|
||||
</section>
|
||||
</div>
|
||||
<div id="ecologyGrid" class="ecology-grid"></div>
|
||||
<div class="field-dialog-actions">
|
||||
<button id="ecologyCloseBtn" class="btn" type="button">閉じる</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="js/data.js?v=9.0.0"></script>
|
||||
<script src="js/math.js?v=9.0.0"></script>
|
||||
<script src="js/assets.js?v=9.0.0"></script>
|
||||
<script src="js/audio.js?v=9.0.0"></script>
|
||||
<script src="js/render.js?v=9.0.0"></script>
|
||||
<script src="js/sim_core.js?v=9.0.0"></script>
|
||||
<script src="js/items.js?v=9.0.0"></script>
|
||||
<script src="js/tarinai.js?v=9.0.0"></script>
|
||||
<script src="js/world.js?v=9.0.0"></script>
|
||||
<script src="js/ui.js?v=9.0.0"></script>
|
||||
<script src="js/ui_charts.js?v=9.0.0"></script>
|
||||
<script src="js/ui_family.js?v=9.0.0"></script>
|
||||
<script src="js/main.js?v=9.0.0"></script>
|
||||
<script src="js/data.js?v=15.9.2" defer></script>
|
||||
<script src="js/math.js?v=15.9.2" defer></script>
|
||||
<script src="js/assets.js?v=15.9.2" defer></script>
|
||||
<script src="js/audio.js?v=15.9.2" defer></script>
|
||||
<script src="js/render.js?v=15.9.2" defer></script>
|
||||
<script src="js/sim_core.js?v=15.9.2" defer></script>
|
||||
<script src="js/items.js?v=15.9.2" defer></script>
|
||||
<script src="js/ants.js?v=15.9.2" defer></script>
|
||||
<script src="js/health.js?v=15.9.2" defer></script>
|
||||
<script src="js/tarinai.js?v=15.9.2" defer></script>
|
||||
<script src="js/world.js?v=15.9.2" defer></script>
|
||||
<script src="js/text_catalog.js?v=15.9.2" defer></script>
|
||||
<script src="js/ui.js?v=15.9.2" defer></script>
|
||||
<script src="js/ui_charts.js?v=15.9.2" defer></script>
|
||||
<script src="js/ui_family.js?v=15.9.2" defer></script>
|
||||
<script src="js/main.js?v=15.9.2" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
406
js/ants.js
Normal file
|
|
@ -0,0 +1,406 @@
|
|||
"use strict";
|
||||
|
||||
const ANT_NEST_START_COUNT = 6;
|
||||
const ANT_NEST_MAX_COUNT = 10;
|
||||
const ANT_NEST_MAX_OUTSIDE = 3;
|
||||
const ANT_WORKER_HP = 32;
|
||||
const ANT_QUEEN_HP = 180;
|
||||
const ANT_WORKER_RADIUS = 4.2;
|
||||
const ANT_QUEEN_RADIUS = 9.5;
|
||||
const ANT_WORKER_SEARCH_SPEED = 8.625;
|
||||
const ANT_WORKER_RETURN_SPEED = 12.0;
|
||||
const ANT_WORKER_RETURN_WEAK_SPEED = 6.0;
|
||||
const ANT_WORKER_DRAG_SPEED = 4.125;
|
||||
const ANT_QUEEN_SPEED = 9.0;
|
||||
|
||||
function clampAntHp(v) {
|
||||
return clamp(Number(v) || 0, 0, ANT_WORKER_HP);
|
||||
}
|
||||
|
||||
function buildAntWorkerPool(count = ANT_NEST_START_COUNT) {
|
||||
const n = clamp(Math.round(Number(count) || 0), 0, ANT_NEST_MAX_COUNT);
|
||||
return Array.from({ length: n }, () => ANT_WORKER_HP);
|
||||
}
|
||||
|
||||
function sanitizeAntWorkerPool(list, fallbackCount = 0) {
|
||||
if (!Array.isArray(list)) return buildAntWorkerPool(fallbackCount);
|
||||
const out = list.map(v => clampAntHp(v)).filter(v => v > 0.01);
|
||||
return out.slice(0, ANT_NEST_MAX_COUNT);
|
||||
}
|
||||
|
||||
function ensureAntNestWorkerPool(nest) {
|
||||
if (!nest || nest.type !== "ant_nest") return [];
|
||||
if (!Array.isArray(nest.antWorkers)) {
|
||||
nest.antWorkers = buildAntWorkerPool(nest.antCount ?? ANT_NEST_START_COUNT);
|
||||
} else {
|
||||
nest.antWorkers = sanitizeAntWorkerPool(nest.antWorkers, nest.antCount ?? nest.antWorkers.length);
|
||||
}
|
||||
return nest.antWorkers;
|
||||
}
|
||||
|
||||
function syncAntNestCount(worldRef, nest) {
|
||||
if (!nest || nest.type !== "ant_nest") return 0;
|
||||
ensureAntNestWorkerPool(nest);
|
||||
const outside = (worldRef?.ants || []).filter(a => a && !a.dead && a.kind === "worker" && a.homeId === nest.id).length;
|
||||
nest.antCount = clamp((nest.antWorkers?.length || 0) + outside, 0, ANT_NEST_MAX_COUNT);
|
||||
return nest.antCount;
|
||||
}
|
||||
|
||||
class AntActor {
|
||||
constructor(worldRef, opts = {}) {
|
||||
this.world = worldRef;
|
||||
this.id = opts.id || (crypto.randomUUID ? crypto.randomUUID() : `ant-${Date.now()}-${Math.random()}`);
|
||||
this.kind = opts.kind || "worker";
|
||||
this.homeId = opts.homeId || "";
|
||||
this.targetId = opts.targetId || "";
|
||||
this.targetToken = opts.targetToken || 0;
|
||||
this.targetRef = opts.targetRef || null;
|
||||
this.x = Number.isFinite(opts.x) ? opts.x : 0;
|
||||
this.y = Number.isFinite(opts.y) ? opts.y : 0;
|
||||
this.vx = opts.vx || 0;
|
||||
this.vy = opts.vy || 0;
|
||||
this.r = opts.r || (this.kind === "queen" ? ANT_QUEEN_RADIUS : ANT_WORKER_RADIUS);
|
||||
this.maxHp = Number.isFinite(opts.maxHp) ? opts.maxHp : (this.kind === "queen" ? ANT_QUEEN_HP : ANT_WORKER_HP);
|
||||
this.hp = Number.isFinite(opts.hp) ? opts.hp : this.maxHp;
|
||||
this.hpBarTimer = Number.isFinite(opts.hpBarTimer) ? opts.hpBarTimer : 0;
|
||||
this.state = opts.state || (this.kind === "queen" ? "founding" : "search");
|
||||
this.age = opts.age || 0;
|
||||
this.seed = opts.seed || Math.random() * 1000;
|
||||
this.wanderAngle = opts.wanderAngle || rand(0, Math.PI * 2);
|
||||
this.nextTurn = opts.nextTurn || rand(0.4, 1.6);
|
||||
this.returnTimer = opts.returnTimer || 0;
|
||||
this.foundingX = opts.foundingX;
|
||||
this.foundingY = opts.foundingY;
|
||||
this.foundingDelayUntil = Number.isFinite(opts.foundingDelayUntil) ? opts.foundingDelayUntil : -Infinity;
|
||||
this.carryLogAt = opts.carryLogAt || -999;
|
||||
this.foundingAttempts = opts.foundingAttempts || 0;
|
||||
this.dead = Boolean(opts.dead);
|
||||
}
|
||||
|
||||
homeNest() {
|
||||
return (this.world?.items || []).find(it => it && !it.dead && it.id === this.homeId && it.type === "ant_nest") || null;
|
||||
}
|
||||
|
||||
targetTarinai() {
|
||||
if (this.targetRef && !this.targetRef.dead && this.targetRef.id === this.targetId && this.targetRef.liveToken === this.targetToken) return this.targetRef;
|
||||
const live = this.world?.liveTarinaiById?.(this.targetId, this.targetToken) || null;
|
||||
this.targetRef = live;
|
||||
return live;
|
||||
}
|
||||
|
||||
activeHaulersForTarget() {
|
||||
return (this.world?.ants || []).filter(a => a && !a.dead && a.kind === "worker" && a.state === "drag" && a.targetId && a.targetId === this.targetId && a.targetToken === this.targetToken);
|
||||
}
|
||||
|
||||
isLeadHauler() {
|
||||
const haulers = this.activeHaulersForTarget();
|
||||
if (!haulers.length) return true;
|
||||
let lead = haulers[0];
|
||||
for (const a of haulers) if (String(a.id) < String(lead.id)) lead = a;
|
||||
return lead === this;
|
||||
}
|
||||
|
||||
chooseTarget(maxDist = 56) {
|
||||
let best = null;
|
||||
let bestScore = Infinity;
|
||||
for (const t of this.world?.tarinai || []) {
|
||||
if (!t || t.dead || t.insideNestBoxId || t.isZunchiSlave) continue;
|
||||
const d = distXY(this.x, this.y, t.x, t.y);
|
||||
if (d > maxDist) continue;
|
||||
if ((t.antDraggedTimer || 0) > 0 && (t.antDraggedBy || "") && t.antDraggedBy !== this.id) {
|
||||
const haulers = (this.world?.ants || []).filter(a => a && !a.dead && a.kind === "worker" && a.state === "drag" && a.targetId === t.id && a.targetToken === t.liveToken).length;
|
||||
if (haulers >= ANT_NEST_MAX_OUTSIDE) continue;
|
||||
}
|
||||
const weak = clamp((100 - (t.energy || 0)) / 100, 0, 1);
|
||||
const score = d - weak * 18 + stableUnit(`${this.id}:${t.id}`, "ant-prey") * 6;
|
||||
if (score < bestScore) {
|
||||
best = t;
|
||||
bestScore = score;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
update(dt) {
|
||||
if (this.dead) return;
|
||||
const hpBefore = this.hp;
|
||||
this.age += dt;
|
||||
if (this.kind === "queen") this.updateQueen(dt);
|
||||
else this.updateWorker(dt);
|
||||
if (Number.isFinite(hpBefore) && this.hp < hpBefore - 0.01) this.hpBarTimer = Math.max(this.hpBarTimer || 0, 1.25);
|
||||
this.hpBarTimer = Math.max(0, (this.hpBarTimer || 0) - dt);
|
||||
}
|
||||
|
||||
updateWorker(dt) {
|
||||
const home = this.homeNest();
|
||||
if (!home) {
|
||||
this.dead = true;
|
||||
return;
|
||||
}
|
||||
if (this.hp <= 0) {
|
||||
this.dieAsCorpse(home);
|
||||
return;
|
||||
}
|
||||
if (this.state === "drag") return this.updateDrag(dt, home);
|
||||
if (this.state === "return") return this.updateReturn(dt, home);
|
||||
this.updateSearch(dt, home);
|
||||
}
|
||||
|
||||
updateSearch(dt, home) {
|
||||
const target = this.chooseTarget(52);
|
||||
if (target) {
|
||||
this.state = "drag";
|
||||
this.targetId = target.id;
|
||||
this.targetToken = target.liveToken || 0;
|
||||
this.targetRef = target;
|
||||
this.carryLogAt = this.world?.time || 0;
|
||||
target.antDraggedTimer = Math.max(target.antDraggedTimer || 0, 0.45);
|
||||
target.antDraggedBy = this.id;
|
||||
target.fearTimer = Math.max(target.fearTimer || 0, 0.55);
|
||||
target.thought = "\u30a2\u30ea\u306b\u904b\u3070\u308c\u3066\u3044\u308b";
|
||||
this.world.drawListDirty = true;
|
||||
return;
|
||||
}
|
||||
this.nextTurn -= dt;
|
||||
if (this.nextTurn <= 0) {
|
||||
const toHome = Math.atan2(home.y - this.y, home.x - this.x);
|
||||
const far = distXY(this.x, this.y, home.x, home.y);
|
||||
const bias = far > 220 ? 0.58 : 0.18;
|
||||
this.wanderAngle = this.wanderAngle * (1 - bias) + toHome * bias + rand(-1.2, 1.2);
|
||||
this.nextTurn = rand(0.7, 1.9);
|
||||
}
|
||||
const hpFactor = clamp(this.hp / ANT_WORKER_HP, 0.45, 1.0);
|
||||
const speed = (ANT_WORKER_SEARCH_SPEED + Math.sin(this.age * 2.4 + this.seed) * 0.75) * hpFactor;
|
||||
this.vx = Math.cos(this.wanderAngle) * speed;
|
||||
this.vy = Math.sin(this.wanderAngle) * speed * 0.78;
|
||||
this.x = clamp(this.x + this.vx * dt, CONFIG.worldPadding, this.world.w - CONFIG.worldPadding);
|
||||
this.y = clamp(this.y + this.vy * dt, CONFIG.worldPadding, this.world.h - CONFIG.worldPadding);
|
||||
if (distXY(this.x, this.y, home.x, home.y) > 220) this.state = "return";
|
||||
}
|
||||
|
||||
returnToNest(home) {
|
||||
if (!home || home.dead || home.type !== "ant_nest") {
|
||||
this.dead = true;
|
||||
return;
|
||||
}
|
||||
ensureAntNestWorkerPool(home).push(clampAntHp(this.hp));
|
||||
syncAntNestCount(this.world, home);
|
||||
this.dead = true;
|
||||
this.world.drawListDirty = true;
|
||||
}
|
||||
|
||||
updateReturn(dt, home) {
|
||||
const d = distXY(this.x, this.y, home.x, home.y) || 1;
|
||||
if (d < Math.max(16, home.r * 0.66)) {
|
||||
this.returnToNest(home);
|
||||
return;
|
||||
}
|
||||
const speed = this.hp <= 8 ? ANT_WORKER_RETURN_WEAK_SPEED : ANT_WORKER_RETURN_SPEED;
|
||||
this.x += (home.x - this.x) / d * speed * dt;
|
||||
this.y += (home.y - this.y) / d * speed * dt;
|
||||
this.vx = (home.x - this.x) / d * speed;
|
||||
this.vy = (home.y - this.y) / d * speed;
|
||||
}
|
||||
|
||||
updateDrag(dt, home) {
|
||||
const target = this.targetTarinai();
|
||||
if (!target || target.dead || target.insideNestBoxId) {
|
||||
this.targetId = "";
|
||||
this.targetToken = 0;
|
||||
this.targetRef = null;
|
||||
this.state = "return";
|
||||
return;
|
||||
}
|
||||
const haulers = Math.max(1, this.activeHaulersForTarget().length);
|
||||
const leadHauler = this.isLeadHauler();
|
||||
target.antDraggedTimer = Math.max(target.antDraggedTimer || 0, 0.35);
|
||||
target.antDraggedBy = this.id;
|
||||
target.state = "panic";
|
||||
target.sleeping = false;
|
||||
target.target = null;
|
||||
target.fearTimer = Math.max(target.fearTimer || 0, 0.36);
|
||||
target.thought = "\u30a2\u30ea\u306b\u5de3\u3078\u904b\u3070\u308c\u3066\u3044\u308b";
|
||||
const dHome = distXY(target.x, target.y, home.x, home.y) || 1;
|
||||
if (dHome < Math.max(18, home.r * 0.66)) {
|
||||
this.world.completeAntHaul?.(home, target);
|
||||
this.targetId = "";
|
||||
this.targetToken = 0;
|
||||
this.targetRef = null;
|
||||
this.state = "return";
|
||||
return;
|
||||
}
|
||||
const hpFactor = clamp(this.hp / ANT_WORKER_HP, 0.45, 1.0);
|
||||
const speed = ANT_WORKER_DRAG_SPEED * hpFactor;
|
||||
const ux = (home.x - target.x) / dHome;
|
||||
const uy = (home.y - target.y) / dHome;
|
||||
if (leadHauler) {
|
||||
target.x = clamp(target.x + ux * speed * dt, CONFIG.worldPadding, this.world.w - CONFIG.worldPadding);
|
||||
target.y = clamp(target.y + uy * speed * dt, CONFIG.worldPadding, this.world.h - CONFIG.worldPadding);
|
||||
target.energy = clamp((target.energy || 0) - dt * 0.028, 0, 100);
|
||||
}
|
||||
const offsetAngle = this.seed + this.age * 2.0 + (haulers > 1 ? this.activeHaulersForTarget().findIndex(a => a === this) * 1.7 : 0);
|
||||
const ring = target.radius * (0.64 + 0.06 * Math.min(haulers, 3));
|
||||
this.x = target.x - ux * ring + Math.cos(offsetAngle) * target.radius * 0.24;
|
||||
this.y = target.y - uy * ring * 0.72 + Math.sin(offsetAngle) * target.radius * 0.18;
|
||||
this.vx = ux * speed;
|
||||
this.vy = uy * speed;
|
||||
|
||||
const aggression = Math.max(0, target.currentPersonality?.aggression || 0);
|
||||
const aggressiveAttack = target.shouldApplyPersonalityBehavior?.("aggression", 1) || aggression >= 0.5 || target.state === "ant_attack";
|
||||
const struggle = aggressiveAttack
|
||||
? (1.25 + aggression * 1.6 + clamp((target.energy || 0) / 100, 0, 1) * 0.35) / Math.max(1, haulers * 0.9)
|
||||
: (0.42 + clamp((target.energy || 0) / 100, 0, 1) * 0.18) / Math.max(1, haulers);
|
||||
this.hp -= dt * struggle;
|
||||
|
||||
if ((this.world.time || 0) - (this.carryLogAt || -999) > 8 && Math.random() < dt * 0.04) {
|
||||
this.carryLogAt = this.world.time || 0;
|
||||
this.world.log(`${target.name}\u304c\u30a2\u30ea\u306b\u5f15\u3063\u5f35\u3089\u308c\u3066\u3044\u308b\u3002`, "event", { participants: [target] });
|
||||
}
|
||||
if (this.hp <= 0) this.dieAsCorpse(home);
|
||||
}
|
||||
|
||||
dieAsCorpse(home = null) {
|
||||
if (this.dead) return;
|
||||
this.dead = true;
|
||||
const corpse = new Item("ant_corpse", this.x, this.y);
|
||||
corpse.amount = 24;
|
||||
corpse.workerSprite = this.workerSpriteId();
|
||||
this.world.items.push(corpse);
|
||||
this.world.effects?.push(new Effect("zunchi_miasma", this.x, this.y - 4, { size: 10, life: 0.44, color: "rgba(82,61,42,0.28)" }));
|
||||
if (home) syncAntNestCount(this.world, home);
|
||||
this.world.updateItemCounts?.();
|
||||
this.world.drawListDirty = true;
|
||||
}
|
||||
|
||||
buildNestAtFoundingSpot(force = false) {
|
||||
if (!this.world) return null;
|
||||
const base = new Item("ant_nest", this.foundingX, this.foundingY);
|
||||
let spot = null;
|
||||
if (this.world.findPlacementSpot) {
|
||||
spot = this.world.findPlacementSpot(base, { allowOriginal: true, maxRadius: force ? 520 : 220, attempts: force ? 160 : 72 });
|
||||
}
|
||||
if (!spot && force) {
|
||||
spot = this.world.placementClampPointFor ? this.world.placementClampPointFor(base, this.foundingX, this.foundingY) : { x: this.foundingX, y: this.foundingY };
|
||||
}
|
||||
if (!spot) return null;
|
||||
base.x = spot.x;
|
||||
base.y = spot.y;
|
||||
base.antCount = ANT_NEST_START_COUNT;
|
||||
base.antWorkers = buildAntWorkerPool(ANT_NEST_START_COUNT);
|
||||
if (!force && this.world.placementBlocked?.(base)) return null;
|
||||
this.world.items.push(base);
|
||||
this.world.itemCounts[base.type] = (this.world.itemCounts[base.type] || 0) + 1;
|
||||
this.world.rebuildSpatial?.();
|
||||
this.world.drawListDirty = true;
|
||||
this.world.log("\u5973\u738b\u30a2\u30ea\u304c\u65b0\u3057\u3044\u30a2\u30ea\u306e\u5de3\u3092\u4f5c\u3063\u305f\u3002", "event");
|
||||
return base;
|
||||
}
|
||||
|
||||
updateQueen(dt) {
|
||||
if (!Number.isFinite(this.foundingX) || !Number.isFinite(this.foundingY)) {
|
||||
const p = this.world?.findAntNestFoundingSpot?.(this) || { x: this.x + rand(-140, 140), y: this.y + rand(-100, 100) };
|
||||
this.foundingX = p.x;
|
||||
this.foundingY = p.y;
|
||||
}
|
||||
let d = distXY(this.x, this.y, this.foundingX, this.foundingY) || 1;
|
||||
if (d < 20) {
|
||||
if ((this.world?.time || 0) < (this.foundingDelayUntil || -Infinity)) {
|
||||
this.vx = 0;
|
||||
this.vy = 0;
|
||||
return;
|
||||
}
|
||||
this.foundingAttempts += 1;
|
||||
const placed = this.buildNestAtFoundingSpot(this.foundingAttempts >= 8);
|
||||
if (placed) {
|
||||
this.dead = true;
|
||||
return;
|
||||
}
|
||||
const retrySpot = this.world?.findAntNestFoundingSpot?.({ x: this.x, y: this.y }) || this.world?.findAntNestFoundingSpot?.(this.homeNest?.() || this);
|
||||
if (retrySpot) {
|
||||
this.foundingX = retrySpot.x;
|
||||
this.foundingY = retrySpot.y;
|
||||
} else {
|
||||
this.wanderAngle += rand(-1.4, 1.4);
|
||||
this.foundingX = clamp(this.x + Math.cos(this.wanderAngle) * 90, 56, this.world.w - 56);
|
||||
this.foundingY = clamp(this.y + Math.sin(this.wanderAngle) * 70, 56, this.world.h - 56);
|
||||
}
|
||||
d = distXY(this.x, this.y, this.foundingX, this.foundingY) || 1;
|
||||
}
|
||||
const speed = ANT_QUEEN_SPEED;
|
||||
this.x += (this.foundingX - this.x) / d * speed * dt;
|
||||
this.y += (this.foundingY - this.y) / d * speed * dt;
|
||||
this.vx = (this.foundingX - this.x) / d * speed;
|
||||
this.vy = (this.foundingY - this.y) / d * speed;
|
||||
}
|
||||
|
||||
workerSpriteId() {
|
||||
return "ant_worker";
|
||||
}
|
||||
|
||||
spriteId() {
|
||||
return this.kind === "queen" ? "ant_queen" : this.workerSpriteId();
|
||||
}
|
||||
|
||||
draw(ctx, time = 0, lighting = null) {
|
||||
if (this.dead) return;
|
||||
const id = this.spriteId();
|
||||
const img = typeof getRenderableImage === "function" ? getRenderableImage(id, id) : images.get(id);
|
||||
const lightState = lighting || getLightingState(this.world);
|
||||
ctx.save();
|
||||
const shadow = projectedShadowParams(lightState, this.kind === "queen" ? 0.58 : 0.26);
|
||||
drawProjectedShadow(ctx, this.x, this.y + this.r * 0.62, this.r * 1.5, this.r * 0.30, { ...shadow, alpha: shadow.alpha * 0.72 });
|
||||
ctx.translate(this.x, this.y);
|
||||
const angle = this.kind === "queen"
|
||||
? Math.atan2((this.foundingY ?? this.y) - this.y, (this.foundingX ?? this.x) - this.x)
|
||||
: Math.atan2(this.vy || 0, this.vx || 1);
|
||||
ctx.rotate(angle);
|
||||
if (img) {
|
||||
const w = this.kind === "queen" ? this.r * 4.1 : this.r * 3.8;
|
||||
const metrics = getImageMetrics(id);
|
||||
const h = w * (metrics?.ratio || 0.52);
|
||||
ctx.globalAlpha = 0.96;
|
||||
ctx.drawImage(img, -w * 0.5, -h * 0.58, w, h);
|
||||
} else {
|
||||
ctx.fillStyle = "rgba(44,44,44,0.92)";
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(0, 0, this.r * 1.3, this.r * 0.72, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.beginPath();
|
||||
ctx.arc(this.r * 1.15, -this.r * 0.05, this.r * 0.46, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.restore();
|
||||
this.drawHealthBar(ctx);
|
||||
}
|
||||
|
||||
drawHealthBar(ctx) {
|
||||
if ((this.hpBarTimer || 0) <= 0) return;
|
||||
const maxHp = Math.max(1, this.maxHp || (this.kind === "queen" ? ANT_QUEEN_HP : ANT_WORKER_HP));
|
||||
const pct = clamp((this.hp || 0) / maxHp, 0, 1);
|
||||
const bw = this.kind === "queen" ? 28 : 18;
|
||||
const bh = this.kind === "queen" ? 4 : 3;
|
||||
const y = this.y - this.r * (this.kind === "queen" ? 2.0 : 2.6);
|
||||
ctx.save();
|
||||
ctx.globalAlpha = clamp((this.hpBarTimer || 0) / 0.65, 0.35, 1);
|
||||
ctx.fillStyle = "rgba(255,252,242,0.86)";
|
||||
roundedRect(ctx, this.x - bw / 2 - 1.5, y - 1.5, bw + 3, bh + 3, 4);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = "rgba(63,50,42,0.30)";
|
||||
roundedRect(ctx, this.x - bw / 2, y, bw, bh, 3);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = pct > 0.45 ? "rgba(118,190,80,0.88)" : (pct > 0.20 ? "rgba(232,172,68,0.90)" : "rgba(218,82,70,0.92)");
|
||||
roundedRect(ctx, this.x - bw / 2, y, bw * pct, bh, 3);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
window.AntActor = AntActor;
|
||||
window.ANT_NEST_START_COUNT = ANT_NEST_START_COUNT;
|
||||
window.ANT_NEST_MAX_COUNT = ANT_NEST_MAX_COUNT;
|
||||
window.ANT_NEST_MAX_OUTSIDE = ANT_NEST_MAX_OUTSIDE;
|
||||
window.ANT_WORKER_HP = ANT_WORKER_HP;
|
||||
window.ANT_QUEEN_HP = ANT_QUEEN_HP;
|
||||
window.buildAntWorkerPool = buildAntWorkerPool;
|
||||
window.ensureAntNestWorkerPool = ensureAntNestWorkerPool;
|
||||
window.syncAntNestCount = syncAntNestCount;
|
||||
20
js/assets.js
|
|
@ -8,8 +8,8 @@ const imageAssetIndex = new Map();
|
|||
const scaledSpriteCache = new Map();
|
||||
const stainOverlayCache = new Map();
|
||||
|
||||
const INITIAL_IMAGE_IDS = new Set(["smile", "zunchi", "zunchi_02"]);
|
||||
const EARLY_IMAGE_IDS = new Set(["smile", "angry", "teary", "jito", "drool", "cry", "sleep", "pokan", "normal_smirk", "normal_tongue", "normal_happy", "weak", "hurt", "hurt2", "zunchi", "zunchi_02"]);
|
||||
const INITIAL_IMAGE_IDS = new Set(["smile", "zunchi", "zunchi_02", "genkotsu"]);
|
||||
const EARLY_IMAGE_IDS = new Set(["smile", "angry", "teary", "jito", "drool", "cry", "sleep", "pokan", "normal_smirk", "normal_tongue", "normal_happy", "weak", "hurt", "hurt2", "zunchi", "zunchi_02", "genkotsu"]);
|
||||
|
||||
function trimCanvasCache(cache, limit = 420) {
|
||||
while (cache.size > limit) {
|
||||
|
|
@ -160,14 +160,18 @@ function getRenderableImage(id, fallbackId = "smile") {
|
|||
return null;
|
||||
}
|
||||
|
||||
function loadImages(ids = null) {
|
||||
function loadImages(ids = null, onProgress = null) {
|
||||
setupAssetIndex();
|
||||
const requested = ids ? new Set(ids) : INITIAL_IMAGE_IDS;
|
||||
const tasks = [];
|
||||
for (const id of requested) {
|
||||
const asset = imageAssetIndex.get(id);
|
||||
if (asset) tasks.push(loadImageAsset(asset, "high"));
|
||||
}
|
||||
const assets = [...requested].map(id => imageAssetIndex.get(id)).filter(Boolean);
|
||||
const total = Math.max(assets.length, 1);
|
||||
let done = 0;
|
||||
if (typeof onProgress === "function") onProgress(done, total, null);
|
||||
const tasks = assets.map(asset => loadImageAsset(asset, "high").then((img) => {
|
||||
done += 1;
|
||||
if (typeof onProgress === "function") onProgress(done, total, asset);
|
||||
return img;
|
||||
}));
|
||||
return Promise.all(tasks).then(() => undefined);
|
||||
}
|
||||
|
||||
|
|
|
|||
75
js/data.js
|
|
@ -32,7 +32,75 @@ const SPRITES = [
|
|||
const DECOR_ASSETS = [
|
||||
{ id: "zunchi", path: "assets/objects/zunchi.png" },
|
||||
{ id: "zunchi_02", path: "assets/objects/zunchi_02.png" },
|
||||
{ id: "ant_queen", path: "assets/objects/ant_queen.png" },
|
||||
{ id: "ant_worker", path: "assets/objects/ant_worker.png" },
|
||||
{ id: "genkotsu", path: "assets/objects/genkotsu.png" },
|
||||
];
|
||||
|
||||
const TOOL_DEFINITIONS = Object.freeze({
|
||||
observe: { id: "observe", label: "\u89b3\u5bdf", placeable: false, scalable: false, icon: "assets/ui/tool_observe.png", 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" },
|
||||
delete: { id: "delete", label: "\u524a\u9664", placeable: false, scalable: false, icon: "assets/ui/tool_delete.png", tooltip: "\u7f6e\u3044\u305f\u9053\u5177\u3084\u6c5a\u308c\u3092\u6d88\u3059\u3002" },
|
||||
poke: { id: "poke", label: "\u3064\u3064\u304f", placeable: false, scalable: false, iconText: "\u{1F448}", tooltip: "\u305f\u308a\u306a\u3044\u3092\u3064\u3064\u304f\u3002\u30dc\u30fc\u30eb\u3082\u3064\u3064\u3044\u3066\u8ee2\u304c\u305b\u308b\u3002" },
|
||||
pinch: { id: "pinch", label: "\u3064\u307e\u3080", placeable: false, scalable: false, iconText: "\u{1F90F}", tooltip: "\u305f\u308a\u306a\u3044\u3084\u7269\u3092\u3064\u307e\u3093\u3067\u79fb\u52d5\u3055\u305b\u308b\u3002" },
|
||||
new: { id: "new", label: "\u8ffd\u52a0", placeable: false, scalable: false, icon: "assets/ui/tool_new.png", tooltip: "\u753b\u9762\u5916\u304b\u3089\u65b0\u3057\u3044\u305f\u308a\u306a\u3044\u3092\u547c\u3076\u3002" },
|
||||
water_hose: { id: "water_hose", label: "\u6d17\u6d44", placeable: false, scalable: false, iconText: "\u{1F6BF}", tooltip: "\u30c9\u30e9\u30c3\u30b0\u3067\u6c5a\u308c\u3092\u304d\u308c\u3044\u306b\u3059\u308b\u3002" },
|
||||
zunchi: { id: "zunchi", itemType: "zunchi", label: "\u305a\u3093\u3061", placeable: true, scalable: true, radius: 14, amount: 240, icon: "assets/ui/tool_zunchi.png", tooltip: "\u305f\u308a\u306a\u3044\u306e\u305a\u3093\u3061\u3002\u75c5\u6c17\u3084\u8349\u306e\u80a5\u6599\u306b\u95a2\u308f\u308b\u3002" },
|
||||
sweet: { id: "sweet", itemType: "sweet", label: "\u305a\u3093\u3060\u9905", placeable: true, scalable: true, radius: 13, amount: 62, icon: "assets/ui/tool_sweet.png", tooltip: "\u305f\u308a\u306a\u3044\u306e\u5927\u597d\u7269\u3002\u4e00\u90e8\u306e\u75c5\u6c17\u3092\u6cbb\u305b\u308b\u3002" },
|
||||
love_mochi: { id: "love_mochi", itemType: "love_mochi", label: "\u3078\u3053\u9905", placeable: true, scalable: true, radius: 13, amount: 62, icon: "assets/ui/tool_love_mochi.png", tooltip: "\u98df\u5f8c\u3057\u3070\u3089\u304f\u7e41\u6b96\u884c\u52d5\u304c\u8d77\u304d\u3084\u3059\u304f\u306a\u308b\u3002" },
|
||||
fight_mochi: { id: "fight_mochi", itemType: "fight_mochi", label: "\u3051\u3093\u304b\u9905", placeable: true, scalable: true, radius: 13, amount: 62, icon: "assets/ui/tool_fight_mochi.png", tooltip: "\u98df\u5f8c\u3057\u3070\u3089\u304f\u3051\u3093\u304b\u304c\u8d77\u304d\u3084\u3059\u304f\u306a\u308b\u3002" },
|
||||
water_bowl: { id: "water_bowl", itemType: "water_bowl", label: "\u6c34\u306e\u76bf", placeable: true, scalable: false, radius: 20, amount: 999, icon: "assets/ui/tool_water_bowl.png", tooltip: "\u885b\u751f\u7684\u306a\u6c34\u304c\u5165\u3063\u305f\u76bf\u3002\u4e00\u90e8\u306e\u75c5\u6c17\u3092\u6cbb\u305b\u308b\u3002" },
|
||||
sleep_drug: { id: "sleep_drug", itemType: "sleep_drug", label: "\u306d\u3080\u308a\u85ac", placeable: true, scalable: true, radius: 13, amount: 58, icon: "assets/ui/tool_sleep_drug.png", tooltip: "\u98df\u3079\u308b\u3068\u306d\u3080\u308a\u75c5\u306b\u306a\u308b\u3002\u30c0\u30e1\u30fc\u30b8\u3067\u5b8c\u6cbb\u3059\u308b\u3002" },
|
||||
grass: { id: "grass", itemType: "grass", label: "\u8349", placeable: true, scalable: true, radius: 17, amount: 120, icon: "assets/ui/tool_grass.png", tooltip: "\u98df\u3079\u7269\u3002\u305f\u308a\u306a\u3044\u304c\u5b89\u5fc3\u3059\u308b\u3002" },
|
||||
stone: { id: "stone", itemType: "stone", label: "\u77f3", placeable: true, scalable: true, radius: 20, amount: 999, icon: "assets/ui/tool_stone.png", tooltip: "\u91cd\u3044\u77f3\u3002\u843d\u3068\u3059\u3068\u5371\u306a\u3044\u3002" },
|
||||
genkotsu: { id: "genkotsu", itemType: "genkotsu", label: "\u3052\u3093\u3053\u3064", placeable: true, scalable: false, radius: 72, amount: 100, icon: "assets/ui/tool_genkotsu.png", tooltip: "\u4e0a\u304b\u3089\u6b63\u7fa9\u3052\u3093\u3053\u3064\u3092\u843d\u3068\u3057\u3001\u30a2\u30ea\u3068\u305f\u308a\u306a\u3044\u306b\u30c0\u30e1\u30fc\u30b8\u3068\u5439\u3063\u98db\u3073\u3092\u4e0e\u3048\u308b\u3002" },
|
||||
bed: { id: "bed", itemType: "bed", label: "\u5e72\u8349\u5bdd\u5e8a", placeable: true, scalable: false, radius: 37, amount: 999, icon: "assets/ui/tool_bed.png", tooltip: "\u8fd1\u304f\u3067\u7720\u308b\u3002\u4f53\u529b\u304c\u56de\u5fa9\u3059\u308b\u3002" },
|
||||
nest_box: { id: "nest_box", itemType: "nest_box", label: "\u5de3\u7bb1", placeable: true, scalable: false, radius: 42, amount: 999, icon: "assets/ui/tool_nest_box.png", collisionShape: "nest_box_3x3", tooltip: "\u3044\u308b\u3060\u3051\u3067\u30b9\u30c8\u30ec\u30b9\u4f4e\u6e1b\u3002\u7720\u308b\u3068\u7761\u7720\u306e\u8cea\u304c\u4e0a\u304c\u308b\u3002" },
|
||||
ant_nest: { id: "ant_nest", itemType: "ant_nest", label: "\u30a2\u30ea\u306e\u5de3", placeable: true, scalable: false, radius: 17, amount: 999, icon: "assets/ui/tool_ant_nest.png", collisionShape: "circle", tooltip: "\u50cd\u304d\u30a2\u30ea\u304c\u305f\u308a\u306a\u3044\u3092\u63a2\u3057\u3001\u5de3\u3078\u904b\u3076\u3002" },
|
||||
ball: { id: "ball", itemType: "ball", label: "\u30dc\u30fc\u30eb", placeable: true, scalable: false, radius: 18, amount: 999, icon: "assets/ui/tool_ball.png", collisionShape: "circle", tooltip: "\u305f\u308a\u306a\u3044\u304c\u904a\u3076\u305f\u3081\u306e\u304a\u3082\u3061\u3083\u3002" },
|
||||
firecracker: { id: "firecracker", itemType: "firecracker", label: "\u7206\u7af9", placeable: true, scalable: true, radius: 15, amount: 999, icon: "assets/ui/tool_firecracker.png", tooltip: "\u7206\u767a\u3092\u8d77\u3053\u3059\u3002" },
|
||||
fence_v: { id: "fence_v", itemType: "fence_v", label: "\u7e26\u306e\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, icon: "assets/ui/tool_fence_v.png", collisionShape: "rect", tooltip: "\u305f\u308a\u306a\u3044\u3092\u901a\u305b\u3093\u307c\u3059\u308b\u3002" },
|
||||
fence_h: { id: "fence_h", itemType: "fence_h", label: "\u6a2a\u306e\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, icon: "assets/ui/tool_fence_h.png", collisionShape: "rect", tooltip: "\u305f\u308a\u306a\u3044\u3092\u901a\u305b\u3093\u307c\u3059\u308b\u3002" },
|
||||
water: { id: "water", itemType: "water", label: "\u6c34", placeable: false, scalable: false, radius: 12, amount: 64, simulationOnly: true },
|
||||
trace: { id: "trace", itemType: "trace", label: "\u8db3\u8de1", placeable: false, scalable: false, radius: 16, amount: 220, simulationOnly: true },
|
||||
splat: { id: "splat", itemType: "splat", label: "\u3057\u3076\u304d", placeable: false, scalable: false, radius: 26, amount: 260, simulationOnly: true },
|
||||
food: { id: "food", itemType: "food", label: "\u98df\u3079\u7269", placeable: false, scalable: false, radius: 14, amount: 85, simulationOnly: true },
|
||||
ant_corpse: { id: "ant_corpse", itemType: "ant_corpse", label: "\u30a2\u30ea\u306e\u6b7b\u9ab8", placeable: false, scalable: false, radius: 8, amount: 24, simulationOnly: true },
|
||||
});
|
||||
|
||||
function toolDefinition(id = "") {
|
||||
return TOOL_DEFINITIONS[id] || null;
|
||||
}
|
||||
|
||||
function toolLabel(id = "") {
|
||||
const def = toolDefinition(id);
|
||||
return def?.label || id || "";
|
||||
}
|
||||
|
||||
function toolItemType(id = "") {
|
||||
const def = toolDefinition(id);
|
||||
return def?.placeable ? (def.itemType || def.id) : null;
|
||||
}
|
||||
|
||||
function itemDefinition(type = "") {
|
||||
return Object.values(TOOL_DEFINITIONS).find(def => (def.itemType || def.id) === type) || null;
|
||||
}
|
||||
|
||||
function itemRadiusFor(type = "", fallback = 12) {
|
||||
return itemDefinition(type)?.radius ?? fallback;
|
||||
}
|
||||
|
||||
function itemAmountFor(type = "", fallback = 80) {
|
||||
return itemDefinition(type)?.amount ?? fallback;
|
||||
}
|
||||
|
||||
function scalableToolIds() {
|
||||
return Object.values(TOOL_DEFINITIONS).filter(def => def.scalable).map(def => def.id);
|
||||
}
|
||||
|
||||
function toolTipsFromDefinitions() {
|
||||
return Object.fromEntries(Object.entries(TOOL_DEFINITIONS).filter(([, def]) => def.tooltip).map(([id, def]) => [id, def.tooltip]));
|
||||
}
|
||||
|
||||
const ALPHA_BOUNDS = {
|
||||
smile: { x: 18, y: 18, w: 476, h: 432, imageW: 512, imageH: 468 },
|
||||
angry: { x: 16, y: 16, w: 480, h: 432, imageW: 512, imageH: 464 },
|
||||
|
|
@ -63,10 +131,12 @@ const ALPHA_BOUNDS = {
|
|||
zunchi_slave: { x: 17, y: 22, w: 478, h: 431, imageW: 512, imageH: 468 },
|
||||
zunchi: { x: 0, y: 0, w: 236, h: 178, imageW: 236, imageH: 178 },
|
||||
zunchi_02: { x: 8, y: 63, w: 220, h: 105, imageW: 236, imageH: 178 },
|
||||
ant_queen: { x: 0, y: 0, w: 300, h: 155, imageW: 300, imageH: 155 },
|
||||
ant_worker: { x: 0, y: 0, w: 140, h: 72, imageW: 140, imageH: 72 },
|
||||
genkotsu: { x: 8, y: 8, w: 220, h: 283, imageW: 236, imageH: 299 },
|
||||
};
|
||||
|
||||
const NEEDS = ["\u98df\u3079\u7269", "\u4ef2\u9593", "\u7720\u308a", "\u52c7\u6c17", "\u6c34", "\u9759\u3051\u3055", "\u3042\u305f\u305f\u304b\u3055", "\u610f\u5473", "\u7a7a\u9593", "\u81ea\u4fe1"];
|
||||
const NAMES = ["ta", "ri", "na", "i", "po", "yo", "nu", "ma", "fu", "he", "shi", "ro", "mu", "a"];
|
||||
|
||||
const CONFIG = {
|
||||
initialPopulation: 18,
|
||||
|
|
@ -78,7 +148,6 @@ const CONFIG = {
|
|||
traceLimit: 64,
|
||||
splatLimit: 48,
|
||||
effectLimit: 96,
|
||||
saveKey: "tarinai-colony-save-v38",
|
||||
worldPadding: 30,
|
||||
hungerPerMinute: 11.2,
|
||||
lonelinessPerMinute: 7.6,
|
||||
|
|
@ -107,5 +176,3 @@ const LIGHTING_TUNING = {
|
|||
goldenOverlayAlpha: 0.045,
|
||||
bloomWashAlpha: 0.040,
|
||||
};
|
||||
|
||||
window.TarinaiData = { SPRITES, DECOR_ASSETS, ALPHA_BOUNDS, NEEDS, NAMES, CONFIG, FIELD_TYPES, LIGHTING_TUNING };
|
||||
|
|
|
|||
157
js/health.js
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
"use strict";
|
||||
|
||||
const HEALTH = (() => {
|
||||
const MAJOR_DAMAGE_WINDOW = 10;
|
||||
const WEAKENED_SUFFIX = "\u3067\u8870\u5f31";
|
||||
const CAUSES = Object.freeze({
|
||||
fight: "\u55a7\u5629",
|
||||
hunger: "\u98e2\u9913",
|
||||
stress: "\u30b9\u30c8\u30ec\u30b9\u904e\u591a",
|
||||
poke: "\u3064\u3064\u304b\u308c\u3059\u304e\u305f",
|
||||
firecracker: "\u7206\u7af9",
|
||||
accident: "\u4e8b\u6545",
|
||||
collision: "\u885d\u7a81",
|
||||
outOfBounds: "\u4ed5\u69d8\u306b\u3088\u308a\u753b\u9762\u5916\u3067\u524a\u9664",
|
||||
lifespan: "\u5929\u5bff\u3092\u5168\u3046\u3057\u305f",
|
||||
sleepDisease: "\u306d\u3080\u308a\u75c5",
|
||||
explosionDisease: "\u7206\u767a\u75c5",
|
||||
fightDisease: "\u304d\u305a\u3064\u304d\u75c5",
|
||||
zunchiDisease: "\u305a\u3093\u3061\u75c5",
|
||||
antEaten: "\u30a2\u30ea\u306b\u98df\u3079\u3089\u308c\u305f",
|
||||
});
|
||||
const DISEASE_RE = /([^\u3001\u3002\s]+\u75c5)/;
|
||||
|
||||
function causeLabel(reason = "") {
|
||||
const text = String(reason || "");
|
||||
if (!text) return "";
|
||||
if (text.includes(WEAKENED_SUFFIX)) return text.replace(/\u3067\u8870\u5f31.*$/, "");
|
||||
if (/\u55a7\u5629|fight|headbutt/.test(text)) return CAUSES.fight;
|
||||
if (/\u7206\u7af9|firecracker|explosion/.test(text)) return CAUSES.firecracker;
|
||||
if (/\u4ed5\u69d8\u306b\u3088\u308a\u753b\u9762\u5916\u3067\u524a\u9664|\u753b\u9762\u5916\u3067\u524a\u9664|out.*bounds|offscreen/.test(text)) return CAUSES.outOfBounds;
|
||||
if (/\u885d\u7a81|collision/.test(text)) return CAUSES.collision;
|
||||
if (/\u3064\u3064\u304b\u308c|\u3064\u3064\u304d|poke/.test(text)) return CAUSES.poke;
|
||||
if (/\u306d\u3080\u308a\u75c5|sleep.*disease/.test(text)) return CAUSES.sleepDisease;
|
||||
if (/\u7206\u767a\u75c5|explosion.*disease/.test(text)) return CAUSES.explosionDisease;
|
||||
if (/\u3051\u3093\u304b\u75c5|\u304d\u305a\u3064\u304d\u75c5|fight.*disease/.test(text)) return CAUSES.fightDisease;
|
||||
if (/\u305a\u3093\u3061\u75c5|zunchi_sick|zunchi.*\u75c5/.test(text)) return CAUSES.zunchiDisease;
|
||||
if (/\u30a2\u30ea\u306b\u98df\u3079\u3089\u308c\u305f|eaten.*ant|ant.*eaten/.test(text)) return CAUSES.antEaten;
|
||||
if (/\u75c5/.test(text)) {
|
||||
const disease = text.match(DISEASE_RE);
|
||||
return disease ? disease[1] : CAUSES.zunchiDisease;
|
||||
}
|
||||
if (/\u98e2|\u7a7a\u8179|\u98df\u3079\u7269|hunger/.test(text)) return CAUSES.hunger;
|
||||
if (/\u30b9\u30c8\u30ec\u30b9|stress/.test(text)) return CAUSES.stress;
|
||||
if (/\u30dc\u30fc\u30eb|\u843d\u3061|\u843d\u4e0b|\u77f3|\u9053\u5177|\u4e8b\u6545|\u5f53\u305f|ball|drop|stone|accident/.test(text)) return CAUSES.accident;
|
||||
if (/\u5bff\u547d|\u5929\u5bff/.test(text)) return CAUSES.lifespan;
|
||||
return "";
|
||||
}
|
||||
|
||||
function recentDamageCause(t, maxAge = MAJOR_DAMAGE_WINDOW) {
|
||||
const now = t.world?.time || 0;
|
||||
if (t.lastDamageCause && now - (t.lastDamageAt || -999) <= maxAge) return t.lastDamageCause;
|
||||
return "";
|
||||
}
|
||||
|
||||
function rememberDamage(t, amount, reason = "") {
|
||||
const cause = causeLabel(reason) || CAUSES.accident;
|
||||
const now = t.world?.time || 0;
|
||||
t.lastDamageCause = cause;
|
||||
t.lastDamageAmount = Math.max(0, amount || 0);
|
||||
t.lastDamageAt = now;
|
||||
if (t.lastDamageAmount >= 10 || t.lastDamageAmount >= Math.max(6, (t.energy || 0) * 0.36)) {
|
||||
t.lastMajorDamageCause = cause;
|
||||
t.lastMajorDamageAmount = t.lastDamageAmount;
|
||||
t.lastMajorDamageAt = now;
|
||||
}
|
||||
return cause;
|
||||
}
|
||||
|
||||
function weakenedDeathReasonFor(cause = "") {
|
||||
const text = String(cause || "").trim();
|
||||
if (!text) return "";
|
||||
return text.includes(WEAKENED_SUFFIX) ? text : `${text}${WEAKENED_SUFFIX}`;
|
||||
}
|
||||
|
||||
function hasRecentMajorDamage(t) {
|
||||
const now = t.world?.time || 0;
|
||||
return Boolean(t.lastMajorDamageCause && now - (t.lastMajorDamageAt || -999) <= MAJOR_DAMAGE_WINDOW);
|
||||
}
|
||||
|
||||
function isFightingContext(t, recent) {
|
||||
return Boolean(t.fightTimer > 0.04 || t.defeatedTimer > 0.04 || t.defeatedById || t.fightWinnerId || recent === CAUSES.fight);
|
||||
}
|
||||
|
||||
function dominantDeathCause(t, reason = "", opts = {}) {
|
||||
const text = String(reason || "").trim();
|
||||
const direct = causeLabel(text);
|
||||
const recentMajor = hasRecentMajorDamage(t);
|
||||
const recent = recentDamageCause(t, 20);
|
||||
const fighting = isFightingContext(t, recent);
|
||||
if (recentMajor && t.lastMajorDamageCause === CAUSES.collision) return CAUSES.collision;
|
||||
if (recentMajor && (!direct || direct === t.lastMajorDamageCause || opts.fromDamage || t.energy <= 22 || t.mood <= 20 || t.stress >= 118)) {
|
||||
return weakenedDeathReasonFor(t.lastMajorDamageCause);
|
||||
}
|
||||
if (direct && direct !== CAUSES.stress) return direct;
|
||||
if (t.zunchiDisease && ((t.zunchiDiseaseSeverity || 0) >= 0.30 || /\u75c5|zunchi/.test(text))) return CAUSES.zunchiDisease;
|
||||
if (t.hunger >= 108 || (t.hunger >= 94 && t.energy <= 26) || /\u98e2|\u7a7a\u8179|hunger/.test(text)) return CAUSES.hunger;
|
||||
if (fighting && (t.energy <= 44 || t.stress >= 112 || t.mood <= 22 || opts.fromDamage)) return CAUSES.fight;
|
||||
if (recent && recent !== CAUSES.stress && (t.energy <= 34 || t.mood <= 20 || t.stress >= 118 || opts.fromDamage)) return recent;
|
||||
if (text.includes(CAUSES.lifespan) || text.includes("\u5bff\u547d")) return CAUSES.lifespan;
|
||||
if (direct) return direct;
|
||||
if (t.stress >= 118 || /\u30b9\u30c8\u30ec\u30b9|stress|\u6c17\u5206/.test(text)) return CAUSES.stress;
|
||||
if (t.energy <= 0.5) return CAUSES.accident;
|
||||
return "";
|
||||
}
|
||||
|
||||
function normalizeDeathReason(t, reason = "", opts = {}) {
|
||||
const text = String(reason || "").trim();
|
||||
if (text.includes(WEAKENED_SUFFIX)) return text;
|
||||
const cause = dominantDeathCause(t, text, opts) || CAUSES.accident;
|
||||
if (cause.includes(WEAKENED_SUFFIX)) return cause;
|
||||
const canWeaken = cause && !cause.endsWith("\u75c5") && cause !== CAUSES.hunger && cause !== CAUSES.stress && cause !== CAUSES.lifespan && cause !== CAUSES.outOfBounds && cause !== CAUSES.collision;
|
||||
if (canWeaken && (opts.weakened || (hasRecentMajorDamage(t) && cause === t.lastMajorDamageCause))) return weakenedDeathReasonFor(cause);
|
||||
return cause;
|
||||
}
|
||||
|
||||
function applyDamage(t, amount, reason = "") {
|
||||
const before = t.energy;
|
||||
const loss = Math.max(0, amount || 0);
|
||||
t.energy = clamp(t.energy - loss, 0, 100);
|
||||
const actualLoss = Math.max(0, before - t.energy);
|
||||
let cause = "";
|
||||
if (actualLoss > 0.01) {
|
||||
cause = rememberDamage(t, actualLoss, reason);
|
||||
if (cause === CAUSES.fight || /\u55a7\u5629|fight/.test(String(reason || ""))) t.recordBleedExposure();
|
||||
if (t.sleepDisease) t.recoverSleepDisease("\u30c0\u30e1\u30fc\u30b8\u3067\u306d\u3080\u308a\u75c5\u304c\u6cbb\u3063\u305f");
|
||||
if (t.wakeFromDamage) t.wakeFromDamage(cause || reason);
|
||||
if (t.maybeBecomeTimidAfterDamage) t.maybeBecomeTimidAfterDamage(cause || reason);
|
||||
t.showHpBar();
|
||||
}
|
||||
if (t.energy <= 0.5) {
|
||||
const majorNow = actualLoss >= Math.max(10, before * 0.42);
|
||||
t.die(normalizeDeathReason(t, reason || cause, { fromDamage: true, weakened: majorNow }));
|
||||
}
|
||||
}
|
||||
|
||||
function briefDeathReason(t, reason = t.deathReason) {
|
||||
const text = String(reason || "");
|
||||
if (!text) return "";
|
||||
const normalized = normalizeDeathReason(t, text);
|
||||
return normalized.length > 12 ? `${normalized.slice(0, 12)}\u2026` : normalized;
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
MAJOR_DAMAGE_WINDOW,
|
||||
CAUSES,
|
||||
causeLabel,
|
||||
recentDamageCause,
|
||||
rememberDamage,
|
||||
weakenedDeathReasonFor,
|
||||
dominantDeathCause,
|
||||
normalizeDeathReason,
|
||||
applyDamage,
|
||||
briefDeathReason,
|
||||
});
|
||||
})();
|
||||
|
||||
window.HEALTH = HEALTH;
|
||||
455
js/items.js
|
|
@ -1,17 +1,23 @@
|
|||
// tarinai_colony_game build 15.6.2: food servings, nest box access, and editable tarinai data
|
||||
"use strict";
|
||||
|
||||
const FOOD_SERVING_TYPES = new Set(["food", "sweet", "love_mochi", "fight_mochi", "sleep_drug"]);
|
||||
const FOOD_SERVINGS_BY_SIZE = { small: 1, medium: 5, large: 15 };
|
||||
function foodServingsForSize(size = "medium") {
|
||||
return FOOD_SERVINGS_BY_SIZE[size] || FOOD_SERVINGS_BY_SIZE.medium;
|
||||
}
|
||||
function isServingFoodType(type = "") {
|
||||
return FOOD_SERVING_TYPES.has(type);
|
||||
}
|
||||
|
||||
class Item {
|
||||
constructor(type, x, y) {
|
||||
this.id = crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`;
|
||||
this.type = type;
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.r = {
|
||||
food: 14, sweet: 13, love_mochi: 13, fight_mochi: 13, water: 12, grass: 17, stone: 20, bed: 37, ball: 18, firecracker: 15, fence_v: 42, fence_h: 42, trace: 16, splat: 26, zunchi: 14
|
||||
}[type] || 12;
|
||||
this.amount = {
|
||||
food: 85, sweet: 62, love_mochi: 62, fight_mochi: 62, water: 64, grass: 120, stone: 999, bed: 999, ball: 999, firecracker: 999, fence_v: 999, fence_h: 999, trace: 220, splat: 260, zunchi: 240
|
||||
}[type] || 80;
|
||||
this.r = itemRadiusFor(type, 12);
|
||||
this.amount = itemAmountFor(type, 80);
|
||||
this.age = 0;
|
||||
this.seed = Math.random() * 1000;
|
||||
this.dropTimer = 0;
|
||||
|
|
@ -52,10 +58,37 @@ class Item {
|
|||
this.freshness = 1;
|
||||
this.zunchiVariant = stableUnit(this.id, "zunchi-variant") < 0.5 ? "zunchi" : "zunchi_02";
|
||||
}
|
||||
if (type === "ant_nest") {
|
||||
this.antCount = ANT_NEST_START_COUNT || 6;
|
||||
this.antSpawnTimer = rand(0.6, 2.2);
|
||||
this.antSpawnCooldown = rand(0.8, 2.4);
|
||||
this.antWorkers = typeof buildAntWorkerPool === "function"
|
||||
? buildAntWorkerPool(this.antCount)
|
||||
: Array.from({ length: this.antCount }, () => ANT_WORKER_HP || 32);
|
||||
this.queenSpawnAt = -999;
|
||||
}
|
||||
if (type === "ant_corpse") {
|
||||
this.amount = 24;
|
||||
this.workerSprite = "ant_worker";
|
||||
this.decayTimer = 0;
|
||||
}
|
||||
if (type === "firecracker") {
|
||||
this.fuseTimer = 5;
|
||||
this.fuseMax = 5;
|
||||
}
|
||||
if (type === "genkotsu") {
|
||||
this.amount = 100;
|
||||
this.dropMax = 0;
|
||||
this.dropImpactDone = false;
|
||||
this.impactFlash = 0;
|
||||
}
|
||||
if (isServingFoodType(type)) {
|
||||
this.toolSize = "medium";
|
||||
this.foodServingScale = 1;
|
||||
this.foodServingsMax = foodServingsForSize(this.toolSize);
|
||||
this.foodServingsRemaining = this.foodServingsMax;
|
||||
this.amount = this.foodServingsRemaining;
|
||||
}
|
||||
}
|
||||
update(dt, worldRef = world) {
|
||||
this.age += dt;
|
||||
|
|
@ -68,9 +101,10 @@ class Item {
|
|||
}
|
||||
}
|
||||
if (this.type === "water") this.amount -= dt * 0.9;
|
||||
if (["sweet", "love_mochi", "fight_mochi"].includes(this.type)) this.amount -= dt * 0.12;
|
||||
if (["sweet", "love_mochi", "fight_mochi", "sleep_drug"].includes(this.type) && !Number.isFinite(this.foodServingsRemaining)) this.amount -= dt * 0.12;
|
||||
if (this.type === "trace") this.amount -= dt * 1.35;
|
||||
if (this.type === "splat") this.amount -= dt * 1.05;
|
||||
if (this.type === "ant_corpse") this.amount -= dt * 0.018;
|
||||
if (this.type === "firecracker") {
|
||||
this.fuseTimer = Math.max(0, (this.fuseTimer ?? 5) - dt);
|
||||
if (this.fuseTimer <= 0) {
|
||||
|
|
@ -78,7 +112,13 @@ class Item {
|
|||
else this.amount = 0;
|
||||
}
|
||||
}
|
||||
if (this.type === "genkotsu") {
|
||||
this.impactFlash = Math.max(0, (this.impactFlash || 0) - dt);
|
||||
if (this.dropImpactDone) this.amount -= dt * 115;
|
||||
else this.amount = Math.max(this.amount || 0, 100);
|
||||
}
|
||||
if (this.type === "ball") this.updateBall(dt, worldRef);
|
||||
if (this.type === "zunchi") this.updateZunchiMotion(dt, worldRef);
|
||||
if (this.type === "grass") {
|
||||
this.lifecycleTimer += dt;
|
||||
const factor = worldRef.grassUpdateFactor ? worldRef.grassUpdateFactor() : 1;
|
||||
|
|
@ -98,19 +138,30 @@ class Item {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateZunchiMotion(dt, worldRef) {
|
||||
const speed = Math.hypot(this.vx || 0, this.vy || 0);
|
||||
if (speed < 0.08) { this.vx = 0; this.vy = 0; return; }
|
||||
this.prevX = this.x;
|
||||
this.prevY = this.y;
|
||||
this.x += (this.vx || 0) * dt;
|
||||
this.y += (this.vy || 0) * dt;
|
||||
const p = Math.max(28, CONFIG.worldPadding || 30);
|
||||
if (this.x < p) { this.x = p; this.vx = Math.abs(this.vx || 0) * 0.34; }
|
||||
if (this.x > worldRef.w - p) { this.x = worldRef.w - p; this.vx = -Math.abs(this.vx || 0) * 0.34; }
|
||||
if (this.y < p) { this.y = p; this.vy = Math.abs(this.vy || 0) * 0.34; }
|
||||
if (this.y > worldRef.h - p) { this.y = worldRef.h - p; this.vy = -Math.abs(this.vy || 0) * 0.34; }
|
||||
const slow = Math.pow(0.68, dt * 2.4);
|
||||
this.vx *= slow;
|
||||
this.vy *= slow;
|
||||
this.spin = (this.spin || 0) + speed * dt / Math.max(8, this.r || 14);
|
||||
worldRef.drawListDirty = true;
|
||||
}
|
||||
|
||||
updateBall(dt, worldRef) {
|
||||
this.prevX = this.x;
|
||||
this.prevY = this.y;
|
||||
const maxSpeed = 1250;
|
||||
const clampVelocity = () => {
|
||||
const speed = Math.hypot(this.vx || 0, this.vy || 0);
|
||||
if (speed > maxSpeed) {
|
||||
this.vx = (this.vx || 0) / speed * maxSpeed;
|
||||
this.vy = (this.vy || 0) / speed * maxSpeed;
|
||||
}
|
||||
return Math.hypot(this.vx || 0, this.vy || 0);
|
||||
};
|
||||
const moving = clampVelocity();
|
||||
const moving = Math.hypot(this.vx || 0, this.vy || 0);
|
||||
if (moving > 0.04) {
|
||||
this.x += (this.vx || 0) * dt;
|
||||
this.y += (this.vy || 0) * dt;
|
||||
|
|
@ -122,7 +173,6 @@ class Item {
|
|||
this.resolveBallObstacleCollisions(dt, worldRef);
|
||||
this.x = clamp(this.x, p, worldRef.w - p);
|
||||
this.y = clamp(this.y, p, worldRef.h - p);
|
||||
clampVelocity();
|
||||
const groundFriction = Math.pow(0.72, dt);
|
||||
this.vx *= groundFriction;
|
||||
this.vy *= groundFriction;
|
||||
|
|
@ -135,21 +185,18 @@ class Item {
|
|||
resolveBallObstacleCollisions(dt, worldRef) {
|
||||
if (!worldRef?.nearbyItems) return;
|
||||
const speed = Math.hypot(this.vx || 0, this.vy || 0);
|
||||
const searchRadius = Math.max(130, (this.r || 18) + speed * Math.max(dt || 0.016, 0.016) + 96);
|
||||
const searchRadius = Math.max(150, (this.r || 18) + speed * Math.max(dt || 0.016, 0.016) + 120);
|
||||
const items = worldRef.nearbyItems(this.x, this.y, searchRadius) || [];
|
||||
let fenceChecks = 0;
|
||||
const maxFenceChecks = CONFIG.maxFenceCollisionChecks ?? 18;
|
||||
for (const it of items) {
|
||||
if (!it || it === this || it.dead) continue;
|
||||
if (it.type === "bed") {
|
||||
this.applyBallHayDrag(it, dt, worldRef);
|
||||
} else if (it.type === "stone") {
|
||||
this.resolveBallCircleBounce(it, (it.r || 20) * 1.08 + (this.r || 18), 0.82, worldRef);
|
||||
} else if (it.type === "fence_v" || it.type === "fence_h") {
|
||||
if (fenceChecks >= maxFenceChecks) continue;
|
||||
fenceChecks += 1;
|
||||
this.resolveBallFenceBounce(it, worldRef);
|
||||
}
|
||||
const rects = worldRef.solidObstacleRects ? worldRef.solidObstacleRects(it) : [];
|
||||
if (!rects.length) continue;
|
||||
for (const rect of rects) this.resolveBallRectBounce(rect, worldRef, it);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -174,7 +221,27 @@ class Item {
|
|||
let dx = this.x - obstacle.x;
|
||||
let dy = this.y - obstacle.y;
|
||||
let d = Math.hypot(dx, dy);
|
||||
if (d >= hitRadius) return;
|
||||
if (d >= hitRadius) {
|
||||
const px = this.prevX;
|
||||
const py = this.prevY;
|
||||
if (!Number.isFinite(px) || !Number.isFinite(py)) return;
|
||||
const sx = this.x - px;
|
||||
const sy = this.y - py;
|
||||
const len2 = sx * sx + sy * sy;
|
||||
if (len2 <= 0.0001) return;
|
||||
const t = clamp(((obstacle.x - px) * sx + (obstacle.y - py) * sy) / len2, 0, 1);
|
||||
const cx = px + sx * t;
|
||||
const cy = py + sy * t;
|
||||
dx = cx - obstacle.x;
|
||||
dy = cy - obstacle.y;
|
||||
d = Math.hypot(dx, dy);
|
||||
if (d >= hitRadius) return;
|
||||
if (d < 0.001) {
|
||||
const speed = Math.hypot(this.vx || 0, this.vy || 0);
|
||||
if (speed > 0.001) { dx = -(this.vx || 0) / speed; dy = -(this.vy || 0) / speed; d = 1; }
|
||||
else { dx = -sx / Math.sqrt(len2); dy = -sy / Math.sqrt(len2); d = 1; }
|
||||
}
|
||||
}
|
||||
if (d < 0.001) {
|
||||
const speed = Math.hypot(this.vx || 0, this.vy || 0);
|
||||
if (speed > 0.001) { dx = -(this.vx || 0) / speed; dy = -(this.vy || 0) / speed; d = 1; }
|
||||
|
|
@ -198,29 +265,69 @@ class Item {
|
|||
this.emitBallBounce(worldRef, obstacle);
|
||||
}
|
||||
|
||||
resolveBallFenceBounce(fence, worldRef) {
|
||||
const vertical = fence.type === "fence_v";
|
||||
const len = Math.max(112, (fence.r || 42) * 3.55);
|
||||
const thick = Math.max(10, (fence.r || 42) * 0.31);
|
||||
const halfLen = len / 2;
|
||||
const sx1 = vertical ? fence.x : fence.x - halfLen;
|
||||
const sy1 = vertical ? fence.y - halfLen : fence.y;
|
||||
const sx2 = vertical ? fence.x : fence.x + halfLen;
|
||||
const sy2 = vertical ? fence.y + halfLen : fence.y;
|
||||
const vx = sx2 - sx1;
|
||||
const vy = sy2 - sy1;
|
||||
const segLenSq = vx * vx + vy * vy || 1;
|
||||
const u = clamp(((this.x - sx1) * vx + (this.y - sy1) * vy) / segLenSq, 0, 1);
|
||||
const cx = sx1 + vx * u;
|
||||
const cy = sy1 + vy * u;
|
||||
const hitRadius = (this.r || 18) + thick / 2 + 2;
|
||||
resolveBallRectBounce(rect, worldRef, source = null) {
|
||||
if (!rect) return;
|
||||
const hitRadius = (this.r || 18) + 2;
|
||||
const cx = clamp(this.x, rect.left, rect.right);
|
||||
const cy = clamp(this.y, rect.top, rect.bottom);
|
||||
let dx = this.x - cx;
|
||||
let dy = this.y - cy;
|
||||
let d = Math.hypot(dx, dy);
|
||||
if (d >= hitRadius) return;
|
||||
if (d >= hitRadius) {
|
||||
const px = this.prevX;
|
||||
const py = this.prevY;
|
||||
const expanded = { left: rect.left - hitRadius, right: rect.right + hitRadius, top: rect.top - hitRadius, bottom: rect.bottom + hitRadius };
|
||||
if (!Number.isFinite(px) || !Number.isFinite(py) || !worldRef?.segmentIntersectsRect?.(px, py, this.x, this.y, expanded)) return;
|
||||
let nx = 0;
|
||||
let ny = 0;
|
||||
const vx = this.vx || 0;
|
||||
const vy = this.vy || 0;
|
||||
const candidates = [];
|
||||
const sx = this.x - px;
|
||||
const sy = this.y - py;
|
||||
if (px < expanded.left && sx > 0) candidates.push({ nx: -1, ny: 0, t: (expanded.left - px) / Math.max(sx, 0.001) });
|
||||
if (px > expanded.right && sx < 0) candidates.push({ nx: 1, ny: 0, t: (px - expanded.right) / Math.max(-sx, 0.001) });
|
||||
if (py < expanded.top && sy > 0) candidates.push({ nx: 0, ny: -1, t: (expanded.top - py) / Math.max(sy, 0.001) });
|
||||
if (py > expanded.bottom && sy < 0) candidates.push({ nx: 0, ny: 1, t: (py - expanded.bottom) / Math.max(-sy, 0.001) });
|
||||
if (candidates.length) {
|
||||
candidates.sort((a, b) => a.t - b.t);
|
||||
nx = candidates[0].nx;
|
||||
ny = candidates[0].ny;
|
||||
} else if (Math.abs(vx) >= Math.abs(vy)) {
|
||||
nx = vx >= 0 ? -1 : 1;
|
||||
} else {
|
||||
ny = vy >= 0 ? -1 : 1;
|
||||
}
|
||||
const toward = vx * nx + vy * ny;
|
||||
if (nx < 0) this.x = expanded.left - 0.5;
|
||||
else if (nx > 0) this.x = expanded.right + 0.5;
|
||||
if (ny < 0) this.y = expanded.top - 0.5;
|
||||
else if (ny > 0) this.y = expanded.bottom + 0.5;
|
||||
if (nx) this.y = clamp(this.y, expanded.top, expanded.bottom);
|
||||
if (ny) this.x = clamp(this.x, expanded.left, expanded.right);
|
||||
if (toward < 0) {
|
||||
this.vx = vx - 1.78 * toward * nx;
|
||||
this.vy = vy - 1.78 * toward * ny;
|
||||
} else {
|
||||
this.vx = vx + nx * 18;
|
||||
this.vy = vy + ny * 18;
|
||||
}
|
||||
this.vx *= 0.94;
|
||||
this.vy *= 0.94;
|
||||
this.spinVelocity = clamp((this.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * 6.2, -38, 38);
|
||||
this.emitBallBounce(worldRef, source || rect.item || rect);
|
||||
return;
|
||||
}
|
||||
if (d < 0.001) {
|
||||
if (vertical) { dx = (this.vx || 0) >= 0 ? -1 : 1; dy = 0; }
|
||||
else { dx = 0; dy = (this.vy || 0) >= 0 ? -1 : 1; }
|
||||
const left = Math.abs(this.x - rect.left);
|
||||
const right = Math.abs(rect.right - this.x);
|
||||
const top = Math.abs(this.y - rect.top);
|
||||
const bottom = Math.abs(rect.bottom - this.y);
|
||||
const m = Math.min(left, right, top, bottom);
|
||||
if (m === left) { dx = -1; dy = 0; }
|
||||
else if (m === right) { dx = 1; dy = 0; }
|
||||
else if (m === top) { dx = 0; dy = -1; }
|
||||
else { dx = 0; dy = 1; }
|
||||
d = 1;
|
||||
}
|
||||
const nx = dx / d;
|
||||
|
|
@ -231,11 +338,19 @@ class Item {
|
|||
if (toward < 0) {
|
||||
this.vx = (this.vx || 0) - 1.78 * toward * nx;
|
||||
this.vy = (this.vy || 0) - 1.78 * toward * ny;
|
||||
} else {
|
||||
this.vx = (this.vx || 0) + nx * 18;
|
||||
this.vy = (this.vy || 0) + ny * 18;
|
||||
}
|
||||
this.vx *= 0.94;
|
||||
this.vy *= 0.94;
|
||||
this.spinVelocity = clamp((this.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * 6.2, -38, 38);
|
||||
this.emitBallBounce(worldRef, fence);
|
||||
this.emitBallBounce(worldRef, source || rect.item || rect);
|
||||
}
|
||||
|
||||
resolveBallFenceBounce(fence, worldRef) {
|
||||
const rects = worldRef?.solidObstacleRects ? worldRef.solidObstacleRects(fence) : [];
|
||||
for (const rect of rects) this.resolveBallRectBounce(rect, worldRef, fence);
|
||||
}
|
||||
|
||||
emitBallBounce(worldRef, obstacle) {
|
||||
|
|
@ -359,117 +474,17 @@ class Item {
|
|||
get dead() {
|
||||
return this.amount <= 0;
|
||||
}
|
||||
serialize() {
|
||||
return {
|
||||
type: this.type, x: this.x, y: this.y, amount: this.amount, age: this.age, seed: this.seed,
|
||||
stage: this.stage, stageTimer: this.stageTimer, fertility: this.fertility, freshness: this.freshness,
|
||||
growth: this.growth, health: this.health, seedTimer: this.seedTimer, eatenAmount: this.eatenAmount, fertilityBoost: this.fertilityBoost, lifeSpan: this.lifeSpan, wither: this.wither,
|
||||
comfort: this.comfort, wear: this.wear, crawlOpen: this.crawlOpen, zunchiVariant: this.zunchiVariant, toolSize: this.toolSize, blastScale: this.blastScale, fuseTimer: this.fuseTimer, fuseMax: this.fuseMax, dropTimer: this.dropTimer, dropMax: this.dropMax,
|
||||
vx: this.vx, vy: this.vy, prevX: this.prevX, prevY: this.prevY,
|
||||
spin: this.spin, spinVelocity: this.spinVelocity, lastKickedAt: this.lastKickedAt, lastKickerId: this.lastKickerId,
|
||||
lastPokedAt: this.lastPokedAt, pokeCombo: this.pokeCombo, lastPokeAngle: this.lastPokeAngle
|
||||
};
|
||||
}
|
||||
static from(o) {
|
||||
const type = o?.type || "grass";
|
||||
const it = Object.create(Item.prototype);
|
||||
Item._loadId = (Item._loadId || 0) + 1;
|
||||
it.id = o.id || `loaded-${Item._loadId}`;
|
||||
it.type = type;
|
||||
it.x = o.x;
|
||||
it.y = o.y;
|
||||
it.r = {
|
||||
food: 14, sweet: 13, love_mochi: 13, fight_mochi: 13, water: 12, grass: 17, stone: 20, bed: 37, ball: 18, firecracker: 15, fence_v: 42, fence_h: 42, trace: 16, splat: 26, zunchi: 14
|
||||
}[type] || 12;
|
||||
it.amount = o.amount ?? ({
|
||||
food: 85, sweet: 62, love_mochi: 62, fight_mochi: 62, water: 64, grass: 120, stone: 999, bed: 999, ball: 999, firecracker: 999, fence_v: 999, fence_h: 999, trace: 220, splat: 260, zunchi: 240
|
||||
}[type] || 80);
|
||||
it.age = o.age || 0;
|
||||
it.seed = o.seed || Math.random() * 1000;
|
||||
it.dropTimer = 0;
|
||||
it.dropMax = 0;
|
||||
it.dropImpactDone = false;
|
||||
it.lifecycleTimer = o.lifecycleTimer ?? rand(0, type === "grass" ? 1.15 : 0.55);
|
||||
it.lifecycleInterval = o.lifecycleInterval ?? (type === "grass" ? (1.75 + stableUnit(it.id, "grass-life") * 0.90) : (type === "zunchi" ? (0.95 + stableUnit(it.id, "zunchi-life") * 0.45) : 0));
|
||||
if (type === "grass") {
|
||||
it.growth = 0.25;
|
||||
it.health = 1;
|
||||
it.seedTimer = rand(18, 38);
|
||||
it.eatenAmount = 0;
|
||||
it.fertilityBoost = 0;
|
||||
}
|
||||
if (type === "bed") {
|
||||
it.comfort = 1;
|
||||
it.wear = 0;
|
||||
}
|
||||
if (type === "ball") {
|
||||
it.vx = o.vx || 0;
|
||||
it.vy = o.vy || 0;
|
||||
it.prevX = o.prevX ?? o.x;
|
||||
it.prevY = o.prevY ?? o.y;
|
||||
it.spin = o.spin || 0;
|
||||
it.spinVelocity = o.spinVelocity || 0;
|
||||
it.lastKickedAt = o.lastKickedAt ?? -999;
|
||||
it.lastKickerId = o.lastKickerId || "";
|
||||
it.lastPokedAt = o.lastPokedAt ?? -999;
|
||||
it.pokeCombo = o.pokeCombo || 0;
|
||||
it.lastPokeAngle = o.lastPokeAngle || rand(0, Math.PI * 2);
|
||||
}
|
||||
if (type === "zunchi") {
|
||||
it.stage = "fresh";
|
||||
it.stageTimer = 0;
|
||||
it.fertility = 0;
|
||||
it.freshness = 1;
|
||||
it.zunchiVariant = "zunchi";
|
||||
}
|
||||
if (type === "firecracker") {
|
||||
it.fuseTimer = 5;
|
||||
it.fuseMax = 5;
|
||||
}
|
||||
it.stage = o.stage || it.stage;
|
||||
it.stageTimer = o.stageTimer || 0;
|
||||
it.fertility = o.fertility || 0;
|
||||
it.freshness = o.freshness ?? it.freshness;
|
||||
it.growth = o.growth ?? it.growth;
|
||||
it.health = o.health ?? it.health;
|
||||
it.seedTimer = o.seedTimer ?? it.seedTimer;
|
||||
it.eatenAmount = o.eatenAmount || 0;
|
||||
it.fertilityBoost = o.fertilityBoost || 0;
|
||||
it.lifeSpan = o.lifeSpan ?? it.lifeSpan;
|
||||
it.wither = o.wither ?? it.wither ?? 0;
|
||||
it.comfort = o.comfort ?? it.comfort;
|
||||
it.wear = o.wear ?? it.wear;
|
||||
it.crawlOpen = o.crawlOpen ?? it.crawlOpen;
|
||||
it.vx = o.vx ?? it.vx ?? 0;
|
||||
it.vy = o.vy ?? it.vy ?? 0;
|
||||
it.prevX = o.prevX ?? it.prevX ?? it.x;
|
||||
it.prevY = o.prevY ?? it.prevY ?? it.y;
|
||||
it.spin = o.spin ?? it.spin ?? 0;
|
||||
it.spinVelocity = o.spinVelocity ?? it.spinVelocity ?? 0;
|
||||
it.lastKickedAt = o.lastKickedAt ?? it.lastKickedAt ?? -999;
|
||||
it.lastKickerId = o.lastKickerId || it.lastKickerId || "";
|
||||
it.lastPokedAt = o.lastPokedAt ?? it.lastPokedAt ?? -999;
|
||||
it.pokeCombo = o.pokeCombo ?? it.pokeCombo ?? 0;
|
||||
it.lastPokeAngle = o.lastPokeAngle ?? it.lastPokeAngle ?? rand(0, Math.PI * 2);
|
||||
it.zunchiVariant = o.zunchiVariant || it.zunchiVariant || "zunchi";
|
||||
it.toolSize = o.toolSize || it.toolSize || "medium";
|
||||
it.blastScale = o.blastScale || it.blastScale || 1;
|
||||
it.fuseTimer = o.fuseTimer ?? it.fuseTimer;
|
||||
it.fuseMax = o.fuseMax ?? it.fuseMax;
|
||||
it.dropTimer = 0;
|
||||
it.dropMax = 0;
|
||||
it.dropImpactDone = true;
|
||||
return it;
|
||||
}
|
||||
draw(ctx, t, lighting = null) {
|
||||
const lightState = lighting || getLightingState(world);
|
||||
const night = clamp(lightState.nightStrength * 1.35, 0, 1);
|
||||
const warm = lightState.warmth;
|
||||
const styleNight = lightState.light < 0.42;
|
||||
const styleWarm = !styleNight && (lightState.goldenStrength > 0.22 || warm > 0.55);
|
||||
const visibleAlpha = clamp(this.amount / 40, 0.24, 1);
|
||||
const servingRatio = isServingFoodType(this.type) ? ((this.foodServingsRemaining ?? this.amount) / Math.max(1, this.foodServingsMax || foodServingsForSize(this.toolSize || "medium"))) : null;
|
||||
const visibleAlpha = isServingFoodType(this.type) ? clamp(servingRatio ?? 1, 0.24, 1) : clamp(this.amount / 40, 0.24, 1);
|
||||
const dropT = this.dropMax > 0 ? clamp(this.dropTimer / this.dropMax, 0, 1) : 0;
|
||||
const dropY = dropT > 0 ? -170 * Math.pow(dropT, 0.42) : 0;
|
||||
const dropFallHeight = this.type === "genkotsu" ? Math.max(360, this.r * 5.8) : 170;
|
||||
const dropY = dropT > 0 ? -dropFallHeight * dropT : 0;
|
||||
const shadow = projectedShadowParams(lightState, Math.max(0.4, this.r / 18));
|
||||
if (this.type !== "trace" && this.type !== "splat") {
|
||||
const zunchiId = this.type === "zunchi" ? (this.zunchiVariant || "zunchi") : null;
|
||||
|
|
@ -540,13 +555,8 @@ class Item {
|
|||
ctx.ellipse(-this.r * 0.28, -this.r * 0.18, this.r * 0.23, this.r * 0.10, -0.35, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
} else if (fightMochi) {
|
||||
ctx.fillStyle = "#d55632";
|
||||
ctx.font = `${Math.round((this.r || 13) * 1.02)}px sans-serif`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText("\u2726", 0, 1);
|
||||
ctx.strokeStyle = "rgba(168,76,42,0.72)";
|
||||
ctx.lineWidth = 1.4;
|
||||
ctx.strokeStyle = "rgba(168,76,42,0.82)";
|
||||
ctx.lineWidth = 1.8;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-this.r * 0.44, -this.r * 0.42);
|
||||
ctx.lineTo(this.r * 0.44, this.r * 0.42);
|
||||
|
|
@ -561,6 +571,72 @@ class Item {
|
|||
ctx.fill();
|
||||
}
|
||||
}
|
||||
} else if (this.type === "sleep_drug") {
|
||||
ctx.fillStyle = "#f5f0ff";
|
||||
ctx.strokeStyle = "rgba(112, 83, 170, 0.55)";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.rotate(-0.45);
|
||||
roundedRect(ctx, -this.r * 0.95, -this.r * 0.45, this.r * 1.90, this.r * 0.90, this.r * 0.45);
|
||||
ctx.fill(); ctx.stroke();
|
||||
ctx.fillStyle = "rgba(152, 116, 218, 0.58)";
|
||||
ctx.fillRect(-1, -this.r * 0.42, 2, this.r * 0.84);
|
||||
ctx.rotate(0.45);
|
||||
ctx.fillStyle = "rgba(74,58,104,0.72)";
|
||||
ctx.font = `${Math.round((this.r || 13) * 0.78)}px sans-serif`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText("Z", 0, this.r * 1.18);
|
||||
} else if (this.type === "genkotsu") {
|
||||
const img = typeof getRenderableImage === "function" ? getRenderableImage("genkotsu", "genkotsu") : images.get("genkotsu");
|
||||
const impact = clamp(this.impactFlash || 0, 0, 1);
|
||||
ctx.save();
|
||||
ctx.shadowColor = "rgba(54,42,31,0.26)";
|
||||
ctx.shadowBlur = 5 + impact * 8;
|
||||
ctx.shadowOffsetY = 4;
|
||||
const squashY = 1 - impact * 0.08;
|
||||
const stretchX = 1 + impact * 0.05;
|
||||
ctx.scale(stretchX, squashY);
|
||||
if (img) {
|
||||
const metrics = typeof getImageMetrics === "function" ? getImageMetrics("genkotsu") : null;
|
||||
const w = this.r * 2.55;
|
||||
const h = w * (metrics?.ratio || 1.27);
|
||||
ctx.drawImage(img, -w * 0.5, -h * 0.76, w, h);
|
||||
} else {
|
||||
ctx.fillStyle = "rgba(255,255,255,0.94)";
|
||||
ctx.strokeStyle = "rgba(64,64,64,0.78)";
|
||||
ctx.lineWidth = 5;
|
||||
roundedRect(ctx, -this.r * 0.72, -this.r * 1.45, this.r * 1.44, this.r * 1.84, 18);
|
||||
ctx.fill(); ctx.stroke();
|
||||
ctx.fillStyle = "rgba(42,46,52,0.92)";
|
||||
ctx.font = `bold ${Math.round(this.r * 0.45)}px sans-serif`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText("\u6b63", 0, -this.r * 0.74);
|
||||
ctx.fillText("\u7fa9", 0, -this.r * 0.27);
|
||||
}
|
||||
if (impact > 0.01) {
|
||||
ctx.globalAlpha = impact * 0.20;
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(0, -this.r * 0.65, this.r * 0.92, this.r * 0.22, -0.10, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.restore();
|
||||
} else if (this.type === "water_bowl") {
|
||||
ctx.fillStyle = "rgba(171, 120, 76, 0.72)";
|
||||
ctx.strokeStyle = "rgba(94, 69, 48, 0.55)";
|
||||
ctx.lineWidth = 2.2;
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(0, 2, this.r * 1.35, this.r * 0.78, 0, 0, Math.PI * 2);
|
||||
ctx.fill(); ctx.stroke();
|
||||
ctx.fillStyle = "rgba(86, 157, 224, 0.58)";
|
||||
ctx.strokeStyle = "rgba(62, 113, 178, 0.36)";
|
||||
ctx.lineWidth = 1.6;
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(0, -1, this.r * 1.02, this.r * 0.50, 0, 0, Math.PI * 2);
|
||||
ctx.fill(); ctx.stroke();
|
||||
ctx.fillStyle = "rgba(255,255,255,0.54)";
|
||||
ctx.beginPath(); ctx.arc(-this.r * 0.32, -this.r * 0.18, 2.8, 0, Math.PI * 2); ctx.fill();
|
||||
} else if (this.type === "water") {
|
||||
ctx.fillStyle = "rgba(86, 157, 224, 0.50)";
|
||||
ctx.strokeStyle = "rgba(62, 113, 178, 0.42)";
|
||||
|
|
@ -632,6 +708,70 @@ class Item {
|
|||
ctx.lineTo(x + Math.cos(a) * len * 0.5, y + Math.sin(a) * len * 0.5);
|
||||
ctx.stroke();
|
||||
}
|
||||
} else if (this.type === "nest_box") {
|
||||
ctx.fillStyle = styleNight ? "#5f4933" : (styleWarm ? "#89613e" : "#775237");
|
||||
ctx.strokeStyle = styleNight ? "#31261f" : "#3f2e24";
|
||||
ctx.lineWidth = 2.6;
|
||||
roundedRect(ctx, -this.r * 1.28, -this.r * 0.80, this.r * 2.56, this.r * 1.52, 9);
|
||||
ctx.fill(); ctx.stroke();
|
||||
ctx.fillStyle = styleNight ? "#6e5439" : "#8a5f3b";
|
||||
roundedRect(ctx, -this.r * 1.12, -this.r * 0.62, this.r * 2.24, this.r * 1.18, 6);
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = styleNight ? "rgba(42,32,25,0.76)" : "rgba(65,45,32,0.72)";
|
||||
ctx.lineWidth = 2.0;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-this.r * 0.95, -this.r * 0.50);
|
||||
ctx.lineTo(this.r * 0.95, -this.r * 0.50);
|
||||
ctx.moveTo(-this.r * 0.95, this.r * 0.48);
|
||||
ctx.lineTo(this.r * 0.95, this.r * 0.48);
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = styleNight ? "#2b211c" : "#3c281f";
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, -this.r * 0.08, this.r * 0.40, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
} else if (this.type === "ant_nest") {
|
||||
const count = clamp(Math.round(this.antCount ?? ANT_NEST_START_COUNT ?? 6), 0, ANT_NEST_MAX_COUNT ?? 10);
|
||||
ctx.fillStyle = styleNight ? "#5e4634" : (styleWarm ? "#956b42" : "#77583a");
|
||||
ctx.strokeStyle = styleNight ? "rgba(43,32,26,0.78)" : "rgba(72,48,31,0.72)";
|
||||
ctx.lineWidth = 2.2;
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(0, 8, this.r * 1.35, this.r * 0.78, 0.08, 0, Math.PI * 2);
|
||||
ctx.fill(); ctx.stroke();
|
||||
ctx.fillStyle = styleNight ? "#211a17" : "#30231d";
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(0, 2, this.r * 0.58, this.r * 0.36, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = "rgba(38,37,35,0.88)";
|
||||
for (let i = 0; i < Math.min(6, count); i++) {
|
||||
const ax = randSeed(this.seed + i * 11, -this.r * 1.0, this.r * 1.0);
|
||||
const ay = randSeed(this.seed + i * 17, -this.r * 0.45, this.r * 0.55);
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(ax, ay, 3.4, 2.1, randSeed(this.seed + i * 23, -0.8, 0.8), 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.fillStyle = "rgba(255,248,220,0.82)";
|
||||
ctx.font = "bold 10px ui-rounded, sans-serif";
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText(`${count}`, 0, this.r * 1.25);
|
||||
} else if (this.type === "ant_corpse") {
|
||||
const img = typeof getRenderableImage === "function" ? getRenderableImage("ant_worker", "ant_worker") : images.get("ant_worker");
|
||||
ctx.save();
|
||||
ctx.rotate((stableUnit(this.id || this.seed, "ant-corpse-rot") - 0.5) * 0.26);
|
||||
ctx.scale(1, -1);
|
||||
ctx.globalAlpha *= clamp((this.amount || 0) / 24, 0.35, 1);
|
||||
if (img) {
|
||||
const w = this.r * 3.9;
|
||||
const metrics = getImageMetrics("ant_worker");
|
||||
const h = w * (metrics?.ratio || 0.52);
|
||||
ctx.drawImage(img, -w * 0.5, -h * 0.55, w, h);
|
||||
} else {
|
||||
ctx.fillStyle = "rgba(42,39,35,0.85)";
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(0, 0, this.r * 1.35, this.r * 0.72, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.restore();
|
||||
} else if (this.type === "ball") {
|
||||
const speed = Math.hypot(this.vx || 0, this.vy || 0);
|
||||
const moving = clamp(speed / 260, 0, 1);
|
||||
|
|
@ -742,4 +882,3 @@ class Item {
|
|||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
30
js/main.js
|
|
@ -6,6 +6,25 @@ let fpsFrames = 0;
|
|||
let perfResizeTimer = 0;
|
||||
let lastPerfLevel = 0;
|
||||
window.__tarinaiFps = 0;
|
||||
|
||||
function setLoadingProgress(text = "", ratio = 0) {
|
||||
const screen = document.getElementById("loadingScreen");
|
||||
if (!screen) return;
|
||||
const pct = clamp(Number(ratio) || 0, 0, 1);
|
||||
const label = document.getElementById("loadingText");
|
||||
const bar = document.getElementById("loadingBar");
|
||||
const percent = document.getElementById("loadingPercent");
|
||||
if (label && text) label.textContent = text;
|
||||
if (bar) bar.style.width = `${Math.round(pct * 100)}%`;
|
||||
if (percent) percent.textContent = `${Math.round(pct * 100)}%`;
|
||||
}
|
||||
|
||||
function hideLoadingScreen() {
|
||||
setLoadingProgress("\u8d77\u52d5\u5b8c\u4e86", 1);
|
||||
const screen = document.getElementById("loadingScreen");
|
||||
if (!screen) return;
|
||||
window.setTimeout(() => screen.classList.add("hidden"), 120);
|
||||
}
|
||||
function loop() {
|
||||
const t = nowSec();
|
||||
const rawDt = t - last;
|
||||
|
|
@ -38,16 +57,25 @@ function loop() {
|
|||
requestAnimationFrame(loop);
|
||||
}
|
||||
|
||||
loadImages().then(() => {
|
||||
setLoadingProgress("\u521d\u671f\u753b\u50cf\u3092\u8aad\u307f\u8fbc\u307f\u4e2d", 0.08);
|
||||
loadImages(null, (done, total) => {
|
||||
const p = total > 0 ? done / total : 1;
|
||||
setLoadingProgress(`\u521d\u671f\u753b\u50cf\u3092\u8aad\u307f\u8fbc\u307f\u4e2d ${done}/${total}`, 0.08 + p * 0.46);
|
||||
}).then(() => {
|
||||
setLoadingProgress("UI\u3092\u6e96\u5099\u4e2d", 0.62);
|
||||
bindUI();
|
||||
setLoadingProgress("\u30d5\u30a3\u30fc\u30eb\u30c9\u3092\u69cb\u7bc9\u4e2d", 0.72);
|
||||
applyFieldLayout("garden");
|
||||
world.reset(null, "garden");
|
||||
selectTool("observe");
|
||||
setLoadingProgress("\u8868\u793a\u3092\u521d\u671f\u5316\u4e2d", 0.84);
|
||||
renderLog(world.logs);
|
||||
renderArchive();
|
||||
render();
|
||||
renderStats();
|
||||
syncTopButtons();
|
||||
if (typeof startBackgroundImageLoading === "function") startBackgroundImageLoading();
|
||||
setLoadingProgress("\u30b7\u30df\u30e5\u30ec\u30fc\u30b7\u30e7\u30f3\u3092\u958b\u59cb\u4e2d", 0.94);
|
||||
loop();
|
||||
requestAnimationFrame(hideLoadingScreen);
|
||||
});
|
||||
|
|
|
|||
109
js/render.js
|
|
@ -124,6 +124,77 @@ function drawItemGlow(ctx, item, lighting, visibleAlpha = 1) {
|
|||
return;
|
||||
}
|
||||
|
||||
function placementPreviewFor(world) {
|
||||
const p = world?.pointer;
|
||||
const type = toolItemType(world?.tool || "");
|
||||
if (!p?.inside || !type) return null;
|
||||
const pad = CONFIG.worldPadding || 30;
|
||||
let x = p.x;
|
||||
let y = p.y;
|
||||
const r = itemRadiusFor(type, 12);
|
||||
const rectOutside = (rect) => Boolean(rect && (rect.left < pad || rect.top < pad || rect.right > world.w - pad || rect.bottom > world.h - pad));
|
||||
const circleOutside = (cx, cy, radius) => cx - radius < pad || cy - radius < pad || cx + radius > world.w - pad || cy + radius > world.h - pad;
|
||||
let blocked = false;
|
||||
let tmp = { type, x, y, r, dead: false };
|
||||
if ((type === "fence_v" || type === "fence_h") && world.fenceRect) {
|
||||
const rawRect = world.fenceRect(tmp);
|
||||
return { type, x, y, r, rect: rawRect, blocked: rectOutside(rawRect) || world.placementBlocked?.(tmp) || world.fencePlacementBlocked?.(tmp) };
|
||||
}
|
||||
if (type === "nest_box" && world.nestBoxBaseRect) {
|
||||
const rect = world.nestBoxBaseRect(tmp);
|
||||
return { type, x, y, r, rect, blocked: rectOutside(rect) || world.placementBlocked?.(tmp) };
|
||||
}
|
||||
if (type === "grass") {
|
||||
blocked = !world.grassSpotOpen?.(x, y, { avoidTarinai: true }) || circleOutside(x, y, Math.max(14, r * 1.55));
|
||||
}
|
||||
if (!blocked) blocked = circleOutside(x, y, Math.max(14, r * (type === "bed" ? 1.55 : 1.25))) || world.placementBlocked?.(tmp);
|
||||
return { type, x, y, r, blocked };
|
||||
}
|
||||
|
||||
function drawPlacementPreview(ctx, world) {
|
||||
const preview = placementPreviewFor(world);
|
||||
if (!preview) return;
|
||||
const { type, x, y, r, rect, blocked } = preview;
|
||||
const pulse = 0.55 + Math.sin((world.time || 0) * 7.0) * 0.10;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = blocked ? 0.44 : 0.72;
|
||||
ctx.lineWidth = blocked ? 2.2 : 2.0;
|
||||
ctx.setLineDash(blocked ? [5, 4] : [7, 5]);
|
||||
ctx.strokeStyle = blocked ? "rgba(194,70,58,0.86)" : "rgba(110,178,55,0.88)";
|
||||
ctx.fillStyle = blocked ? "rgba(232,84,72,0.14)" : "rgba(190,236,92,0.15)";
|
||||
if (rect) {
|
||||
roundedRect(ctx, rect.left, rect.top, rect.right - rect.left, rect.bottom - rect.top, 8);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
if (type === "nest_box" && world.nestBoxSolidRects) {
|
||||
ctx.setLineDash([3, 4]);
|
||||
ctx.strokeStyle = blocked ? "rgba(194,70,58,0.38)" : "rgba(110,178,55,0.42)";
|
||||
for (const part of world.nestBoxSolidRects({ type, x, y, r, dead: false })) {
|
||||
ctx.strokeRect(part.left, part.top, part.right - part.left, part.bottom - part.top);
|
||||
}
|
||||
}
|
||||
} else if (type === "water_bowl" || type === "stone" || type === "ball" || type === "firecracker" || type === "zunchi" || type.includes("mochi") || type === "sweet" || type === "sleep_drug") {
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, Math.max(16, r * (1.18 + pulse * 0.04)), 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
} else {
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(x, y, Math.max(14, r * 1.55), Math.max(9, r * 0.86), 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.setLineDash([]);
|
||||
ctx.globalAlpha = blocked ? 0.52 : 0.70;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x - 8, y);
|
||||
ctx.lineTo(x + 8, y);
|
||||
ctx.moveTo(x, y - 8);
|
||||
ctx.lineTo(x, y + 8);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawCreatureGlow(ctx, tarinai, drawW, drawH, lighting) {
|
||||
if (tarinai.dead || tarinai.world.selected !== tarinai) return;
|
||||
ctx.save();
|
||||
|
|
@ -580,6 +651,7 @@ function render() {
|
|||
if (perf < 3 || world.weather === "light_rain") drawRain(ctx, screenW, screenH, world.time);
|
||||
|
||||
beginFieldTransform();
|
||||
drawPlacementPreview(ctx, world);
|
||||
for (const it of world.items) {
|
||||
if (it.type === "trace" || it.type === "splat") it.draw(ctx, world.time, lighting);
|
||||
}
|
||||
|
|
@ -633,6 +705,7 @@ function render() {
|
|||
}
|
||||
};
|
||||
drawAtScreenPosition(world.selected, () => drawSelectedCard(ctx, world, lighting));
|
||||
drawNestBoxTooltip(ctx, world);
|
||||
|
||||
// Time HUD
|
||||
ctx.save();
|
||||
|
|
@ -675,6 +748,37 @@ function render() {
|
|||
}
|
||||
}
|
||||
|
||||
function drawNestBoxTooltip(ctx, world) {
|
||||
const info = world.pointerNestBoxInfo?.();
|
||||
if (!info) return;
|
||||
const box = info.box;
|
||||
const capacity = info.capacity || world.nestBoxCapacity?.(box) || 5;
|
||||
const title = `\u5de3\u7bb1 (${info.occupants.length}/${capacity})\u5339`;
|
||||
const lines = info.occupants.length
|
||||
? [title, ...info.occupants.map(t => t.name)]
|
||||
: [title, "\u4e2d\u306b\u306f\u8ab0\u3082\u3044\u306a\u3044"];
|
||||
ctx.save();
|
||||
ctx.font = "12px Yomogi, sans-serif";
|
||||
const textW = lines.reduce((m, line) => Math.max(m, ctx.measureText(line).width), 0);
|
||||
const lineH = 17;
|
||||
const w = Math.min(240, Math.max(92, textW + 22));
|
||||
const h = Math.max(38, 16 + lines.length * lineH);
|
||||
const pos = world.worldToScreen ? world.worldToScreen(box.x, box.y) : { x: box.x, y: box.y };
|
||||
const x = clamp(pos.x - w / 2, 8, ctx.canvas.width - w - 8);
|
||||
const y = clamp(pos.y - box.r * 1.8 - h, 8, ctx.canvas.height - h - 8);
|
||||
ctx.fillStyle = "rgba(46,38,30,0.62)";
|
||||
ctx.strokeStyle = "rgba(255,248,220,0.72)";
|
||||
ctx.lineWidth = 1.2;
|
||||
roundedRect(ctx, x, y, w, h, 10);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = "#fff8e6";
|
||||
ctx.textAlign = "left";
|
||||
ctx.textBaseline = "middle";
|
||||
lines.forEach((line, i) => ctx.fillText(line, x + 11, y + 11 + lineH * i + lineH / 2));
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawSelectedCard(ctx, world, lighting) {
|
||||
const t = world.selected;
|
||||
if (!t || t.dead || !world.tarinai.includes(t)) return;
|
||||
|
|
@ -699,10 +803,9 @@ function drawSelectedCard(ctx, world, lighting) {
|
|||
const child = t.juvenile && t.parentToFollow?.() ? " / \u5b50" : "";
|
||||
const zHint = t.digest > 4.9 ? " / \u305a\u3093\u3061" : "";
|
||||
ctx.fillText(`${stateLabel(t.state)}${child}${zHint}`, x + 10, y + 35);
|
||||
const personality = t.personalityLabel ? t.personalityLabel() : (t.personality || "\u4e0d\u660e");
|
||||
ctx.fillText(`\u6027\u683c ${personality}`, x + 10, y + 52);
|
||||
const genLabel = (t.displayGeneration && t.displayGeneration()) ? ` \u4e16\u4ee3 ${t.generation}` : "";
|
||||
ctx.fillText(`\u6c17\u5206 ${fmt(t.mood)}${genLabel} \u4f53\u529b ${fmt(t.energy)}`, x + 10, y + 68);
|
||||
ctx.fillText(`\u6c17\u5206 ${fmt(t.mood)}${genLabel} \u4f53\u529b ${fmt(t.energy)}`, x + 10, y + 52);
|
||||
ctx.fillText(`\u30b9\u30c8\u30ec\u30b9 ${fmt(t.stress)} \u7a7a\u8179 ${fmt(t.hunger)}`, x + 10, y + 68);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
"use strict";
|
||||
|
||||
window.TarinaiSim = { makeName, makeTrait, PERSONALITIES, SpatialGrid, Effect, Item, Tarinai, World, world };
|
||||
201
js/sim_core.js
|
|
@ -64,19 +64,187 @@ function stableUnit(seed, salt = "") {
|
|||
return ((h >>> 0) % 100000) / 100000;
|
||||
}
|
||||
|
||||
const PERSONALITIES = {
|
||||
timid: { label: "\u81c6\u75c5", desc: "\u9003\u3052\u3084\u3059\u304f\u3001\u55a7\u5629\u3092\u907f\u3051\u308b", fear: 1.42, fight: 0.48, social: 0.96, relation: 1.04, sleep: 1.00, zunda: 1.00, play: 0.78 },
|
||||
glutton: { label: "\u98df\u3044\u3057\u3093\u574a", desc: "\u305a\u3093\u3060\u9905\u3092\u512a\u5148\u3057\u3084\u3059\u3044", fear: 0.92, fight: 0.82, social: 1.00, relation: 1.00, sleep: 0.95, zunda: 1.42, play: 0.84 },
|
||||
sleepy: { label: "\u7720\u305f\u304c\u308a", desc: "\u65e9\u3081\u306b\u5bdd\u5e8a\u3078\u5411\u304b\u3046", fear: 0.96, fight: 0.72, social: 0.92, relation: 0.96, sleep: 1.34, zunda: 0.92, play: 0.68 },
|
||||
lonely: { label: "\u5bc2\u3057\u304c\u308a", desc: "\u4ed6\u306e\u305f\u308a\u306a\u3044\u3078\u5bc4\u308a\u3084\u3059\u3044", fear: 1.02, fight: 0.70, social: 1.34, relation: 1.38, sleep: 0.96, zunda: 1.00, play: 1.08 },
|
||||
irritable: { label: "\u6012\u308a\u3063\u307d\u3044", desc: "\u55a7\u5629\u304c\u8d77\u304d\u3084\u3059\u3044", fear: 0.82, fight: 1.82, social: 0.78, relation: 0.78, sleep: 0.90, zunda: 0.94, play: 0.82 },
|
||||
playful: { label: "\u904a\u3073\u597d\u304d", desc: "\u30dc\u30fc\u30eb\u3084\u52d5\u304f\u3082\u306e\u306b\u5f15\u304d\u5bc4\u305b\u3089\u308c\u3084\u3059\u3044", fear: 0.92, fight: 0.74, social: 1.10, relation: 1.04, sleep: 0.94, zunda: 0.96, play: 1.72 },
|
||||
calm: { label: "\u30de\u30a4\u30da\u30fc\u30b9", desc: "\u53cd\u5fdc\u304c\u7a4f\u3084\u304b\u3067\u5b89\u5b9a\u3057\u3084\u3059\u3044", fear: 0.82, fight: 0.58, social: 1.02, relation: 1.08, sleep: 1.02, zunda: 0.94, play: 0.86 },
|
||||
};
|
||||
const PERSONALITY_IDS = Object.keys(PERSONALITIES);
|
||||
const PERSONALITIES = Object.freeze({});
|
||||
const PERSONALITY_IDS = [];
|
||||
|
||||
function personalityForSeed(seed) {
|
||||
return stableChoice(seed, "personality", PERSONALITY_IDS) || "calm";
|
||||
return "";
|
||||
}
|
||||
|
||||
const PERSONALITY_KEYS = ["aggression", "openness", "sociability", "neuroticism"];
|
||||
const PERSONALITY_DAILY_LIMIT = { perAxis: 0.08, total: 0.20 };
|
||||
const FRIEND_AFFINITY_THRESHOLD = 16;
|
||||
const PERSONALITY_LABELS = {
|
||||
aggression: { label: "\u653b\u6483\u6027", low: "\u304a\u3060\u3084\u304b", high: "\u6012\u308a\u3063\u307d\u3044" },
|
||||
openness: { label: "\u958b\u653e\u6027", low: "\u4fdd\u5b88\u7684", high: "\u904a\u3073\u597d\u304d" },
|
||||
sociability: { label: "\u793e\u4ea4\u6027", low: "\u4e00\u4eba\u597d\u304d", high: "\u4ef2\u9593\u597d\u304d" },
|
||||
neuroticism: { label: "\u795e\u7d4c\u8cea\u6027", low: "\u7121\u9813\u7740", high: "\u7e4a\u7d30" },
|
||||
};
|
||||
|
||||
function clampPersonalityValue(value) {
|
||||
return clamp(Number(value) || 0, -1, 1);
|
||||
}
|
||||
|
||||
function normalizePersonality(source = null) {
|
||||
const out = {};
|
||||
for (const key of PERSONALITY_KEYS) out[key] = clampPersonalityValue(source?.[key]);
|
||||
return out;
|
||||
}
|
||||
|
||||
function randomBirthPersonality(seed = "") {
|
||||
const out = {};
|
||||
for (const key of PERSONALITY_KEYS) out[key] = clampPersonalityValue(stableUnit(seed, `personality-${key}`) * 1.2 - 0.6);
|
||||
return out;
|
||||
}
|
||||
|
||||
function personalityFromParents(a, b) {
|
||||
const pa = ensurePersonality(a).currentPersonality;
|
||||
const pb = ensurePersonality(b).currentPersonality;
|
||||
const out = {};
|
||||
for (const key of PERSONALITY_KEYS) out[key] = clampPersonalityValue(((pa[key] || 0) + (pb[key] || 0)) / 2 + rand(-0.2, 0.2));
|
||||
return out;
|
||||
}
|
||||
|
||||
function getTraitStrength(value) {
|
||||
const v = clampPersonalityValue(value);
|
||||
if (v >= 0.75) return { direction: 1, strength: "strong" };
|
||||
if (v >= 0.5) return { direction: 1, strength: "slight" };
|
||||
if (v <= -0.75) return { direction: -1, strength: "strong" };
|
||||
if (v <= -0.5) return { direction: -1, strength: "slight" };
|
||||
return { direction: 0, strength: "neutral" };
|
||||
}
|
||||
|
||||
function personalityBand(value) {
|
||||
const t = getTraitStrength(value);
|
||||
return t.direction === 0 ? "neutral" : `${t.direction > 0 ? "high" : "low"}-${t.strength}`;
|
||||
}
|
||||
|
||||
function personalityEffectValue(value) {
|
||||
const trait = getTraitStrength(value);
|
||||
if (!trait.direction) return 0;
|
||||
return trait.direction * (trait.strength === "strong" ? 1 : 0.5);
|
||||
}
|
||||
|
||||
function personalityReasonText(reason = "") {
|
||||
const key = String(reason || "").replace(/[.\u3002]+$/g, "").trim();
|
||||
const map = {
|
||||
"after winning fights": "\u55a7\u5629\u306b\u52dd\u3063\u305f\u5f71\u97ff\u3067",
|
||||
"after losing fights": "\u55a7\u5629\u306b\u8ca0\u3051\u305f\u5f71\u97ff\u3067",
|
||||
"after being petted": "\u64ab\u3067\u3089\u308c\u305f\u5f71\u97ff\u3067",
|
||||
"after staying near family": "\u89aa\u5b50\u306e\u8fd1\u304f\u3067\u904e\u3054\u3057\u305f\u5f71\u97ff\u3067",
|
||||
"after staying near friends": "\u53cb\u9054\u306e\u8fd1\u304f\u3067\u904e\u3054\u3057\u305f\u5f71\u97ff\u3067",
|
||||
"after having no friends": "\u53cb\u9054\u304c\u3044\u306a\u3044\u6642\u9593\u304c\u7d9a\u3044\u305f\u5f71\u97ff\u3067",
|
||||
"after repeated contact with poop": "\u305a\u3093\u3061\u306b\u6163\u308c\u305f\u5f71\u97ff\u3067",
|
||||
"after repeated contact with corpses": "\u6b7b\u9ab8\u306b\u6163\u308c\u305f\u5f71\u97ff\u3067",
|
||||
"after being hurt": "\u75db\u307f\u3092\u899a\u3048\u305f\u5f71\u97ff\u3067",
|
||||
};
|
||||
return map[key] || key;
|
||||
}
|
||||
|
||||
function personalityBecomeText(label = "") {
|
||||
const text = String(label || "");
|
||||
if (text === "\u6012\u308a\u3063\u307d\u3044") return "\u6012\u308a\u3063\u307d\u304f";
|
||||
return text;
|
||||
}
|
||||
|
||||
function personalityAppearanceText(label = "") {
|
||||
const text = String(label || "");
|
||||
if (text === "\u6012\u308a\u3063\u307d\u3044") return "\u6012\u308a\u3063\u307d\u3044\u69d8\u5b50";
|
||||
return `${text}\u306a\u69d8\u5b50`;
|
||||
}
|
||||
|
||||
function getPersonalityTraitTags(tarinai) {
|
||||
const p = ensurePersonality(tarinai).currentPersonality;
|
||||
const tags = [];
|
||||
for (const key of PERSONALITY_KEYS) {
|
||||
const trait = getTraitStrength(p[key]);
|
||||
if (!trait.direction) continue;
|
||||
const label = PERSONALITY_LABELS[key]?.[trait.direction > 0 ? "high" : "low"] || key;
|
||||
tags.push(trait.strength === "slight" ? `\u5c11\u3057${label}` : label);
|
||||
}
|
||||
const cowardly = (p.neuroticism || 0) >= 0.75 && (p.aggression || 0) <= -0.5;
|
||||
if (cowardly && !tags.includes("\u81c6\u75c5")) tags.push("\u81c6\u75c5");
|
||||
return tags;
|
||||
}
|
||||
|
||||
function personalityDailyState(tarinai) {
|
||||
const day = tarinai?.world?.day || 1;
|
||||
const state = tarinai.personalityDaily || {};
|
||||
if (state.day !== day) {
|
||||
tarinai.personalityDaily = { day, total: 0, byKey: {} };
|
||||
return tarinai.personalityDaily;
|
||||
}
|
||||
state.byKey = state.byKey || {};
|
||||
for (const key of Object.keys(state.byKey)) {
|
||||
const v = state.byKey[key];
|
||||
if (typeof v === "number") state.byKey[key] = { up: Math.max(0, v), down: Math.max(0, -v) };
|
||||
else state.byKey[key] = { up: Math.max(0, Number(v?.up) || 0), down: Math.max(0, Number(v?.down) || 0) };
|
||||
}
|
||||
state.total = Math.max(0, Number(state.total) || 0);
|
||||
tarinai.personalityDaily = state;
|
||||
return state;
|
||||
}
|
||||
|
||||
function ensurePersonality(tarinai) {
|
||||
if (!tarinai) return { birthPersonality: normalizePersonality(), currentPersonality: normalizePersonality() };
|
||||
if (!tarinai.birthPersonality) tarinai.birthPersonality = randomBirthPersonality(tarinai.id || Math.random());
|
||||
tarinai.birthPersonality = normalizePersonality(tarinai.birthPersonality);
|
||||
tarinai.currentPersonality = normalizePersonality(tarinai.currentPersonality || tarinai.birthPersonality);
|
||||
personalityDailyState(tarinai);
|
||||
return { birthPersonality: tarinai.birthPersonality, currentPersonality: tarinai.currentPersonality };
|
||||
}
|
||||
|
||||
function personalityThresholdLogText(tarinai, key, value, reason = "", previousBand = "") {
|
||||
const trait = getTraitStrength(value);
|
||||
const name = tarinai?.name || "\u305f\u308a\u306a\u3044";
|
||||
if (!trait.direction) {
|
||||
const oldSide = String(previousBand).startsWith("low") ? "low" : "high";
|
||||
const oldLabel = PERSONALITY_LABELS[key]?.[oldSide] || key;
|
||||
return `\u300c${name}\u300d\u306f${personalityAppearanceText(oldLabel)}\u304c\u76ee\u7acb\u305f\u306a\u304f\u306a\u3063\u305f\u3002`;
|
||||
}
|
||||
const label = PERSONALITY_LABELS[key]?.[trait.direction > 0 ? "high" : "low"] || key;
|
||||
const visible = trait.strength === "slight" ? `\u5c11\u3057${personalityBecomeText(label)}` : personalityBecomeText(label);
|
||||
const prefix = personalityReasonText(reason);
|
||||
return `\u300c${name}\u300d\u306f${prefix ? `${prefix}\u3001` : ""}${visible}\u306a\u3063\u305f\u3002`;
|
||||
}
|
||||
|
||||
function adjustPersonality(tarinai, key, delta, reason = "") {
|
||||
if (!tarinai || !PERSONALITY_KEYS.includes(key)) return 0;
|
||||
ensurePersonality(tarinai);
|
||||
const requested = Number(delta) || 0;
|
||||
if (!requested) return 0;
|
||||
const state = personalityDailyState(tarinai);
|
||||
const dir = Math.sign(requested);
|
||||
const used = state.byKey[key] || { up: 0, down: 0 };
|
||||
const dirKey = dir > 0 ? "up" : "down";
|
||||
const keyRoom = Math.max(0, PERSONALITY_DAILY_LIMIT.perAxis - (used[dirKey] || 0));
|
||||
const totalRoom = Math.max(0, PERSONALITY_DAILY_LIMIT.total - (state.total || 0));
|
||||
const allowed = Math.min(Math.abs(requested), keyRoom, totalRoom);
|
||||
if (allowed <= 0) return 0;
|
||||
const before = tarinai.currentPersonality[key] || 0;
|
||||
const beforeBand = personalityBand(before);
|
||||
const applied = dir * allowed;
|
||||
const after = clampPersonalityValue(before + applied);
|
||||
const actual = after - before;
|
||||
if (!actual) return 0;
|
||||
tarinai.currentPersonality[key] = after;
|
||||
state.byKey[key] = used;
|
||||
used[actual > 0 ? "up" : "down"] = (used[actual > 0 ? "up" : "down"] || 0) + Math.abs(actual);
|
||||
state.total = (state.total || 0) + Math.abs(actual);
|
||||
const afterBand = personalityBand(after);
|
||||
if (beforeBand !== afterBand) {
|
||||
const text = personalityThresholdLogText(tarinai, key, after, reason, beforeBand);
|
||||
tarinai.world?.log?.(text, "observe");
|
||||
tarinai.addRecord?.(text, "observe");
|
||||
}
|
||||
return actual;
|
||||
}
|
||||
|
||||
function shouldApplyPersonalityBehavior(tarinai, key, direction = 1) {
|
||||
const p = ensurePersonality(tarinai).currentPersonality;
|
||||
const trait = getTraitStrength(p[key]);
|
||||
if (trait.direction !== Math.sign(direction || 1)) return false;
|
||||
return trait.strength === "strong" || Math.random() < 0.5;
|
||||
}
|
||||
|
||||
function relationDefaults() {
|
||||
|
|
@ -85,12 +253,14 @@ function relationDefaults() {
|
|||
|
||||
function isParentChild(a, b) {
|
||||
if (!a || !b) return false;
|
||||
return Boolean(a.parents?.includes(b.id) || b.parents?.includes(a.id));
|
||||
const aKey = a.familyKey || a.id;
|
||||
const bKey = b.familyKey || b.id;
|
||||
return Boolean(a.parents?.includes(bKey) || b.parents?.includes(aKey));
|
||||
}
|
||||
|
||||
function relationDisplayName(worldRef, id) {
|
||||
if (!id) return "\u306a\u3057";
|
||||
const live = worldRef?.tarinai?.find(t => t.id === id);
|
||||
const live = worldRef?.tarinai?.find(t => t.id === id || t.familyKey === id);
|
||||
if (live) return live.name;
|
||||
const fam = worldRef?.family?.[id];
|
||||
return fam?.name || "\u4e0d\u660e";
|
||||
|
|
@ -138,6 +308,10 @@ class Effect {
|
|||
ctx.beginPath();
|
||||
ctx.ellipse(0, 0, this.size * 1.25, this.size * 0.72, this.seed, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
} else if (this.type === "heart") {
|
||||
const s = this.size * (0.80 + (1 - alpha) * 0.36);
|
||||
ctx.rotate(Math.sin(this.seed + (1 - alpha) * 3.2) * 0.18);
|
||||
drawHeartShape(ctx, 0, 0, s, { fill: this.color || "rgba(240,91,135,0.92)", stroke: "rgba(255,252,246,0.88)", alpha: 1 });
|
||||
} else if (this.type === "ring") {
|
||||
ctx.strokeStyle = this.color;
|
||||
ctx.lineWidth = 2;
|
||||
|
|
@ -317,4 +491,3 @@ function drawHeartShape(ctx, x, y, size, opts = {}) {
|
|||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
|
|
|
|||
1112
js/tarinai.js
54
js/text_catalog.js
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
"use strict";
|
||||
|
||||
const TEXT_CATALOG = {
|
||||
toolTips: toolTipsFromDefinitions(),
|
||||
ecologyCards: [
|
||||
{
|
||||
title: "\u305f\u308a\u306a\u3044\u3068\u306f",
|
||||
image: "assets/sprites/tarinai_01_smile.png",
|
||||
text: "\u5ead\u3067\u66ae\u3089\u3059\u5c0f\u3055\u306a\u751f\u304d\u7269\u3002\u7a7a\u8179\u3001\u7720\u6c17\u3001\u6c17\u5206\u3001\u30b9\u30c8\u30ec\u30b9\u3001\u4f53\u529b\u3067\u884c\u52d5\u304c\u5909\u308f\u308b\u3002",
|
||||
},
|
||||
{
|
||||
title: "\u4f53\u529b\u3068\u30b9\u30c8\u30ec\u30b9",
|
||||
image: "assets/ui/ecology_hp_stress.png",
|
||||
text: "\u30d0\u30fc\u306f\u4e0a\u304c\u4f53\u529b\u3001\u4e0b\u304c\u30b9\u30c8\u30ec\u30b9\u3002\u4f53\u529b\u306f\u3051\u304c\u3084\u4e8b\u6545\u3067\u6e1b\u308a\u30010\u3067\u6b7b\u4ea1\u3002\u30b9\u30c8\u30ec\u30b9\u306f\u6050\u6016\u3001\u6df7\u96d1\u3001\u6c5a\u308c\u3001\u3051\u3093\u304b\u3067\u5897\u3048\u3001\u9ad8\u3044\u3068\u884c\u52d5\u304c\u4e71\u308c\u308b\u3002",
|
||||
},
|
||||
{
|
||||
title: "\u66ae\u3089\u3057\u3068\u6b32\u6c42",
|
||||
image: "assets/sprites/tarinai_05_drool.png",
|
||||
text: "\u98df\u3079\u7269\u3001\u6c34\u3001\u5bdd\u5e8a\u3001\u5de3\u7bb1\u3001\u4ef2\u9593\u3092\u63a2\u3057\u3066\u52d5\u304f\u3002\u96e8\u3001\u6c17\u6e29\u3001\u8349\u3001\u6c5a\u308c\u3001\u9053\u5177\u3082\u72b6\u614b\u306b\u5f71\u97ff\u3059\u308b\u3002",
|
||||
},
|
||||
{
|
||||
title: "\u6027\u683c\u3068\u500b\u6027",
|
||||
image: "assets/sprites/tarinai_21_normal_happy.png",
|
||||
text: "\u653b\u6483\u6027\u3001\u958b\u653e\u6027\u3001\u793e\u4ea4\u6027\u3001\u795e\u7d4c\u8cea\u6027\u304c\u7d4c\u9a13\u3067\u5c11\u3057\u305a\u3064\u5909\u308f\u308b\u3002\u95be\u5024\u3092\u8d85\u3048\u308b\u3068\u6027\u683c\u30bf\u30b0\u3068\u884c\u52d5\u5dee\u306b\u51fa\u308b\u3002",
|
||||
},
|
||||
{
|
||||
title: "\u5bb6\u65cf\u3068\u95a2\u4fc2",
|
||||
image: "assets/sprites/tarinai_27_birth_ritual.png",
|
||||
text: "\u89aa\u5b50\u3001\u5171\u540c\u89aa\u3001\u53cb\u9054\u3001\u6575\u5bfe\u3001\u6050\u6016\u3092\u8a18\u9332\u3059\u308b\u3002\u5b50\u306f\u89aa\u306e\u73fe\u5728\u6027\u683c\u3092\u53d7\u3051\u7d99\u304e\u3001\u5bb6\u7cfb\u56f3\u306b\u4e16\u4ee3\u304c\u6b8b\u308b\u3002",
|
||||
},
|
||||
{
|
||||
title: "\u75c5\u6c17\u3068\u6cbb\u7642",
|
||||
image: "assets/sprites/tarinai_24_stress_dizzy.png",
|
||||
text: "\u305a\u3093\u3061\u75c5\u3001\u306d\u3080\u308a\u75c5\u3001\u7206\u767a\u75c5\u3001\u304d\u305a\u3064\u304d\u75c5\u304c\u3042\u308b\u3002\u75c5\u6c17\u3054\u3068\u306b\u6c34\u3084\u305a\u3093\u3060\u9905\u306a\u3069\u3092\u6c42\u3081\u308b\u3002",
|
||||
},
|
||||
{
|
||||
title: "\u3051\u3093\u304b\u3068\u4e8b\u6545",
|
||||
image: "assets/sprites/tarinai_26_intimidate.png",
|
||||
text: "\u5a01\u5687\u3001\u6d41\u8840\u3001\u7206\u7af9\u3001\u30dc\u30fc\u30eb\u3001\u843d\u4e0b\u7269\u3067\u50b7\u3064\u304f\u3002\u6b7b\u56e0\u306f\u3051\u3093\u304b\u3001\u98e2\u9913\u3001\u75c5\u6c17\u3001\u4e8b\u6545\u306a\u3069\u306b\u5206\u304b\u308c\u308b\u3002",
|
||||
},
|
||||
{
|
||||
title: "\u9053\u5177\u3068\u4ecb\u5165",
|
||||
image: "assets/sprites/tarinai_18_fear.png",
|
||||
text: "\u3064\u3064\u304f\u3001\u3064\u307e\u3080\u3001\u9905\u3001\u6c34\u3001\u6d17\u6d44\u3001\u5de3\u7bb1\u3001\u67f5\u3001\u7206\u7af9\u306a\u3069\u3067\u74b0\u5883\u3092\u4f5c\u308b\u3002\u52a9\u3051\u306b\u3082\u5371\u967a\u306b\u3082\u306a\u308b\u3002",
|
||||
},
|
||||
{
|
||||
title: "\u89b3\u5bdf\u306e\u65b9\u6cd5",
|
||||
image: "assets/sprites/tarinai_11_weak.png",
|
||||
text: "\u89b3\u5bdf\u30c4\u30fc\u30eb\u3001\u661f\u30de\u30fc\u30af\u3001\u89b3\u5bdf\u30ed\u30b0\u3001\u5bb6\u7cfb\u56f3\u3001\u6b7b\u56e0\u7d71\u8a08\u304b\u3089\u500b\u4f53\u306e\u5909\u5316\u3092\u8ffd\u3048\u308b\u3002",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
window.TEXT_CATALOG = TEXT_CATALOG;
|
||||
404
js/ui.js
|
|
@ -13,6 +13,8 @@ const ui = {
|
|||
creditsCloseBtn: document.getElementById("creditsCloseBtn"),
|
||||
toolPalette: document.getElementById("toolPalette"),
|
||||
toolCard: document.getElementById("toolCard"),
|
||||
selectedCard: document.getElementById("selectedCard"),
|
||||
selectedCloseBtn: document.getElementById("selectedCloseBtn"),
|
||||
colonyChartTabs: document.getElementById("colonyChartTabs"),
|
||||
selectedInfo: document.getElementById("selectedInfo"),
|
||||
log: document.getElementById("log"),
|
||||
|
|
@ -22,6 +24,7 @@ const ui = {
|
|||
colonyChart: document.getElementById("colonyChart"),
|
||||
colonyChartLegend: document.getElementById("colonyChartLegend"),
|
||||
archiveContent: document.getElementById("archiveContent"),
|
||||
archiveUpdateToggleBtn: document.getElementById("archiveUpdateToggleBtn"),
|
||||
archiveResetBtn: document.getElementById("archiveResetBtn"),
|
||||
archiveResetDialog: document.getElementById("archiveResetDialog"),
|
||||
archiveResetCancelBtn: document.getElementById("archiveResetCancelBtn"),
|
||||
|
|
@ -29,6 +32,7 @@ const ui = {
|
|||
fieldDialog: document.getElementById("fieldDialog"),
|
||||
fieldCancelBtn: document.getElementById("fieldCancelBtn"),
|
||||
ecologyDialog: document.getElementById("ecologyDialog"),
|
||||
ecologyGrid: document.getElementById("ecologyGrid"),
|
||||
ecologyCloseBtn: document.getElementById("ecologyCloseBtn"),
|
||||
};
|
||||
|
||||
|
|
@ -36,6 +40,8 @@ const uiCache = {
|
|||
stats: {},
|
||||
selectedSnapshot: "",
|
||||
selectedEmpty: false,
|
||||
selectedCollapsedCategories: new Set(),
|
||||
archiveUpdateEnabled: false,
|
||||
toolButtons: [],
|
||||
archiveVersion: "",
|
||||
archiveRows: [],
|
||||
|
|
@ -44,9 +50,12 @@ const uiCache = {
|
|||
archiveHtmlCache: new Map(),
|
||||
archiveRenderTimer: 0,
|
||||
archiveRenderForce: false,
|
||||
archiveRenderRunning: false,
|
||||
archiveScheduled: false,
|
||||
archivePendingOffscreen: false,
|
||||
archivePendingWhileRunning: false,
|
||||
archivePendingDirtyAfterRun: false,
|
||||
archiveLastHiddenCheckAt: -Infinity,
|
||||
archiveLastRenderedAt: 0,
|
||||
archiveLastScheduleFamilyVersion: null,
|
||||
archiveDiagnostics: { renders: 0, lastMs: 0, lastFamilies: 0, lastNodes: 0, skippedWhileRunning: 0 },
|
||||
|
|
@ -57,6 +66,7 @@ const uiCache = {
|
|||
chartBucketStart: -Infinity,
|
||||
chartBucketEnd: -Infinity,
|
||||
chartBucketAccum: null,
|
||||
environmentInitialRow: null,
|
||||
lastChartSample: -Infinity,
|
||||
lastChartDraw: "",
|
||||
lastChartDrawAt: 0,
|
||||
|
|
@ -117,7 +127,7 @@ function logKindLabel(kind) {
|
|||
}
|
||||
|
||||
function tarinaiSpritePathForId(id) {
|
||||
const live = world?.tarinai?.find(t => t.id === id && !t.dead);
|
||||
const live = world?.tarinai?.find(t => (t.id === id || t.familyKey === id) && !t.dead);
|
||||
const sid = live?.spriteId ? live.spriteId() : live?.type;
|
||||
const fam = world?.family?.[id];
|
||||
const fallback = fam?.type;
|
||||
|
|
@ -129,9 +139,113 @@ function relationIconHtml(id) {
|
|||
return `<img class="relation-face" src="${tarinaiSpritePathForId(id)}" alt="">`;
|
||||
}
|
||||
|
||||
function relationEntries(t, kind = "friend") {
|
||||
const rows = [];
|
||||
const liveIds = new Set((world?.tarinai || []).filter(o => o && !o.dead && o !== t).map(o => o.id));
|
||||
for (const [id, rel] of Object.entries(t?.relationships || {})) {
|
||||
if (!liveIds.has(id)) continue;
|
||||
const score = kind === "enemy" ? (rel.fear || 0) : (rel.affinity || 0);
|
||||
const threshold = kind === "enemy" ? 0.5 : FRIEND_AFFINITY_THRESHOLD;
|
||||
if (score <= threshold) continue;
|
||||
rows.push({ id, score, name: relationDisplayName(world, id) });
|
||||
}
|
||||
rows.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name, "ja"));
|
||||
return rows;
|
||||
}
|
||||
|
||||
function relationListHtml(t, kind = "friend") {
|
||||
const rows = relationEntries(t, kind);
|
||||
if (!rows.length) return "\u306a\u3057";
|
||||
const sign = kind === "friend" ? "+" : "";
|
||||
return `<div class="relation-chip-list">${rows.map(r => `
|
||||
<button type="button" class="relation-chip ${kind === "enemy" ? "enemy" : "friend"}" data-focus-tarinai-id="${escapeHtml(r.id)}" title="${escapeHtml(r.name)}\u3078\u79fb\u52d5">
|
||||
${relationIconHtml(r.id)}
|
||||
<span class="relation-chip-name">${escapeHtml(r.name)}</span>
|
||||
<em>${sign}${fmt(r.score)}</em>
|
||||
</button>`).join("")}</div>`;
|
||||
}
|
||||
|
||||
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; }
|
||||
world.selected = t;
|
||||
t.focusPulseTimer = Math.max(t.focusPulseTimer || 0, 1.25);
|
||||
selectTool("observe");
|
||||
if (Number.isFinite(t.x) && Number.isFinite(t.y)) {
|
||||
world.cameraX = t.x - (world.visibleWorldW ? world.visibleWorldW() : (world.viewportW || world.w)) / 2;
|
||||
world.cameraY = t.y - (world.visibleWorldH ? world.visibleWorldH() : (world.viewportH || world.h)) / 2;
|
||||
world.clampCamera?.();
|
||||
}
|
||||
uiCache.selectedSnapshot = "";
|
||||
renderSelected();
|
||||
renderStats();
|
||||
render?.();
|
||||
showToast(`\u300c${t.name}\u300d\u3078\u79fb\u52d5\u3057\u307e\u3057\u305f\u3002`);
|
||||
return true;
|
||||
}
|
||||
|
||||
function quoteNameInRecordText(text, name) {
|
||||
const raw = String(text || "");
|
||||
const n = String(name || "").trim();
|
||||
if (!n) return raw;
|
||||
const parts = raw.split(n);
|
||||
if (parts.length <= 1) return raw;
|
||||
let out = "";
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
const before = parts[i].slice(-1);
|
||||
const after = parts[i + 1].charAt(0);
|
||||
out += parts[i] + ((before === "\u300c" && after === "\u300d") ? n : `\u300c${n}\u300d`);
|
||||
}
|
||||
return out + parts[parts.length - 1];
|
||||
}
|
||||
|
||||
function renameSelectedTarinai(rawName) {
|
||||
const t = world.selected;
|
||||
if (!t || t.dead || !world.tarinai.includes(t)) return false;
|
||||
const next = String(rawName || "").replace(/\s+/g, " ").trim().slice(0, 18);
|
||||
if (!next) {
|
||||
uiCache.selectedSnapshot = "";
|
||||
renderSelected();
|
||||
return false;
|
||||
}
|
||||
if (next === t.name) return false;
|
||||
const oldName = t.name;
|
||||
t.name = next;
|
||||
if (t.isZunchiSlave) t.formerName = next;
|
||||
world.recordFamily?.(t);
|
||||
world.markFamilyTreeDirty?.("rename");
|
||||
uiCache.archiveFamilyVersion = null;
|
||||
uiCache.selectedSnapshot = "";
|
||||
renderSelected();
|
||||
renderArchive?.();
|
||||
render?.();
|
||||
showToast(`\u300c${oldName}\u300d\u3092\u300c${next}\u300d\u306b\u6539\u540d\u3057\u307e\u3057\u305f\u3002`);
|
||||
return true;
|
||||
}
|
||||
|
||||
function personalityValueText(value) {
|
||||
const n = clamp(Number(value) || 0, -1, 1);
|
||||
return `${n >= 0 ? "+" : ""}${n.toFixed(2)}`;
|
||||
}
|
||||
|
||||
function personalityRowsHtml(personality = {}) {
|
||||
const p = normalizePersonality(personality);
|
||||
return PERSONALITY_KEYS.map(key => `<span>${escapeHtml(PERSONALITY_LABELS[key]?.label || key)}: ${personalityValueText(p[key])}</span>`).join("");
|
||||
}
|
||||
|
||||
function selectedDataCategoryHtml(id, label, bodyHtml) {
|
||||
if (!uiCache.selectedCollapsedCategories) uiCache.selectedCollapsedCategories = new Set();
|
||||
const collapsed = uiCache.selectedCollapsedCategories.has(id);
|
||||
return `<section class="selected-category${collapsed ? " collapsed" : ""}" data-selected-category="${escapeHtml(id)}">
|
||||
<button class="selected-category-toggle" type="button" aria-expanded="${collapsed ? "false" : "true"}">${escapeHtml(label)}</button>
|
||||
<div class="selected-category-body">${bodyHtml}</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function renderSelected() {
|
||||
const t = world.selected;
|
||||
if (!t || !world.tarinai.includes(t)) {
|
||||
if (!t || t.dead || !world.tarinai.includes(t)) {
|
||||
ui.selectedCard?.classList.add("hidden");
|
||||
if (uiCache.selectedEmpty) return;
|
||||
uiCache.selectedSnapshot = "";
|
||||
uiCache.selectedEmpty = true;
|
||||
|
|
@ -140,49 +254,106 @@ function renderSelected() {
|
|||
return;
|
||||
}
|
||||
|
||||
ui.selectedCard?.classList.remove("hidden");
|
||||
uiCache.selectedEmpty = false;
|
||||
const activeNameInput = document.activeElement?.matches?.("[data-selected-name]") && document.activeElement?.dataset?.selectedId === t.id;
|
||||
if (activeNameInput) return;
|
||||
const rel = t.relationSummary ? t.relationSummary() : {};
|
||||
const fightRecord = relationFightRecord(t);
|
||||
const generationDisplay = t.displayGeneration ? t.displayGeneration() : ((t.hasPaired || t.parents?.length || t.children?.length) ? t.generation : "");
|
||||
ensurePersonality(t);
|
||||
const personalityTags = t.personalityTags ? t.personalityTags() : getPersonalityTraitTags(t);
|
||||
const personalitySnapshot = [
|
||||
...PERSONALITY_KEYS.map(key => personalityValueText(t.birthPersonality?.[key])),
|
||||
...PERSONALITY_KEYS.map(key => personalityValueText(t.currentPersonality?.[key])),
|
||||
personalityTags.join(",")
|
||||
].join(",");
|
||||
const snapshot = [
|
||||
t.id, t.type, t.name, (t.displayGeneration ? t.displayGeneration() : ((t.hasPaired || t.parents?.length || t.children?.length) ? t.generation : "")), t.state, t.personality, t.thought, targetLabel(t.target),
|
||||
t.id, t.type, t.name, generationDisplay, t.state, t.thought, targetLabel(t.target),
|
||||
fmt(t.age), fmt(t.lifeSpan), fmt(t.hunger), fmt(t.loneliness),
|
||||
fmt(t.energy), fmt(t.stress), fmt(t.mood),
|
||||
t.parentNames?.join("+") || "", t.children?.length || 0, fmt(t.lack),
|
||||
rel.friend?.id || "", rel.friend?.score || 0, rel.friend?.id ? tarinaiSpritePathForId(rel.friend.id) : "", rel.fear?.id || "", rel.fear?.score || 0, rel.fear?.id ? tarinaiSpritePathForId(rel.fear.id) : "", fightRecord,
|
||||
relationEntries(t, "friend").map(r => `${r.id}:${fmt(r.score)}:${r.name}`).join(","), relationEntries(t, "enemy").map(r => `${r.id}:${fmt(r.score)}:${r.name}`).join(","), fightRecord,
|
||||
t.isZunchiSlave ? 1 : 0, fmt(t.totalFightLosses || 0), fmt(t.loveMochiTimer || 0), fmt(t.fightMochiTimer || 0),
|
||||
t.zunchiDisease ? 1 : 0, fmt(t.zunchiDiseaseSeverity || 0), fmt(t.zunchiStain || 0)
|
||||
t.zunchiDisease ? 1 : 0, fmt(t.zunchiDiseaseSeverity || 0), fmt(t.zunchiStain || 0),
|
||||
t.sleepDisease ? 1 : 0, t.explosionDisease ? 1 : 0, fmt(t.explosionDiseaseTimer || 0), t.fightDisease ? 1 : 0, t.favorite ? 1 : 0,
|
||||
personalitySnapshot,
|
||||
(t.records || []).slice(0, 6).map(r => `${Math.round(r.time || 0)}:${r.kind || ""}:${r.text || ""}`).join("~")
|
||||
].join("|");
|
||||
if (uiCache.selectedSnapshot === snapshot) return;
|
||||
uiCache.selectedSnapshot = snapshot;
|
||||
|
||||
ui.selectedInfo.className = "selected-info";
|
||||
const effectBadges = [];
|
||||
effectBadges.push(`<span class="badge">\u6027\u683c ${escapeHtml(t.personalityLabel ? t.personalityLabel() : t.personality || "\u4e0d\u660e")}</span>`);
|
||||
if (t.isZunchiSlave) effectBadges.push(`<span class="badge">\u305a\u3093\u3061\u3069\u308c\u3044</span>`);
|
||||
if (t.favorite) effectBadges.push(`<span class="badge">\u2605</span>`);
|
||||
if (t.zunchiDisease) effectBadges.push(`<span class="badge">\u305a\u3093\u3061\u75c5 ${fmt((t.zunchiDiseaseSeverity || 0) * 100)}</span>`);
|
||||
if (t.sleepDisease) effectBadges.push(`<span class="badge">\u306d\u3080\u308a\u75c5</span>`);
|
||||
if (t.explosionDisease) effectBadges.push(`<span class="badge">\u7206\u767a\u75c5 ${fmt(t.explosionDiseaseTimer || 0)}</span>`);
|
||||
if (t.fightDisease) effectBadges.push(`<span class="badge">\u304d\u305a\u3064\u304d\u75c5</span>`);
|
||||
if ((t.loveMochiTimer || 0) > 0.1) effectBadges.push(`<span class="badge">\u3078\u3053\u9905 ${fmt(t.loveMochiTimer)}</span>`);
|
||||
if ((t.fightMochiTimer || 0) > 0.1) effectBadges.push(`<span class="badge">\u3051\u3093\u304b\u9905 ${fmt(t.fightMochiTimer)}</span>`);
|
||||
ui.selectedInfo.innerHTML = `
|
||||
<div><span class="badge">${escapeHtml(t.label())}</span><span class="badge">${escapeHtml(t.name)}</span>${(t.displayGeneration ? t.displayGeneration() : ((t.hasPaired || t.parents?.length || t.children?.length) ? t.generation : null)) ? `<span class="badge">\u4e16\u4ee3 ${t.generation}</span>` : ""}${effectBadges.join("")}</div>
|
||||
<div class="info-row"><span>\u6027\u683c</span><strong>${escapeHtml(t.personalityLabel ? t.personalityLabel() : t.personality || "\u4e0d\u660e")}</strong></div>
|
||||
const personalityTagHtml = personalityTags.length
|
||||
? personalityTags.map(tag => `<span class="personality-tag">${escapeHtml(tag)}</span>`).join("")
|
||||
: `<span class="personality-tag neutral">\u4e2d\u7acb</span>`;
|
||||
const personalityHtml = `
|
||||
<div class="info-row personality-values"><span>\u6027\u683c\u30bf\u30b0</span><strong>${personalityTagHtml}</strong></div>
|
||||
<div class="info-row personality-values"><span>\u8a95\u751f\u6642\u6027\u683c</span><strong>${personalityRowsHtml(t.birthPersonality)}</strong></div>
|
||||
<div class="info-row personality-values"><span>\u73fe\u5728\u6027\u683c</span><strong>${personalityRowsHtml(t.currentPersonality)}</strong></div>
|
||||
`;
|
||||
const recordHtml = (t.records || []).slice(0, 8).map(r => {
|
||||
const timeText = world.clockString ? (world.clockStringFromTime ? world.clockStringFromTime(r.time || 0) : formatRecordTime(r.time || 0)) : formatRecordTime(r.time || 0);
|
||||
const recordText = quoteNameInRecordText(r.text || "", t.name);
|
||||
return `<li><span>${escapeHtml(timeText)}</span><strong>${escapeHtml(recordText)}</strong></li>`;
|
||||
}).join("") || `<li><span>-</span><strong>\u8a18\u9332\u306a\u3057</strong></li>`;
|
||||
const basicHtml = `
|
||||
<div class="info-row"><span>\u4e16\u4ee3</span><strong>${escapeHtml(generationDisplay || "\u4e0d\u660e")}</strong></div>
|
||||
<div class="info-row"><span>\u72b6\u614b</span><strong>${escapeHtml(stateLabel(t.state))}</strong></div>
|
||||
<div class="info-row"><span>\u7406\u7531</span><strong>${escapeHtml(reasonLabel(t))}</strong></div>
|
||||
<div class="info-row"><span>\u5bfe\u8c61</span><strong>${escapeHtml(targetLabel(t.target))}</strong></div>
|
||||
<div class="info-row"><span>\u5e74\u9f62</span><strong>${fmt(t.age)} / ${fmt(t.lifeSpan)}</strong></div>
|
||||
`;
|
||||
const healthHtml = `
|
||||
<div class="info-row"><span>\u7a7a\u8179</span><strong>${fmt(t.hunger)}</strong></div>
|
||||
<div class="info-row"><span>\u5bc2\u3057\u3055</span><strong>${fmt(t.loneliness)}</strong></div>
|
||||
<div class="info-row"><span>\u4f53\u529b</span><strong>${fmt(t.energy)}</strong></div>
|
||||
<div class="info-row"><span>\u30b9\u30c8\u30ec\u30b9</span><strong>${fmt(t.stress)}</strong></div>
|
||||
<div class="info-row"><span>\u305a\u3093\u3061\u6c5a\u308c</span><strong>${fmt(t.zunchiStain || 0)}</strong></div>
|
||||
<div class="info-row"><span>\u6c17\u5206</span><strong>${fmt(t.mood)}</strong></div>
|
||||
<div class="info-row relation-row"><span>\u4ef2\u826f\u3057</span><strong>${rel.friend ? `${relationIconHtml(rel.friend.id)}${escapeHtml(`${rel.friend.name} +${fmt(rel.friend.score)}`)}` : "\u306a\u3057"}</strong></div>
|
||||
<div class="info-row relation-row"><span>\u907f\u3051\u308b\u76f8\u624b</span><strong>${rel.fear ? `${relationIconHtml(rel.fear.id)}${escapeHtml(`${rel.fear.name} ${fmt(rel.fear.score)}`)}` : "\u306a\u3057"}</strong></div>
|
||||
<div class="info-row"><span>\u55a7\u5629\u8a18\u61b6</span><strong>${escapeHtml(fightRecord)}</strong></div>
|
||||
<div class="info-row"><span>\u7d2f\u8a08\u6557\u5317</span><strong>${t.totalFightLosses || 0}</strong></div>
|
||||
<div class="info-row"><span>\u89aa</span><strong>${escapeHtml(t.parentNames?.length ? t.parentNames.join(" + ") : "\u4e0d\u660e")}</strong></div>
|
||||
<div class="info-row"><span>\u5b50</span><strong>${t.children?.length || 0}</strong></div>
|
||||
<div class="info-row"><span>\u305f\u308a\u306a\u3044</span><strong>${fmt(t.lack)}</strong></div>
|
||||
`;
|
||||
const relationHtml = `
|
||||
<div class="info-row relation-row relation-list-row"><span>\u53cb\u9054</span><strong>${relationListHtml(t, "friend")}</strong></div>
|
||||
<div class="info-row relation-row relation-list-row"><span>\u6575\u5bfe</span><strong>${relationListHtml(t, "enemy")}</strong></div>
|
||||
<div class="info-row"><span>\u55a7\u5629\u8a18\u61b6</span><strong>${escapeHtml(fightRecord)}</strong></div>
|
||||
<div class="info-row"><span>\u7d2f\u8a08\u6557\u5317</span><strong>${t.totalFightLosses || 0}</strong></div>
|
||||
`;
|
||||
const familyHtml = `
|
||||
<div class="info-row"><span>\u89aa</span><strong>${escapeHtml(t.parentNames?.length ? t.parentNames.join(" + ") : "\u4e0d\u660e")}</strong></div>
|
||||
<div class="info-row"><span>\u5b50</span><strong>${t.children?.length || 0}</strong></div>
|
||||
`;
|
||||
ui.selectedInfo.innerHTML = `
|
||||
<div class="selected-name-editor">
|
||||
<input class="selected-name-input" data-selected-name data-selected-id="${escapeHtml(t.id)}" value="${escapeHtml(t.name)}" maxlength="18" aria-label="\u305f\u308a\u306a\u3044\u306e\u540d\u524d" title="\u540d\u524d\u3092\u7de8\u96c6">
|
||||
</div>
|
||||
${effectBadges.length ? `<div class="selected-effects">${effectBadges.join("")}</div>` : ""}
|
||||
<div class="selected-data-groups">
|
||||
${selectedDataCategoryHtml("basic", "\u57fa\u672c", basicHtml)}
|
||||
${selectedDataCategoryHtml("health", "\u4f53\u8abf", healthHtml)}
|
||||
${selectedDataCategoryHtml("personality", "\u6027\u683c", personalityHtml)}
|
||||
${selectedDataCategoryHtml("relation", "\u95a2\u4fc2", relationHtml)}
|
||||
${selectedDataCategoryHtml("family", "\u5bb6\u65cf", familyHtml)}
|
||||
${selectedDataCategoryHtml("records", "\u8a18\u9332", `<div class="selected-records"><h3>\u500b\u4f53\u5225\u306e\u8a18\u9332</h3><ol>${recordHtml}</ol></div>`)}
|
||||
</div>
|
||||
<div class="selected-actions"><button type="button" class="favorite-toggle ${t.favorite ? "active" : ""}" data-selected-action="favorite">${t.favorite ? "\u2605 \u304a\u6c17\u306b\u5165\u308a\u89e3\u9664" : "\u2606 \u304a\u6c17\u306b\u5165\u308a"}</button></div>
|
||||
`;
|
||||
}
|
||||
|
||||
function formatRecordTime(time) {
|
||||
const totalMinutes = Math.floor(((time || 0) % (CONFIG.dayLength || 120)) / (CONFIG.dayLength || 120) * 24 * 60);
|
||||
const hh = String(Math.floor(totalMinutes / 60)).padStart(2, "0");
|
||||
const mm = String(totalMinutes % 60).padStart(2, "0");
|
||||
return `${hh}:${mm}`;
|
||||
}
|
||||
|
||||
function stateLabel(s) {
|
||||
|
|
@ -193,6 +364,7 @@ function stateLabel(s) {
|
|||
play_ball: "\u30dc\u30fc\u30eb\u904a\u3073",
|
||||
seek_water: "\u6c34\u3092\u63a2\u3059",
|
||||
zunchi_sick: "\u305a\u3093\u3061\u75c5",
|
||||
fight_sick: "\u304d\u305a\u3064\u304d\u75c5",
|
||||
system: "\u305a\u3093\u3061\u75c5",
|
||||
seek_friend: "\u4ef2\u9593\u3092\u63a2\u3059",
|
||||
follow_parent: "\u89aa\u306b\u3064\u3044\u3066\u3044\u304f",
|
||||
|
|
@ -216,7 +388,8 @@ function reasonLabel(t) {
|
|||
if (t.state === "intimidate") return "\u76f8\u624b\u3092\u5a01\u5687\u3057\u3066\u3044\u308b";
|
||||
if (t.state === "birth_ritual") return "\u5b50\u304c\u73fe\u308c\u308b\u524d\u306b\u4f53\u3092\u3086\u3059\u3063\u3066\u3044\u308b";
|
||||
if (t.state === "seek_food") return "\u304a\u8179\u304c\u3059\u3044\u3066\u3044\u308b";
|
||||
if (t.state === "seek_water") return "\u305a\u3093\u3061\u75c5\u3067\u6c34\u3092\u63a2\u3057\u3066\u3044\u308b";
|
||||
if (t.state === "seek_water") return t.explosionDisease ? "\u7206\u767a\u75c5\u3067\u6c34\u3092\u63a2\u3057\u3066\u3044\u308b" : "\u305a\u3093\u3061\u75c5\u3067\u6c34\u3092\u63a2\u3057\u3066\u3044\u308b";
|
||||
if (t.state === "fight_sick") return "\u304d\u305a\u3064\u304d\u75c5\u3067\u5f53\u3066\u3082\u306a\u304f\u5f77\u5fa8\u3063\u3066\u3044\u308b";
|
||||
if (t.state === "zunchi_sick" || t.state === "system") return "\u305a\u3093\u3061\u75c5";
|
||||
return "\u5468\u56f2\u3092\u898b\u3066\u3044\u308b";
|
||||
}
|
||||
|
|
@ -224,7 +397,7 @@ function reasonLabel(t) {
|
|||
function targetLabel(target) {
|
||||
if (!target) return "\u306a\u3057";
|
||||
if (target.name) return target.name;
|
||||
if (target.type) return { sweet: "\u305a\u3093\u3060\u9905", love_mochi: "\u3078\u3053\u9905", fight_mochi: "\u3051\u3093\u304b\u9905", food: "\u98df\u4e8b", grass: "\u8349", water: "\u6c34", bed: "\u5e72\u8349\u5bdd\u5e8a", ball: "\u30dc\u30fc\u30eb", stone: "\u77f3", zunchi: "\u305a\u3093\u3061", system: "\u305a\u3093\u3061\u75c5", zunchi_sick: "\u305a\u3093\u3061\u75c5", trace: "\u8db3\u8de1", splat: "\u3057\u3076\u304d" }[target.type] || target.type;
|
||||
if (target.type) return { sweet: "\u305a\u3093\u3060\u9905", love_mochi: "\u3078\u3053\u9905", fight_mochi: "\u3051\u3093\u304b\u9905", sleep_drug: "\u306d\u3080\u308a\u85ac", food: "\u98df\u4e8b", grass: "\u8349", water: "\u6c34", water_bowl: "\u6c34\u306e\u76bf", bed: "\u5e72\u8349\u5bdd\u5e8a", nest_box: "\u5de3\u7bb1", ball: "\u30dc\u30fc\u30eb", stone: "\u77f3", genkotsu: "\u3052\u3093\u3053\u3064", zunchi: "\u305a\u3093\u3061", system: "\u305a\u3093\u3061\u75c5", zunchi_sick: "\u305a\u3093\u3061\u75c5", fight_sick: "\u304d\u305a\u3064\u304d\u75c5", trace: "\u8db3\u8de1", splat: "\u3057\u3076\u304d" }[target.type] || target.type;
|
||||
if (Number.isFinite(target.x) && Number.isFinite(target.y)) return "\u30ab\u30fc\u30bd\u30eb";
|
||||
return "\u4e0d\u660e";
|
||||
}
|
||||
|
|
@ -327,7 +500,62 @@ function hydrateLazyImages(root) {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
function applyToolTips() {
|
||||
const tips = window.TEXT_CATALOG?.toolTips || {};
|
||||
if (!ui.toolPalette) return;
|
||||
let pop = document.getElementById("toolTipPopover");
|
||||
if (!pop) {
|
||||
pop = document.createElement("div");
|
||||
pop.id = "toolTipPopover";
|
||||
pop.className = "tool-tip-popover hidden";
|
||||
document.body.appendChild(pop);
|
||||
}
|
||||
const showTip = (btn) => {
|
||||
const baseTip = btn?.dataset?.tip || "";
|
||||
if (!baseTip) return;
|
||||
let tip = baseTip;
|
||||
if (btn?.dataset?.tool === "nest_box") {
|
||||
const count = world?.nestBoxToolTipCount?.();
|
||||
if (count) tip = `${baseTip}
|
||||
(${count.current}/${count.max})\u5339`;
|
||||
}
|
||||
pop.textContent = tip;
|
||||
pop.classList.remove("hidden");
|
||||
const rect = btn.getBoundingClientRect();
|
||||
const popW = pop.offsetWidth || 220;
|
||||
const popH = pop.offsetHeight || 36;
|
||||
const left = rect.left - popW - 12;
|
||||
pop.style.left = `${clamp(left, 8, Math.max(8, window.innerWidth - popW - 8))}px`;
|
||||
pop.style.top = `${clamp(rect.top + rect.height / 2 - popH / 2, 8, Math.max(8, window.innerHeight - popH - 8))}px`;
|
||||
};
|
||||
const hideTip = () => pop.classList.add("hidden");
|
||||
for (const btn of ui.toolPalette.querySelectorAll(".tool[data-tool]")) {
|
||||
const tip = tips[btn.dataset.tool];
|
||||
if (!tip) continue;
|
||||
btn.dataset.tip = tip;
|
||||
if (btn.dataset.tipBound === "1") continue;
|
||||
btn.dataset.tipBound = "1";
|
||||
btn.addEventListener("mouseenter", () => showTip(btn));
|
||||
btn.addEventListener("focus", () => showTip(btn));
|
||||
btn.addEventListener("mouseleave", hideTip);
|
||||
btn.addEventListener("blur", hideTip);
|
||||
}
|
||||
}
|
||||
|
||||
function renderEcologyCards() {
|
||||
if (!ui.ecologyGrid || ui.ecologyGrid.dataset.ready === "1") return;
|
||||
const cards = window.TEXT_CATALOG?.ecologyCards || [];
|
||||
ui.ecologyGrid.innerHTML = cards.map(card => `
|
||||
<section class="ecology-card">
|
||||
<img data-src="${escapeHtml(card.image || "assets/sprites/tarinai_01_smile.png")}" loading="lazy" decoding="async" alt="">
|
||||
<div><strong>${escapeHtml(card.title || "")}</strong><p>${escapeHtml(card.text || "")}</p></div>
|
||||
</section>`).join("");
|
||||
ui.ecologyGrid.dataset.ready = "1";
|
||||
}
|
||||
|
||||
function openEcologyDialog() {
|
||||
renderEcologyCards();
|
||||
hydrateLazyImages(ui.ecologyDialog);
|
||||
ui.ecologyDialog?.classList.remove("hidden");
|
||||
}
|
||||
|
|
@ -409,9 +637,39 @@ function syncTopButtons() {
|
|||
ui.pauseBtn.textContent = world.paused ? "\u518d\u958b" : "\u4e00\u6642\u505c\u6b62";
|
||||
ui.speedBtn.textContent = `\u901f\u5ea6 x${world.speed}`;
|
||||
if (ui.soundBtn) ui.soundBtn.textContent = audio.enabled ? "\u97f3 ON" : "\u97f3 OFF";
|
||||
syncArchiveUpdateButton();
|
||||
}
|
||||
|
||||
const SCALABLE_TOOLS = new Set(["zunchi", "sweet", "love_mochi", "fight_mochi", "grass", "stone", "firecracker", "fence_v", "fence_h"]);
|
||||
function archiveAutoUpdateEnabled() {
|
||||
return Boolean(uiCache.archiveUpdateEnabled);
|
||||
}
|
||||
|
||||
function syncArchiveUpdateButton() {
|
||||
const btn = ui.archiveUpdateToggleBtn;
|
||||
if (!btn) return;
|
||||
const on = archiveAutoUpdateEnabled();
|
||||
btn.textContent = on ? "\u66f4\u65b0 ON" : "\u66f4\u65b0 OFF";
|
||||
btn.classList.toggle("active", on);
|
||||
btn.setAttribute("aria-pressed", on ? "true" : "false");
|
||||
}
|
||||
|
||||
function setArchiveUpdateEnabled(on) {
|
||||
uiCache.archiveUpdateEnabled = Boolean(on);
|
||||
syncArchiveUpdateButton();
|
||||
if (uiCache.archiveUpdateEnabled) {
|
||||
uiCache.archiveVersion = "";
|
||||
uiCache.archiveFamilyVersion = null;
|
||||
world.familyTreeDirty = true;
|
||||
scheduleArchiveWindowRender?.();
|
||||
showToast("\u5bb6\u7cfb\u56f3\u306e\u66f4\u65b0\u3092ON\u306b\u3057\u307e\u3057\u305f\u3002");
|
||||
} else {
|
||||
if (uiCache.archiveRenderTimer) { clearTimeout(uiCache.archiveRenderTimer); uiCache.archiveRenderTimer = 0; }
|
||||
uiCache.archiveScheduled = false;
|
||||
showToast("\u5bb6\u7cfb\u56f3\u306e\u66f4\u65b0\u3092OFF\u306b\u3057\u307e\u3057\u305f\u3002");
|
||||
}
|
||||
}
|
||||
|
||||
const SCALABLE_TOOLS = new Set(scalableToolIds());
|
||||
const SIZE_ORDER = ["small", "medium", "large"];
|
||||
const SIZE_LABELS = { small: "\u5c0f", medium: "\u4e2d", large: "\u5927" };
|
||||
|
||||
|
|
@ -455,7 +713,9 @@ function syncToolSizeBadges() {
|
|||
}
|
||||
|
||||
function bindUI() {
|
||||
uiCache.toolButtons = Array.from(ui.toolPalette.querySelectorAll(".tool"));
|
||||
applyToolTips();
|
||||
renderEcologyCards();
|
||||
uiCache.toolButtons = Array.from(ui.toolPalette?.querySelectorAll(".tool") || []);
|
||||
syncToolSizeBadges();
|
||||
ui.colonyChartTabs?.addEventListener("click", (e) => {
|
||||
const btn = e.target.closest("button[data-chart]");
|
||||
|
|
@ -488,18 +748,78 @@ function bindUI() {
|
|||
const node = e.target.closest("[data-family-tarinai-id]");
|
||||
if (!node) return;
|
||||
const id = node.getAttribute("data-family-tarinai-id");
|
||||
const t = world.tarinai.find(x => x.id === id && !x.dead);
|
||||
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; }
|
||||
world.selected = t;
|
||||
t.focusPulseTimer = Math.max(t.focusPulseTimer || 0, 1.15);
|
||||
selectTool("observe");
|
||||
if (Number.isFinite(t.x) && Number.isFinite(t.y)) {
|
||||
world.cameraX = t.x - (world.visibleWorldW ? world.visibleWorldW() : (world.viewportW || world.w)) / 2;
|
||||
world.cameraY = t.y - (world.visibleWorldH ? world.visibleWorldH() : (world.viewportH || world.h)) / 2;
|
||||
world.clampCamera?.();
|
||||
}
|
||||
renderStats();
|
||||
showToast(`${t.name}\u3092\u9078\u629e\u3057\u307e\u3057\u305f\u3002`);
|
||||
});
|
||||
|
||||
if (!uiCache.archiveAutoRefreshTimer && typeof window !== "undefined") {
|
||||
uiCache.archiveAutoRefreshTimer = window.setInterval(() => {
|
||||
if (!ui.archiveContent || !archiveAutoUpdateEnabled()) return;
|
||||
scheduleArchiveWindowRender();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
ui.selectedInfo?.addEventListener("click", (e) => {
|
||||
const categoryToggle = e.target.closest(".selected-category-toggle");
|
||||
if (categoryToggle) {
|
||||
const category = categoryToggle.closest(".selected-category");
|
||||
const id = category?.dataset?.selectedCategory || "";
|
||||
if (!category || !id) return;
|
||||
if (!uiCache.selectedCollapsedCategories) uiCache.selectedCollapsedCategories = new Set();
|
||||
category.classList.toggle("collapsed");
|
||||
const collapsed = category.classList.contains("collapsed");
|
||||
if (collapsed) uiCache.selectedCollapsedCategories.add(id);
|
||||
else uiCache.selectedCollapsedCategories.delete(id);
|
||||
categoryToggle.setAttribute("aria-expanded", collapsed ? "false" : "true");
|
||||
return;
|
||||
}
|
||||
const focusBtn = e.target.closest("[data-focus-tarinai-id]");
|
||||
if (focusBtn) {
|
||||
e.preventDefault();
|
||||
focusTarinaiById(focusBtn.getAttribute("data-focus-tarinai-id"));
|
||||
return;
|
||||
}
|
||||
const btn = e.target.closest("[data-selected-action=\"favorite\"]");
|
||||
if (!btn || !world.selected || world.selected.dead) return;
|
||||
world.selected.favorite = !world.selected.favorite;
|
||||
uiCache.selectedSnapshot = "";
|
||||
renderSelected();
|
||||
render();
|
||||
showToast(world.selected.favorite ? "\u304a\u6c17\u306b\u5165\u308a\u306b\u3057\u307e\u3057\u305f\u3002" : "\u304a\u6c17\u306b\u5165\u308a\u3092\u89e3\u9664\u3057\u307e\u3057\u305f\u3002");
|
||||
});
|
||||
ui.selectedInfo?.addEventListener("change", (e) => {
|
||||
const input = e.target.closest("[data-selected-name]");
|
||||
if (!input) return;
|
||||
renameSelectedTarinai(input.value);
|
||||
});
|
||||
ui.selectedInfo?.addEventListener("keydown", (e) => {
|
||||
const input = e.target.closest("[data-selected-name]");
|
||||
if (!input) return;
|
||||
if (e.key === "Enter") { e.preventDefault(); input.blur(); }
|
||||
if (e.key === "Escape") { e.preventDefault(); input.value = world.selected?.name || ""; input.blur(); }
|
||||
});
|
||||
ui.selectedCloseBtn?.addEventListener("click", () => {
|
||||
world.selected = null;
|
||||
uiCache.selectedSnapshot = "";
|
||||
uiCache.selectedEmpty = false;
|
||||
renderSelected();
|
||||
render();
|
||||
});
|
||||
|
||||
ui.pauseBtn?.addEventListener("click", () => {
|
||||
world.paused = !world.paused;
|
||||
const archiveStale = uiCache.archiveFamilyVersion !== (world.familyVersion || 0);
|
||||
if (world.paused && (uiCache.archivePendingWhileRunning || archiveStale)) {
|
||||
if (archiveAutoUpdateEnabled() && world.paused && (uiCache.archivePendingWhileRunning || archiveStale)) {
|
||||
uiCache.archivePendingWhileRunning = false;
|
||||
scheduleArchiveWindowRender();
|
||||
}
|
||||
|
|
@ -536,6 +856,8 @@ function bindUI() {
|
|||
ui.ecologyDialog?.addEventListener("click", (e) => {
|
||||
if (e.target === ui.ecologyDialog) closeEcologyDialog();
|
||||
});
|
||||
ui.archiveUpdateToggleBtn?.addEventListener("click", () => setArchiveUpdateEnabled(!archiveAutoUpdateEnabled()));
|
||||
syncArchiveUpdateButton();
|
||||
ui.archiveResetBtn?.addEventListener("click", openArchiveResetDialog);
|
||||
ui.archiveResetCancelBtn?.addEventListener("click", closeArchiveResetDialog);
|
||||
ui.archiveResetDialog?.addEventListener("click", (e) => {
|
||||
|
|
@ -543,7 +865,7 @@ function bindUI() {
|
|||
});
|
||||
ui.archiveResetConfirmBtn?.addEventListener("click", () => {
|
||||
world.resetFamilyTree?.();
|
||||
uiCache.archiveVersion = "";
|
||||
window.resetArchiveRenderState?.();
|
||||
closeArchiveResetDialog();
|
||||
renderArchive();
|
||||
renderSelected();
|
||||
|
|
@ -599,6 +921,20 @@ function bindUI() {
|
|||
|
||||
canvas?.addEventListener("contextmenu", (e) => e.preventDefault());
|
||||
canvas?.addEventListener("mousedown", (e) => {
|
||||
if (e.button === 0 && world.tool === "water_hose") {
|
||||
const p = screenToWorld(e);
|
||||
if (!p.inside) return;
|
||||
e.preventDefault();
|
||||
uiCache.hosing = true;
|
||||
uiCache.hoseMoved = false;
|
||||
uiCache.hoseLastWorldX = p.x;
|
||||
uiCache.hoseLastWorldY = p.y;
|
||||
uiCache.hoseStartX = e.clientX;
|
||||
uiCache.hoseStartY = e.clientY;
|
||||
world.applyWaterHose?.(p.x, p.y, 0, 0, 0.10);
|
||||
render();
|
||||
return;
|
||||
}
|
||||
if (e.button === 0 && world.tool === "pinch") {
|
||||
const p = screenToWorld(e);
|
||||
if (!p.inside) return;
|
||||
|
|
@ -641,6 +977,13 @@ function bindUI() {
|
|||
uiCache.panLastY = e.clientY;
|
||||
});
|
||||
window.addEventListener("mouseup", (e) => {
|
||||
if (uiCache.hosing) {
|
||||
uiCache.hosing = false;
|
||||
canvas.style.cursor = world.tool === "water_hose" ? "crosshair" : (world.tool === "observe" ? "default" : (world.tool === "pinch" ? "grab" : (world.tool === "poke" || world.tool === "delete" ? "pointer" : "crosshair")));
|
||||
world.rebuildSpatial?.();
|
||||
renderStats();
|
||||
return;
|
||||
}
|
||||
if (uiCache.grabbing) {
|
||||
const target = uiCache.grabTarget;
|
||||
if (target) {
|
||||
|
|
@ -669,6 +1012,19 @@ function bindUI() {
|
|||
canvas.style.cursor = world.tool === "observe" ? "default" : (world.tool === "pinch" ? "grab" : (world.tool === "poke" || world.tool === "delete" ? "pointer" : "crosshair"));
|
||||
});
|
||||
window.addEventListener("mousemove", (e) => {
|
||||
if (uiCache.hosing) {
|
||||
const p = screenToWorld(e);
|
||||
const dx = p.x - (uiCache.hoseLastWorldX ?? p.x);
|
||||
const dy = p.y - (uiCache.hoseLastWorldY ?? p.y);
|
||||
uiCache.hoseLastWorldX = p.x;
|
||||
uiCache.hoseLastWorldY = p.y;
|
||||
const totalMove = Math.hypot(e.clientX - (uiCache.hoseStartX || e.clientX), e.clientY - (uiCache.hoseStartY || e.clientY));
|
||||
if (totalMove > 3) uiCache.hoseMoved = true;
|
||||
if (p.inside) world.applyWaterHose?.(p.x, p.y, dx, dy, 0.10);
|
||||
canvas.style.cursor = "crosshair";
|
||||
render();
|
||||
return;
|
||||
}
|
||||
if (uiCache.grabbing) {
|
||||
const target = uiCache.grabTarget;
|
||||
if (!target || target.dead) {
|
||||
|
|
@ -722,6 +1078,10 @@ function bindUI() {
|
|||
});
|
||||
|
||||
canvas?.addEventListener("click", (e) => {
|
||||
if (uiCache.hoseMoved) {
|
||||
uiCache.hoseMoved = false;
|
||||
return;
|
||||
}
|
||||
if (uiCache.grabMoved) {
|
||||
uiCache.grabMoved = false;
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
function renderStats() {
|
||||
const alive = world.tarinai;
|
||||
const avg = (fn) => alive.length ? alive.reduce((a, t) => a + fn(t), 0) / alive.length : 0;
|
||||
const environment = world.environmentScores ? world.environmentScores() : { security: 100, hygiene: 100, happiness: 100 };
|
||||
const values = {
|
||||
pop: alive.length,
|
||||
dead: world.deadCount,
|
||||
|
|
@ -10,7 +11,9 @@ function renderStats() {
|
|||
lonely: avg(t => t.loneliness),
|
||||
stress: avg(t => t.stress),
|
||||
lack: avg(t => t.lack),
|
||||
events: { ...(world.eventCounters || {}) },
|
||||
security: environment.security,
|
||||
hygiene: environment.hygiene,
|
||||
happiness: environment.happiness,
|
||||
objects: { ...(world.itemCounts || {}) },
|
||||
};
|
||||
setTextIfChanged(ui.statPop, "pop", values.pop);
|
||||
|
|
@ -18,11 +21,7 @@ function renderStats() {
|
|||
updateColonyHistory(values);
|
||||
drawColonyChart(values);
|
||||
renderSelected();
|
||||
const familyVersion = world.familyVersion || 0;
|
||||
if (uiCache.archiveLastScheduleFamilyVersion !== familyVersion) {
|
||||
uiCache.archiveLastScheduleFamilyVersion = familyVersion;
|
||||
scheduleArchiveWindowRender();
|
||||
}
|
||||
if (world.familyTreeDirty) scheduleArchiveWindowRender();
|
||||
}
|
||||
|
||||
function updateColonyHistory(values) {
|
||||
|
|
@ -35,18 +34,16 @@ function updateColonyHistory(values) {
|
|||
uiCache.chartBucketStart = -Infinity;
|
||||
uiCache.chartBucketEnd = -Infinity;
|
||||
uiCache.chartBucketAccum = null;
|
||||
uiCache.environmentInitialRow = null;
|
||||
uiCache.lastChartDraw = "";
|
||||
}
|
||||
const eventKeys = ["birth", "death", "fight", "accident", "food", "grass"];
|
||||
const objectKeys = ["grass", "zunchi"];
|
||||
const objectKeys = ["grass", "zunchi", "trace"];
|
||||
const makeBucket = (start) => ({
|
||||
start,
|
||||
end: start + interval,
|
||||
count: 0,
|
||||
sums: { pop: 0, hunger: 0, lonely: 0, stress: 0, lack: 0 },
|
||||
sums: { pop: 0, hunger: 0, lonely: 0, stress: 0, lack: 0, security: 0, hygiene: 0, happiness: 0 },
|
||||
objects: Object.fromEntries(objectKeys.map(k => [k, 0])),
|
||||
eventsStart: Object.fromEntries(eventKeys.map(k => [k, (values.events || {})[k] || 0])),
|
||||
events: Object.fromEntries(eventKeys.map(k => [k, 0])),
|
||||
});
|
||||
if (!uiCache.chartBucketAccum) {
|
||||
const start = Math.floor(t / interval) * interval;
|
||||
|
|
@ -63,15 +60,16 @@ function updateColonyHistory(values) {
|
|||
lonely: bucket.sums.lonely / bucket.count,
|
||||
stress: bucket.sums.stress / bucket.count,
|
||||
lack: bucket.sums.lack / bucket.count,
|
||||
security: bucket.sums.security / bucket.count,
|
||||
hygiene: bucket.sums.hygiene / bucket.count,
|
||||
happiness: bucket.sums.happiness / bucket.count,
|
||||
...Object.fromEntries(objectKeys.map(k => [`obj_${k}`, bucket.objects[k] / bucket.count])),
|
||||
...Object.fromEntries(eventKeys.map(k => [`ev_${k}`, bucket.events[k] || 0])),
|
||||
};
|
||||
history.push(row);
|
||||
if (history.length > maxPoints) history.splice(0, history.length - maxPoints);
|
||||
};
|
||||
while (t >= uiCache.chartBucketEnd) {
|
||||
const b = uiCache.chartBucketAccum;
|
||||
for (const k of eventKeys) b.events[k] = Math.max(0, ((values.events || {})[k] || 0) - (b.eventsStart[k] || 0));
|
||||
pushBucket(b);
|
||||
uiCache.chartBucketStart = uiCache.chartBucketEnd;
|
||||
uiCache.chartBucketEnd = uiCache.chartBucketStart + interval;
|
||||
|
|
@ -84,14 +82,15 @@ function updateColonyHistory(values) {
|
|||
bucket.sums.lonely += values.lonely || 0;
|
||||
bucket.sums.stress += values.stress || 0;
|
||||
bucket.sums.lack += values.lack || 0;
|
||||
bucket.sums.security += values.security || 0;
|
||||
bucket.sums.hygiene += values.hygiene || 0;
|
||||
bucket.sums.happiness += values.happiness || 0;
|
||||
for (const k of objectKeys) bucket.objects[k] += (values.objects || {})[k] || 0;
|
||||
for (const k of eventKeys) bucket.events[k] = Math.max(0, ((values.events || {})[k] || 0) - (bucket.eventsStart[k] || 0));
|
||||
}
|
||||
|
||||
function chartRows(values) {
|
||||
const bucket = uiCache.chartBucketAccum;
|
||||
const eventKeys = ["birth", "death", "fight", "accident", "food", "grass"];
|
||||
const objectKeys = ["grass", "zunchi"];
|
||||
const objectKeys = ["grass", "zunchi", "trace"];
|
||||
const preview = bucket && bucket.count > 0 ? {
|
||||
t: Math.max(world.time || 0, bucket.start || 0),
|
||||
pop: bucket.sums.pop / bucket.count,
|
||||
|
|
@ -99,8 +98,10 @@ function chartRows(values) {
|
|||
lonely: bucket.sums.lonely / bucket.count,
|
||||
stress: bucket.sums.stress / bucket.count,
|
||||
lack: bucket.sums.lack / bucket.count,
|
||||
security: bucket.sums.security / bucket.count,
|
||||
hygiene: bucket.sums.hygiene / bucket.count,
|
||||
happiness: bucket.sums.happiness / bucket.count,
|
||||
...Object.fromEntries(objectKeys.map(k => [`obj_${k}`, bucket.objects[k] / bucket.count])),
|
||||
...Object.fromEntries(eventKeys.map(k => [`ev_${k}`, bucket.events[k] || 0])),
|
||||
} : null;
|
||||
return uiCache.chartHistory.length ? [...uiCache.chartHistory, ...(preview ? [preview] : [])] : [preview || { t: world.time || 0, ...values }];
|
||||
}
|
||||
|
|
@ -112,8 +113,13 @@ function drawColonyChart(values) {
|
|||
const perf = world.performanceLevel ? world.performanceLevel() : 0;
|
||||
const minInterval = perf >= 3 ? 2600 : perf === 2 ? 1800 : perf === 1 ? 950 : 0;
|
||||
if (minInterval && uiCache.lastChartDrawAt && now - uiCache.lastChartDrawAt < minInterval) return;
|
||||
const rows = chartRows(values);
|
||||
const mode = uiCache.chartMode || "population";
|
||||
if (mode === "environment" && !uiCache.chartHistory.length && !uiCache.environmentInitialRow) {
|
||||
uiCache.environmentInitialRow = { t: world.time || 0, ...values };
|
||||
}
|
||||
const rows = mode === "environment"
|
||||
? (uiCache.chartHistory.length ? uiCache.chartHistory : [uiCache.environmentInitialRow || { t: world.time || 0, ...values }])
|
||||
: chartRows(values);
|
||||
const last = rows[rows.length - 1] || values;
|
||||
const series = chartSeries(mode);
|
||||
const seriesSnapshot = series.map(s => `${s.key}:${Math.round(Number(last[s.key] || 0) * 10)}`).join(",");
|
||||
|
|
@ -123,7 +129,7 @@ function drawColonyChart(values) {
|
|||
uiCache.lastChartDrawAt = now;
|
||||
const cssW = Math.max(280, Math.floor(chart.clientWidth || chart.offsetWidth || 320));
|
||||
const cssH = Math.max(160, Math.floor(chart.clientHeight || chart.offsetHeight || 188));
|
||||
if (mode === "events") return drawEventsChart(chart, rows, cssW, cssH);
|
||||
if (mode === "environment") return drawEnvironmentChart(chart, rows, cssW, cssH);
|
||||
const pad = { l: 30, r: 12, t: 12, b: 24 };
|
||||
const w = cssW - pad.l - pad.r;
|
||||
const h = cssH - pad.t - pad.b;
|
||||
|
|
@ -150,22 +156,20 @@ function drawColonyChart(values) {
|
|||
if (ui.colonyChartLegend) ui.colonyChartLegend.innerHTML = series.map(s => `<span><i style="background:${s.color}"></i>${s.label} ${formatChartValue(s.key, rows[rows.length - 1] || values)}</span>`).join("");
|
||||
}
|
||||
|
||||
function drawEventsChart(chart, rows, cssW, cssH) {
|
||||
const series = chartSeries("events");
|
||||
const weekRows = rows.slice(-7 * 12);
|
||||
const counts = series.map(s => ({ ...s, value: weekRows.reduce((sum, row) => sum + Math.max(0, Number(row[s.key] || 0)), 0) }));
|
||||
const total = Math.max(1, counts.reduce((a, b) => a + b.value, 0));
|
||||
const pad = { l: 68, r: 16, t: 12, b: 16 };
|
||||
function drawEnvironmentChart(chart, rows, cssW, cssH) {
|
||||
const series = chartSeries("environment");
|
||||
const row = rows[rows.length - 1] || {};
|
||||
const pad = { l: 58, r: 18, t: 16, b: 18 };
|
||||
const barW = cssW - pad.l - pad.r;
|
||||
const rowH = Math.max(18, (cssH - pad.t - pad.b) / counts.length);
|
||||
const bars = counts.map((s, i) => {
|
||||
const y = pad.t + i * rowH + 4;
|
||||
const pct = s.value / total;
|
||||
const bw = Math.max(2, barW * pct);
|
||||
return `<text x="${pad.l - 8}" y="${(y + 11).toFixed(1)}" text-anchor="end" font-size="10" fill="rgba(84,68,48,0.76)">${s.label}</text><rect x="${pad.l}" y="${y.toFixed(1)}" width="${barW}" height="${Math.max(8, rowH - 8).toFixed(1)}" rx="6" fill="rgba(84,68,48,0.08)"/><rect x="${pad.l}" y="${y.toFixed(1)}" width="${bw.toFixed(1)}" height="${Math.max(8, rowH - 8).toFixed(1)}" rx="6" fill="${s.color}"/><text x="${(pad.l + Math.min(barW - 4, bw + 6)).toFixed(1)}" y="${(y + 11).toFixed(1)}" font-size="10" fill="rgba(42,36,29,0.82)">${s.value}\u4ef6 / ${Math.round(pct * 100)}%</text>`;
|
||||
const rowH = Math.max(30, (cssH - pad.t - pad.b) / series.length);
|
||||
const bars = series.map((s, i) => {
|
||||
const y = pad.t + i * rowH + 6;
|
||||
const v = clamp(Number(row[s.key] || 0), 0, 100);
|
||||
const bw = barW * v / 100;
|
||||
return `<text x="${pad.l - 8}" y="${(y + 13).toFixed(1)}" text-anchor="end" font-size="11" fill="rgba(84,68,48,0.80)">${s.label}</text><rect x="${pad.l}" y="${y.toFixed(1)}" width="${barW}" height="${Math.max(12, rowH - 14).toFixed(1)}" rx="7" fill="rgba(84,68,48,0.08)"/><rect x="${pad.l}" y="${y.toFixed(1)}" width="${bw.toFixed(1)}" height="${Math.max(12, rowH - 14).toFixed(1)}" rx="7" fill="${s.color}"/><text x="${(pad.l + Math.min(barW - 26, Math.max(6, bw + 7))).toFixed(1)}" y="${(y + 13).toFixed(1)}" font-size="11" fill="rgba(42,36,29,0.82)">${Math.round(v)}</text>`;
|
||||
}).join("");
|
||||
chart.innerHTML = `<svg viewBox="0 0 ${cssW} ${cssH}" preserveAspectRatio="none" aria-hidden="true"><rect x="0" y="0" width="${cssW}" height="${cssH}" rx="14" fill="rgba(255,255,255,0.18)"/>${bars}</svg>`;
|
||||
if (ui.colonyChartLegend) ui.colonyChartLegend.innerHTML = `<span>1\u9031\u9593\u306e\u5272\u5408 / \u4ef6\u6570</span>`;
|
||||
chart.innerHTML = `<svg viewBox="0 0 ${cssW} ${cssH}" preserveAspectRatio="none" aria-hidden="true"><rect x="0" y="0" width="${cssW}" height="${cssH}" rx="14" fill="rgba(255,255,255,0.18)"/>${bars}<text x="${pad.l}" y="${cssH - 5}" font-size="10" fill="rgba(117,106,94,0.76)">0-100</text></svg>`;
|
||||
if (ui.colonyChartLegend) ui.colonyChartLegend.innerHTML = series.map(s => `<span><i style="background:${s.color}"></i>${s.label} ${formatChartValue(s.key, row)}</span>`).join("");
|
||||
}
|
||||
|
||||
function chartSeries(mode = "population") {
|
||||
|
|
@ -178,14 +182,12 @@ function chartSeries(mode = "population") {
|
|||
if (mode === "objects") return [
|
||||
{ key: "obj_grass", label: "\u8349", color: "#5b9d61" },
|
||||
{ key: "obj_zunchi", label: "\u305a\u3093\u3061", color: "#6f8a3f" },
|
||||
{ key: "obj_trace", label: "\u6b7b\u9ab8", color: "#8a7054" },
|
||||
];
|
||||
if (mode === "events") return [
|
||||
{ key: "ev_birth", label: "\u8a95\u751f", color: "#73b94b" },
|
||||
{ key: "ev_death", label: "\u6b7b\u4ea1", color: "#5b534b" },
|
||||
{ key: "ev_fight", label: "\u55a7\u5629", color: "#d65e46" },
|
||||
{ key: "ev_accident", label: "\u4e8b\u6545", color: "#e28836" },
|
||||
{ key: "ev_food", label: "\u98df\u4e8b", color: "#76b94d" },
|
||||
{ key: "ev_grass", label: "\u8349", color: "#489443" },
|
||||
if (mode === "environment") return [
|
||||
{ key: "security", label: "\u6cbb\u5b89", color: "#d65e46", percent: true },
|
||||
{ key: "hygiene", label: "\u885b\u751f", color: "#6f8a3f", percent: true },
|
||||
{ key: "happiness", label: "\u5e78\u798f", color: "#d98bc0", percent: true },
|
||||
];
|
||||
return [{ key: "pop", label: "\u500b\u4f53", color: "#5d8ee6" }];
|
||||
}
|
||||
|
|
@ -194,4 +196,3 @@ function formatChartValue(key, values) {
|
|||
const v = values[key] ?? 0;
|
||||
return String(Math.round(v));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
"use strict";
|
||||
|
||||
window.TarinaiUI = { canvas, ctx, ui, renderLog, renderSelected, stateLabel, renderStats, escapeHtml, renderArchive, validateFamilyTree, resizeCanvas, applyFieldLayout, screenToWorld, selectTool, showToast, saveGame, loadGame, syncTopButtons, bindUI };
|
||||
677
js/ui_family.js
|
|
@ -8,17 +8,46 @@ function lineageEdgeFamily(family = world.family || {}) {
|
|||
return family || {};
|
||||
}
|
||||
|
||||
let lineageRelationIndexCache = { family: null, version: null, index: null };
|
||||
|
||||
function lineageBuildRelationIndex(family) {
|
||||
const known = new Set(Object.keys(family || {}));
|
||||
const parentsByChild = new Map();
|
||||
const childrenByParent = new Map();
|
||||
const addParent = (childId, parentId) => {
|
||||
if (!childId || !parentId || childId === parentId || !known.has(childId) || !known.has(parentId)) return;
|
||||
if (!parentsByChild.has(childId)) parentsByChild.set(childId, new Set());
|
||||
if (!childrenByParent.has(parentId)) childrenByParent.set(parentId, new Set());
|
||||
parentsByChild.get(childId).add(parentId);
|
||||
childrenByParent.get(parentId).add(childId);
|
||||
};
|
||||
for (const n of Object.values(family || {})) {
|
||||
if (!n?.id) continue;
|
||||
for (const parentId of n.parents || []) addParent(n.id, parentId);
|
||||
for (const childId of n.children || []) addParent(childId, n.id);
|
||||
}
|
||||
return { parentsByChild, childrenByParent };
|
||||
}
|
||||
|
||||
function lineageRelationIndex(family = world.family || {}) {
|
||||
family = lineageEdgeFamily(family);
|
||||
if (family !== (world.family || {})) return lineageBuildRelationIndex(family);
|
||||
const version = world.familyVersion || 0;
|
||||
if (lineageRelationIndexCache.family !== family || lineageRelationIndexCache.version !== version) {
|
||||
lineageRelationIndexCache = { family, version, index: lineageBuildRelationIndex(family) };
|
||||
}
|
||||
return lineageRelationIndexCache.index;
|
||||
}
|
||||
|
||||
function lineageMutualParentIds(n, family = world.family || {}, idSet = null) {
|
||||
const seen = new Set();
|
||||
const parents = [];
|
||||
if (!n?.id) return parents;
|
||||
const relationIndex = lineageRelationIndex(family);
|
||||
const candidates = new Set((n.parents || []).filter(Boolean));
|
||||
// Older saves may contain only one side of the parent/child edge. For
|
||||
// rendering, recover the missing side by scanning parent.children as well.
|
||||
for (const parent of Object.values(family || {})) {
|
||||
if (!parent?.id || parent.id === n.id) continue;
|
||||
if (Array.isArray(parent.children) && parent.children.includes(n.id)) candidates.add(parent.id);
|
||||
}
|
||||
// Runtime updates can briefly touch one side first; recover the reciprocal
|
||||
// edge from the relation index so the archive does not flicker.
|
||||
for (const parentId of relationIndex.parentsByChild.get(n.id) || []) candidates.add(parentId);
|
||||
for (const id of candidates) {
|
||||
if (!id || id === n.id || seen.has(id)) continue;
|
||||
if (idSet && !idSet.has(id)) continue;
|
||||
|
|
@ -38,13 +67,10 @@ function lineageMutualChildIds(n, family = world.family || {}, idSet = null) {
|
|||
const seen = new Set();
|
||||
const children = [];
|
||||
if (!n?.id) return children;
|
||||
const relationIndex = lineageRelationIndex(family);
|
||||
const candidates = new Set((n.children || []).filter(Boolean));
|
||||
// Same recovery as above, in the opposite direction: child.parents is enough
|
||||
// to draw a real parent/child edge even if parent.children was not persisted.
|
||||
for (const child of Object.values(family || {})) {
|
||||
if (!child?.id || child.id === n.id) continue;
|
||||
if (Array.isArray(child.parents) && child.parents.includes(n.id)) candidates.add(child.id);
|
||||
}
|
||||
// Same recovery as above, in the opposite direction.
|
||||
for (const childId of relationIndex.childrenByParent.get(n.id) || []) candidates.add(childId);
|
||||
for (const id of candidates) {
|
||||
if (!id || id === n.id || seen.has(id)) continue;
|
||||
if (idSet && !idSet.has(id)) continue;
|
||||
|
|
@ -65,11 +91,13 @@ function lineageHasActualRelation(n, family = world.family || {}) {
|
|||
}
|
||||
|
||||
function lineageRelatedIdSet(family) {
|
||||
family = lineageEdgeFamily(family);
|
||||
const relationIndex = lineageRelationIndex(family);
|
||||
const ids = new Set();
|
||||
for (const n of Object.values(family || {}).filter(Boolean)) {
|
||||
const parents = lineageMutualParentIds(n, family);
|
||||
const children = lineageMutualChildIds(n, family);
|
||||
if (parents.length || children.length) ids.add(n.id);
|
||||
const parents = relationIndex.parentsByChild.get(n.id) || [];
|
||||
const children = relationIndex.childrenByParent.get(n.id) || [];
|
||||
if (parents.size || children.size) ids.add(n.id);
|
||||
for (const id of parents) ids.add(id);
|
||||
for (const id of children) ids.add(id);
|
||||
}
|
||||
|
|
@ -123,6 +151,167 @@ function liveFamilyComponents(family) {
|
|||
return comps;
|
||||
}
|
||||
|
||||
function lineageIdleSchedule(fn, timeout = 180) {
|
||||
const schedule = window.requestIdleCallback || ((cb) => setTimeout(cb, 0));
|
||||
schedule(fn, { timeout });
|
||||
}
|
||||
|
||||
function lineageSameFamilyToken(token) {
|
||||
return uiCache.archiveRenderToken === token;
|
||||
}
|
||||
|
||||
function lineageBuildComponentsIdle(family, token, onProgress, onDone) {
|
||||
family = lineageEdgeFamily(family);
|
||||
const entries = Object.values(family || {}).filter(n => n?.id);
|
||||
const known = new Set(entries.map(n => n.id));
|
||||
const parentsByChild = new Map();
|
||||
const childrenByParent = new Map();
|
||||
const relatedIds = new Set();
|
||||
const graph = new Map();
|
||||
const comps = [];
|
||||
let stage = "relations";
|
||||
let entryIndex = 0;
|
||||
let nodeIndex = 0;
|
||||
let nodes = [];
|
||||
let sortedNodes = [];
|
||||
let dfsIndex = 0;
|
||||
const seen = new Set();
|
||||
|
||||
const addParent = (childId, parentId) => {
|
||||
if (!childId || !parentId || childId === parentId || !known.has(childId) || !known.has(parentId)) return;
|
||||
if (!parentsByChild.has(childId)) parentsByChild.set(childId, new Set());
|
||||
if (!childrenByParent.has(parentId)) childrenByParent.set(parentId, new Set());
|
||||
parentsByChild.get(childId).add(parentId);
|
||||
childrenByParent.get(parentId).add(childId);
|
||||
};
|
||||
const addGraphEdge = (a, b) => {
|
||||
if (!a || !b || !graph.has(a) || !graph.has(b)) return;
|
||||
graph.get(a).add(b);
|
||||
graph.get(b).add(a);
|
||||
};
|
||||
const validParentChild = (childId, parentId) => {
|
||||
const child = family[childId];
|
||||
const parent = family[parentId];
|
||||
if (!child || !parent) return false;
|
||||
const childListsParent = Array.isArray(child.parents) && child.parents.includes(parentId);
|
||||
const parentListsChild = Array.isArray(parent.children) && parent.children.includes(childId);
|
||||
return childListsParent || parentListsChild;
|
||||
};
|
||||
const finishEmpty = () => {
|
||||
lineageRelationIndexCache = { family, version: world.familyVersion || 0, index: { parentsByChild, childrenByParent } };
|
||||
onDone([]);
|
||||
};
|
||||
|
||||
const pump = () => {
|
||||
if (!lineageSameFamilyToken(token)) return;
|
||||
const started = performance.now();
|
||||
while (performance.now() - started < 6) {
|
||||
if (stage === "relations") {
|
||||
if (entryIndex >= entries.length) {
|
||||
lineageRelationIndexCache = { family, version: world.familyVersion || 0, index: { parentsByChild, childrenByParent } };
|
||||
stage = "related";
|
||||
entryIndex = 0;
|
||||
onProgress?.("\u95a2\u4fc2\u3092\u6574\u7406\u4e2d", 0, entries.length);
|
||||
continue;
|
||||
}
|
||||
const n = entries[entryIndex++];
|
||||
for (const parentId of n.parents || []) addParent(n.id, parentId);
|
||||
for (const childId of n.children || []) addParent(childId, n.id);
|
||||
} else if (stage === "related") {
|
||||
if (entryIndex >= entries.length) {
|
||||
nodes = Array.from(relatedIds).map(id => family[id]).filter(Boolean);
|
||||
for (const n of nodes) graph.set(n.id, new Set());
|
||||
if (!nodes.length) return finishEmpty();
|
||||
stage = "graph";
|
||||
nodeIndex = 0;
|
||||
onProgress?.("\u5bb6\u7cfb\u3092\u7d50\u5408\u4e2d", 0, nodes.length);
|
||||
continue;
|
||||
}
|
||||
const n = entries[entryIndex++];
|
||||
const parents = parentsByChild.get(n.id) || new Set();
|
||||
const children = childrenByParent.get(n.id) || new Set();
|
||||
if (parents.size || children.size) relatedIds.add(n.id);
|
||||
for (const id of parents) relatedIds.add(id);
|
||||
for (const id of children) relatedIds.add(id);
|
||||
} else if (stage === "graph") {
|
||||
if (nodeIndex >= nodes.length) {
|
||||
sortedNodes = nodes.slice().sort(lineageNodeSort);
|
||||
stage = "components";
|
||||
dfsIndex = 0;
|
||||
onProgress?.("\u5bb6\u7cfb\u3092\u5206\u5272\u4e2d", 0, sortedNodes.length);
|
||||
continue;
|
||||
}
|
||||
const n = nodes[nodeIndex++];
|
||||
const parentCandidates = new Set((n.parents || []).filter(Boolean));
|
||||
for (const parentId of parentsByChild.get(n.id) || []) parentCandidates.add(parentId);
|
||||
for (const parentId of parentCandidates) {
|
||||
if (parentId !== n.id && graph.has(parentId) && validParentChild(n.id, parentId)) addGraphEdge(n.id, parentId);
|
||||
}
|
||||
const childCandidates = new Set((n.children || []).filter(Boolean));
|
||||
for (const childId of childrenByParent.get(n.id) || []) childCandidates.add(childId);
|
||||
for (const childId of childCandidates) {
|
||||
if (childId !== n.id && graph.has(childId) && validParentChild(childId, n.id)) addGraphEdge(n.id, childId);
|
||||
}
|
||||
} else if (stage === "components") {
|
||||
while (dfsIndex < sortedNodes.length && seen.has(sortedNodes[dfsIndex].id)) dfsIndex += 1;
|
||||
if (dfsIndex >= sortedNodes.length) {
|
||||
comps.sort((a, b) => {
|
||||
const ag = Math.min(...a.map(n => n.generation || 1));
|
||||
const bg = Math.min(...b.map(n => n.generation || 1));
|
||||
const at = Math.min(...a.map(n => n.birthTime || 0));
|
||||
const bt = Math.min(...b.map(n => n.birthTime || 0));
|
||||
return ag - bg || at - bt || b.length - a.length;
|
||||
});
|
||||
onDone(comps);
|
||||
return;
|
||||
}
|
||||
const start = sortedNodes[dfsIndex++];
|
||||
const stack = [start.id];
|
||||
const comp = [];
|
||||
seen.add(start.id);
|
||||
while (stack.length && performance.now() - started < 6) {
|
||||
const id = stack.pop();
|
||||
const item = family[id];
|
||||
if (item) comp.push(item);
|
||||
for (const next of graph.get(id) || []) {
|
||||
if (seen.has(next)) continue;
|
||||
seen.add(next);
|
||||
stack.push(next);
|
||||
}
|
||||
}
|
||||
if (stack.length) {
|
||||
const continueComp = () => {
|
||||
if (!lineageSameFamilyToken(token)) return;
|
||||
const resumedStarted = performance.now();
|
||||
while (stack.length && performance.now() - resumedStarted < 6) {
|
||||
const id = stack.pop();
|
||||
const item = family[id];
|
||||
if (item) comp.push(item);
|
||||
for (const next of graph.get(id) || []) {
|
||||
if (seen.has(next)) continue;
|
||||
seen.add(next);
|
||||
stack.push(next);
|
||||
}
|
||||
}
|
||||
if (stack.length) return lineageIdleSchedule(continueComp, 180);
|
||||
if (comp.some(x => x.alive)) comps.push(comp);
|
||||
lineageIdleSchedule(pump, 180);
|
||||
};
|
||||
return lineageIdleSchedule(continueComp, 180);
|
||||
}
|
||||
if (comp.some(x => x.alive)) comps.push(comp);
|
||||
}
|
||||
}
|
||||
onProgress?.(
|
||||
stage === "relations" ? "\u95a2\u4fc2\u3092\u6574\u7406\u4e2d" : stage === "related" ? "\u8868\u793a\u5bfe\u8c61\u3092\u9078\u5225\u4e2d" : stage === "graph" ? "\u5bb6\u7cfb\u3092\u7d50\u5408\u4e2d" : "\u5bb6\u7cfb\u3092\u5206\u5272\u4e2d",
|
||||
stage === "graph" ? nodeIndex : entryIndex,
|
||||
stage === "graph" ? nodes.length : entries.length
|
||||
);
|
||||
lineageIdleSchedule(pump, 180);
|
||||
};
|
||||
lineageIdleSchedule(pump, 160);
|
||||
}
|
||||
|
||||
function familyStatusText(n) {
|
||||
if (!n) return "\u4e0d\u660e";
|
||||
return n.alive ? "\u751f\u5b58" : "\u6b7b\u4ea1";
|
||||
|
|
@ -134,6 +323,8 @@ function familyDeathReasonLabel(rawReason, entity = null) {
|
|||
if (text.includes("\u3067\u8870\u5f31")) return text.length > 12 ? `${text.slice(0, 12)}\u2026` : text;
|
||||
if (/\u55a7\u5629|fight/.test(text)) return "\u55a7\u5629";
|
||||
if (/\u7206\u7af9|firecracker|explosion/.test(text)) return "\u7206\u7af9";
|
||||
if (/\u4ed5\u69d8\u306b\u3088\u308a\u753b\u9762\u5916\u3067\u524a\u9664|\u753b\u9762\u5916\u3067\u524a\u9664|out.*bounds|offscreen/.test(text)) return "\u4ed5\u69d8\u306b\u3088\u308a\u753b\u9762\u5916\u3067\u524a\u9664";
|
||||
if (/\u885d\u7a81|collision/.test(text)) return "\u885d\u7a81";
|
||||
if (/\u3064\u3064\u304b\u308c|\u3064\u3064\u304d|poke/.test(text)) return "\u3064\u3064\u304b\u308c\u3059\u304e\u305f";
|
||||
if (/\u305a\u3093\u3061\u75c5|zunchi_sick|zunchi.*\u75c5/.test(text)) return "\u305a\u3093\u3061\u75c5";
|
||||
if (/\u75c5/.test(text)) {
|
||||
|
|
@ -142,8 +333,8 @@ function familyDeathReasonLabel(rawReason, entity = null) {
|
|||
}
|
||||
if (/\u98e2|\u7a7a\u8179|\u98df\u3079\u7269|hunger/.test(text)) return "\u98e2\u9913";
|
||||
if (/\u30b9\u30c8\u30ec\u30b9|stress|\u6c17\u5206/.test(text)) return "\u30b9\u30c8\u30ec\u30b9\u904e\u591a";
|
||||
if (/\u5bff\u547d/.test(text)) return "\u5bff\u547d";
|
||||
if (/\u30dc\u30fc\u30eb|\u843d\u3061|\u843d\u4e0b|\u77f3|\u9053\u5177|\u4e8b\u6545|\u5f53\u305f|\u885d\u7a81|ball|drop|stone|accident/.test(text)) return "\u4e8b\u6545";
|
||||
if (/\u5bff\u547d|\u5929\u5bff/.test(text)) return "\u5929\u5bff\u3092\u5168\u3046\u3057\u305f";
|
||||
if (/\u30dc\u30fc\u30eb|\u843d\u3061|\u843d\u4e0b|\u77f3|\u9053\u5177|\u4e8b\u6545|\u5f53\u305f|ball|drop|stone|accident/.test(text)) return "\u4e8b\u6545";
|
||||
return "\u4e8b\u6545";
|
||||
}
|
||||
|
||||
|
|
@ -156,7 +347,7 @@ function lineageNodeSort(a, b) {
|
|||
}
|
||||
|
||||
function lineageTreeNodeHtml(n, x, y) {
|
||||
const entity = n?.id ? world.tarinai.find(t => t.id === n.id) : null;
|
||||
const entity = n?.id ? world.tarinai.find(t => (t.familyKey || t.id) === n.id) : null;
|
||||
const live = entity && !entity.dead ? entity : null;
|
||||
const img = n ? tarinaiSpritePathForId(n.id) : SPRITES[0].path;
|
||||
const name = escapeHtml(n?.name || "\u4e0d\u660e");
|
||||
|
|
@ -165,9 +356,7 @@ function lineageTreeNodeHtml(n, x, y) {
|
|||
const rawReason = !n?.alive && n?.deathReason ? String(n.deathReason) : "";
|
||||
const briefReason = familyDeathReasonLabel(rawReason, entity);
|
||||
const reason = briefReason ? ` / ${briefReason}` : "";
|
||||
const personality = live?.personalityLabel ? live.personalityLabel() : ((PERSONALITIES[n?.personality] || {}).label || n?.personality || "");
|
||||
const personalityPart = personality ? ` / \u6027\u683c ${personality}` : "";
|
||||
const title = escapeHtml(`${n?.name || "\u4e0d\u660e"} / \u4e16\u4ee3 ${n?.generation || "?"} / ${status}${personalityPart}${reason}`);
|
||||
const title = escapeHtml(`${n?.name || "\u4e0d\u660e"} / \u4e16\u4ee3 ${n?.generation || "?"} / ${status}${reason}`);
|
||||
const deathLine = briefReason ? `<span class="death-reason">${escapeHtml(briefReason)}</span>` : "";
|
||||
const cls = n ? ((live || n.alive) ? "alive" : "dead") : "missing";
|
||||
const childCls = (n?.parents || []).length ? " child" : "";
|
||||
|
|
@ -454,7 +643,8 @@ function lineageRowsForLayout(nodes, generations, idToNode, idSet) {
|
|||
const partnerEdges = lineagePartnerEdgesByGeneration(nodes, idToNode, idSet);
|
||||
const totalNodes = nodes.length;
|
||||
const maxRow = Math.max(0, ...rows.map(row => row.length));
|
||||
const passes = totalNodes > 180 || maxRow > 32 ? 2 : (totalNodes > 90 || maxRow > 18 ? 3 : 6);
|
||||
const veryLarge = totalNodes > 220 || maxRow > 44;
|
||||
const passes = veryLarge ? 1 : (totalNodes > 180 || maxRow > 32 ? 2 : (totalNodes > 90 || maxRow > 18 ? 3 : 6));
|
||||
|
||||
for (let pass = 0; pass < passes; pass++) {
|
||||
let rank = lineageRankMap(rows);
|
||||
|
|
@ -464,9 +654,11 @@ function lineageRowsForLayout(nodes, generations, idToNode, idSet) {
|
|||
const childRank = new Map(rows[i + 1].map((n, index) => [n.id, index]));
|
||||
rows[i] = lineageOrderParentsByChildren(rows[i], idSet, childRank);
|
||||
}
|
||||
rank = lineageRankMap(rows);
|
||||
rows = rows.map((row, i) => lineageOrderPartnerClusters(row, partnerEdges.get(generations[i]), rank, idToNode));
|
||||
if (totalNodes <= 90 && maxRow <= 16) rows = lineageOptimizeRows(rows, idSet);
|
||||
if (!veryLarge) {
|
||||
rank = lineageRankMap(rows);
|
||||
rows = rows.map((row, i) => lineageOrderPartnerClusters(row, partnerEdges.get(generations[i]), rank, idToNode));
|
||||
if (totalNodes <= 90 && maxRow <= 16) rows = lineageOptimizeRows(rows, idSet);
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
|
@ -683,7 +875,39 @@ function lineageLayoutRows(rows, idSet) {
|
|||
return { positions, width, height, nodeW: NODE_W, nodeH: NODE_H, topPad: TOP_PAD, rowGap: ROW_GAP };
|
||||
}
|
||||
|
||||
function lineagePartnerPathFromPositions(a, b, nodeW, nodeH, unionY, attachOffsetA = 0, attachOffsetB = 0) {
|
||||
function lineageSegmentKind(x1, y1, x2, y2) {
|
||||
if (Math.abs(y2 - y1) < 0.01) return 'h';
|
||||
if (Math.abs(x2 - x1) < 0.01) return 'v';
|
||||
return 'd';
|
||||
}
|
||||
|
||||
function lineageSegmentsFromPoints(points) {
|
||||
const segments = [];
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
const [x1, y1] = points[i - 1];
|
||||
const [x2, y2] = points[i];
|
||||
segments.push({ kind: lineageSegmentKind(x1, y1, x2, y2), x1, y1, x2, y2 });
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
function lineagePathFromPoints(points) {
|
||||
if (!points.length) return "";
|
||||
return `M ${points[0][0].toFixed(1)} ${points[0][1].toFixed(1)} ` + points.slice(1).map(p => `L ${p[0].toFixed(1)} ${p[1].toFixed(1)}`).join(' ');
|
||||
}
|
||||
|
||||
function lineageCrossGenerationPartnerPath(a, b, nodeW, nodeH, unionY) {
|
||||
const ax = a.x + nodeW / 2;
|
||||
const bx = b.x + nodeW / 2;
|
||||
const ay = a.y + nodeH;
|
||||
const by = b.y + nodeH;
|
||||
const y = Math.max(unionY, ay + 12, by + 12);
|
||||
const unionX = Math.abs(ax - bx) < 18 ? Math.max(ax, bx) + 30 : (ax + bx) / 2;
|
||||
const points = [[ax, ay], [ax, y], [unionX, y], [bx, y], [bx, by]];
|
||||
return { d: lineagePathFromPoints(points), segments: lineageSegmentsFromPoints(points), unionX, unionY: y };
|
||||
}
|
||||
|
||||
function lineageSameGenerationPartnerPath(a, b, nodeW, nodeH, unionY, attachOffsetA = 0, attachOffsetB = 0) {
|
||||
const left = a.x <= b.x ? a : b;
|
||||
const right = left === a ? b : a;
|
||||
const midYLeft = left.y + nodeH / 2 + attachOffsetA;
|
||||
|
|
@ -702,14 +926,12 @@ function lineagePartnerPathFromPositions(a, b, nodeW, nodeH, unionY, attachOffse
|
|||
const points = [
|
||||
[x1, midYLeft], [x1 + bend, midYLeft], [x1 + bend, y], [x2 - bend, y], [x2 - bend, midYRight], [x2, midYRight]
|
||||
];
|
||||
const d = `M ${points[0][0].toFixed(1)} ${points[0][1].toFixed(1)} ` + points.slice(1).map(p => `L ${p[0].toFixed(1)} ${p[1].toFixed(1)}`).join(' ');
|
||||
const segments = [];
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
const [x1s, y1s] = points[i - 1];
|
||||
const [x2s, y2s] = points[i];
|
||||
segments.push({ kind: Math.abs(y2s - y1s) < 0.01 ? 'h' : (Math.abs(x2s - x1s) < 0.01 ? 'v' : 'd'), x1: x1s, y1: y1s, x2: x2s, y2: y2s });
|
||||
}
|
||||
return { d, segments };
|
||||
return { d: lineagePathFromPoints(points), segments: lineageSegmentsFromPoints(points), unionX: (x1 + x2) / 2, unionY: y };
|
||||
}
|
||||
|
||||
function lineagePartnerPathFromPositions(a, b, nodeW, nodeH, unionY, attachOffsetA = 0, attachOffsetB = 0) {
|
||||
if (a.rowIndex !== b.rowIndex) return lineageCrossGenerationPartnerPath(a, b, nodeW, nodeH, unionY);
|
||||
return lineageSameGenerationPartnerPath(a, b, nodeW, nodeH, unionY, attachOffsetA, attachOffsetB);
|
||||
}
|
||||
|
||||
function lineageVerticalBridgePath(x, y1, y2, bridges = [], radius = 8.5) {
|
||||
|
|
@ -758,6 +980,15 @@ function lineageBuildChildPathGroup(group, nodeW, nodeH, bridgeHorizontals) {
|
|||
const bridges = lineageIntersectionYsForVertical(p.x, fromY, p.y, bridgeHorizontals);
|
||||
return { d: lineageVerticalBridgePath(p.x, fromY, p.y, bridges) };
|
||||
}
|
||||
if (childPoints.length === 1) {
|
||||
const p = childPoints[0];
|
||||
const parentBridges = lineageIntersectionYsForVertical(joinX, fromY, laneY, bridgeHorizontals);
|
||||
const childBridges = lineageIntersectionYsForVertical(p.x, laneY, p.y, bridgeHorizontals);
|
||||
const parentLane = lineageVerticalBridgePath(joinX, fromY, laneY, parentBridges);
|
||||
const horizontal = ` L ${p.x.toFixed(1)} ${laneY.toFixed(1)}`;
|
||||
const childLane = lineageVerticalBridgePath(p.x, laneY, p.y, childBridges).replace(/^M\s+[-\d.]+\s+[-\d.]+/, "");
|
||||
return { d: `${parentLane}${horizontal}${childLane}` };
|
||||
}
|
||||
const minX = Math.min(joinX, ...childPoints.map(p => p.x));
|
||||
const maxX = Math.max(joinX, ...childPoints.map(p => p.x));
|
||||
let d = lineageVerticalBridgePath(joinX, fromY, laneY, lineageIntersectionYsForVertical(joinX, fromY, laneY, bridgeHorizontals));
|
||||
|
|
@ -795,6 +1026,47 @@ function lineageComponentSignature(component, family) {
|
|||
.join("|");
|
||||
}
|
||||
|
||||
function lineageComponentIdentity(component) {
|
||||
return Array.from(new Set((component || []).map(n => n?.id).filter(Boolean))).sort().join(",");
|
||||
}
|
||||
|
||||
function lineageFamilyCacheKey(component, family, signature = "") {
|
||||
const uniqueComponent = Array.from(new Map((component || []).filter(Boolean).map(n => [n.id, n])).values()).sort(lineageNodeSort);
|
||||
return `${lineageComponentIdentity(uniqueComponent)}:${signature || lineageComponentSignature(uniqueComponent, family)}`;
|
||||
}
|
||||
|
||||
function lineageHydrateFamilyHtml(html, index) {
|
||||
return html
|
||||
.split("{{LINEAGE_INDEX}}").join(String(index + 1))
|
||||
.split("{{LINEAGE_MASK_ID}}").join(`lineage-node-mask-${index}`);
|
||||
}
|
||||
|
||||
function lineageCaptureArchiveScroll() {
|
||||
const root = ui.archiveContent;
|
||||
if (!root) return null;
|
||||
const familyScroll = {};
|
||||
for (const el of root.querySelectorAll(".lineage-family-tree[data-lineage-key]")) {
|
||||
const key = el.getAttribute("data-lineage-key");
|
||||
if (key) familyScroll[key] = el.scrollLeft || 0;
|
||||
}
|
||||
return { top: root.scrollTop || 0, left: root.scrollLeft || 0, familyScroll };
|
||||
}
|
||||
|
||||
function lineageRestoreArchiveScroll(state) {
|
||||
const root = ui.archiveContent;
|
||||
if (!root || !state) return;
|
||||
const maxTop = Math.max(0, root.scrollHeight - root.clientHeight);
|
||||
const maxLeft = Math.max(0, root.scrollWidth - root.clientWidth);
|
||||
root.scrollTop = Math.min(state.top || 0, maxTop);
|
||||
root.scrollLeft = Math.min(state.left || 0, maxLeft);
|
||||
for (const el of root.querySelectorAll(".lineage-family-tree[data-lineage-key]")) {
|
||||
const key = el.getAttribute("data-lineage-key");
|
||||
if (!key || !Object.prototype.hasOwnProperty.call(state.familyScroll || {}, key)) continue;
|
||||
el.scrollLeft = Math.min(state.familyScroll[key] || 0, Math.max(0, el.scrollWidth - el.clientWidth));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function lineageExpectedChildEdgeCount(component, family) {
|
||||
family = lineageEdgeFamily(family);
|
||||
const idSet = new Set(component.map(x => x?.id).filter(Boolean));
|
||||
|
|
@ -805,30 +1077,19 @@ function lineageExpectedChildEdgeCount(component, family) {
|
|||
|
||||
function lineageVisibleRelationSignature(family) {
|
||||
family = lineageEdgeFamily(family);
|
||||
const known = new Set(Object.keys(family));
|
||||
const parentEdges = new Map();
|
||||
const childEdges = new Map();
|
||||
const relationIndex = lineageRelationIndex(family);
|
||||
const relatedIds = new Set();
|
||||
const addEdge = (parentId, childId) => {
|
||||
if (!parentId || !childId || parentId === childId || !known.has(parentId) || !known.has(childId)) return;
|
||||
if (!parentEdges.has(childId)) parentEdges.set(childId, new Set());
|
||||
if (!childEdges.has(parentId)) childEdges.set(parentId, new Set());
|
||||
parentEdges.get(childId).add(parentId);
|
||||
childEdges.get(parentId).add(childId);
|
||||
relatedIds.add(parentId);
|
||||
for (const [childId, parents] of relationIndex.parentsByChild.entries()) {
|
||||
if (!parents.size) continue;
|
||||
relatedIds.add(childId);
|
||||
};
|
||||
for (const n of Object.values(family || {})) {
|
||||
if (!n?.id) continue;
|
||||
for (const parentId of n.parents || []) addEdge(parentId, n.id);
|
||||
for (const childId of n.children || []) addEdge(n.id, childId);
|
||||
for (const parentId of parents) relatedIds.add(parentId);
|
||||
}
|
||||
return Array.from(relatedIds)
|
||||
.sort()
|
||||
.map(id => {
|
||||
const n = family[id] || {};
|
||||
const parents = Array.from(parentEdges.get(id) || []).sort().join(",");
|
||||
const children = Array.from(childEdges.get(id) || []).sort().join(",");
|
||||
const parents = Array.from(relationIndex.parentsByChild.get(id) || []).sort().join(",");
|
||||
const children = Array.from(relationIndex.childrenByParent.get(id) || []).sort().join(",");
|
||||
return [
|
||||
id,
|
||||
n.name || "",
|
||||
|
|
@ -859,7 +1120,7 @@ function lineageSetArchiveStaleStatus(show) {
|
|||
return;
|
||||
}
|
||||
if (existing) return;
|
||||
ui.archiveContent.insertAdjacentHTML("afterbegin", `<div class="lineage-stale-status">\u5bb6\u7cfb\u56f3\u306e\u66f4\u65b0\u306f\u4e00\u6642\u505c\u6b62\u4e2d\u3067\u3059\u3002\u4e00\u6642\u505c\u6b62\u3059\u308b\u3068\u53cd\u6620\u3055\u308c\u307e\u3059\u3002</div>`);
|
||||
ui.archiveContent.insertAdjacentHTML("afterbegin", `<div class="lineage-stale-status">\u5bb6\u7cfb\u56f3\u306e\u66f4\u65b0\u306fOFF\u3067\u3059\u3002\u300c\u66f4\u65b0\u300d\u3092ON\u306b\u3059\u308b\u3068\u53cd\u6620\u3055\u308c\u307e\u3059\u3002</div>`);
|
||||
}
|
||||
|
||||
function lineageAssignPartnerAttachOffsets(linkGroups) {
|
||||
|
|
@ -912,6 +1173,7 @@ function lineageBuildLinks(nodes, idSet, layout) {
|
|||
linkGroups.push({ parents, children, parentBottom, childTop, joinX, directCouple, attachOffsets: new Map() });
|
||||
}
|
||||
lineageAssignPartnerAttachOffsets(linkGroups);
|
||||
const childLinkEdges = linkGroups.reduce((sum, group) => sum + group.children.length * group.parents.length, 0);
|
||||
|
||||
const byGap = new Map();
|
||||
for (const group of linkGroups) {
|
||||
|
|
@ -940,15 +1202,17 @@ function lineageBuildLinks(nodes, idSet, layout) {
|
|||
const attachA = 0;
|
||||
const attachB = 0;
|
||||
const partnerY = ((left.y + nodeH / 2) + (right.y + nodeH / 2)) / 2;
|
||||
const clearY = clamp(group.parentBottom + 22 + i * 2, partnerY + 18, group.childTop - 36);
|
||||
const partner = lineagePartnerPathFromPositions(left, right, nodeW, nodeH, partnerY, attachA, attachB);
|
||||
if (Number.isFinite(partner.unionX)) group.joinX = partner.unionX;
|
||||
const unionY = Number.isFinite(partner.unionY) ? partner.unionY : partnerY;
|
||||
const clearY = clamp(group.parentBottom + 22 + i * 2, unionY + 10, group.childTop - 36);
|
||||
group.fromY = clearY;
|
||||
group.busY = group.children.length === 1 ? Math.min(group.childTop - 18, clearY + 28) : Math.min(group.childTop - 24, Math.max(clearY + 24, laneY + 18 + i * 3));
|
||||
const partner = lineagePartnerPathFromPositions(left, right, nodeW, nodeH, partnerY, attachA, attachB);
|
||||
if (partner.d) {
|
||||
partnerLinks.push(partner.d);
|
||||
horizontalSegments.push(...partner.segments.filter(seg => seg.kind === 'h'));
|
||||
}
|
||||
const trunk = lineageVerticalBridgePath(group.joinX, partnerY, group.fromY, []);
|
||||
const trunk = lineageVerticalBridgePath(group.joinX, unionY, group.fromY, []);
|
||||
if (trunk) childLinks.push(trunk);
|
||||
} else {
|
||||
group.fromY = group.parentBottom;
|
||||
|
|
@ -960,8 +1224,8 @@ function lineageBuildLinks(nodes, idSet, layout) {
|
|||
for (const groupsInGap of byGap.values()) {
|
||||
groupsInGap.sort((a, b) => a.joinX - b.joinX || a.children.length - b.children.length);
|
||||
for (const group of groupsInGap) {
|
||||
if (group.children.length > 1) {
|
||||
const childCenters = group.children.map(c => c.x + nodeW / 2).sort((a, b) => a - b);
|
||||
const childCenters = group.children.map(c => c.x + nodeW / 2).sort((a, b) => a - b);
|
||||
if (group.children.length > 1 || (childCenters.length === 1 && Math.abs(childCenters[0] - group.joinX) > 0.5)) {
|
||||
horizontalSegments.push({ kind: 'h', x1: Math.min(group.joinX, ...childCenters), y1: group.busY, x2: Math.max(group.joinX, ...childCenters), y2: group.busY });
|
||||
}
|
||||
const child = lineageBuildChildPathGroup(group, nodeW, nodeH, horizontalSegments);
|
||||
|
|
@ -969,7 +1233,7 @@ function lineageBuildLinks(nodes, idSet, layout) {
|
|||
}
|
||||
}
|
||||
|
||||
return { partnerLinks, childLinks, childLinkGroups: linkGroups.length };
|
||||
return { partnerLinks, childLinks, childLinkGroups: linkGroups.length, childLinkEdges };
|
||||
}
|
||||
|
||||
function lineageSplitByActualEdges(component) {
|
||||
|
|
@ -1036,6 +1300,73 @@ function lineageCleanComponent(component, family) {
|
|||
return expanded.filter(n => lineageMutualParentIds(n, family, idSet).length || lineageMutualChildIds(n, family, idSet).length);
|
||||
}
|
||||
|
||||
function lineageFastLayoutNeeded(nodes, idSet, family) {
|
||||
const edgeCount = nodes.reduce((sum, n) => sum + lineageParentIds(n, idSet, family).length, 0);
|
||||
const maxGenerationWidth = Math.max(0, ...Array.from(new Set(nodes.map(n => n.generation || 1))).map(g => nodes.filter(n => (n.generation || 1) === g).length));
|
||||
return {
|
||||
edgeCount,
|
||||
fast: nodes.length > 140 || edgeCount > 260 || maxGenerationWidth > 34,
|
||||
};
|
||||
}
|
||||
|
||||
function lineageLayoutRowsFast(rows, idSet) {
|
||||
const NODE_W = 132;
|
||||
const NODE_H = 68;
|
||||
const SIBLING_GAP = 22;
|
||||
const ROW_GAP = 116;
|
||||
const LEFT_PAD = 82;
|
||||
const TOP_PAD = 44;
|
||||
const RIGHT_PAD = 48;
|
||||
const BOTTOM_PAD = 30;
|
||||
const positions = new Map();
|
||||
let width = 420;
|
||||
rows.forEach((row, rowIndex) => {
|
||||
const y = TOP_PAD + rowIndex * (NODE_H + ROW_GAP);
|
||||
let x = LEFT_PAD;
|
||||
for (const n of row) {
|
||||
positions.set(n.id, { x, y, rowIndex, node: n });
|
||||
x += NODE_W + SIBLING_GAP;
|
||||
}
|
||||
width = Math.max(width, x - SIBLING_GAP + RIGHT_PAD);
|
||||
});
|
||||
const height = TOP_PAD + Math.max(0, rows.length - 1) * (NODE_H + ROW_GAP) + NODE_H + BOTTOM_PAD;
|
||||
return { positions, width, height, nodeW: NODE_W, nodeH: NODE_H, topPad: TOP_PAD, rowGap: ROW_GAP, fastMode: true };
|
||||
}
|
||||
|
||||
function lineageBuildLinksFast(nodes, idSet, layout) {
|
||||
const { positions, nodeW, nodeH } = layout;
|
||||
const partnerLinks = [];
|
||||
const childLinks = [];
|
||||
const seenPartners = new Set();
|
||||
let childLinkGroups = 0;
|
||||
let childLinkEdges = 0;
|
||||
for (const child of nodes) {
|
||||
const childPos = positions.get(child.id);
|
||||
if (!childPos) continue;
|
||||
const parents = lineageParentIds(child, idSet).map(id => positions.get(id)).filter(Boolean).sort((a, b) => a.x - b.x);
|
||||
if (!parents.length) continue;
|
||||
childLinkGroups += 1;
|
||||
childLinkEdges += parents.length;
|
||||
const childX = childPos.x + nodeW / 2;
|
||||
const childY = childPos.y;
|
||||
const fromY = Math.max(...parents.map(p => p.y + nodeH));
|
||||
const joinX = parents.reduce((sum, p) => sum + p.x + nodeW / 2, 0) / parents.length;
|
||||
if (parents.length >= 2) {
|
||||
const left = parents[0];
|
||||
const right = parents[parents.length - 1];
|
||||
const partnerKey = parents.map(p => p.node?.id).filter(Boolean).sort().join("+");
|
||||
if (partnerKey && !seenPartners.has(partnerKey)) {
|
||||
seenPartners.add(partnerKey);
|
||||
const y = ((left.y + nodeH / 2) + (right.y + nodeH / 2)) / 2;
|
||||
partnerLinks.push(`M ${(left.x + nodeW).toFixed(1)} ${y.toFixed(1)} L ${right.x.toFixed(1)} ${y.toFixed(1)}`);
|
||||
}
|
||||
}
|
||||
const laneY = Math.min(childY - 18, fromY + Math.max(28, (childY - fromY) * 0.48));
|
||||
childLinks.push(`M ${joinX.toFixed(1)} ${fromY.toFixed(1)} L ${joinX.toFixed(1)} ${laneY.toFixed(1)} L ${childX.toFixed(1)} ${laneY.toFixed(1)} L ${childX.toFixed(1)} ${childY.toFixed(1)}`);
|
||||
}
|
||||
return { partnerLinks, childLinks, childLinkGroups, childLinkEdges };
|
||||
}
|
||||
|
||||
function buildLineageTreeLayout(component, family) {
|
||||
family = lineageEdgeFamily(family);
|
||||
const cleaned = lineageCleanComponent(component, family);
|
||||
|
|
@ -1044,22 +1375,23 @@ function buildLineageTreeLayout(component, family) {
|
|||
const idSet = new Set(idToNode.keys());
|
||||
const generations = Array.from(new Set(nodes.map(n => n.generation || 1))).sort((a, b) => a - b);
|
||||
const rows = lineageRowsForLayout(nodes, generations, idToNode, idSet);
|
||||
const layout = lineageLayoutRows(rows, idSet);
|
||||
const links = lineageBuildLinks(nodes, idSet, layout);
|
||||
const perf = lineageFastLayoutNeeded(nodes, idSet, family);
|
||||
const layout = perf.fast ? lineageLayoutRowsFast(rows, idSet) : lineageLayoutRows(rows, idSet);
|
||||
const links = perf.fast ? lineageBuildLinksFast(nodes, idSet, layout) : lineageBuildLinks(nodes, idSet, layout);
|
||||
|
||||
const labels = generations.map((g, rowIndex) => ({ generation: g, y: layout.topPad + rowIndex * (layout.nodeH + layout.rowGap) + 18 }));
|
||||
const renderedNodes = nodes.map(n => ({ node: n, ...(layout.positions.get(n.id) || {}) })).filter(n => Number.isFinite(n.x) && Number.isFinite(n.y));
|
||||
return { ...layout, labels, nodes: renderedNodes, childLinks: links.childLinks, partnerLinks: links.partnerLinks, childLinkGroups: links.childLinkGroups };
|
||||
return { ...layout, labels, nodes: renderedNodes, childLinks: links.childLinks, partnerLinks: links.partnerLinks, childLinkGroups: links.childLinkGroups, childLinkEdges: links.childLinkEdges, fastMode: perf.fast, edgeCount: perf.edgeCount };
|
||||
}
|
||||
|
||||
function lineageFamilyHtml(component, index, family, signature = "") {
|
||||
const uniqueComponent = Array.from(new Map(component.filter(Boolean).map(n => [n.id, n])).values()).sort(lineageNodeSort);
|
||||
const cacheKey = `${index}:${signature || lineageComponentSignature(uniqueComponent, family)}`;
|
||||
if (uiCache.archiveHtmlCache?.has(cacheKey)) return uiCache.archiveHtmlCache.get(cacheKey);
|
||||
const cacheKey = lineageFamilyCacheKey(uniqueComponent, family, signature);
|
||||
if (uiCache.archiveHtmlCache?.has(cacheKey)) return lineageHydrateFamilyHtml(uiCache.archiveHtmlCache.get(cacheKey), index);
|
||||
const layout = buildLineageTreeLayout(uniqueComponent, family);
|
||||
const aliveCount = uniqueComponent.filter(n => n.alive).length;
|
||||
const childEdgeCount = lineageExpectedChildEdgeCount(uniqueComponent, family);
|
||||
const maskId = `lineage-node-mask-${index}`;
|
||||
const maskId = "{{LINEAGE_MASK_ID}}";
|
||||
const nodeMaskRects = layout.nodes
|
||||
.map(p => `<rect x="${(p.x + 1).toFixed(1)}" y="${(p.y + 1).toFixed(1)}" width="${(layout.nodeW - 2).toFixed(1)}" height="${(layout.nodeH - 2).toFixed(1)}" rx="9" fill="black"></rect>`)
|
||||
.join("");
|
||||
|
|
@ -1069,8 +1401,8 @@ function lineageFamilyHtml(component, index, family, signature = "") {
|
|||
const nodeHtml = layout.nodes.map(p => lineageTreeNodeHtml(p.node, p.x, p.y)).join("");
|
||||
const empty = !layout.childLinks.length ? `<div class="lineage-empty-family">\u89aa\u5b50\u95a2\u4fc2\u306e\u7dda\u306f\u307e\u3060\u3042\u308a\u307e\u305b\u3093\u3002</div>` : "";
|
||||
|
||||
const html = `<section class="lineage-family lineage-family-tree" data-lineage-child-edges="${childEdgeCount}" data-lineage-child-groups="${layout.childLinkGroups || 0}" data-lineage-child-paths="${layout.childLinks.length}">
|
||||
<h3>\u5bb6\u7cfb ${index + 1} / ${uniqueComponent.length}\u5339 / \u751f\u5b58 ${aliveCount}</h3>
|
||||
const html = `<section class="lineage-family lineage-family-tree" data-lineage-index="{{LINEAGE_INDEX}}" data-lineage-key="${escapeHtml(cacheKey)}" data-lineage-child-edges="${childEdgeCount}" data-lineage-rendered-child-edges="${layout.childLinkEdges || 0}" data-lineage-child-groups="${layout.childLinkGroups || 0}" data-lineage-child-paths="${layout.childLinks.length}">
|
||||
<h3>\u5bb6\u7cfb {{LINEAGE_INDEX}} / ${uniqueComponent.length}\u5339 / \u751f\u5b58 ${aliveCount}</h3>
|
||||
<div class="lineage-tree-panel" style="width:${Math.ceil(layout.width)}px;height:${Math.ceil(layout.height)}px">
|
||||
<svg class="lineage-links" viewBox="0 0 ${Math.ceil(layout.width)} ${Math.ceil(layout.height)}" aria-hidden="true">
|
||||
<defs><mask id="${maskId}" maskUnits="userSpaceOnUse"><rect x="0" y="0" width="${Math.ceil(layout.width)}" height="${Math.ceil(layout.height)}" fill="white"></rect>${nodeMaskRects}</mask></defs>
|
||||
|
|
@ -1080,29 +1412,40 @@ function lineageFamilyHtml(component, index, family, signature = "") {
|
|||
</div>
|
||||
</section>`;
|
||||
uiCache.archiveHtmlCache?.set(cacheKey, html);
|
||||
return html;
|
||||
return lineageHydrateFamilyHtml(html, index);
|
||||
}
|
||||
|
||||
function renderArchive() {
|
||||
if (!ui.archiveContent) return;
|
||||
if (typeof archiveAutoUpdateEnabled === "function" && !archiveAutoUpdateEnabled()) {
|
||||
uiCache.archiveScheduled = false;
|
||||
if (!uiCache.archiveVersion) {
|
||||
ui.archiveContent.innerHTML = `<div class="selected-info empty">\u5bb6\u7cfb\u56f3\u306e\u66f4\u65b0\u306fOFF\u3067\u3059\u3002\u300c\u66f4\u65b0\u300d\u3092ON\u306b\u3059\u308b\u3068\u63cf\u753b\u3057\u307e\u3059\u3002</div>`;
|
||||
} else if (world.familyTreeDirty) {
|
||||
lineageSetArchiveStaleStatus(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (uiCache.deferArchiveUntil && performance.now() < uiCache.deferArchiveUntil) return;
|
||||
uiCache.deferArchiveUntil = 0;
|
||||
uiCache.archiveScheduled = false;
|
||||
const scrollState = lineageCaptureArchiveScroll();
|
||||
const familyVersion = world.familyVersion || 0;
|
||||
if (uiCache.archiveVersion && uiCache.archiveFamilyVersion === familyVersion) return;
|
||||
const familyDirty = Boolean(world.familyTreeDirty);
|
||||
if (uiCache.archiveRenderRunning) {
|
||||
if (familyDirty) uiCache.archivePendingDirtyAfterRun = true;
|
||||
return;
|
||||
}
|
||||
if (uiCache.archiveVersion && uiCache.archiveFamilyVersion === familyVersion && !familyDirty) return;
|
||||
if (uiCache.archiveVersion && !lineageArchiveNearViewport()) {
|
||||
uiCache.archivePendingOffscreen = true;
|
||||
return;
|
||||
}
|
||||
const running = world && !world.paused;
|
||||
if (uiCache.archiveVersion && running) {
|
||||
uiCache.archivePendingWhileRunning = true;
|
||||
if (uiCache.archiveDiagnostics) uiCache.archiveDiagnostics.skippedWhileRunning = (uiCache.archiveDiagnostics.skippedWhileRunning || 0) + 1;
|
||||
lineageSetArchiveStaleStatus(true);
|
||||
return;
|
||||
const hiddenInterval = 30000;
|
||||
const hiddenNow = performance.now();
|
||||
if (hiddenNow - (uiCache.archiveLastHiddenCheckAt || -Infinity) < hiddenInterval) return;
|
||||
uiCache.archiveLastHiddenCheckAt = hiddenNow;
|
||||
}
|
||||
const now = performance.now();
|
||||
const minUpdateGap = 700;
|
||||
const minUpdateGap = 4800;
|
||||
if (uiCache.archiveVersion && !uiCache.archiveRenderForce && now - (uiCache.archiveLastRenderedAt || 0) < minUpdateGap) {
|
||||
if (!uiCache.archiveRenderTimer) {
|
||||
uiCache.archiveRenderTimer = setTimeout(() => {
|
||||
|
|
@ -1119,18 +1462,12 @@ function renderArchive() {
|
|||
world.pruneExtinctFamilies();
|
||||
}
|
||||
const family = world.family || {};
|
||||
const relationSignature = lineageVisibleRelationSignature(family);
|
||||
if (uiCache.archiveVersion && uiCache.archiveRelationSignature === relationSignature) {
|
||||
uiCache.archiveFamilyVersion = world.familyVersion || 0;
|
||||
const version = String(world.familyVersion || 0);
|
||||
if (uiCache.archiveVersion === version) {
|
||||
world.familyTreeDirty = false;
|
||||
uiCache.archivePendingDirtyAfterRun = false;
|
||||
return;
|
||||
}
|
||||
const ids = Object.keys(family);
|
||||
const edgeCount = ids.reduce((sum, id) => {
|
||||
const n = family[id];
|
||||
return sum + (Array.isArray(n?.parents) ? n.parents.length : 0) + (Array.isArray(n?.children) ? n.children.length : 0);
|
||||
}, 0);
|
||||
const version = `${world.familyVersion || 0}:${ids.length}:${edgeCount}`;
|
||||
if (uiCache.archiveVersion === version) return;
|
||||
const renderStarted = performance.now();
|
||||
lineageSetArchiveStaleStatus(false);
|
||||
if (uiCache.archiveRenderTimer) {
|
||||
|
|
@ -1140,58 +1477,109 @@ function renderArchive() {
|
|||
uiCache.archiveVersion = version;
|
||||
uiCache.archiveFamilyVersion = world.familyVersion || 0;
|
||||
uiCache.archiveLastScheduleFamilyVersion = world.familyVersion || 0;
|
||||
uiCache.archiveRelationSignature = relationSignature;
|
||||
uiCache.archiveLastRenderedAt = performance.now();
|
||||
uiCache.lastArchiveWindow = "";
|
||||
const components = liveFamilyComponents(family).flatMap(lineageSplitByActualEdges).map(c => lineageCleanComponent(c, family)).filter(c => c.length);
|
||||
uiCache.archiveRows = components;
|
||||
const signatures = components.map(component => lineageComponentSignature(component, family));
|
||||
if (!uiCache.archiveHtmlCache) uiCache.archiveHtmlCache = new Map();
|
||||
const activeCacheKeys = new Set(signatures.map((signature, index) => `${index}:${signature}`));
|
||||
for (const key of Array.from(uiCache.archiveHtmlCache.keys())) {
|
||||
if (!activeCacheKeys.has(key)) uiCache.archiveHtmlCache.delete(key);
|
||||
}
|
||||
const activeCacheKeys = new Set();
|
||||
const token = `${version}:${Math.random().toString(36).slice(2)}`;
|
||||
uiCache.archiveRenderToken = token;
|
||||
if (!components.length) {
|
||||
ui.archiveContent.innerHTML = `<div class="selected-info empty">\u751f\u304d\u3066\u3044\u308b\u69cb\u6210\u54e1\u3092\u6301\u3064\u5bb6\u7cfb\u306f\u307e\u3060\u3042\u308a\u307e\u305b\u3093\u3002</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
ui.archiveContent.innerHTML = `<div class="lineage-render-status">\u5bb6\u7cfb\u56f3\u3092\u63cf\u753b\u4e2d\u2026 0 / ${components.length}</div><div class="lineage-forest"></div>`;
|
||||
uiCache.archiveRenderRunning = true;
|
||||
ui.archiveContent.innerHTML = `<div class="lineage-render-status">\u5bb6\u7cfb\u56f3\u3092\u6e96\u5099\u4e2d\u2026</div><div class="lineage-forest"></div>`;
|
||||
lineageRestoreArchiveScroll(scrollState);
|
||||
const status = ui.archiveContent.querySelector(".lineage-render-status");
|
||||
const forest = ui.archiveContent.querySelector(".lineage-forest");
|
||||
let index = 0;
|
||||
const pump = () => {
|
||||
if (uiCache.archiveRenderToken !== token || !forest) return;
|
||||
const renderComponents = (components) => {
|
||||
if (uiCache.archiveRenderToken !== token || !forest) {
|
||||
uiCache.archiveRenderRunning = false;
|
||||
return;
|
||||
}
|
||||
const splitComponents = components.flatMap(lineageSplitByActualEdges).filter(c => c.length);
|
||||
uiCache.archiveRows = splitComponents;
|
||||
if (!splitComponents.length) {
|
||||
ui.archiveContent.innerHTML = `<div class="selected-info empty">\u751f\u304d\u3066\u3044\u308b\u69cb\u6210\u54e1\u3092\u6301\u3064\u5bb6\u7cfb\u306f\u307e\u3060\u3042\u308a\u307e\u305b\u3093\u3002</div>`;
|
||||
lineageRestoreArchiveScroll(scrollState);
|
||||
uiCache.archiveRenderRunning = false;
|
||||
world.familyTreeDirty = false;
|
||||
uiCache.archivePendingDirtyAfterRun = false;
|
||||
return;
|
||||
}
|
||||
if (status) status.textContent = `\u5bb6\u7cfb\u56f3\u3092\u63cf\u753b\u4e2d\u2026 0 / ${splitComponents.length}`;
|
||||
let index = 0;
|
||||
const pump = () => {
|
||||
if (uiCache.archiveRenderToken !== token || !forest) {
|
||||
uiCache.archiveRenderRunning = false;
|
||||
return;
|
||||
}
|
||||
const started = performance.now();
|
||||
let nodeBudget = 72;
|
||||
let nodeBudget = 28;
|
||||
let renderedThisSlice = 0;
|
||||
while (index < components.length) {
|
||||
const component = components[index];
|
||||
while (index < splitComponents.length) {
|
||||
const component = splitComponents[index];
|
||||
if (renderedThisSlice > 0 && component.length > nodeBudget) break;
|
||||
forest.insertAdjacentHTML("beforeend", lineageFamilyHtml(component, index, family, signatures[index]));
|
||||
const signature = lineageComponentSignature(component, family);
|
||||
activeCacheKeys.add(lineageFamilyCacheKey(component, family, signature));
|
||||
forest.insertAdjacentHTML("beforeend", lineageFamilyHtml(component, index, family, signature));
|
||||
nodeBudget -= Math.max(1, component.length);
|
||||
index += 1;
|
||||
renderedThisSlice += 1;
|
||||
if (index < components.length && (nodeBudget <= 0 || performance.now() - started > 14)) break;
|
||||
if (index < splitComponents.length && (nodeBudget <= 0 || performance.now() - started > 6)) break;
|
||||
}
|
||||
if (status) status.textContent = `\u5bb6\u7cfb\u56f3\u3092\u63cf\u753b\u4e2d\u2026 ${index} / ${components.length}`;
|
||||
if (index < components.length) {
|
||||
const schedule = window.requestIdleCallback || ((fn) => setTimeout(fn, 0));
|
||||
schedule(pump, { timeout: 120 });
|
||||
} else if (status) {
|
||||
status.remove();
|
||||
lineageRestoreArchiveScroll(scrollState);
|
||||
if (status) status.textContent = `\u5bb6\u7cfb\u56f3\u3092\u63cf\u753b\u4e2d\u2026 ${index} / ${splitComponents.length}`;
|
||||
if (index < splitComponents.length) {
|
||||
lineageIdleSchedule(pump, 220);
|
||||
} else {
|
||||
if (status) status.remove();
|
||||
for (const key of Array.from(uiCache.archiveHtmlCache.keys())) {
|
||||
if (!activeCacheKeys.has(key)) uiCache.archiveHtmlCache.delete(key);
|
||||
}
|
||||
lineageRestoreArchiveScroll(scrollState);
|
||||
const renderDirtiedAgain = uiCache.archivePendingDirtyAfterRun || (world.familyVersion || 0) !== familyVersion;
|
||||
uiCache.archiveRenderRunning = false;
|
||||
world.familyTreeDirty = renderDirtiedAgain;
|
||||
if (uiCache.archiveDiagnostics) {
|
||||
uiCache.archiveDiagnostics.renders = (uiCache.archiveDiagnostics.renders || 0) + 1;
|
||||
uiCache.archiveDiagnostics.lastMs = Math.round((performance.now() - renderStarted) * 10) / 10;
|
||||
uiCache.archiveDiagnostics.lastFamilies = components.length;
|
||||
uiCache.archiveDiagnostics.lastNodes = components.reduce((sum, component) => sum + component.length, 0);
|
||||
uiCache.archiveDiagnostics.lastFamilies = splitComponents.length;
|
||||
uiCache.archiveDiagnostics.lastNodes = splitComponents.reduce((sum, component) => sum + component.length, 0);
|
||||
}
|
||||
if (renderDirtiedAgain) {
|
||||
uiCache.archivePendingDirtyAfterRun = false;
|
||||
scheduleArchiveWindowRender();
|
||||
}
|
||||
}
|
||||
};
|
||||
const schedule = window.requestIdleCallback || ((fn) => setTimeout(fn, 0));
|
||||
schedule(pump, { timeout: 80 });
|
||||
lineageIdleSchedule(pump, 160);
|
||||
};
|
||||
lineageBuildComponentsIdle(
|
||||
family,
|
||||
token,
|
||||
(label, done, total) => {
|
||||
if (status) status.textContent = `${label}\u2026 ${Math.min(done || 0, total || 0)} / ${total || 0}`;
|
||||
},
|
||||
renderComponents
|
||||
);
|
||||
}
|
||||
|
||||
function resetArchiveRenderState() {
|
||||
if (uiCache.archiveRenderTimer) {
|
||||
clearTimeout(uiCache.archiveRenderTimer);
|
||||
uiCache.archiveRenderTimer = 0;
|
||||
}
|
||||
uiCache.archiveRenderToken = "";
|
||||
uiCache.archiveRenderRunning = false;
|
||||
uiCache.archiveScheduled = false;
|
||||
uiCache.archivePendingOffscreen = false;
|
||||
uiCache.archivePendingWhileRunning = false;
|
||||
uiCache.archivePendingDirtyAfterRun = false;
|
||||
uiCache.archiveLastHiddenCheckAt = -Infinity;
|
||||
uiCache.archiveLastScheduleFamilyVersion = null;
|
||||
uiCache.archiveVersion = "";
|
||||
uiCache.archiveFamilyVersion = null;
|
||||
uiCache.archiveRelationSignature = "";
|
||||
uiCache.archiveRows = [];
|
||||
uiCache.lastArchiveWindow = "";
|
||||
uiCache.archiveHtmlCache?.clear?.();
|
||||
}
|
||||
|
||||
function validateFamilyTree() {
|
||||
|
|
@ -1211,36 +1599,21 @@ function validateFamilyTree() {
|
|||
}
|
||||
document.querySelectorAll(".lineage-family-tree[data-lineage-child-edges]").forEach((section, index) => {
|
||||
const expectedEdges = Number(section.getAttribute("data-lineage-child-edges") || 0);
|
||||
const renderedEdges = Number(section.getAttribute("data-lineage-rendered-child-edges") || 0);
|
||||
const expectedGroups = Number(section.getAttribute("data-lineage-child-groups") || 0);
|
||||
const renderedPaths = Number(section.getAttribute("data-lineage-child-paths") || 0);
|
||||
const pathCount = section.querySelectorAll(".lineage-links .child-link").length;
|
||||
if (renderedEdges !== expectedEdges) {
|
||||
issues.push({ type: "render-child-edge-count-mismatch", familyIndex: index, expectedEdges, renderedEdges });
|
||||
}
|
||||
if (expectedEdges > 0 && pathCount < 1) {
|
||||
issues.push({ type: "missing-render-child-link", familyIndex: index, expectedEdges, pathCount });
|
||||
}
|
||||
if (expectedGroups > 0 && pathCount < expectedGroups) {
|
||||
issues.push({ type: "missing-render-child-link-group", familyIndex: index, expectedGroups, pathCount });
|
||||
}
|
||||
const panel = section.querySelector(".lineage-tree-panel");
|
||||
const svg = section.querySelector(".lineage-links");
|
||||
if (panel && svg) {
|
||||
const panelRect = panel.getBoundingClientRect();
|
||||
const nodes = Array.from(panel.querySelectorAll(".tree-node")).map(node => {
|
||||
const r = node.getBoundingClientRect();
|
||||
return {
|
||||
id: node.getAttribute("data-family-tarinai-id") || "",
|
||||
left: r.left - panelRect.left + 1,
|
||||
top: r.top - panelRect.top + 1,
|
||||
right: r.right - panelRect.left - 1,
|
||||
bottom: r.bottom - panelRect.top - 1,
|
||||
};
|
||||
});
|
||||
section.querySelectorAll(".lineage-links .child-link").forEach((path, pathIndex) => {
|
||||
if (!path.getBBox) return;
|
||||
const box = path.getBBox();
|
||||
for (const node of nodes) {
|
||||
const overlaps = box.x < node.right && box.x + box.width > node.left && box.y < node.bottom && box.y + box.height > node.top;
|
||||
if (overlaps) issues.push({ type: "child-link-bbox-overlaps-node-body", familyIndex: index, pathIndex, node: node.id });
|
||||
}
|
||||
});
|
||||
if (renderedPaths !== pathCount) {
|
||||
issues.push({ type: "render-child-path-count-mismatch", familyIndex: index, renderedPaths, pathCount });
|
||||
}
|
||||
});
|
||||
return issues;
|
||||
|
|
@ -1251,17 +1624,34 @@ function renderArchiveWindow() {
|
|||
}
|
||||
|
||||
function scheduleArchiveWindowRender() {
|
||||
if (uiCache.archiveVersion && uiCache.archiveFamilyVersion === (world.familyVersion || 0)) return;
|
||||
if (typeof archiveAutoUpdateEnabled === "function" && !archiveAutoUpdateEnabled()) {
|
||||
uiCache.archiveScheduled = false;
|
||||
if (world.familyTreeDirty) lineageSetArchiveStaleStatus(true);
|
||||
return;
|
||||
}
|
||||
const familyDirty = Boolean(world.familyTreeDirty);
|
||||
if (uiCache.archiveVersion && uiCache.archiveFamilyVersion === (world.familyVersion || 0) && !familyDirty) return;
|
||||
if (uiCache.archiveRenderRunning) {
|
||||
if (familyDirty) uiCache.archivePendingDirtyAfterRun = true;
|
||||
return;
|
||||
}
|
||||
if (uiCache.archiveVersion && !lineageArchiveNearViewport()) {
|
||||
uiCache.archivePendingOffscreen = true;
|
||||
const now = performance.now();
|
||||
if (now - (uiCache.archiveLastHiddenCheckAt || -Infinity) < 30000) return;
|
||||
uiCache.archiveLastHiddenCheckAt = now;
|
||||
}
|
||||
if (uiCache.archiveRenderTimer) return;
|
||||
if (uiCache.archiveScheduled) return;
|
||||
uiCache.archiveScheduled = true;
|
||||
const schedule = window.requestIdleCallback || ((fn) => setTimeout(fn, 0));
|
||||
schedule(() => {
|
||||
renderArchive();
|
||||
}, { timeout: 700 });
|
||||
}, { timeout: 1200 });
|
||||
}
|
||||
|
||||
function schedulePendingArchiveRender() {
|
||||
if (typeof archiveAutoUpdateEnabled === "function" && !archiveAutoUpdateEnabled()) return;
|
||||
if (!uiCache.archivePendingOffscreen) return;
|
||||
if (!lineageArchiveNearViewport()) return;
|
||||
uiCache.archivePendingOffscreen = false;
|
||||
|
|
@ -1270,3 +1660,4 @@ function schedulePendingArchiveRender() {
|
|||
|
||||
window.validateFamilyTree = validateFamilyTree;
|
||||
window.familyTreeDiagnostics = () => ({ ...(uiCache.archiveDiagnostics || {}) });
|
||||
window.resetArchiveRenderState = resetArchiveRenderState;
|
||||
|
|
|
|||