diff --git a/.htaccess b/.htaccess new file mode 100644 index 0000000..d579227 --- /dev/null +++ b/.htaccess @@ -0,0 +1,24 @@ + + 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 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" + + + + Header set Cache-Control "no-cache, no-store, must-revalidate" + Header set Pragma "no-cache" + Header set Expires "0" + + + Header set Cache-Control "public, max-age=2592000" + + + + AddOutputFilterByType DEFLATE text/html text/css application/javascript application/json text/plain + diff --git a/README.md b/README.md index a874c63..1cb4af3 100644 --- a/README.md +++ b/README.md @@ -1,210 +1,210 @@ -# たりないコロニー観察所 +# たりないコロニー観察所 -ブラウザで動く、観察主体の小さなコロニーゲームです。 -外部ライブラリやサーバーは不要です。 +ブラウザで動く、観察主体の小さなコロニーゲームです。 +外部ライブラリやサーバーは不要です。 -## 起動方法 +## 起動方法 -1. ZIPを展開 -2. `index.html` をブラウザで開く +1. ZIPを展開 +2. `index.html` をブラウザで開く -Chrome / Edge / Firefox / Safari の近年版を想定しています。 +Chrome / Edge / Firefox / Safari の近年版を想定しています。 -## ゲーム内容 +## ゲーム内容 -「たりない」たちが観察皿の中で勝手に暮らします。 -基本は眺めるだけですが、餌・水・草・石・寝床・などを配置して軽く介入できます。 +「たりない」たちが観察皿の中で勝手に暮らします。 +基本は眺めるだけですが、餌・水・草・石・寝床・などを配置して軽く介入できます。 -## 主な要素 +## 主な要素 -- 9種類のたりない画像を使用 -- 空腹・孤独・眠気・不安・たりない度 -- 餌探し、睡眠、群れ寄り、パニック、繁殖、死亡 -- 死後の「たりない跡」と草の発生 -- 個体選択による詳細観察 -- 観察ログ -- 一時停止 / 速度変更 / 保存 / 読込 -- localStorageによるブラウザ内セーブ +- 9種類のたりない画像を使用 +- 空腹・孤独・眠気・不安・たりない度 +- 餌探し、睡眠、群れ寄り、パニック、繁殖、死亡 +- 死後の「たりない跡」と草の発生 +- 個体選択による詳細観察 +- 観察ログ +- 一時停止 / 速度変更 / 保存 / 読込 +- localStorageによるブラウザ内セーブ -## 操作 +## 操作 -- 観察:個体クリックで詳細表示 -- ふつうの餌 / あまい餌 / 水滴 / 草 / ぬくい石 / ふわふわ:キャンバスクリックで配置 -- つつく:個体をクリックして一時的に不安を上げる -- 迷い込み:指定位置に新しいたりないを追加 +- 観察:個体クリックで詳細表示 +- ふつうの餌 / あまい餌 / 水滴 / 草 / ぬくい石 / ふわふわ:キャンバスクリックで配置 +- つつく:個体をクリックして一時的に不安を上げる +- 迷い込み:指定位置に新しいたりないを追加 -## 素材について +## 素材について -同梱画像は本プロトタイプ用に加工した「たりない」素材です。 -公開・配布・商用利用を行う場合は、元画像作成者・権利者の許諾範囲に従ってください。 +同梱画像は本プロトタイプ用に加工した「たりない」素材です。 +公開・配布・商用利用を行う場合は、元画像作成者・権利者の許諾範囲に従ってください。 -## ファイル構成 +## ファイル構成 ```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 +├─ css/ +│ └─ styles.css +├─ js/ +│ └─ game.js +├─ assets/ +│ └─ sprites/ +│ ├─ tarinai_01_smile.png +│ ├─ ... +│ └─ _contact_sheet.png +└─ docs/ + └─ design_notes.md ``` -## 今後の拡張候補 +## 今後の拡張候補 -- 個体ごとの家系図 -- 図鑑・実績 -- より精密なコロニー分裂 -- 草・死跡・餌の生態循環 -- 音と環境変化 -- 画像素材の追加差分 +- 個体ごとの家系図 +- 図鑑・実績 +- より精密なコロニー分裂 +- 草・死跡・餌の生態循環 +- 音と環境変化 +- 画像素材の追加差分 -## 更新内容(最新版) +## 更新内容(最新版) -- たりないの表示サイズを大幅に縮小 -- 右移動時に左右反転 -- 食事中の専用エフェクト -- つつきすぎによる死亡演出(06→09→死亡) -- 死亡時の緑のしぶき跡 -- 感情に応じたスプライト自動切替 -- 昼夜サイクルと時間HUD +- たりないの表示サイズを大幅に縮小 +- 右移動時に左右反転 +- 食事中の専用エフェクト +- つつきすぎによる死亡演出(06→09→死亡) +- 死亡時の緑のしぶき跡 +- 感情に応じたスプライト自動切替 +- 昼夜サイクルと時間HUD -## 更新内容(v3) +## 更新内容(v3) -- 出生・死亡バランスを再調整:飢餓速度を下げ、寿命と上限個体数を増やし、観察が長く続くように調整 -- 図鑑を追加:観測済みスプライト、イベント、死因を記録 -- 系譜を追加:繁殖時に親子関係・世代・出生時刻を保存 -- 選択個体に親・子数・気分・つつき耐久を表示 -- Web Audioによる簡易効果音を追加(音 ON ボタンで有効化) -- 昼夜サイクルをログ・図鑑イベントにも反映 -- 食事、繁殖、死亡、つつき、時間帯切替のイベント記録を追加 +- 出生・死亡バランスを再調整:飢餓速度を下げ、寿命と上限個体数を増やし、観察が長く続くように調整 +- 図鑑を追加:観測済みスプライト、イベント、死因を記録 +- 系譜を追加:繁殖時に親子関係・世代・出生時刻を保存 +- 選択個体に親・子数・気分・つつき耐久を表示 +- Web Audioによる簡易効果音を追加(音 ON ボタンで有効化) +- 昼夜サイクルをログ・図鑑イベントにも反映 +- 食事、繁殖、死亡、つつき、時間帯切替のイベント記録を追加 -## 更新内容(v4) +## 更新内容(v4) -- 選択個体パネルから「足りないもの」「考えていること」を削除 -- 系譜をリスト形式から樹状図形式へ変更 -- 子個体が成長するまで親に追従する挙動を追加 -- 食事エフェクト量を抑制し、餌・あまい餌・草の色を反映 -- 昼夜サイクルの光を地面・アイテム・たりない本体へ反映 -- 近距離・低機嫌・過密時に発生する喧嘩を追加 -- 喧嘩イベントを図鑑イベントへ記録 -- 保存キーは `tarinai-colony-save-v4` +- 選択個体パネルから「足りないもの」「考えていること」を削除 +- 系譜をリスト形式から樹状図形式へ変更 +- 子個体が成長するまで親に追従する挙動を追加 +- 食事エフェクト量を抑制し、餌・あまい餌・草の色を反映 +- 昼夜サイクルの光を地面・アイテム・たりない本体へ反映 +- 近距離・低機嫌・過密時に発生する喧嘩を追加 +- 喧嘩イベントを図鑑イベントへ記録 +- 保存キーは `tarinai-colony-save-v4` -## 更新内容(v5) +## 更新内容(v5) -- 草は自然発生するように変更 -- 草は自然には消えず、少しずつ伸びる -- 草を食べると残量が減り、表示される本数も減る -- 暗い時間帯にスプライトの透過部分へ矩形状の色が乗る問題を修正 -- HUDの観察日を「1日目」表記へ変更 -- HUDの「明るさ」表示を時刻表記へ変更 -- 図鑑UIを完全に削除 -- 系譜を右パネルからメイン画面下部へ移動 -- 保存キーは `tarinai-colony-save-v5` +- 草は自然発生するように変更 +- 草は自然には消えず、少しずつ伸びる +- 草を食べると残量が減り、表示される本数も減る +- 暗い時間帯にスプライトの透過部分へ矩形状の色が乗る問題を修正 +- HUDの観察日を「1日目」表記へ変更 +- HUDの「明るさ」表示を時刻表記へ変更 +- 図鑑UIを完全に削除 +- 系譜を右パネルからメイン画面下部へ移動 +- 保存キーは `tarinai-colony-save-v5` -## 更新内容(v6) +## 更新内容(v6) -- 喧嘩中に頭突き攻撃を行うように変更 -- 草と石が昼夜サイクルの光に反応するように変更 -- 睡眠中は完全に移動停止 -- 09_stretch が見えるよう、つつき耐久を 9 段階に調整 - - 3 回目以降: 06_cry - - 6 回目以降: 09_stretch - - 9 回目: 死亡 -- たりないがカーソルに反応 - - 好意的な個体は 01_smile で寄ってくる - - 敵対的・不安定な個体は逃げる -- 保存キーは `tarinai-colony-save-v6` +- 喧嘩中に頭突き攻撃を行うように変更 +- 草と石が昼夜サイクルの光に反応するように変更 +- 睡眠中は完全に移動停止 +- 09_stretch が見えるよう、つつき耐久を 9 段階に調整 + - 3 回目以降: 06_cry + - 6 回目以降: 09_stretch + - 9 回目: 死亡 +- たりないがカーソルに反応 + - 好意的な個体は 01_smile で寄ってくる + - 敵対的・不安定な個体は逃げる +- 保存キーは `tarinai-colony-save-v6` -## 更新内容(v7) +## 更新内容(v7) -- メイン画面の外観を「庭の観察」テイストへ刷新 -- 空・日差し・庭枠・土の質感・周辺の草花を追加 -- たりないに接地影を追加 -- 石・草・各アイテムにも影と周囲とのなじみを追加 -- 選択リングを観察レンズ風の二重リングに変更 -- 子個体と親個体の関係が見えやすいよう、近距離時に薄い追従ガイド線を追加 -- 右上HUDと全体UIを庭の観察ノート風に調整 -- 保存キーは `tarinai-colony-save-v7` +- メイン画面の外観を「庭の観察」テイストへ刷新 +- 空・日差し・庭枠・土の質感・周辺の草花を追加 +- たりないに接地影を追加 +- 石・草・各アイテムにも影と周囲とのなじみを追加 +- 選択リングを観察レンズ風の二重リングに変更 +- 子個体と親個体の関係が見えやすいよう、近距離時に薄い追従ガイド線を追加 +- 右上HUDと全体UIを庭の観察ノート風に調整 +- 保存キーは `tarinai-colony-save-v7` -## 更新内容(v8) +## 更新内容(v8) -- アニメーションを調整し、呼吸感・移動感・喧嘩時の勢いを強化 -- 庭素材を追加(小石・苔・クローバー・落ち葉・周辺の草花) -- 太陽や月そのものは描かず、時間帯に応じた投影影を追加 -- ずんちを追加。食事を重ねると排泄し、庭に残る -- 09_stretch が通常個体でもはっきり表示されるよう補強 -- 系譜の接続線を太く・連続的にして見やすく修正 -- 未使用だった図鑑系の状態管理や関連スタイルを削除 -- 保存キーは `tarinai-colony-save-v8` +- アニメーションを調整し、呼吸感・移動感・喧嘩時の勢いを強化 +- 庭素材を追加(小石・苔・クローバー・落ち葉・周辺の草花) +- 太陽や月そのものは描かず、時間帯に応じた投影影を追加 +- ずんちを追加。食事を重ねると排泄し、庭に残る +- 09_stretch が通常個体でもはっきり表示されるよう補強 +- 系譜の接続線を太く・連続的にして見やすく修正 +- 未使用だった図鑑系の状態管理や関連スタイルを削除 +- 保存キーは `tarinai-colony-save-v8` -## 更新内容(v9) +## 更新内容(v9) -- 投影影が見えにくい問題を修正 - - blur フィルタ依存をやめ、軽量な楕円影へ変更 - - たりない・石・草・アイテムの影を濃く調整 -- 重すぎる問題を軽量化 - - 庭背景の粒・草花・落ち葉描画数を削減 - - filter blur を廃止 - - 草の自動発生上限と発生頻度を抑制 - - アイテム上限を削減 - - 食事パーティクル頻度を抑制 -- 死亡後の緑のしぶきが時間経過でゆっくり薄く消えるように変更 -- 保存キーは `tarinai-colony-save-v9` +- 投影影が見えにくい問題を修正 + - blur フィルタ依存をやめ、軽量な楕円影へ変更 + - たりない・石・草・アイテムの影を濃く調整 +- 重すぎる問題を軽量化 + - 庭背景の粒・草花・落ち葉描画数を削減 + - filter blur を廃止 + - 草の自動発生上限と発生頻度を抑制 + - アイテム上限を削減 + - 食事パーティクル頻度を抑制 +- 死亡後の緑のしぶきが時間経過でゆっくり薄く消えるように変更 +- 保存キーは `tarinai-colony-save-v9` -## 更新内容(v10) +## 更新内容(v10) -- 新規スプライト5枚を追加 - - `10_hurt`: 痛い時・喧嘩中に打たれた時 - - `11_weak`: 低体力時 - - `12_back`: 奥方向へ進む時 - - `13_flee`: 喧嘩に負けて敗走する時 - - `14_zunda_eat`: ずんだ餅を食べる時 -- 追加画像はすべて透明PNG化し、`512x468` のキャンバスへ正規化 -- 追加スプライトは行動専用扱いにし、通常個体の基本タイプとしてはランダム出現しないように調整 +- 新規スプライト5枚を追加 + - `10_hurt`: 痛い時・喧嘩中に打たれた時 + - `11_weak`: 低体力時 + - `12_back`: 奥方向へ進む時 + - `13_flee`: 喧嘩に負けて敗走する時 + - `14_zunda_eat`: ずんだ餅を食べる時 +- 追加画像はすべて透明PNG化し、`512x468` のキャンバスへ正規化 +- 追加スプライトは行動専用扱いにし、通常個体の基本タイプとしてはランダム出現しないように調整 -## 更新内容(v11) +## 更新内容(v11) -- 個体ごとの性格を追加 - - 臆病、食いしん坊、眠たがり、寂しがり、怒りっぽい、マイペース - - 性格は睡眠、喧嘩、ずんだ餅反応、恐怖、対個体行動へ影響 -- 選択パネルに観察理由を追加 - - 現在の行動理由 - - 対象 - - 仲の良い個体 - - 避けている個体 - - 喧嘩記憶 -- 関係性と喧嘩記憶を追加 - - 近くで過ごす個体同士は親和度が上がる - - 喧嘩に負けた相手を記憶し、しばらく避けやすくなる - - 勝敗はログと選択パネルに反映 -- hay bed に快適度・混雑ペナルティを追加 - - 空いているベッドを選びやすくなる - - 混雑時は回復効率が下がり、ストレスが上がる -- 草の生態系を強化 - - 雨、水、ずんち由来の肥沃地で育ちやすくなる - - 強い晴天で水分がないと弱りやすくなる - - 草の周辺ではストレス・恐怖が少し下がる -- 表情スプライトの優先順位を整理 - - ずんだ餅、被ダメージ、敗走、恐怖、低体力、睡眠、喧嘩、奥移動、通常の順に競合解決 -- ログを種類別に色分け - - 誕生、死亡、喧嘩、食事、草、環境、関係、観察 -- 喧嘩で転ぶ演出を追加 - - 打撃時に低確率で転ぶ - - 敗北時に転倒し、土煙が出る -- 保存キーは `tarinai-colony-save-v11` +- 個体ごとの性格を追加 + - 臆病、食いしん坊、眠たがり、寂しがり、怒りっぽい、マイペース + - 性格は睡眠、喧嘩、ずんだ餅反応、恐怖、対個体行動へ影響 +- 選択パネルに観察理由を追加 + - 現在の行動理由 + - 対象 + - 仲の良い個体 + - 避けている個体 + - 喧嘩記憶 +- 関係性と喧嘩記憶を追加 + - 近くで過ごす個体同士は親和度が上がる + - 喧嘩に負けた相手を記憶し、しばらく避けやすくなる + - 勝敗はログと選択パネルに反映 +- hay bed に快適度・混雑ペナルティを追加 + - 空いているベッドを選びやすくなる + - 混雑時は回復効率が下がり、ストレスが上がる +- 草の生態系を強化 + - 雨、水、ずんち由来の肥沃地で育ちやすくなる + - 強い晴天で水分がないと弱りやすくなる + - 草の周辺ではストレス・恐怖が少し下がる +- 表情スプライトの優先順位を整理 + - ずんだ餅、被ダメージ、敗走、恐怖、低体力、睡眠、喧嘩、奥移動、通常の順に競合解決 +- ログを種類別に色分け + - 誕生、死亡、喧嘩、食事、草、環境、関係、観察 +- 喧嘩で転ぶ演出を追加 + - 打撃時に低確率で転ぶ + - 敗北時に転倒し、土煙が出る +- 保存キーは `tarinai-colony-save-v11` diff --git a/css/styles.css b/css/styles.css index 21f0d8f..84bd045 100644 --- a/css/styles.css +++ b/css/styles.css @@ -350,14 +350,14 @@ h1 { .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: "⚽"; + 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: "⚽"; + content: "\26bd "; background: none; font-size: 48px; line-height: 1; @@ -372,14 +372,14 @@ h1 { } .tool[data-tool="poke"] { --tool-icon: none; } .tool[data-tool="poke"]::before { - content: "👈"; + 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: "👈"; + content: "\1f448 "; background: none; font-size: 48px; line-height: 1; @@ -392,6 +392,74 @@ h1 { width: 54px; height: 54px; } + +.tool[data-tool="pinch"] { --tool-icon: none; } +.tool[data-tool="pinch"]::before { + 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 "; + 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: none; } +.tool[data-tool="love_mochi"]::before { + content: "\1f495 "; + background: none; + 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 "; + 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="delete"] { --tool-icon: url("../assets/ui/tool_delete.png"); } .tool[data-tool="new"] { --tool-icon: url("../assets/ui/tool_new.png"); } .tool.selected { @@ -429,8 +497,8 @@ h1 { font-weight: 700; font-size: 12px; } -.tool-category-toggle::before { content: "▾"; display: inline-block; width: 16px; color: var(--muted); } -.tool-category.collapsed .tool-category-toggle::before { content: "▸"; } +.tool-category-toggle::before { content: "\25be "; display: inline-block; width: 16px; color: var(--muted); } +.tool-category.collapsed .tool-category-toggle::before { content: "\25b8 "; } .tool-category-body { padding: 8px; } .tool-category.collapsed .tool-category-body { display: none; } .tool-size-badge { @@ -844,6 +912,7 @@ body::before { overflow: visible; pointer-events: none; z-index: 1; + isolation: isolate; } .lineage-render-status { position: sticky; @@ -860,6 +929,22 @@ body::before { border: 1px solid rgba(84,68,48,0.10); box-shadow: 0 4px 10px rgba(64,48,32,0.06); } +.lineage-stale-status { + position: sticky; + top: 0; + left: 0; + z-index: 6; + width: max-content; + max-width: calc(100% - 12px); + margin: 0 0 8px; + padding: 6px 10px; + border-radius: 999px; + font-size: 12px; + color: rgba(92, 75, 52, 0.82); + background: rgba(255, 249, 232, 0.92); + border: 1px solid rgba(150, 115, 46, 0.16); + box-shadow: 0 4px 10px rgba(64,48,32,0.05); +} .lineage-family-tree .lineage-links path { fill: none; stroke: rgba(107, 88, 61, 0.58); @@ -883,11 +968,28 @@ body::before { min-height: 68px; box-sizing: border-box; align-items: flex-start; + background: linear-gradient(180deg, #fffdf8, #f8f1e5); + box-shadow: 0 2px 5px rgba(64,48,32,0.035); + contain: layout paint style; z-index: 2; + isolation: isolate; } .lineage-family-tree .tree-node.child { border-color: rgba(103,178,44,0.34); } +.lineage-family-tree .tree-node.dead { + opacity: 1; + filter: none; +} +.lineage-family-tree .tree-node[data-family-tarinai-id]:hover { + transform: none; +} +.lineage-family-tree .tree-node .family-portrait { + right: 0; + bottom: -6px; + width: 58px; + height: 58px; +} .lineage-family-tree .tree-generation-label { position: absolute; left: 10px; diff --git a/docs/code_map.md b/docs/code_map.md index 2ba87a2..41910c2 100644 --- a/docs/code_map.md +++ b/docs/code_map.md @@ -7,14 +7,12 @@ Use this map to open the smallest relevant file first. - `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. -- `js/sim.js`: compatibility export for `window.TarinaiSim`. ## 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. -- `js/ui_exports.js`: compatibility export for `window.TarinaiUI`. ## Common Tasks - Change hunger, sleep, fighting, panic, eating, or movement: start with `js/tarinai.js`. diff --git a/docs/design_notes.md b/docs/design_notes.md index 2b1c1e8..f5a9b42 100644 --- a/docs/design_notes.md +++ b/docs/design_notes.md @@ -1,163 +1,163 @@ -# たりないコロニー観察所:仕様メモ +# たりないコロニー観察所:仕様メモ -## コンセプト +## コンセプト -「不足を解決するゲーム」ではなく、 -「不足を抱えた生き物が、それでも勝手に暮らす様子を眺めるゲーム」。 +「不足を解決するゲーム」ではなく、 +「不足を抱えた生き物が、それでも勝手に暮らす様子を眺めるゲーム」。 -プレイヤーは神ではなく、観察者兼飼育係程度の立場。 -すべてを最適化するより、少し足りない状態の揺らぎを観察することを主目的にする。 +プレイヤーは神ではなく、観察者兼飼育係程度の立場。 +すべてを最適化するより、少し足りない状態の揺らぎを観察することを主目的にする。 -## 現在実装しているループ +## 現在実装しているループ -1. 個体が空腹・孤独・眠気・不安を内部状態として持つ -2. 状態に応じて餌・仲間・寝床を探す -3. プレイヤーは環境にアイテムを置いて介入する -4. 介入と個体行動により、繁殖・死亡・観察ログが生まれる -5. 死亡個体は「たりない跡」を残し、環境に記録として残る +1. 個体が空腹・孤独・眠気・不安を内部状態として持つ +2. 状態に応じて餌・仲間・寝床を探す +3. プレイヤーは環境にアイテムを置いて介入する +4. 介入と個体行動により、繁殖・死亡・観察ログが生まれる +5. 死亡個体は「たりない跡」を残し、環境に記録として残る -## 個体状態 +## 個体状態 -| 状態 | 内容 | +| 状態 | 内容 | |---|---| -| 空腹 | 高いほど餌・草を探す。上がりすぎると死亡 | -| 孤独 | 高いほど仲間に寄る。近くに仲間がいると下がる | -| 眠気残量 | 低いほど寝る。寝床があると回復が速い | -| 不安 | 高いほど慌てる。水・石・寝床などで下がる | -| たりない度 | 上記の総合的な不安定さ | +| 空腹 | 高いほど餌・草を探す。上がりすぎると死亡 | +| 孤独 | 高いほど仲間に寄る。近くに仲間がいると下がる | +| 眠気残量 | 低いほど寝る。寝床があると回復が速い | +| 不安 | 高いほど慌てる。水・石・寝床などで下がる | +| たりない度 | 上記の総合的な不安定さ | -## 個体タイプ +## 個体タイプ -9枚のスプライトに対応。 +9枚のスプライトに対応。 -- にこたりない -- いかりたりない -- うるうるたりない -- じとたりない -- よだれたりない -- ぐしゃたりない -- すやたりない -- ぽかんたりない -- のびたりない +- にこたりない +- いかりたりない +- うるうるたりない +- じとたりない +- よだれたりない +- ぐしゃたりない +- すやたりない +- ぽかんたりない +- のびたりない -各タイプは、空腹速度・社交性・睡眠傾向・不安耐性・移動速度に差がある。 +各タイプは、空腹速度・社交性・睡眠傾向・不安耐性・移動速度に差がある。 -## 介入アイテム +## 介入アイテム -| アイテム | 役割 | +| アイテム | 役割 | |---|---| -| ふつうの餌 | 空腹を直接下げる | -| あまい餌 | 空腹と不安を下げるが依存的な挙動を生みやすい | -| 水滴 | 不安を下げる。時間で蒸発する | -| 草 | 軽い餌・落ち着き場・死後の循環要素 | -| ぬくい石 | 周辺温度を上げ、不安を下げる | -| ふわふわ | 寝床。睡眠回復を強める | -| つつく | 個体に一時的な不安と速度を与える | -| 迷い込み | 新規個体を追加する | +| ふつうの餌 | 空腹を直接下げる | +| あまい餌 | 空腹と不安を下げるが依存的な挙動を生みやすい | +| 水滴 | 不安を下げる。時間で蒸発する | +| 草 | 軽い餌・落ち着き場・死後の循環要素 | +| ぬくい石 | 周辺温度を上げ、不安を下げる | +| ふわふわ | 寝床。睡眠回復を強める | +| つつく | 個体に一時的な不安と速度を与える | +| 迷い込み | 新規個体を追加する | -## 生死 +## 生死 -死亡条件は現在、主に以下。 +死亡条件は現在、主に以下。 -- 空腹過多 -- 寿命 -- 不安過多 -- 空腹と眠気の複合 +- 空腹過多 +- 寿命 +- 不安過多 +- 空腹と眠気の複合 -死亡時には「たりない跡」が生成され、一定確率で草も発生する。 -これは観察ゲームとして「失敗」ではなく、生態系の記録として扱う。 +死亡時には「たりない跡」が生成され、一定確率で草も発生する。 +これは観察ゲームとして「失敗」ではなく、生態系の記録として扱う。 -## 繁殖 +## 繁殖 -近距離にいる2個体が、以下を満たすと低確率で新個体が発生する。 +近距離にいる2個体が、以下を満たすと低確率で新個体が発生する。 -- 空腹が高すぎない -- 眠気残量が十分ある -- 繁殖クールダウンが終わっている -- コロニーが過密でない +- 空腹が高すぎない +- 眠気残量が十分ある +- 繁殖クールダウンが終わっている +- コロニーが過密でない -子は親のタイプ・足りないものを一部継承し、低確率で変異する。 +子は親のタイプ・足りないものを一部継承し、低確率で変異する。 -## 設計上の注意 +## 設計上の注意 -- 完全な最適化ゲームに寄せすぎない -- 数値を見せつつ、ログ文で生態観察感を出す -- 介入は強いが万能ではない -- 死亡や衰退も観察価値として扱う -- 「むらさき」は画像作成者名のため、ゲーム内の異常現象名には使用していない +- 完全な最適化ゲームに寄せすぎない +- 数値を見せつつ、ログ文で生態観察感を出す +- 介入は強いが万能ではない +- 死亡や衰退も観察価値として扱う +- 「むらさき」は画像作成者名のため、ゲーム内の異常現象名には使用していない -## v3設計メモ +## v3設計メモ -- 観察時間を伸ばすため、空腹・孤独の上昇をやや抑制し、寿命を長めにした。 -- 図鑑は「達成」ではなく観察記録として扱う。スプライト、イベント、死因をゆるく解放する。 -- 系譜は全個体を複雑なツリーで描画せず、出生ログとして読みやすさを優先した。 -- 音は外部素材を使わず、Web Audioの短い合成音で実装した。ブラウザ仕様上、音 ON を押すまで鳴らない。 +- 観察時間を伸ばすため、空腹・孤独の上昇をやや抑制し、寿命を長めにした。 +- 図鑑は「達成」ではなく観察記録として扱う。スプライト、イベント、死因をゆるく解放する。 +- 系譜は全個体を複雑なツリーで描画せず、出生ログとして読みやすさを優先した。 +- 音は外部素材を使わず、Web Audioの短い合成音で実装した。ブラウザ仕様上、音 ON を押すまで鳴らない。 -## v4 仕様追記 +## v4 仕様追記 -### 子の追従 -繁殖で生まれた子は `childGrowTime` の間、親のどちらか近い方を追従する。成長すると通常AIへ移行する。 +### 子の追従 +繁殖で生まれた子は `childGrowTime` の間、親のどちらか近い方を追従する。成長すると通常AIへ移行する。 -### 系譜 -系譜は `family` レジストリで保持し、親子関係を樹状図で表示する。表示時は同じ個体を重複させず、共有された親子関係は複数の接続線で表す。 +### 系譜 +系譜は `family` レジストリで保持し、親子関係を樹状図で表示する。表示時は同じ個体を重複させず、共有された親子関係は複数の接続線で表す。 -### 食事エフェクト -食事パーティクルは表示頻度を抑え、対象アイテムの色を反映する。 +### 食事エフェクト +食事パーティクルは表示頻度を抑え、対象アイテムの色を反映する。 -### 喧嘩 -近距離、低気分、怒り個体、過密などを条件に小さな喧嘩が発生する。喧嘩中は 02_angry を優先表示し、互いにノックバックする。 +### 喧嘩 +近距離、低気分、怒り個体、過密などを条件に小さな喧嘩が発生する。喧嘩中は 02_angry を優先表示し、互いにノックバックする。 -### 昼夜 -夜間・夕方の色を背景だけでなく、観察皿・アイテム・たりない本体にも重ねる。 +### 昼夜 +夜間・夕方の色を背景だけでなく、観察皿・アイテム・たりない本体にも重ねる。 -## v5 仕様追記 +## v5 仕様追記 -### 草 -草はコロニー内に自動で発生する。草アイテムは `amount` を草量として持ち、時間でゆっくり伸びる。食べられると `amount` が減り、描画本数も減る。 +### 草 +草はコロニー内に自動で発生する。草アイテムは `amount` を草量として持ち、時間でゆっくり伸びる。食べられると `amount` が減り、描画本数も減る。 -### 昼夜の描画修正 -スプライト本体への色補正は `source-atop` の矩形塗りを廃止し、`ctx.filter` に変更した。これにより透過部分へ矩形状の色が乗る問題を避ける。 +### 昼夜の描画修正 +スプライト本体への色補正は `source-atop` の矩形塗りを廃止し、`ctx.filter` に変更した。これにより透過部分へ矩形状の色が乗る問題を避ける。 -### 系譜 -図鑑UIは削除し、系譜のみをメイン画面下部に配置する。 +### 系譜 +図鑑UIは削除し、系譜のみをメイン画面下部に配置する。 -## v6 仕様追記 +## v6 仕様追記 -### 頭突き喧嘩 -喧嘩中は一定周期で相手へ突進し、近距離でノックバックと喧嘩エフェクトを発生させる。 +### 頭突き喧嘩 +喧嘩中は一定周期で相手へ突進し、近距離でノックバックと喧嘩エフェクトを発生させる。 -### 睡眠 -睡眠状態では速度をゼロにし、移動処理を行わない。寝床が近くても寝ながら移動しない。 +### 睡眠 +睡眠状態では速度をゼロにし、移動処理を行わない。寝床が近くても寝ながら移動しない。 -### カーソル反応 -カーソルが近い場合、気分の良い個体は笑顔で寄り、不安・怒り・つつき記憶のある個体は逃げる。 +### カーソル反応 +カーソルが近い場合、気分の良い個体は笑顔で寄り、不安・怒り・つつき記憶のある個体は逃げる。 -## v7 ビジュアル改修 +## v7 ビジュアル改修 -- 背景をシャーレ風から庭の観察区画風へ変更。 -- 地面は木枠に囲まれた明るい土床とし、細かな土粒、苔色のムラ、縁の草、野花を加えた。 -- たりないとアイテムの接地影を追加し、庭の上に実在している感覚を強めた。 -- 右上HUDと下部ラベルを観察札のような見た目へ調整。 -- 親子の追従を視認しやすくするため、近距離では薄い点線ガイドを描く。 +- 背景をシャーレ風から庭の観察区画風へ変更。 +- 地面は木枠に囲まれた明るい土床とし、細かな土粒、苔色のムラ、縁の草、野花を加えた。 +- たりないとアイテムの接地影を追加し、庭の上に実在している感覚を強めた。 +- 右上HUDと下部ラベルを観察札のような見た目へ調整。 +- 親子の追従を視認しやすくするため、近距離では薄い点線ガイドを描く。 -## v8 ビジュアル / 実装改修 +## v8 ビジュアル / 実装改修 -- 直射日光・月光の方向をもとに、たりないとアイテムへ投影影を付与。 -- 背景では天体本体は描かず、空色と影方向のみで昼夜を表現する。 -- 食事の蓄積で「ずんち」を排泄する消化処理を追加。 -- 09_stretch は通常描画でも横にやや広く扱い、視認性を上げた。 -- 系譜ツリーの接続線は擬似要素を再設計し、途切れにくい連結表現へ変更。 -- 旧図鑑用の状態管理(codex)と未使用スタイルを整理。 +- 直射日光・月光の方向をもとに、たりないとアイテムへ投影影を付与。 +- 背景では天体本体は描かず、空色と影方向のみで昼夜を表現する。 +- 食事の蓄積で「ずんち」を排泄する消化処理を追加。 +- 09_stretch は通常描画でも横にやや広く扱い、視認性を上げた。 +- 系譜ツリーの接続線は擬似要素を再設計し、途切れにくい連結表現へ変更。 +- 旧図鑑用の状態管理(codex)と未使用スタイルを整理。 -## v9 修正 +## v9 修正 -- `ctx.filter = blur(...)` を使った影を廃止し、低負荷な楕円描画へ変更。 -- 背景装飾のループ数を削減し、毎フレームの描画負荷を下げた。 -- 死亡時の `splat` は amount を連続的なアルファ値へ反映し、数分かけてフェードアウトする。 +- `ctx.filter = blur(...)` を使った影を廃止し、低負荷な楕円描画へ変更。 +- 背景装飾のループ数を削減し、毎フレームの描画負荷を下げた。 +- 死亡時の `splat` は amount を連続的なアルファ値へ反映し、数分かけてフェードアウトする。 diff --git a/index.html b/index.html index 743e3ae..6210372 100644 --- a/index.html +++ b/index.html @@ -3,17 +3,16 @@ - たりない観察 - + たりない観察 +
- 333の部屋に移動 + 333の部屋に移動
-

たりない観察

-

操作: 左クリックで選択・配置 / つまむでドラッグ / 右ドラッグでパン / ホイールで拡大縮小

+

たりない観察

@@ -21,7 +20,7 @@ - +
@@ -43,7 +42,7 @@ - +
@@ -52,8 +51,8 @@
- - + +
@@ -124,78 +123,76 @@ - - - - - - - - - - - - - - - + + + + + + + + + + + + + diff --git a/js/assets.js b/js/assets.js index 2e64ba1..e5a8cb2 100644 --- a/js/assets.js +++ b/js/assets.js @@ -2,10 +2,15 @@ const images = new Map(); const imageBoundsCache = new WeakMap(); const imageMetrics = new Map(); +const imagePromises = new Map(); +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"]); + function trimCanvasCache(cache, limit = 420) { while (cache.size > limit) { const first = cache.keys().next().value; @@ -79,32 +84,111 @@ function getCachedStainOverlayCanvas(id, img, w, h, stain = 0, seed = 0.5) { return canvas; } -function loadImages() { - const assets = [...SPRITES, ...DECOR_ASSETS]; - let loaded = 0; - return new Promise((resolve) => { - for (const s of assets) { - const img = new Image(); - img.onload = img.onerror = () => { - const w = img.naturalWidth || img.width || ALPHA_BOUNDS[s.id]?.imageW || 512; - const h = img.naturalHeight || img.height || ALPHA_BOUNDS[s.id]?.imageH || 512; - const bounds = ALPHA_BOUNDS[s.id] || { x: 0, y: 0, w, h, imageW: w, imageH: h }; - imageMetrics.set(s.id, { - w, - h, - ratio: h / Math.max(w, 1), - visibleBottomRatio: (bounds.y + bounds.h) / Math.max(bounds.imageH, 1), - }); - loaded += 1; - if (loaded === assets.length) resolve(); - }; - img.alphaBounds = ALPHA_BOUNDS[s.id] || null; - img.src = s.path; - images.set(s.id, img); - } +function primeImageMetrics(asset) { + if (!asset || imageMetrics.has(asset.id)) return; + const bounds = ALPHA_BOUNDS[asset.id]; + const w = bounds?.imageW || 512; + const h = bounds?.imageH || 512; + imageMetrics.set(asset.id, { + w, + h, + ratio: h / Math.max(w, 1), + visibleBottomRatio: bounds ? (bounds.y + bounds.h) / Math.max(bounds.imageH, 1) : 1, }); } +function setupAssetIndex() { + if (imageAssetIndex.size) return; + for (const asset of [...SPRITES, ...DECOR_ASSETS]) { + imageAssetIndex.set(asset.id, asset); + primeImageMetrics(asset); + } +} + +function markImageLoaded(asset, img) { + const w = img.naturalWidth || img.width || ALPHA_BOUNDS[asset.id]?.imageW || 512; + const h = img.naturalHeight || img.height || ALPHA_BOUNDS[asset.id]?.imageH || 512; + const bounds = ALPHA_BOUNDS[asset.id] || { x: 0, y: 0, w, h, imageW: w, imageH: h }; + imageMetrics.set(asset.id, { + w, + h, + ratio: h / Math.max(w, 1), + visibleBottomRatio: (bounds.y + bounds.h) / Math.max(bounds.imageH, 1), + }); +} + +function isImageReady(id) { + const img = images.get(id); + return !!(img && img.complete && (img.naturalWidth || img.width)); +} + +function loadImageAsset(asset, priority = "auto") { + if (!asset) return Promise.resolve(null); + setupAssetIndex(); + primeImageMetrics(asset); + if (isImageReady(asset.id)) return Promise.resolve(images.get(asset.id)); + if (imagePromises.has(asset.id)) return imagePromises.get(asset.id); + const img = images.get(asset.id) || new Image(); + img.decoding = "async"; + if ("fetchPriority" in img) img.fetchPriority = priority; + img.alphaBounds = ALPHA_BOUNDS[asset.id] || null; + images.set(asset.id, img); + const promise = new Promise((resolve) => { + img.onload = () => { markImageLoaded(asset, img); resolve(img); }; + img.onerror = () => { resolve(null); }; + }); + imagePromises.set(asset.id, promise); + if (!img.src) img.src = asset.path; + return promise; +} + +function ensureImage(id) { + setupAssetIndex(); + const asset = imageAssetIndex.get(id); + if (!asset) return null; + primeImageMetrics(asset); + const existing = images.get(id); + if (!existing || !existing.src) loadImageAsset(asset, "low"); + return images.get(id) || null; +} + +function getRenderableImage(id, fallbackId = "smile") { + if (id) ensureImage(id); + if (isImageReady(id)) return images.get(id); + if (fallbackId && fallbackId !== id) ensureImage(fallbackId); + if (isImageReady(fallbackId)) return images.get(fallbackId); + return null; +} + +function loadImages(ids = 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")); + } + return Promise.all(tasks).then(() => undefined); +} + +function startBackgroundImageLoading() { + setupAssetIndex(); + const allAssets = [...SPRITES, ...DECOR_ASSETS]; + const loadGroup = (ids, priority = "low") => { + for (const id of ids) { + const asset = imageAssetIndex.get(id); + if (asset) loadImageAsset(asset, priority); + } + }; + const schedule = window.requestIdleCallback + ? (fn, timeout = 1200) => window.requestIdleCallback(fn, { timeout }) + : (fn) => setTimeout(fn, 60); + schedule(() => loadGroup(EARLY_IMAGE_IDS, "auto"), 450); + schedule(() => { + for (const asset of allAssets) loadImageAsset(asset, "low"); + }, 2200); +} + function imageAlphaBounds(img) { if (!img) return null; if (img.alphaBounds) return img.alphaBounds; @@ -119,7 +203,12 @@ function imageAlphaBounds(img) { } function getImageMetrics(idOrImg) { - if (typeof idOrImg === "string") return imageMetrics.get(idOrImg) || null; + if (typeof idOrImg === "string") { + setupAssetIndex(); + const asset = imageAssetIndex.get(idOrImg); + if (asset) primeImageMetrics(asset); + return imageMetrics.get(idOrImg) || null; + } if (!idOrImg) return null; for (const [id, img] of images) { if (img === idOrImg) return imageMetrics.get(id) || null; @@ -129,5 +218,6 @@ function getImageMetrics(idOrImg) { return w && h ? { w, h, ratio: h / w, visibleBottomRatio: 1 } : null; } +setupAssetIndex(); -window.TarinaiAssets = { images, imageBoundsCache, imageMetrics, scaledSpriteCache, stainOverlayCache, loadImages, imageAlphaBounds, getImageMetrics, getCachedSpriteCanvas, getCachedStainOverlayCanvas }; +window.TarinaiAssets = { images, imageBoundsCache, imageMetrics, imagePromises, scaledSpriteCache, stainOverlayCache, loadImages, startBackgroundImageLoading, ensureImage, isImageReady, getRenderableImage, imageAlphaBounds, getImageMetrics, getCachedSpriteCanvas, getCachedStainOverlayCanvas }; diff --git a/js/data.js b/js/data.js index 66f7039..dc3de08 100644 --- a/js/data.js +++ b/js/data.js @@ -72,6 +72,8 @@ const CONFIG = { initialPopulation: 18, maxPopulation: Number.MAX_SAFE_INTEGER, itemLimit: 260, + fenceLimit: 72, + maxFenceCollisionChecks: 18, zunchiLimit: 56, traceLimit: 64, splatLimit: 48, diff --git a/js/items.js b/js/items.js index 312fdc1..c4bd39d 100644 --- a/js/items.js +++ b/js/items.js @@ -120,6 +120,8 @@ class Item { if (this.y < p) { this.y = p; this.vy = Math.abs(this.vy || 0) * 0.72; this.spinVelocity *= -0.72; } if (this.y > worldRef.h - p) { this.y = worldRef.h - p; this.vy = -Math.abs(this.vy || 0) * 0.72; this.spinVelocity *= -0.72; } 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; @@ -135,6 +137,8 @@ class Item { 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 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") { @@ -142,6 +146,8 @@ class Item { } 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); } } @@ -467,7 +473,7 @@ class Item { 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; - const zunchiImg = zunchiId ? (images.get(zunchiId) || images.get("zunchi")) : null; + const zunchiImg = zunchiId ? (typeof getRenderableImage === "function" ? getRenderableImage(zunchiId, "zunchi") : (images.get(zunchiId) || images.get("zunchi"))) : null; if (zunchiImg) { const metrics = getImageMetrics(zunchiId || "zunchi") || getImageMetrics("zunchi"); const ratio = metrics?.ratio || 178 / 236; @@ -528,7 +534,7 @@ class Item { ctx.font = `${Math.round((this.r || 13) * 1.15)}px sans-serif`; ctx.textAlign = "center"; ctx.textBaseline = "middle"; - ctx.fillText("❤", 0, 1); + ctx.fillText("\u2764", 0, 1); ctx.fillStyle = "rgba(255,255,255,0.55)"; ctx.beginPath(); ctx.ellipse(-this.r * 0.28, -this.r * 0.18, this.r * 0.23, this.r * 0.10, -0.35, 0, Math.PI * 2); @@ -538,7 +544,7 @@ class Item { ctx.font = `${Math.round((this.r || 13) * 1.02)}px sans-serif`; ctx.textAlign = "center"; ctx.textBaseline = "middle"; - ctx.fillText("✦", 0, 1); + ctx.fillText("\u2726", 0, 1); ctx.strokeStyle = "rgba(168,76,42,0.72)"; ctx.lineWidth = 1.4; ctx.beginPath(); @@ -637,7 +643,7 @@ class Item { ctx.font = `${Math.round((this.r || 18) * 2.0)}px "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif`; ctx.textAlign = "center"; ctx.textBaseline = "middle"; - ctx.fillText("⚽", 0, 1); + ctx.fillText("\u26bd", 0, 1); if (moving > 0.55) { ctx.globalAlpha = 0.14 + moving * 0.10; ctx.shadowColor = "transparent"; diff --git a/js/main.js b/js/main.js index 7b2b2b6..d2e6542 100644 --- a/js/main.js +++ b/js/main.js @@ -48,5 +48,6 @@ loadImages().then(() => { render(); renderStats(); syncTopButtons(); + if (typeof startBackgroundImageLoading === "function") startBackgroundImageLoading(); loop(); }); diff --git a/js/render.js b/js/render.js index 34c1468..2b624db 100644 --- a/js/render.js +++ b/js/render.js @@ -681,9 +681,9 @@ function drawSelectedCard(ctx, world, lighting) { const w = world.viewportW || world.w; const h = world.viewportH || world.h; const cardW = 152; - const cardH = 66; + const cardH = 80; let x = t.x + 18; - let y = t.y - 86; + let y = t.y - 100; x = clamp(x, 10, w - cardW - 10); y = clamp(y, 10, h - cardH - 10); ctx.save(); @@ -699,8 +699,10 @@ 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 + 52); + ctx.fillText(`\u6c17\u5206 ${fmt(t.mood)}${genLabel} \u4f53\u529b ${fmt(t.energy)}`, x + 10, y + 68); ctx.restore(); } diff --git a/js/sim.js b/js/sim.js index 7f68635..da252cd 100644 --- a/js/sim.js +++ b/js/sim.js @@ -1,3 +1,3 @@ -"use strict"; +"use strict"; window.TarinaiSim = { makeName, makeTrait, PERSONALITIES, SpatialGrid, Effect, Item, Tarinai, World, world }; diff --git a/js/tarinai.js b/js/tarinai.js index e0a9698..5a2edb7 100644 --- a/js/tarinai.js +++ b/js/tarinai.js @@ -30,11 +30,18 @@ class Tarinai { this.energy = opts.energy ?? rand(54, 98); this.stress = opts.stress ?? rand(0, 20); this.hpBarTimer = opts.hpBarTimer ?? 0; + this.stressBarTimer = opts.stressBarTimer ?? 0; this.affection = opts.affection ?? rand(0, 30); this.need = opts.need || pick(NEEDS); this.state = opts.state || "idle"; this.dead = !!opts.dead; this.deathReason = opts.deathReason || ""; + this.lastDamageCause = opts.lastDamageCause || ""; + this.lastDamageAmount = opts.lastDamageAmount ?? 0; + this.lastDamageAt = opts.lastDamageAt ?? -999; + this.lastMajorDamageCause = opts.lastMajorDamageCause || ""; + this.lastMajorDamageAmount = opts.lastMajorDamageAmount ?? 0; + this.lastMajorDamageAt = opts.lastMajorDamageAt ?? -999; this.thought = ""; this.target = null; this.targetKey = ""; @@ -79,7 +86,7 @@ class Tarinai { this.formerName = opts.formerName || null; this.loveMochiTimer = opts.loveMochiTimer ?? 0; this.fightMochiTimer = opts.fightMochiTimer ?? 0; - if (this.isZunchiSlave) this.name = "ずんちどれい"; + if (this.isZunchiSlave) this.name = "\u305a\u3093\u3061\u3069\u308c\u3044"; this.tempComfort = opts.tempComfort ?? 0.62; this.tempTimer = opts.tempTimer ?? stableUnit(this.id, "temp-start") * 0.55; this.parents = opts.parents || []; @@ -127,8 +134,8 @@ class Tarinai { return { id: this.id, name: this.name, type: this.type, x: this.x, y: this.y, vx: this.vx, vy: this.vy, scale: this.scale, age: this.age, lifeSpan: this.lifeSpan, generation: this.generation, hasPaired: this.hasPaired, - dead: this.dead, deathReason: this.deathReason, - hunger: this.hunger, loneliness: this.loneliness, energy: this.energy, stress: this.stress, hpBarTimer: this.hpBarTimer, + dead: this.dead, deathReason: this.deathReason, lastDamageCause: this.lastDamageCause, lastDamageAmount: this.lastDamageAmount, lastDamageAt: this.lastDamageAt, lastMajorDamageCause: this.lastMajorDamageCause, lastMajorDamageAmount: this.lastMajorDamageAmount, lastMajorDamageAt: this.lastMajorDamageAt, + hunger: this.hunger, loneliness: this.loneliness, energy: this.energy, stress: this.stress, hpBarTimer: this.hpBarTimer, stressBarTimer: this.stressBarTimer, affection: this.affection, need: this.need, state: this.state, goodMode: this.goodMode, facing: this.facing, visualFacing: this.visualFacing, facingLockUntil: this.facingLockUntil, eatTimer: this.eatTimer, foodReactTimer: this.foodReactTimer, surpriseTimer: this.surpriseTimer, fearTimer: this.fearTimer, @@ -266,7 +273,7 @@ class Tarinai { poke() { audio.poke(); this.damage(rand(9, 16), "\u3064\u3064\u304b\u308c\u3059\u304e"); - this.stress = clamp(this.stress + 22, 0, 130); + this.addStress(22, { threshold: 8 }); this.surpriseTimer = 0.55; this.fearTimer = Math.max(this.fearTimer, 1.2); this.pokeFlashTimer = 0.55; @@ -281,18 +288,119 @@ class Tarinai { this.hpBarTimer = Math.max(this.hpBarTimer || 0, duration); } + showStressBar(duration = 4.2) { + this.stressBarTimer = Math.max(this.stressBarTimer || 0, duration); + } + + addStress(amount, opts = {}) { + const delta = Math.max(0, amount || 0); + if (delta <= 0) return 0; + const before = this.stress || 0; + this.stress = clamp(before + delta, 0, 130); + const gained = Math.max(0, this.stress - before); + const threshold = opts.threshold ?? 8; + if (gained >= threshold) this.showStressBar(opts.duration ?? 4.2); + return gained; + } + + damageCauseLabel(reason = "") { + const text = String(reason || ""); + if (!text) return ""; + if (text.includes("\u3067\u8870\u5f31")) return text.replace(/\u3067\u8870\u5f31.*$/, ""); + if (/\u55a7\u5629|fight|headbutt/.test(text)) return "\u55a7\u5629"; + if (/\u7206\u7af9|firecracker|explosion/.test(text)) return "\u7206\u7af9"; + 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)) { + const disease = text.match(/([^\u3001\u3002\s]+\u75c5)/); + return disease ? disease[1] : "\u305a\u3093\u3061\u75c5"; + } + if (/\u98e2|\u7a7a\u8179|\u98df\u3079\u7269|hunger/.test(text)) return "\u98e2\u9913"; + if (/\u30b9\u30c8\u30ec\u30b9|stress/.test(text)) return "\u30b9\u30c8\u30ec\u30b9\u904e\u591a"; + 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/.test(text)) return "\u5bff\u547d"; + return ""; + } + + recentDamageCause(maxAge = 10) { + const now = this.world?.time || 0; + if (this.lastDamageCause && now - (this.lastDamageAt || -999) <= maxAge) return this.lastDamageCause; + return ""; + } + + rememberDamage(amount, reason = "") { + const cause = this.damageCauseLabel(reason) || "\u4e8b\u6545"; + const now = this.world?.time || 0; + this.lastDamageCause = cause; + this.lastDamageAmount = Math.max(0, amount || 0); + this.lastDamageAt = now; + if (this.lastDamageAmount >= 10 || this.lastDamageAmount >= Math.max(6, (this.energy || 0) * 0.36)) { + this.lastMajorDamageCause = cause; + this.lastMajorDamageAmount = this.lastDamageAmount; + this.lastMajorDamageAt = now; + } + return cause; + } + + weakenedDeathReasonFor(cause = "") { + const text = String(cause || "").trim(); + if (!text) return ""; + return text.includes("\u3067\u8870\u5f31") ? text : `${text}\u3067\u8870\u5f31`; + } + + dominantDeathCause(reason = "", opts = {}) { + const text = String(reason || "").trim(); + const direct = this.damageCauseLabel(text); + const now = this.world?.time || 0; + const recentMajor = this.lastMajorDamageCause && now - (this.lastMajorDamageAt || -999) <= 10; + const recent = this.recentDamageCause ? this.recentDamageCause(20) : ""; + const fighting = this.fightTimer > 0.04 || this.defeatedTimer > 0.04 || this.defeatedById || this.fightWinnerId || recent === "\u55a7\u5629"; + if (recentMajor && (!direct || direct === this.lastMajorDamageCause || opts.fromDamage || this.energy <= 22 || this.mood <= 20 || this.stress >= 118)) { + return this.weakenedDeathReasonFor(this.lastMajorDamageCause); + } + if (direct && direct !== "\u30b9\u30c8\u30ec\u30b9\u904e\u591a") return direct; + if (this.zunchiDisease && ((this.zunchiDiseaseSeverity || 0) >= 0.30 || /\u75c5|zunchi/.test(text))) return "\u305a\u3093\u3061\u75c5"; + if (this.hunger >= 108 || (this.hunger >= 94 && this.energy <= 26) || /\u98e2|\u7a7a\u8179|hunger/.test(text)) return "\u98e2\u9913"; + if (fighting && (this.energy <= 44 || this.stress >= 112 || this.mood <= 22 || opts.fromDamage)) return "\u55a7\u5629"; + if (recent && recent !== "\u30b9\u30c8\u30ec\u30b9\u904e\u591a" && (this.energy <= 34 || this.mood <= 20 || this.stress >= 118 || opts.fromDamage)) return recent; + if (text.includes("\u5bff\u547d")) return "\u5bff\u547d"; + if (direct) return direct; + if (this.stress >= 118 || /\u30b9\u30c8\u30ec\u30b9|stress|\u6c17\u5206/.test(text)) return "\u30b9\u30c8\u30ec\u30b9\u904e\u591a"; + if (this.energy <= 0.5) return "\u4e8b\u6545"; + return ""; + } + + normalizeDeathReason(reason = "", opts = {}) { + const text = String(reason || "").trim(); + if (text.includes("\u3067\u8870\u5f31")) return text; + const cause = this.dominantDeathCause(text, opts) || "\u4e8b\u6545"; + if (cause.includes("\u3067\u8870\u5f31")) return cause; + const now = this.world?.time || 0; + const recentMajor = this.lastMajorDamageCause && now - (this.lastMajorDamageAt || -999) <= 10; + const canWeaken = cause && !cause.endsWith("\u75c5") && cause !== "\u98e2\u9913" && cause !== "\u30b9\u30c8\u30ec\u30b9\u904e\u591a" && cause !== "\u5bff\u547d"; + if (canWeaken && (opts.weakened || (recentMajor && cause === this.lastMajorDamageCause))) return this.weakenedDeathReasonFor(cause); + return cause; + } + damage(amount, reason = "") { const before = this.energy; - this.energy = clamp(this.energy - Math.max(0, amount || 0), 0, 100); - if (this.energy < before - 0.01) this.showHpBar(); - if (this.energy <= 0.5 && reason) this.die(reason); + const loss = Math.max(0, amount || 0); + this.energy = clamp(this.energy - loss, 0, 100); + const actualLoss = Math.max(0, before - this.energy); + let cause = ""; + if (actualLoss > 0.01) { + cause = this.rememberDamage(actualLoss, reason); + this.showHpBar(); + } + if (this.energy <= 0.5) { + const majorNow = actualLoss >= Math.max(10, before * 0.42); + this.die(this.normalizeDeathReason(reason || cause, { fromDamage: true, weakened: majorNow })); + } } recoverHealth(amount) { if (this.dead) return; - const before = this.energy; this.energy = clamp(this.energy + Math.max(0, amount || 0), 0, 100); - if (this.energy > before + 0.01 && before < 99.5) this.showHpBar(2.4); } familyJoined() { @@ -421,12 +529,8 @@ class Tarinai { briefDeathReason(reason = this.deathReason) { const text = String(reason || ""); if (!text) return ""; - if (text.includes("食べ物がたりない")) return "飢え"; - if (text.includes("寿命")) return "寿命"; - if (text.includes("ストレス")) return "過ストレス"; - if (text.includes("疲れ")) return "衰弱"; - if (text.includes("つつかれ")) return "つつき"; - return text.length > 10 ? `${text.slice(0, 10)}…` : text; + const normalized = this.normalizeDeathReason ? this.normalizeDeathReason(text) : text; + return normalized.length > 12 ? `${normalized.slice(0, 12)}\u2026` : normalized; } variantSprite(category) { @@ -452,8 +556,8 @@ class Tarinai { this.zunchiDiseaseCooldown = 12; this.fearTimer = Math.max(this.fearTimer || 0, 0.35); this.surpriseTimer = Math.max(this.surpriseTimer || 0, 0.45); - this.thought = "ずんち病にかかった"; - this.world?.spawnBubble?.(this.x, this.y - this.radius * 1.20, "うぅ…", "rgba(38,82,38,0.82)"); + this.thought = "\u305a\u3093\u3061\u75c5\u306b\u304b\u304b\u3063\u305f"; + this.world?.spawnBubble?.(this.x, this.y - this.radius * 1.20, "\u3046\u3045\u2026", "rgba(38,82,38,0.82)"); return true; } @@ -464,8 +568,8 @@ class Tarinai { this.zunchiDiseaseCooldown = 18; this.zunchiStain = clamp((this.zunchiStain || 0) - 35, 0, 100); this.stress = clamp(this.stress - 8, 0, 130); - this.thought = reason || "ずんち病が治まった"; - this.world?.spawnBubble?.(this.x, this.y - this.radius * 1.18, "はう", "rgba(62,104,62,0.78)"); + this.thought = reason || "\u305a\u3093\u3061\u75c5\u304c\u6cbb\u307e\u3063\u305f"; + this.world?.spawnBubble?.(this.x, this.y - this.radius * 1.18, "\u306f\u3046", "rgba(62,104,62,0.78)"); return true; } @@ -491,16 +595,16 @@ class Tarinai { } if (!this.zunchiDisease && this.zunchiStain > 82 && Math.random() < dt * clamp((this.zunchiStain - 82) / 18, 0, 1) * 0.045) { this.infectZunchiDisease(null, true); - this.world?.log?.(`${this.name}はずんち病を発症した。`, "accident"); + this.world?.log?.(`${this.name}\u306f\u305a\u3093\u3061\u75c5\u3092\u767a\u75c7\u3057\u305f\u3002`, "accident"); } if (this.zunchiDisease) { - this.zunchiDiseaseSeverity = clamp((this.zunchiDiseaseSeverity || 0.7) + dt * 0.002, 0, 1.25); - this.stress = clamp(this.stress + dt * 0.22 * this.zunchiDiseaseSeverity, 0, 130); + this.zunchiDiseaseSeverity = clamp((this.zunchiDiseaseSeverity || 0.7) + dt * 0.004, 0, 1.25); + this.stress = clamp(this.stress + dt * (0.28 + 0.18 * this.zunchiDiseaseSeverity), 0, 130); this.energy = clamp(this.energy - dt * 0.035 * this.zunchiDiseaseSeverity, 0, 100); if (this.energy > 82) this.zunchiDiseaseSeverity = Math.max(0, this.zunchiDiseaseSeverity - dt * (this.energy > 94 ? 0.020 : 0.010)); this.emitZunchiDiseaseEffect(dt); if (this.zunchiDiseaseSeverity <= 0.04) { - if (this.recoverZunchiDisease("体力が戻り、ずんち病が治まった")) this.world?.log?.(`${this.name}のずんち病が治まった。`, "accident"); + if (this.recoverZunchiDisease("\u4f53\u529b\u304c\u623b\u308a\u3001\u305a\u3093\u3061\u75c5\u304c\u6cbb\u307e\u3063\u305f")) this.world?.log?.(`${this.name}\u306e\u305a\u3093\u3061\u75c5\u304c\u6cbb\u307e\u3063\u305f\u3002`, "accident"); } } } @@ -521,7 +625,7 @@ class Tarinai { if (this.isZunchiSlave) return false; this.formerName = this.formerName || this.name; this.isZunchiSlave = true; - this.name = "ずんちどれい"; + this.name = "\u305a\u3093\u3061\u3069\u308c\u3044"; this.birthRitualTimer = 0; this.birthPartnerId = null; this.birthRitualLeader = false; @@ -529,7 +633,7 @@ class Tarinai { this.target = null; this.reproductionTimer = Math.max(this.reproductionTimer || 0, CONFIG.reproductionCooldown + rand(8, 18)); this.loveMochiTimer = 0; - this.stress = clamp(this.stress + 14, 0, 130); + this.addStress(14, { threshold: 8 }); this.hunger = Math.max(this.hunger, 40); this.visibleSpriteId = "zunchi_slave"; this.spriteLockUntil = 0; @@ -597,9 +701,14 @@ class Tarinai { this.blastSpinTimer = Math.max(0, (this.blastSpinTimer || 0) - dt); this.postBirthPeaceTimer = Math.max(0, (this.postBirthPeaceTimer || 0) - dt); this.hpBarTimer = Math.max(0, (this.hpBarTimer || 0) - dt); + this.stressBarTimer = Math.max(0, (this.stressBarTimer || 0) - dt); this.updateZunchiDisease(dt); this.loveMochiTimer = Math.max(0, (this.loveMochiTimer || 0) - dt); this.fightMochiTimer = Math.max(0, (this.fightMochiTimer || 0) - dt); + if (this.fightMochiTimer > 0.04) { + this.stress = clamp(this.stress + dt * 0.11, 0, 130); + if (this.state !== "fight") this.fearTimer = Math.max(this.fearTimer || 0, 0.10); + } if (this.intimidateTimer <= 0.04) this.intimidateTargetId = null; if (this.birthRitualTimer > 0) { const beforeBirthTimer = this.birthRitualTimer; @@ -713,7 +822,7 @@ class Tarinai { this.maintainTargetProgress(dt); this.move(dt); if (this.world.resolveFenceCollision) this.world.resolveFenceCollision(this); - this.checkLife(); + this.checkLife(dt); } applyEnvironment(dt) { @@ -757,7 +866,7 @@ class Tarinai { } sleepFurnitureLabel(target) { - return "干草寝床"; + return "\u5e72\u8349\u5bdd\u5e8a"; } sleepSpotFor(bed) { @@ -873,6 +982,22 @@ class Tarinai { ? dayP >= this.sleepStart && dayP < this.sleepEnd : dayP >= this.sleepStart || dayP < this.sleepEnd; + if (this.hasFightMochiEffect() && this.fightTimer <= 0.04 && this.intimidateTimer <= 0.04 && this.defeatedTimer <= 0.04) { + const opponent = this.world.nearestOther(this, 320); + if (opponent) { + const d = dist(this, opponent); + if (d <= Math.max(48, this.radius + opponent.radius + 18)) { + if (this.world.startForcedFight?.(this, opponent)) return; + } + this.state = "seek_friend"; + this.target = opponent; + this.goodMode = "angry"; + this.thought = "\u3051\u3093\u304b\u9905\u3067\u76f8\u624b\u3092\u63a2\u3057\u3066\u3044\u308b"; + if (Math.random() < dt * 0.24) this.bubble("!", 1.4, "rgba(160,74,45,0.78)"); + return; + } + } + const recentRival = this.recentFightRival ? this.recentFightRival(60, 170) : null; const feared = this.strongestRelation("fear"); if (!wasSleeping && (recentRival || (feared && feared.score > 12))) { @@ -892,12 +1017,12 @@ class Tarinai { if (water && !water.dead) { this.state = "seek_water"; this.target = water; - this.thought = "ずんち病で水を探している"; + this.thought = "\u305a\u3093\u3061\u75c5\u3067\u6c34\u3092\u63a2\u3057\u3066\u3044\u308b"; return; } this.state = "zunchi_sick"; this.target = null; - this.thought = "ずんち病で当てもなく彷徨っている"; + this.thought = "\u305a\u3093\u3061\u75c5\u3067\u5f53\u3066\u3082\u306a\u304f\u5f77\u5fa8\u3063\u3066\u3044\u308b"; if (Math.random() < dt * 0.35) this.wanderAngle += rand(-2.4, 2.4); return; } @@ -908,7 +1033,7 @@ class Tarinai { this.state = "seek_food"; this.target = specialMochi; this.foodReactTimer = Math.max(this.foodReactTimer, 0.34); - this.thought = specialMochi.type === "love_mochi" ? "媚薬餅を探している" : "喧嘩餅を探している"; + this.thought = specialMochi.type === "love_mochi" ? "\u3078\u3053\u9905\u3092\u63a2\u3057\u3066\u3044\u308b" : "\u3051\u3093\u304b\u9905\u3092\u63a2\u3057\u3066\u3044\u308b"; return; } } else if (this.hunger > 20) { @@ -917,7 +1042,7 @@ class Tarinai { this.state = "seek_food"; this.target = zOnly; this.foodReactTimer = Math.max(this.foodReactTimer, 0.30); - this.thought = "ずんちを探している"; + this.thought = "\u305a\u3093\u3061\u3092\u63a2\u3057\u3066\u3044\u308b"; return; } } @@ -952,7 +1077,7 @@ class Tarinai { if (distXY(this.x, this.y, spot.x, spot.y) > this.radius + 10) { this.state = "seek_bed"; this.target = bed; - this.thought = `${this.sleepFurnitureLabel(bed)}へ向かっている`; + this.thought = `${this.sleepFurnitureLabel(bed)}\u3078\u5411\u304b\u3063\u3066\u3044\u308b`; return; } } @@ -963,7 +1088,7 @@ class Tarinai { this.nextSleepBubbleAt = this.world.time + 4.5; this.world.spawnBubble(this.x, this.y - this.radius * 1.18, "Zzz...", "rgba(65,70,92,0.72)"); } - this.thought = bed ? "ベッドの近くで眠っている" : "眠っている"; + this.thought = bed ? "\u30d9\u30c3\u30c9\u306e\u8fd1\u304f\u3067\u7720\u3063\u3066\u3044\u308b" : "\u7720\u3063\u3066\u3044\u308b"; return; } @@ -1007,7 +1132,7 @@ class Tarinai { this.state = "play_ball"; this.target = ball; this.goodMode = "smile"; - this.thought = this.personality === "playful" ? "ボールに引き寄せられている" : "転がるものが気になっている"; + this.thought = this.personality === "playful" ? "\u30dc\u30fc\u30eb\u306b\u5f15\u304d\u5bc4\u305b\u3089\u308c\u3066\u3044\u308b" : "\u8ee2\u304c\u308b\u3082\u306e\u304c\u6c17\u306b\u306a\u3063\u3066\u3044\u308b"; return; } } @@ -1130,13 +1255,15 @@ class Tarinai { this.hunger -= eating * 0.82; this.recoverHealth(eating * 0.24); this.affection += eating * 0.04; - this.stress = clamp(this.stress - eating * 0.25, 0, 130); if (it.type === "love_mochi") { + this.stress = clamp(this.stress - eating * 0.25, 0, 130); this.loveMochiTimer = Math.max(this.loveMochiTimer || 0, 42 + rand(4, 12)); - this.bubble("♡", 2.4, "rgba(190,82,132,0.78)"); + this.bubble("\u2661", 2.4, "rgba(190,82,132,0.78)"); this.lastMealColor = "#ff7bab"; } else { - this.fightMochiTimer = Math.max(this.fightMochiTimer || 0, 36 + rand(4, 10)); + this.stress = clamp(this.stress + eating * 0.22, 0, 130); + this.fearTimer = Math.max(this.fearTimer || 0, 0.32); + this.fightMochiTimer = Math.max(this.fightMochiTimer || 0, 52 + rand(10, 18)); this.bubble("!", 2.0, "rgba(180,86,52,0.80)"); this.lastMealColor = "#e07c43"; } @@ -1150,7 +1277,7 @@ class Tarinai { this.makePoop(eating * 0.20); if (this.world.time > this.nextEatSound) { audio.eat(); this.nextEatSound = this.world.time + 0.42; } if (this.world.time - this.lastLog > 9 && Math.random() < 0.02) { - this.world.log(`${this.name}は${it.type === "love_mochi" ? "媚薬餅" : "喧嘩餅"}をしばらく味わった。`, "food"); + this.world.log(`${this.name}\u306f${it.type === "love_mochi" ? "\u3078\u3053\u9905" : "\u3051\u3093\u304b\u9905"}\u3092\u3057\u3070\u3089\u304f\u5473\u308f\u3063\u305f\u3002`, "food"); this.lastLog = this.world.time; } break; @@ -1200,7 +1327,7 @@ class Tarinai { this.lastMealColor = "#6b8d3f"; this.eatTimer = Math.max(this.eatTimer, 0.24); this.eatCooldown = rand(0.9, 1.4); - this.bubble(this.isZunchiSlave ? "もぐ" : "...", 2.2, "rgba(74,91,50,0.78)"); + this.bubble(this.isZunchiSlave ? "\u3082\u3050" : "...", 2.2, "rgba(74,91,50,0.78)"); const mouth = this.mouthPosition(it); this.world.spawnEatEffect(mouth.x, mouth.y, "#6b8d3f"); if (this.world.time > this.nextEatSound) { audio.eat(); this.nextEatSound = this.world.time + 0.7; } @@ -1215,8 +1342,8 @@ class Tarinai { this.zunchiStain = clamp((this.zunchiStain || 0) - dt * 10.5, 0, 100); if (this.zunchiDisease) { this.zunchiDiseaseSeverity = Math.max(0, (this.zunchiDiseaseSeverity || 1) - dt * (this.energy > 70 ? 0.050 : 0.028)); - if (this.zunchiDiseaseSeverity <= 0.04 && this.recoverZunchiDisease("水でずんち病が治まった")) { - this.world.log(`${this.name}は水でずんち病が治まった。`, "accident"); + if (this.zunchiDiseaseSeverity <= 0.04 && this.recoverZunchiDisease("\u6c34\u3067\u305a\u3093\u3061\u75c5\u304c\u6cbb\u307e\u3063\u305f")) { + this.world.log(`${this.name}\u306f\u6c34\u3067\u305a\u3093\u3061\u75c5\u304c\u6cbb\u307e\u3063\u305f\u3002`, "accident"); } } it.amount -= dt * 2.4; @@ -1277,11 +1404,16 @@ class Tarinai { if (d < 0.01 || d > 74) continue; closeCount += 1; + const fightMochiContact = this.hasFightMochiEffect() || o.hasFightMochiEffect(); + if (fightMochiContact && d <= Math.max(42, this.radius + o.radius + 16)) { + if (this.world.startForcedFight?.(this, o)) continue; + } + if (this.zunchiDisease && !o.zunchiDisease && (o.zunchiDiseaseCooldown || 0) <= 0 && d < 54 && Math.random() < dt * 0.065 * clamp(1 - d / 58, 0.12, 1)) { - if (o.infectZunchiDisease(this)) this.world.log(`${this.name}のずんち病が${o.name}にうつった。`, "accident"); + if (o.infectZunchiDisease(this)) this.world.log(`${this.name}\u306e\u305a\u3093\u3061\u75c5\u304c${o.name}\u306b\u3046\u3064\u3063\u305f\u3002`, "accident"); } if (o.zunchiDisease && !this.zunchiDisease && (this.zunchiDiseaseCooldown || 0) <= 0 && d < 54 && Math.random() < dt * 0.065 * clamp(1 - d / 58, 0.12, 1)) { - if (this.infectZunchiDisease(o)) this.world.log(`${o.name}のずんち病が${this.name}にうつった。`, "accident"); + if (this.infectZunchiDisease(o)) this.world.log(`${o.name}\u306e\u305a\u3093\u3061\u75c5\u304c${this.name}\u306b\u3046\u3064\u3063\u305f\u3002`, "accident"); } const pairBond = this.world.areCoParents?.(this, o); @@ -1326,8 +1458,8 @@ class Tarinai { o.vy += ny * rand(34, 64); this.stress = clamp(this.stress + 1.6, 0, 130); o.stress = clamp(o.stress + 2.8, 0, 130); - this.damage(1.2); - o.damage(1.6); + this.damage(1.2, "\u55a7\u5629"); + o.damage(1.6, "\u55a7\u5629"); this.hurtTimer = Math.max(this.hurtTimer, 1.4); o.hurtTimer = Math.max(o.hurtTimer, 1.8); this.adjustRelation(o, -0.16, 0.12, "fight"); @@ -1385,7 +1517,7 @@ class Tarinai { move(dt) { let ax = 0, ay = 0; - const baseSpeed = 46.5 * 1.5 * this.trait.speed * (this.energy < 20 ? 0.42 : 1) * (this.fallTimer > 0 ? 0.35 : 1); + const baseSpeed = 46.5 * 1.5 * this.trait.speed * (this.zunchiDisease ? 0.5 : 1) * (this.energy < 20 ? 0.42 : 1) * (this.fallTimer > 0 ? 0.35 : 1); if (this.state === "sleep") { const bedComfort = this.isSleepFurniture(this.target) ? this.world.bedComfort(this.target) : 0.72; @@ -1542,19 +1674,32 @@ class Tarinai { if (this.y > maxY) { this.y = maxY; this.vy = -Math.abs(this.vy) * 0.5; this.wanderAngle = rand(-2.2, -0.7); this.surpriseTimer = 0.15; } } - checkLife() { + checkLife(dt = 0.016) { let reason = ""; - if (this.hunger >= 108) reason = "\u98df\u3079\u7269\u304c\u305f\u308a\u306a\u3044"; + if (this.energy <= 0) { + reason = this.dominantDeathCause("", { lowHealth: true }) || "\u4e8b\u6545"; + } + else if (this.hunger >= 108) reason = "\u98e2\u9913"; else if (this.age >= this.lifeSpan) reason = "\u5bff\u547d"; - else if (this.stress >= 124) reason = "\u30b9\u30c8\u30ec\u30b9\u304c\u591a\u3059\u304e\u308b"; - else if (this.energy <= 0 && this.hunger > 90) reason = "\u75b2\u308c\u3068\u7a7a\u8179\u304c\u91cd\u306a\u3063\u305f"; + else if (this.stress >= 124) { + reason = this.dominantDeathCause("\u30b9\u30c8\u30ec\u30b9\u904e\u591a", { stressDeath: true }) || "\u30b9\u30c8\u30ec\u30b9\u904e\u591a"; + } + else if (this.mood <= 20) { + const lowMood = clamp((20 - this.mood) / 20, 0, 1); + const bodyLoad = clamp((this.hunger - 72) / 42, 0, 1) * 0.018 + clamp((28 - this.energy) / 28, 0, 1) * 0.020 + clamp((this.stress - 86) / 44, 0, 1) * 0.022; + const deathRisk = 0.010 + lowMood * 0.060 + bodyLoad; + if (Math.random() < Math.max(0, dt) * deathRisk) { + reason = this.dominantDeathCause("", { lowMood: true }) || "\u30b9\u30c8\u30ec\u30b9\u904e\u591a"; + } + } if (reason) this.die(reason); } die(reason) { if (this.dead) return; + const finalReason = this.normalizeDeathReason ? this.normalizeDeathReason(reason) : (reason || "\u4e8b\u6545"); this.dead = true; - this.deathReason = reason; + this.deathReason = finalReason; this.world.deadCount += 1; this.world.items.push(new Item("trace", this.x, this.y)); this.world.items.push(new Item("splat", this.x, this.y)); @@ -1562,9 +1707,9 @@ class Tarinai { const spot = this.world.findGrassPlantingSpot(this.x, this.y, { allowOriginal: false, minRadius: 26, maxRadius: 76 }); if (spot) this.world.items.push(new Item("grass", spot.x, spot.y)); } - this.world.markDead(this, reason); + this.world.markDead(this, finalReason); audio.death(); - this.world.log(`${this.name}\u306f\u9759\u304b\u306a\u75d5\u8de1\u3092\u6b8b\u3057\u305f\u3002\u7406\u7531: ${reason}`, "death"); + this.world.log(`${this.name}\u306f\u9759\u304b\u306a\u75d5\u8de1\u3092\u6b8b\u3057\u305f\u3002\u6b7b\u56e0: ${finalReason}`, "death"); if (this.world.selected === this) this.world.selected = null; } @@ -1572,8 +1717,8 @@ class Tarinai { if (this.dead) return; const lightState = lighting || getLightingState(this.world); const sprite = this.spriteId(); - const img = images.get(sprite) || images.get(this.type); - const metrics = getImageMetrics(sprite) || getImageMetrics(this.type); + const img = typeof getRenderableImage === "function" ? getRenderableImage(sprite, "smile") : (images.get(sprite) || images.get(this.type)); + const metrics = getImageMetrics(sprite) || getImageMetrics(this.type) || getImageMetrics("smile"); const w = metrics?.w || 512; const h = metrics?.h || 512; const moveSpeed = Math.hypot(this.vx, this.vy); @@ -1623,12 +1768,16 @@ class Tarinai { const shadow = projectedShadowParams(lightState, 0.95 + this.scale * 0.8); const shadowContactY = this.y + bob + (drawH / squish) * 0.30; - drawImageProjectedShadow(ctx, img, this.x, shadowContactY, drawW * squish, drawH / squish, shadow, { - faceRight, - alpha: shadow.alpha * (this.state === "sleep" ? 1.10 : 0.92), - widthScale: this.state === "sleep" ? 1.12 : 1, - heightScale: this.state === "sleep" ? 0.88 : 1, - }); + if (img) { + drawImageProjectedShadow(ctx, img, this.x, shadowContactY, drawW * squish, drawH / squish, shadow, { + faceRight, + alpha: shadow.alpha * (this.state === "sleep" ? 1.10 : 0.92), + widthScale: this.state === "sleep" ? 1.12 : 1, + heightScale: this.state === "sleep" ? 0.88 : 1, + }); + } else { + drawProjectedShadow(ctx, this.x, shadowContactY, drawW * 0.52, drawH * 0.13, { ...shadow, alpha: shadow.alpha * 0.82 }); + } drawCreatureGlow(ctx, this, drawW, drawH, lightState); if (this.birthRitualTimer > 0.04 && this.birthRitualLeader) { const partner = this.world.tarinai.find(o => o.id === this.birthPartnerId && !o.dead); @@ -1722,9 +1871,21 @@ class Tarinai { const renderScale = typeof SPRITE_CACHE_SCALE === "number" ? SPRITE_CACHE_SCALE : 2.0; const cacheW = Math.max(1, Math.round((dw * renderScale) / 8) * 8); const cacheH = Math.max(1, Math.round((dh * renderScale) / 8) * 8); - const spriteCanvas = typeof getCachedSpriteCanvas === "function" ? getCachedSpriteCanvas(sprite, img, cacheW, cacheH) : img; - ctx.drawImage(spriteCanvas, -dw / 2, -dh / 2, dw, dh); - if ((this.zunchiStain || 0) > 3 && typeof getCachedStainOverlayCanvas === "function") { + if (img) { + const spriteCanvas = typeof getCachedSpriteCanvas === "function" ? getCachedSpriteCanvas(sprite, img, cacheW, cacheH) : img; + ctx.drawImage(spriteCanvas, -dw / 2, -dh / 2, dw, dh); + } else { + ctx.save(); + ctx.fillStyle = this.stress > 62 ? "#f1c3a8" : "#f4ead6"; + ctx.strokeStyle = "rgba(92,71,49,0.48)"; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.ellipse(0, 0, dw * 0.42, dh * 0.36, 0, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + ctx.restore(); + } + if (img && (this.zunchiStain || 0) > 3 && typeof getCachedStainOverlayCanvas === "function") { const stainCanvas = getCachedStainOverlayCanvas(sprite, img, cacheW, cacheH, clamp((this.zunchiStain || 0) / 100, 0, 1), this.zunchiStainSeed || 0.5); if (stainCanvas) { ctx.save(); @@ -1733,15 +1894,6 @@ class Tarinai { ctx.restore(); } } - if (this.zunchiDisease) { - ctx.save(); - ctx.globalAlpha = 0.12 + clamp(this.zunchiDiseaseSeverity || 0, 0, 1) * 0.10; - ctx.fillStyle = "rgba(9, 74, 23, 0.75)"; - ctx.beginPath(); - ctx.ellipse(0, drawH * 0.05, drawW * 0.56, drawH * 0.38, Math.sin(t + this.age) * 0.12, 0, Math.PI * 2); - ctx.fill(); - ctx.restore(); - } drawDawnRimLight(ctx, dw, dh, lightState); if (faceRight) ctx.scale(-1, 1); @@ -1758,24 +1910,35 @@ class Tarinai { ctx.restore(); - if ((this.hpBarTimer || 0) > 0) { + const hpActive = (this.hpBarTimer || 0) > 0; + const stressActive = (this.stressBarTimer || 0) > 0; + if (hpActive || stressActive) { const bw = Math.max(28, this.radius * 2.35); const bh = 5; const x = this.x - bw / 2; - const y = this.y - drawH * 0.55 - 16; - const pct = clamp(this.energy / 100, 0, 1); - ctx.save(); - ctx.globalAlpha = clamp((this.hpBarTimer || 0) / 0.7, 0.35, 1); - ctx.fillStyle = "rgba(255,252,242,0.86)"; - roundedRect(ctx, x - 2, y - 2, bw + 4, bh + 4, 5); - ctx.fill(); - ctx.fillStyle = "rgba(72,58,45,0.22)"; - roundedRect(ctx, x, y, bw, bh, 4); - 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, x, y, bw * pct, bh, 4); - ctx.fill(); - ctx.restore(); + const baseY = this.y - drawH * 0.55 - 16; + const drawMeter = (y, pct, fill, alpha) => { + ctx.save(); + ctx.globalAlpha = alpha; + ctx.fillStyle = "rgba(255,252,242,0.86)"; + roundedRect(ctx, x - 2, y - 2, bw + 4, bh + 4, 5); + ctx.fill(); + ctx.fillStyle = "rgba(72,58,45,0.22)"; + roundedRect(ctx, x, y, bw, bh, 4); + ctx.fill(); + ctx.fillStyle = fill; + roundedRect(ctx, x, y, bw * pct, bh, 4); + ctx.fill(); + ctx.restore(); + }; + if (stressActive) { + const pct = clamp((this.stress || 0) / 130, 0, 1); + drawMeter(baseY - (hpActive ? 11 : 0), pct, pct > 0.72 ? "rgba(178,74,110,0.92)" : "rgba(212,126,84,0.90)", clamp((this.stressBarTimer || 0) / 0.7, 0.35, 1)); + } + if (hpActive) { + const pct = clamp(this.energy / 100, 0, 1); + drawMeter(baseY, pct, pct > 0.45 ? "rgba(118,190,80,0.88)" : (pct > 0.20 ? "rgba(232,172,68,0.90)" : "rgba(218,82,70,0.92)"), clamp((this.hpBarTimer || 0) / 0.7, 0.35, 1)); + } } } diff --git a/js/ui.js b/js/ui.js index a7f7f53..78692a1 100644 --- a/js/ui.js +++ b/js/ui.js @@ -39,6 +39,17 @@ const uiCache = { toolButtons: [], archiveVersion: "", archiveRows: [], + archiveFamilyVersion: null, + archiveRelationSignature: "", + archiveHtmlCache: new Map(), + archiveRenderTimer: 0, + archiveRenderForce: false, + archiveScheduled: false, + archivePendingOffscreen: false, + archivePendingWhileRunning: false, + archiveLastRenderedAt: 0, + archiveLastScheduleFamilyVersion: null, + archiveDiagnostics: { renders: 0, lastMs: 0, lastFamilies: 0, lastNodes: 0, skippedWhileRunning: 0 }, archiveScrollScheduled: false, deferArchiveUntil: 0, lastArchiveWindow: "", @@ -55,6 +66,15 @@ const uiCache = { panLastX: 0, panLastY: 0, panMoved: false, + grabbing: false, + grabTarget: null, + grabKind: "", + grabMoved: false, + grabStartX: 0, + grabStartY: 0, + grabLastWorldX: 0, + grabLastWorldY: 0, + grabLastSpatialAt: 0, }; function renderLog(logs) { @@ -137,10 +157,11 @@ function renderSelected() { ui.selectedInfo.className = "selected-info"; const effectBadges = []; - if (t.isZunchiSlave) effectBadges.push(`ずんちどれい`); - if (t.zunchiDisease) effectBadges.push(`ずんち病 ${fmt((t.zunchiDiseaseSeverity || 0) * 100)}`); - if ((t.loveMochiTimer || 0) > 0.1) effectBadges.push(`媚薬餅 ${fmt(t.loveMochiTimer)}`); - if ((t.fightMochiTimer || 0) > 0.1) effectBadges.push(`喧嘩餅 ${fmt(t.fightMochiTimer)}`); + effectBadges.push(`\u6027\u683c ${escapeHtml(t.personalityLabel ? t.personalityLabel() : t.personality || "\u4e0d\u660e")}`); + if (t.isZunchiSlave) effectBadges.push(`\u305a\u3093\u3061\u3069\u308c\u3044`); + if (t.zunchiDisease) effectBadges.push(`\u305a\u3093\u3061\u75c5 ${fmt((t.zunchiDiseaseSeverity || 0) * 100)}`); + if ((t.loveMochiTimer || 0) > 0.1) effectBadges.push(`\u3078\u3053\u9905 ${fmt(t.loveMochiTimer)}`); + if ((t.fightMochiTimer || 0) > 0.1) effectBadges.push(`\u3051\u3093\u304b\u9905 ${fmt(t.fightMochiTimer)}`); ui.selectedInfo.innerHTML = `
${escapeHtml(t.label())}${escapeHtml(t.name)}${(t.displayGeneration ? t.displayGeneration() : ((t.hasPaired || t.parents?.length || t.children?.length) ? t.generation : null)) ? `\u4e16\u4ee3 ${t.generation}` : ""}${effectBadges.join("")}
\u6027\u683c${escapeHtml(t.personalityLabel ? t.personalityLabel() : t.personality || "\u4e0d\u660e")}
@@ -152,7 +173,7 @@ function renderSelected() {
\u5bc2\u3057\u3055${fmt(t.loneliness)}
\u4f53\u529b${fmt(t.energy)}
\u30b9\u30c8\u30ec\u30b9${fmt(t.stress)}
-
ずんち汚れ${fmt(t.zunchiStain || 0)}
+
\u305a\u3093\u3061\u6c5a\u308c${fmt(t.zunchiStain || 0)}
\u6c17\u5206${fmt(t.mood)}
\u4ef2\u826f\u3057${rel.friend ? `${relationIconHtml(rel.friend.id)}${escapeHtml(`${rel.friend.name} +${fmt(rel.friend.score)}`)}` : "\u306a\u3057"}
\u907f\u3051\u308b\u76f8\u624b${rel.fear ? `${relationIconHtml(rel.fear.id)}${escapeHtml(`${rel.fear.name} ${fmt(rel.fear.score)}`)}` : "\u306a\u3057"}
@@ -168,8 +189,11 @@ function stateLabel(s) { return { idle: "\u5f85\u6a5f", seek_food: "\u98df\u3079\u7269\u3078\u5411\u304b\u3046", - seek_bed: "寝床へ向かう", - play_ball: "ボール遊び", + seek_bed: "\u5bdd\u5e8a\u3078\u5411\u304b\u3046", + play_ball: "\u30dc\u30fc\u30eb\u904a\u3073", + seek_water: "\u6c34\u3092\u63a2\u3059", + zunchi_sick: "\u305a\u3093\u3061\u75c5", + system: "\u305a\u3093\u3061\u75c5", seek_friend: "\u4ef2\u9593\u3092\u63a2\u3059", follow_parent: "\u89aa\u306b\u3064\u3044\u3066\u3044\u304f", cursor_friend: "\u30ab\u30fc\u30bd\u30eb\u306b\u3064\u3044\u3066\u3044\u304f", @@ -192,13 +216,15 @@ 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 === "zunchi_sick" || t.state === "system") return "\u305a\u3093\u3061\u75c5"; return "\u5468\u56f2\u3092\u898b\u3066\u3044\u308b"; } 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: "\u5a9a\u85ac\u9905", fight_mochi: "\u55a7\u5629\u9905", food: "\u98df\u4e8b", grass: "\u8349", water: "\u6c34", bed: "\u5e72\u8349\u5bdd\u5e8a", ball: "\u30dc\u30fc\u30eb", stone: "\u77f3", zunchi: "\u305a\u3093\u3061", 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", 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 (Number.isFinite(target.x) && Number.isFinite(target.y)) return "\u30ab\u30fc\u30bd\u30eb"; return "\u4e0d\u660e"; } @@ -249,8 +275,8 @@ function formatLogDisplayText(text) { for (const n of Object.values(world?.family || {})) if (n?.name) names.add(n.name); const sorted = [...names].filter(Boolean).sort((a, b) => b.length - a.length); for (const name of sorted) { - const re = new RegExp(`(^|[^「])(${escapeRegExp(name)})(?=[とはがをにのもへからで、。]|$)`, "g"); - out = out.replace(re, (m, prefix, found) => `${prefix}「${found}」`); + const re = new RegExp(`(^|[^\u300c])(${escapeRegExp(name)})(?=[\u3068\u306f\u304c\u3092\u306b\u306e\u3082\u3078\u304b\u3089\u3067\u3001\u3002]|$)`, "g"); + out = out.replace(re, (m, prefix, found) => `${prefix}\u300c${found}\u300d`); } return out; } @@ -294,7 +320,15 @@ function closeFieldDialog() { ui.fieldDialog?.classList.add("hidden"); } +function hydrateLazyImages(root) { + const scope = root || document; + for (const img of scope.querySelectorAll("img[data-src]")) { + if (!img.getAttribute("src")) img.setAttribute("src", img.dataset.src || ""); + } +} + function openEcologyDialog() { + hydrateLazyImages(ui.ecologyDialog); ui.ecologyDialog?.classList.remove("hidden"); } @@ -434,6 +468,8 @@ function bindUI() { }); syncToolSizeBadges(); ui.archiveContent?.addEventListener("scroll", scheduleArchiveWindowRender, { passive: true }); + window.addEventListener("scroll", schedulePendingArchiveRender, { passive: true }); + window.addEventListener("resize", schedulePendingArchiveRender, { passive: true }); const bindArchiveHorizontal = (root) => { if (!root) return; root.addEventListener("wheel", (e) => { @@ -460,11 +496,16 @@ function bindUI() { showToast(`${t.name}\u3092\u9078\u629e\u3057\u307e\u3057\u305f\u3002`); }); - ui.pauseBtn.addEventListener("click", () => { + ui.pauseBtn?.addEventListener("click", () => { world.paused = !world.paused; + const archiveStale = uiCache.archiveFamilyVersion !== (world.familyVersion || 0); + if (world.paused && (uiCache.archivePendingWhileRunning || archiveStale)) { + uiCache.archivePendingWhileRunning = false; + scheduleArchiveWindowRender(); + } syncTopButtons(); }); - ui.speedBtn.addEventListener("click", () => { + ui.speedBtn?.addEventListener("click", () => { const speeds = [1, 2, 4]; const i = speeds.indexOf(world.speed); world.speed = speeds[(i + 1) % speeds.length]; @@ -481,7 +522,7 @@ function bindUI() { ui.creditsDialog?.addEventListener("click", (e) => { if (e.target === ui.creditsDialog) closeCreditsDialog(); }); - ui.resetBtn.addEventListener("click", () => { + ui.resetBtn?.addEventListener("click", () => { openFieldDialog(); }); ui.fieldCancelBtn?.addEventListener("click", closeFieldDialog); @@ -506,10 +547,10 @@ function bindUI() { closeArchiveResetDialog(); renderArchive(); renderSelected(); - showToast("家系図をリセットしました。"); + showToast("\u5bb6\u7cfb\u56f3\u3092\u30ea\u30bb\u30c3\u30c8\u3057\u307e\u3057\u305f\u3002"); }); - ui.toolPalette.addEventListener("click", (e) => { + ui.toolPalette?.addEventListener("click", (e) => { const toggle = e.target.closest(".tool-category-toggle"); if (toggle) { const category = toggle.closest(".tool-category"); @@ -556,14 +597,14 @@ function bindUI() { chainScroll(ui.archiveContent); containOwnVerticalScroll(ui.log); - canvas.addEventListener("contextmenu", (e) => e.preventDefault()); - canvas.addEventListener("mousedown", (e) => { + canvas?.addEventListener("contextmenu", (e) => e.preventDefault()); + canvas?.addEventListener("mousedown", (e) => { if (e.button === 0 && world.tool === "pinch") { const p = screenToWorld(e); if (!p.inside) return; const target = world.findGrabTargetAt ? world.findGrabTargetAt(p.x, p.y) : null; if (!target) { - showToast("つまめる対象がありません。"); + showToast("\u3064\u307e\u3081\u308b\u5bfe\u8c61\u304c\u3042\u308a\u307e\u305b\u3093\u3002"); return; } e.preventDefault(); @@ -616,6 +657,7 @@ function bindUI() { uiCache.grabTarget = null; uiCache.grabKind = ""; world.rebuildSpatial?.(); + uiCache.grabLastSpatialAt = 0; canvas.style.cursor = world.tool === "pinch" ? "grab" : (world.tool === "observe" ? "default" : (world.tool === "poke" || world.tool === "delete" ? "pointer" : "crosshair")); renderStats(); return; @@ -657,9 +699,12 @@ function bindUI() { target.spinVelocity = clamp((target.spinVelocity || 0) + dx * 0.06, -38, 38); } world.drawListDirty = true; - world.rebuildSpatial?.(); + const now = performance.now(); + if (!uiCache.grabLastSpatialAt || now - uiCache.grabLastSpatialAt > 80) { + world.rebuildSpatial?.(); + uiCache.grabLastSpatialAt = now; + } canvas.style.cursor = "grabbing"; - render(); return; } if (!uiCache.panning) return; @@ -676,7 +721,7 @@ function bindUI() { } }); - canvas.addEventListener("click", (e) => { + canvas?.addEventListener("click", (e) => { if (uiCache.grabMoved) { uiCache.grabMoved = false; return; @@ -691,7 +736,7 @@ function bindUI() { renderStats(); }); - canvas.addEventListener("mousemove", (e) => { + canvas?.addEventListener("mousemove", (e) => { const p = screenToWorld(e); const prevX = world.pointer.x; const prevY = world.pointer.y; @@ -702,8 +747,8 @@ function bindUI() { world.pointer.motion = clamp(delta / 18, 0, 2); world.pointer.movedAt = performance.now(); }); - canvas.addEventListener("wheel", resizeFieldFromWheel, { passive: false }); - canvas.addEventListener("mouseleave", () => { + canvas?.addEventListener("wheel", resizeFieldFromWheel, { passive: false }); + canvas?.addEventListener("mouseleave", () => { world.pointer.inside = false; world.pointer.motion = 0; }); diff --git a/js/ui_charts.js b/js/ui_charts.js index df3ffe4..b9cb768 100644 --- a/js/ui_charts.js +++ b/js/ui_charts.js @@ -1,4 +1,4 @@ -"use strict"; +"use strict"; function renderStats() { const alive = world.tarinai; @@ -18,7 +18,11 @@ function renderStats() { updateColonyHistory(values); drawColonyChart(values); renderSelected(); - renderArchive(); + const familyVersion = world.familyVersion || 0; + if (uiCache.archiveLastScheduleFamilyVersion !== familyVersion) { + uiCache.archiveLastScheduleFamilyVersion = familyVersion; + scheduleArchiveWindowRender(); + } } function updateColonyHistory(values) { diff --git a/js/ui_exports.js b/js/ui_exports.js index 9a80ef9..6253b33 100644 --- a/js/ui_exports.js +++ b/js/ui_exports.js @@ -1,3 +1,3 @@ -"use strict"; +"use strict"; window.TarinaiUI = { canvas, ctx, ui, renderLog, renderSelected, stateLabel, renderStats, escapeHtml, renderArchive, validateFamilyTree, resizeCanvas, applyFieldLayout, screenToWorld, selectTool, showToast, saveGame, loadGame, syncTopButtons, bindUI }; diff --git a/js/ui_family.js b/js/ui_family.js index a2f3154..d5452cb 100644 --- a/js/ui_family.js +++ b/js/ui_family.js @@ -78,7 +78,7 @@ function lineageRelatedIdSet(family) { function liveFamilyComponents(family) { // Childless pairs, including pairs currently performing the birth ritual, are - // intentionally excluded. A 家系図 node is created only after an actual parent/child + // intentionally excluded. A \u5bb6\u7cfb\u56f3 node is created only after an actual parent/child // relation exists; otherwise ritual participants become isolated pseudo-families. family = lineageEdgeFamily(family); const relatedIds = lineageRelatedIdSet(family); @@ -124,9 +124,29 @@ function liveFamilyComponents(family) { } function familyStatusText(n) { - if (!n) return "不明"; - return n.alive ? "生存" : "死亡"; + if (!n) return "\u4e0d\u660e"; + return n.alive ? "\u751f\u5b58" : "\u6b7b\u4ea1"; } +function familyDeathReasonLabel(rawReason, entity = null) { + const text = String(rawReason || ""); + if (!text) return ""; + if (entity?.briefDeathReason) return entity.briefDeathReason(text); + 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 (/\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)) { + const disease = text.match(/([^\u3001\u3002\s]+\u75c5)/); + return disease ? disease[1] : "\u305a\u3093\u3061\u75c5"; + } + 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"; + return "\u4e8b\u6545"; +} + function lineageNodeSort(a, b) { return (a.generation || 1) - (b.generation || 1) || @@ -139,19 +159,21 @@ function lineageTreeNodeHtml(n, x, y) { const entity = n?.id ? world.tarinai.find(t => 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 || "不明"); + const name = escapeHtml(n?.name || "\u4e0d\u660e"); const status = familyStatusText(n); const gen = n?.generation ? `G${n.generation}` : "G?"; const rawReason = !n?.alive && n?.deathReason ? String(n.deathReason) : ""; - const briefReason = rawReason && entity?.briefDeathReason ? entity.briefDeathReason(rawReason) : (rawReason ? String(rawReason) : ""); + const briefReason = familyDeathReasonLabel(rawReason, entity); const reason = briefReason ? ` / ${briefReason}` : ""; - const title = escapeHtml(`${n?.name || "不明"} / 世代 ${n?.generation || "?"} / ${status}${reason}`); + 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 deathLine = briefReason ? `${escapeHtml(briefReason)}` : ""; const cls = n ? ((live || n.alive) ? "alive" : "dead") : "missing"; const childCls = (n?.parents || []).length ? " child" : ""; const data = n?.id ? ` data-family-tarinai-id="${escapeHtml(n.id)}"` : ""; return `
- +
${name} ${escapeHtml(gen)} / ${escapeHtml(status)} @@ -162,7 +184,7 @@ function lineageTreeNodeHtml(n, x, y) { /* * Family archive rendering is intentionally a top-down tree diagram. - * Do not replace this section with list/cards/readable grids: the archive must remain a 家系図. + * Do not replace this section with list/cards/readable grids: the archive must remain a \u5bb6\u7cfb\u56f3. * The layout below may reorder siblings/parents inside the same generation only to reduce crossings. */ function lineageParentIds(n, idSet, family = world.family || {}) { @@ -731,7 +753,7 @@ function lineageBuildChildPathGroup(group, nodeW, nodeH, bridgeHorizontals) { const childPoints = children .map(c => ({ x: c.x + nodeW / 2, y: c.y })) .sort((a, b) => a.x - b.x || a.y - b.y); - if (childPoints.length === 1 && Math.abs(childPoints[0].x - joinX) <= 40) { + if (childPoints.length === 1 && Math.abs(childPoints[0].x - joinX) <= 0.5) { const p = childPoints[0]; const bridges = lineageIntersectionYsForVertical(p.x, fromY, p.y, bridgeHorizontals); return { d: lineageVerticalBridgePath(p.x, fromY, p.y, bridges) }; @@ -748,6 +770,98 @@ function lineageBuildChildPathGroup(group, nodeW, nodeH, bridgeHorizontals) { return { d }; } +function lineageComponentSignature(component, family) { + family = lineageEdgeFamily(family); + const idSet = new Set(component.map(x => x?.id).filter(Boolean)); + return component + .filter(Boolean) + .map(n => { + const parents = lineageMutualParentIds(n, family, idSet).join(","); + const children = lineageMutualChildIds(n, family, idSet).join(","); + return [ + n.id, + n.name || "", + n.type || "", + n.generation || 1, + n.birthTime || 0, + n.hasPaired ? 1 : 0, + n.alive === false ? 0 : 1, + n.deathReason || "", + parents, + children, + ].join(":"); + }) + .sort() + .join("|"); +} + +function lineageExpectedChildEdgeCount(component, family) { + family = lineageEdgeFamily(family); + const idSet = new Set(component.map(x => x?.id).filter(Boolean)); + let count = 0; + for (const n of component || []) count += lineageParentIds(n, idSet, family).length; + return count; +} + +function lineageVisibleRelationSignature(family) { + family = lineageEdgeFamily(family); + const known = new Set(Object.keys(family)); + const parentEdges = new Map(); + const childEdges = new Map(); + 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); + 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); + } + 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(","); + return [ + id, + n.name || "", + n.type || "", + n.generation || 1, + n.birthTime || 0, + n.alive === false ? 0 : 1, + n.deathReason || "", + parents, + children, + ].join(":"); + }) + .join("|"); +} + +function lineageArchiveNearViewport() { + if (!ui.archiveContent?.getBoundingClientRect) return true; + const rect = ui.archiveContent.getBoundingClientRect(); + const h = window.innerHeight || document.documentElement?.clientHeight || 0; + return rect.top < h + 260 && rect.bottom > -260; +} + +function lineageSetArchiveStaleStatus(show) { + if (!ui.archiveContent) return; + const existing = ui.archiveContent.querySelector(".lineage-stale-status"); + if (!show) { + existing?.remove(); + return; + } + if (existing) return; + ui.archiveContent.insertAdjacentHTML("afterbegin", `
\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
`); +} + function lineageAssignPartnerAttachOffsets(linkGroups) { const buckets = new Map(); const pushBucket = (nodeId, side, group) => { @@ -855,7 +969,7 @@ function lineageBuildLinks(nodes, idSet, layout) { } } - return { partnerLinks, childLinks }; + return { partnerLinks, childLinks, childLinkGroups: linkGroups.length }; } function lineageSplitByActualEdges(component) { @@ -935,36 +1049,81 @@ function buildLineageTreeLayout(component, family) { 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 }; + return { ...layout, labels, nodes: renderedNodes, childLinks: links.childLinks, partnerLinks: links.partnerLinks, childLinkGroups: links.childLinkGroups }; } -function lineageFamilyHtml(component, index, family) { +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 layout = buildLineageTreeLayout(uniqueComponent, family); const aliveCount = uniqueComponent.filter(n => n.alive).length; - const labels = layout.labels.map(label => `
世代 ${label.generation}
`).join(""); + const childEdgeCount = lineageExpectedChildEdgeCount(uniqueComponent, family); + const maskId = `lineage-node-mask-${index}`; + const nodeMaskRects = layout.nodes + .map(p => ``) + .join(""); + const labels = layout.labels.map(label => `
\u4e16\u4ee3 ${label.generation}
`).join(""); const partnerPaths = layout.partnerLinks.map(d => ``).join(""); const childPaths = layout.childLinks.map(d => ``).join(""); const nodeHtml = layout.nodes.map(p => lineageTreeNodeHtml(p.node, p.x, p.y)).join(""); - const empty = !layout.childLinks.length ? `
親子関係の線はまだありません。
` : ""; + const empty = !layout.childLinks.length ? `
\u89aa\u5b50\u95a2\u4fc2\u306e\u7dda\u306f\u307e\u3060\u3042\u308a\u307e\u305b\u3093\u3002
` : ""; - return `
-

家系 ${index + 1} / ${uniqueComponent.length}匹 / 生存 ${aliveCount}

+ const html = `
+

\u5bb6\u7cfb ${index + 1} / ${uniqueComponent.length}\u5339 / \u751f\u5b58 ${aliveCount}

${labels}${nodeHtml}${empty}
`; + uiCache.archiveHtmlCache?.set(cacheKey, html); + return html; } function renderArchive() { if (!ui.archiveContent) return; if (uiCache.deferArchiveUntil && performance.now() < uiCache.deferArchiveUntil) return; uiCache.deferArchiveUntil = 0; - if (world.normalizeFamily) world.normalizeFamily(); + uiCache.archiveScheduled = false; + const familyVersion = world.familyVersion || 0; + if (uiCache.archiveVersion && uiCache.archiveFamilyVersion === familyVersion) 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 now = performance.now(); + const minUpdateGap = 700; + if (uiCache.archiveVersion && !uiCache.archiveRenderForce && now - (uiCache.archiveLastRenderedAt || 0) < minUpdateGap) { + if (!uiCache.archiveRenderTimer) { + uiCache.archiveRenderTimer = setTimeout(() => { + uiCache.archiveRenderTimer = 0; + uiCache.archiveRenderForce = true; + renderArchive(); + uiCache.archiveRenderForce = false; + }, Math.max(80, minUpdateGap - (now - (uiCache.archiveLastRenderedAt || 0)))); + } + return; + } + if (world.familyPrunePending && world.pruneExtinctFamilies) { + world.familyPrunePending = false; + world.pruneExtinctFamilies(); + } const family = world.family || {}; + const relationSignature = lineageVisibleRelationSignature(family); + if (uiCache.archiveVersion && uiCache.archiveRelationSignature === relationSignature) { + uiCache.archiveFamilyVersion = world.familyVersion || 0; + return; + } const ids = Object.keys(family); const edgeCount = ids.reduce((sum, id) => { const n = family[id]; @@ -972,41 +1131,67 @@ function renderArchive() { }, 0); const version = `${world.familyVersion || 0}:${ids.length}:${edgeCount}`; if (uiCache.archiveVersion === version) return; + const renderStarted = performance.now(); + lineageSetArchiveStaleStatus(false); + if (uiCache.archiveRenderTimer) { + clearTimeout(uiCache.archiveRenderTimer); + uiCache.archiveRenderTimer = 0; + } 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 token = `${version}:${Math.random().toString(36).slice(2)}`; uiCache.archiveRenderToken = token; if (!components.length) { - ui.archiveContent.innerHTML = `
生きている構成員を持つ家系はまだありません。
`; + ui.archiveContent.innerHTML = `
\u751f\u304d\u3066\u3044\u308b\u69cb\u6210\u54e1\u3092\u6301\u3064\u5bb6\u7cfb\u306f\u307e\u3060\u3042\u308a\u307e\u305b\u3093\u3002
`; return; } - ui.archiveContent.innerHTML = `
家系図を描画中… 0 / ${components.length}
`; + ui.archiveContent.innerHTML = `
\u5bb6\u7cfb\u56f3\u3092\u63cf\u753b\u4e2d\u2026 0 / ${components.length}
`; 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 started = performance.now(); - let nodeBudget = 180; + let nodeBudget = 72; + let renderedThisSlice = 0; while (index < components.length) { const component = components[index]; - forest.insertAdjacentHTML("beforeend", lineageFamilyHtml(component, index, family)); + if (renderedThisSlice > 0 && component.length > nodeBudget) break; + forest.insertAdjacentHTML("beforeend", lineageFamilyHtml(component, index, family, signatures[index])); nodeBudget -= Math.max(1, component.length); index += 1; + renderedThisSlice += 1; if (index < components.length && (nodeBudget <= 0 || performance.now() - started > 14)) break; } - if (status) status.textContent = `家系図を描画中… ${index} / ${components.length}`; + 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(); + 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); + } } }; - pump(); + const schedule = window.requestIdleCallback || ((fn) => setTimeout(fn, 0)); + schedule(pump, { timeout: 80 }); } function validateFamilyTree() { @@ -1024,6 +1209,40 @@ function validateFamilyTree() { if (n?.id && !rendered.has(n.id)) issues.push({ type: "missing-render-node", id: n.id }); } } + document.querySelectorAll(".lineage-family-tree[data-lineage-child-edges]").forEach((section, index) => { + const expectedEdges = Number(section.getAttribute("data-lineage-child-edges") || 0); + const expectedGroups = Number(section.getAttribute("data-lineage-child-groups") || 0); + const pathCount = section.querySelectorAll(".lineage-links .child-link").length; + 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 }); + } + }); + } + }); return issues; } @@ -1032,7 +1251,22 @@ function renderArchiveWindow() { } function scheduleArchiveWindowRender() { - renderArchive(); + if (uiCache.archiveVersion && uiCache.archiveFamilyVersion === (world.familyVersion || 0)) return; + if (uiCache.archiveRenderTimer) return; + if (uiCache.archiveScheduled) return; + uiCache.archiveScheduled = true; + const schedule = window.requestIdleCallback || ((fn) => setTimeout(fn, 0)); + schedule(() => { + renderArchive(); + }, { timeout: 700 }); +} + +function schedulePendingArchiveRender() { + if (!uiCache.archivePendingOffscreen) return; + if (!lineageArchiveNearViewport()) return; + uiCache.archivePendingOffscreen = false; + scheduleArchiveWindowRender(); } window.validateFamilyTree = validateFamilyTree; +window.familyTreeDiagnostics = () => ({ ...(uiCache.archiveDiagnostics || {}) }); diff --git a/js/world.js b/js/world.js index b5134aa..1bac9c4 100644 --- a/js/world.js +++ b/js/world.js @@ -24,6 +24,7 @@ class World { this.drawListDirty = true; this.selected = null; this.deadCount = 0; + this.lastBirthAt = -999; this.maxGeneration = 1; this.family = {}; this.familyVersion = 0; @@ -91,10 +92,27 @@ class World { const base = CONFIG.grassLimit ?? 96; const mediumScale = Math.max(0.22, FIELD_TYPES?.garden?.worldScale || 1); const ratio = this.fieldWorldScale() / mediumScale; - // 中サイズを CONFIG.grassLimit の基準にし、フィールドの論理サイズに応じて増減する。 + // \u4e2d\u30b5\u30a4\u30ba\u3092 CONFIG.grassLimit \u306e\u57fa\u6e96\u306b\u3057\u3001\u30d5\u30a3\u30fc\u30eb\u30c9\u306e\u8ad6\u7406\u30b5\u30a4\u30ba\u306b\u5fdc\u3058\u3066\u5897\u6e1b\u3059\u308b\u3002 return Math.max(12, Math.round(base * ratio)); } + fenceLimit() { + const base = CONFIG.fenceLimit ?? 72; + const mediumScale = Math.max(0.22, FIELD_TYPES?.garden?.worldScale || 1); + const ratio = Math.max(0.24, this.fieldWorldScale() / mediumScale); + return clamp(Math.round(base * Math.sqrt(ratio)), 28, 140); + } + + isFenceType(type = "") { + return type === "fence_v" || type === "fence_h"; + } + + liveFenceCount() { + let count = 0; + for (const it of this.items || []) if (!it.dead && this.isFenceType(it.type)) count += 1; + return count; + } + baseZoomMin() { return CONFIG.fieldZoomMin || 0.42; } @@ -299,6 +317,7 @@ class World { this.drawListDirty = true; this.selected = null; this.deadCount = 0; + this.lastBirthAt = -999; this.maxGeneration = 1; this.family = {}; this.familyVersion = 0; @@ -337,6 +356,7 @@ class World { recordFamily(t) { if (!this.family) this.family = {}; + if (!t?.id) return; const uniqueIds = (list) => Array.from(new Set((list || []).filter(Boolean))); const sameList = (a, b) => { const aa = uniqueIds(a); @@ -351,6 +371,7 @@ class World { id: t.id, name: t.name, type: t.type, + personality: t.personality || "", generation: t.generation, hasPaired: !!t.hasPaired, parents: nextParents, @@ -366,6 +387,7 @@ class World { const nextAlive = !t.dead; if (entry.name !== t.name) changed = true; if (entry.type !== t.type) changed = true; + if ((entry.personality || "") !== (t.personality || "")) changed = true; if (entry.hasPaired !== !!t.hasPaired) changed = true; if (entry.generation !== t.generation) changed = true; if ((entry.deathReason || "") !== (t.deathReason || entry.deathReason || "")) changed = true; @@ -374,6 +396,7 @@ class World { if (!sameList(entry.children, nextChildren)) changed = true; entry.name = t.name; entry.type = t.type; + entry.personality = t.personality || entry.personality || ""; entry.alive = nextAlive; entry.generation = t.generation; entry.hasPaired = !!t.hasPaired; @@ -394,6 +417,7 @@ class World { const uniqueIds = (list, allow) => Array.from(new Set((list || []).filter(id => id && (!allow || allow.has(id))))); const sameList = (a, b) => a.length === b.length && a.every((id, i) => id === b[i]); for (const t of this.tarinai || []) { + if (!this.family[t.id] && !(t.hasPaired || (t.parents || []).length || (t.children || []).length)) continue; const before = this.familyVersion || 0; this.recordFamily(t); changed = changed || before !== (this.familyVersion || 0); @@ -414,6 +438,7 @@ class World { id, name: live?.name || n.name || id, type: live?.type || n.type || "smile", + personality: live?.personality || n.personality || "", generation: Math.max(1, live?.generation || n.generation || 1), hasPaired: !!(live?.hasPaired || n.hasPaired), parents, @@ -448,6 +473,13 @@ class World { changed = true; } } + for (const id of Object.keys(family)) { + const n = family[id]; + if (!n?.hasPaired && !(n?.parents || []).length && !(n?.children || []).length) { + delete family[id]; + changed = true; + } + } for (const [id, live] of liveById.entries()) { const n = family[id]; if (!n) continue; @@ -513,15 +545,16 @@ class World { markDead(t, reason) { if (!this.family) this.family = {}; + const finalReason = t?.normalizeDeathReason ? t.normalizeDeathReason(reason) : reason; if (this.family[t.id]) { - if (this.family[t.id].alive !== false || this.family[t.id].deathReason !== reason) { + if (this.family[t.id].alive !== false || this.family[t.id].deathReason !== finalReason) { this.family[t.id].alive = false; - this.family[t.id].deathReason = reason; + this.family[t.id].deathReason = finalReason; + this.family[t.id].personality = t.personality || this.family[t.id].personality || ""; this.familyVersion = (this.familyVersion || 0) + 1; } } - this.normalizeFamily(); - this.pruneExtinctFamilies(); + this.familyPrunePending = true; } pruneExtinctFamilies(opts = {}) { @@ -571,7 +604,7 @@ class World { const t = new Tarinai(this, opts); this.tarinai.push(t); this.drawListDirty = true; - this.recordFamily(t); + if (t.hasPaired || (t.parents || []).length || (t.children || []).length) this.recordFamily(t); this.maxGeneration = Math.max(this.maxGeneration, t.generation); return t; } @@ -626,7 +659,8 @@ class World { const child = this.spawnChild(a, b, i, childCount); if (child) { child.postBirthPeaceTimer = 18; children.push(child); } } - if (children.length > 1) this.log(`${a.name}\u3068${b.name}\u306e\u8fd1\u304f\u306b${children.length}\u5339\u306e\u5b50\u304c\u73fe\u308c\u305f\u3002`, "birth"); + if (children.length > 0) this.lastBirthAt = this.time; + if (children.length > 1) this.log(`${a.name}\u3068${b.name}\u306e\u5b50\u304c${children.length}\u5339\u751f\u307e\u308c\u305f\u3002`, "birth"); return children[0] || null; } @@ -656,8 +690,10 @@ class World { spawnChild(a, b, index = 0, total = 1) { const inherited = Math.random() < 0.56 ? a.type : b.type; const mutation = Math.random() < 0.18 ? baseSpriteId() : inherited; + const inheritedPersonality = a.personality === b.personality ? a.personality : (Math.random() < 0.5 ? a.personality : b.personality); const child = this.addTarinai({ type: mutation, + personality: inheritedPersonality || personalityForSeed(`${a.id}:${b.id}:${this.time}:${index}`), name: makeName(), age: 0, x: (a.x + b.x) / 2 + rand(-14, 14) + (index - (total - 1) / 2) * 18, @@ -690,14 +726,64 @@ class World { this.recordFamily(child); if (this.family[a.id] && !this.family[a.id].children.includes(child.id)) this.family[a.id].children.push(child.id); if (this.family[b.id] && !this.family[b.id].children.includes(child.id)) this.family[b.id].children.push(child.id); - this.normalizeFamily(); + this.familyCleanVersion = null; audio.birth(); - if (total <= 1) this.log(`${child.name}\u304c${a.name}\u3068${b.name}\u306e\u8fd1\u304f\u306b\u73fe\u308c\u305f\u3002`, "birth"); + this.lastBirthAt = this.time; + if (total <= 1) this.log(`${child.name}\u304c${a.name}\u3068${b.name}\u306e\u5b50\u3068\u3057\u3066\u751f\u307e\u308c\u305f\u3002`, "birth"); return child; } + startForcedFight(a, b) { + if (!a || !b || a === b || a.dead || b.dead) return false; + const alreadyFighting = a.fightTimer > 0.04 && b.fightTimer > 0.04 && ((a.fightTargetIds || []).includes(b.id) || a.fightTargetId === b.id); + if (alreadyFighting) return true; + for (const t of [a, b]) { + t.birthRitualTimer = 0; + t.birthRitualMax = 0; + t.birthPartnerId = null; + t.birthRitualLeader = false; + t.birthRitualRole = 0; + t.postBirthPeaceTimer = 0; + t.fightCooldown = 0; + t.intimidateTimer = 0; + t.intimidateTargetId = null; + t.intimidatedTimer = 0; + t.defeatedTimer = 0; + t.defeatedById = null; + t.fightWinnerId = null; + t.target = null; + t.state = "idle"; + } + this.startFightMochiIntimidation(a, b); + this.startFight(a, b); + return true; + } + + startFightMochiIntimidation(a, b) { + if (!a || !b || a.dead || b.dead) return false; + const aDrug = (a.fightMochiTimer || 0) > 0.04; + const bDrug = (b.fightMochiTimer || 0) > 0.04; + const actor = aDrug && !bDrug ? a : (!aDrug && bDrug ? b : (a.energy + a.stress * 0.36 >= b.energy + b.stress * 0.36 ? a : b)); + const target = actor === a ? b : a; + actor.intimidateTimer = Math.max(actor.intimidateTimer || 0, rand(0.85, 1.35)); + actor.intimidateTargetId = target.id; + actor.surpriseTimer = Math.max(actor.surpriseTimer || 0, 0.28); + target.intimidatedTimer = Math.max(target.intimidatedTimer || 0, rand(0.75, 1.25)); + target.fearTimer = Math.max(target.fearTimer || 0, 0.72 * target.personalityProfile().fear); + actor.adjustRelation(target, -0.05, 0.06, "intimidate"); + target.adjustRelation(actor, -0.12, 0.30 * target.personalityProfile().fear, "intimidate"); + this.effects.push(new Effect("ring", actor.x, actor.y - actor.radius * 0.65, { + life: 0.34, + size: actor.radius * 0.62, + color: "rgba(154, 88, 42, 0.74)", + })); + this.spawnBubble(actor.x, actor.y - actor.radius * 1.30, "!", "rgba(120,62,38,0.82)"); + return true; + } + startConflict(a, b) { if (!a || !b || a.dead || b.dead) return; + if ((a.fightMochiTimer || 0) > 0.04 || (b.fightMochiTimer || 0) > 0.04) { this.startForcedFight(a, b); return; } if (this.areParentChild(a, b) || this.areCoParents?.(a, b)) return; if ((a.postBirthPeaceTimer || 0) > 0 || (b.postBirthPeaceTimer || 0) > 0) return; if (a.fightTimer > 0.04 || b.fightTimer > 0.04 || a.intimidateTimer > 0.04 || b.intimidateTimer > 0.04) return; @@ -788,10 +874,10 @@ class World { winner.fightWinnerId = winner.id; winner.defeatedById = null; winner.defeatedTimer = 0; - a.stress = clamp(a.stress + rand(8, 18), 0, 130); - b.stress = clamp(b.stress + rand(8, 18), 0, 130); - a.damage(rand(2, 7)); - b.damage(rand(2, 7)); + a.addStress ? a.addStress(rand(8, 18), { threshold: 8 }) : (a.stress = clamp(a.stress + rand(8, 18), 0, 130)); + b.addStress ? b.addStress(rand(8, 18), { threshold: 8 }) : (b.stress = clamp(b.stress + rand(8, 18), 0, 130)); + a.damage(rand(2, 7), "\u55a7\u5629"); + b.damage(rand(2, 7), "\u55a7\u5629"); const dx = b.x - a.x, dy = b.y - a.y; const d = Math.hypot(dx, dy) || 1; a.vx -= dx / d * rand(32, 62); a.vy -= dy / d * rand(32, 62); @@ -839,17 +925,17 @@ class World { t.target = { x, y, dead: false }; t.surpriseTimer = Math.max(t.surpriseTimer, 0.95); t.fearTimer = Math.max(t.fearTimer, 1.2); - t.thought = "爆竹でたたき起こされた"; + t.thought = "\u7206\u7af9\u3067\u305f\u305f\u304d\u8d77\u3053\u3055\u308c\u305f"; } - t.stress = clamp(t.stress + rand(8, 15), 0, 130); + t.addStress ? t.addStress(rand(8, 15), { threshold: 8 }) : (t.stress = clamp(t.stress + rand(8, 15), 0, 130)); t.fearTimer = Math.max(t.fearTimer, 0.58); const dx = t.x - x; const dy = t.y - y; const d = Math.hypot(dx, dy) || 1; if (d > blastRadius) continue; const p = clamp(1 - d / blastRadius, 0, 1); - t.damage(5 + p * 18, "\u7206\u7af9\u306b\u9a5a\u3044\u305f"); - t.stress = clamp(t.stress + 22 * p, 0, 130); + t.damage(5 + p * 18, "\u7206\u7af9"); + t.addStress ? t.addStress(22 * p, { threshold: 8 }) : (t.stress = clamp(t.stress + 22 * p, 0, 130)); t.hurtTimer = Math.max(t.hurtTimer, 1.5 + p * 1.8); t.surpriseTimer = Math.max(t.surpriseTimer, 0.85); t.fearTimer = Math.max(t.fearTimer, 1.7 + p); @@ -894,7 +980,7 @@ class World { blastedBalls += 1; } audio.explode(); - this.log(blastedBalls ? "爆竹が派手にはじけ、ボールが急加速した。" : "爆竹が派手にはじけ、庭がざわついた。", "accident"); + this.log(blastedBalls ? "\u7206\u7af9\u304c\u6d3e\u624b\u306b\u306f\u3058\u3051\u3001\u30dc\u30fc\u30eb\u304c\u6025\u52a0\u901f\u3057\u305f\u3002" : "\u7206\u7af9\u304c\u6d3e\u624b\u306b\u306f\u3058\u3051\u3001\u5ead\u304c\u3056\u308f\u3064\u3044\u305f\u3002", "accident"); } spawnZunchi(x, y) { @@ -1038,8 +1124,8 @@ class World { loser.totalFightLosses = (loser.totalFightLosses || 0) + 1; if ((loser.totalFightLosses || 0) >= 5 && loser.becomeZunchiSlave?.(winner)) { const oldName = loser.formerName || loser.name; - this.spawnBubble(loser.x, loser.y - loser.radius * 1.20, "ぶりゅ…", "rgba(74,91,50,0.78)"); - this.log(`${oldName}は5回喧嘩に負け、ずんちどれいになった。`, "fight"); + this.spawnBubble(loser.x, loser.y - loser.radius * 1.20, "\u3076\u308a\u3085\u2026", "rgba(74,91,50,0.78)"); + this.log(`${oldName}\u306f5\u56de\u55a7\u5629\u306b\u8ca0\u3051\u3001\u305a\u3093\u3061\u3069\u308c\u3044\u306b\u306a\u3063\u305f\u3002`, "fight"); } loser.fearTimer = Math.max(loser.fearTimer, 1.2 * loser.personalityProfile().fear); this.spawnBubble(loser.x, loser.y - loser.radius * 1.28, "\u306f\u3041\u3041\u3041\uff01", "rgba(84,66,86,0.80)"); @@ -1051,7 +1137,7 @@ class World { } itemDropImpact(it) { - if (!it || it.dead) return; + if (!it || it.dead || it.type === "water") return; const isStone = it.type === "stone"; const radius = isStone ? 64 : Math.max(34, (it.r || 14) * 1.75); const damage = isStone ? rand(20, 34) : rand(3, 7); @@ -1108,7 +1194,7 @@ class World { for (const t of arr.slice(5)) { t.state = "idle"; t.target = null; - t.thought = "ボールが混み合っているので眺めている"; + t.thought = "\u30dc\u30fc\u30eb\u304c\u6df7\u307f\u5408\u3063\u3066\u3044\u308b\u306e\u3067\u773a\u3081\u3066\u3044\u308b"; t.wanderAngle = Math.atan2(t.y - ball.y, t.x - ball.x) + rand(-0.45, 0.45); } } @@ -1146,8 +1232,8 @@ class World { ball.amount = Math.max(ball.amount || 999, 999); audio.poke(); this.effects.push(new Effect("ring", ball.x, ball.y, { size: Math.max(16, ball.r * (0.95 + ball.pokeCombo * 0.05)), life: 0.22, color: "rgba(255,255,255,0.58)" })); - if (ball.pokeCombo >= 4 || speed > 430) this.log(`ボールを連続でつつき、勢いが増した。`, "observe"); - else this.log(`ボールをつついて弾いた。`, "observe"); + if (ball.pokeCombo >= 4 || speed > 430) this.log(`\u30dc\u30fc\u30eb\u3092\u9023\u7d9a\u3067\u3064\u3064\u304d\u3001\u52e2\u3044\u304c\u5897\u3057\u305f\u3002`, "observe"); + else this.log(`\u30dc\u30fc\u30eb\u3092\u3064\u3064\u3044\u3066\u5f3e\u3044\u305f\u3002`, "observe"); return true; } @@ -1199,9 +1285,10 @@ class World { t.lastBallDamageAt = now; t.vx += bvx * push; t.vy += bvy * push; - t.damage(damage, "超高速のボールに当たった"); + t.damage(damage, "\u8d85\u9ad8\u901f\u306e\u30dc\u30fc\u30eb\u306b\u5f53\u305f\u3063\u305f"); const timidStress = t.personality === "timid" ? 1.55 : 1.0; - t.stress = clamp(t.stress + clamp(damage * 0.55 * timidStress, 3, t.personality === "timid" ? 26 : 18), 0, 130); + const stressGain = clamp(damage * 0.55 * timidStress, 3, t.personality === "timid" ? 26 : 18); + t.addStress ? t.addStress(stressGain, { threshold: 8 }) : (t.stress = clamp(t.stress + stressGain, 0, 130)); t.hurtTimer = Math.max(t.hurtTimer, damage > 16 ? 2.4 : 1.35); t.fallTimer = Math.max(t.fallTimer, damage > 14 ? 0.82 : 0.38); t.fallMax = Math.max(t.fallMax || 0.82, t.fallTimer); @@ -1209,7 +1296,7 @@ class World { t.fearTimer = Math.max(t.fearTimer, 0.8); this.spawnFallEffect(t.x, t.y + t.radius * 0.55, clamp(damage / 18, 0.55, 1.6)); this.effects.push(new Effect("ring", hitX, hitY, { size: Math.max(20, ball.r * 1.25), life: 0.22, color: "rgba(210,75,65,0.50)" })); - if (this.relationNotice(t.id, ball.id || "ball", "fast-ball-hit", 2.8)) this.log(`${t.name}は超高速のボールに当たってダメージを受けた。`, "accident"); + if (this.relationNotice(t.id, ball.id || "ball", "fast-ball-hit", 2.8)) this.log(`${t.name}\u306f\u8d85\u9ad8\u901f\u306e\u30dc\u30fc\u30eb\u306b\u5f53\u305f\u3063\u3066\u30c0\u30e1\u30fc\u30b8\u3092\u53d7\u3051\u305f\u3002`, "accident"); ball.vx = (ball.vx || 0) * 0.58 - nx * Math.min(110, ballSpeed * 0.10); ball.vy = (ball.vy || 0) * 0.58 - ny * Math.min(110, ballSpeed * 0.10); ball.spinVelocity = clamp((ball.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * 8.5, -38, 38); @@ -1248,9 +1335,9 @@ class World { t.goodMode = playIntent || playful ? "smile" : t.goodMode; if (playIntent && now > (t.nextPlayBubbleAt || 0)) { t.nextPlayBubbleAt = now + rand(2.0, 3.8); - this.spawnBubble(t.x, t.y - t.radius * 1.15, pick(["!", "はう", "?" ]), "rgba(70,96,50,0.76)"); + this.spawnBubble(t.x, t.y - t.radius * 1.15, pick(["!", "\u306f\u3046", "?" ]), "rgba(70,96,50,0.76)"); } - if (playful && this.relationNotice(t.id, ball.id || "ball", "ball-kick", 16)) this.log(`${t.name}はボールを追いかけて弾いた。`, "observe"); + if (playful && this.relationNotice(t.id, ball.id || "ball", "ball-kick", 16)) this.log(`${t.name}\u306f\u30dc\u30fc\u30eb\u3092\u8ffd\u3044\u304b\u3051\u3066\u5f3e\u3044\u305f\u3002`, "observe"); this.effects.push(new Effect("ring", ball.x, ball.y, { size: Math.max(12, ball.r * 0.80), life: 0.20, color: "rgba(170,210,95,0.50)" })); } } @@ -1306,8 +1393,13 @@ class World { resolveFenceCollision(t) { if (!t || t.dead) return; const rr = Math.max(8, t.radius * 0.74); - for (const it of this.nearbyItems(t.x, t.y, rr + 92)) { - if (it.dead || (it.type !== "fence_v" && it.type !== "fence_h")) continue; + const maxChecks = CONFIG.maxFenceCollisionChecks ?? 18; + let checked = 0; + let pushed = false; + for (const it of this.nearbyItems(t.x, t.y, rr + 132)) { + if (checked >= maxChecks) break; + if (it.dead || !this.isFenceType(it.type)) continue; + checked += 1; const r = this.fenceRect(it); if (!r) continue; const cx = clamp(t.x, r.left, r.right); @@ -1327,12 +1419,20 @@ class World { else if (m === top) { dx = 0; dy = -1; d = 1; } else { dx = 0; dy = 1; d = 1; } } - const push = rr - d + 0.8; + const push = Math.min(rr - d + 0.8, Math.max(10, rr * 0.92)); t.x += dx / d * push; t.y += dy / d * push; + pushed = true; if (Math.abs(dx) > Math.abs(dy)) t.vx *= -0.18; else t.vy *= -0.18; } + if (pushed) { + const pad = CONFIG.worldPadding + Math.max(2, t.radius * 0.18); + t.x = clamp(t.x, pad, this.w - pad); + t.y = clamp(t.y, pad, this.h - pad); + t.vx = clamp(t.vx || 0, -130, 130); + t.vy = clamp(t.vy || 0, -130, 130); + } } @@ -1354,9 +1454,9 @@ class World { const cx = (x1 + x2) / 2; const cy = (y1 + y2) / 2; const maxD = Math.hypot(x2 - x1, y2 - y1) / 2 + 120; - for (const it of this.items) { - if (it.dead || (it.type !== "fence_v" && it.type !== "fence_h")) continue; - if (distXY(cx, cy, it.x, it.y) > maxD + 110) continue; + for (const it of this.nearbyItems(cx, cy, maxD + padding + 160)) { + if (it.dead || !this.isFenceType(it.type)) continue; + if (distXY(cx, cy, it.x, it.y) > maxD + padding + 130) continue; const base = this.fenceRect(it); if (!base) continue; const r = { left: base.left - padding, right: base.right + padding, top: base.top - padding, bottom: base.bottom + padding }; @@ -1523,10 +1623,19 @@ class World { pruneOldest("trace", CONFIG.traceLimit ?? 64); pruneOldest("splat", CONFIG.splatLimit ?? 48); pruneOldest("grass", this.grassLimit ? this.grassLimit() : (CONFIG.grassLimit ?? 96)); + const fenceLimit = this.fenceLimit ? this.fenceLimit() : (CONFIG.fenceLimit ?? 72); + const liveFences = this.liveFenceCount ? this.liveFenceCount() : 0; + if (liveFences > fenceLimit) { + const victims = this.items + .filter(it => this.isFenceType?.(it.type) && !it.dead) + .sort((a, b) => (b.age || 0) - (a.age || 0)) + .slice(0, liveFences - fenceLimit); + for (const it of victims) it.amount = 0; + } const limit = CONFIG.itemLimit ?? Infinity; if (!Number.isFinite(limit) || this.items.length <= limit) return; - const priority = { trace: 1, splat: 2, zunchi: 3, water: 4, grass: 5, sweet: 6, firecracker: 7 }; + const priority = { trace: 1, splat: 2, zunchi: 3, water: 4, grass: 5, sweet: 6, firecracker: 7, fence_v: 8, fence_h: 8 }; const removable = this.items .filter(it => !it.dead && (priority[it.type] || 99) < 99) .sort((a, b) => (priority[a.type] || 99) - (priority[b.type] || 99) || (b.age || 0) - (a.age || 0)); @@ -1618,12 +1727,12 @@ class World { audio.phase(); } - if (this.tarinai.length > 0 && this.tarinai.length <= 10 && Math.random() < dt * 0.022) { + if (this.tarinai.length > 0 && this.tarinai.length <= 10 && this.time - (this.lastBirthAt || -999) > 12 && Math.random() < dt * 0.022) { const spot = this.edgeSpawnPoint(null, null, 42, true); const t = this.addTarinai({ x: spot.x, y: spot.y, vx: spot.vx, vy: spot.vy, type: baseSpriteId(), entryTimer: 2.0 }); t.wanderAngle = spot.angle; t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.35); - this.log(`${t.name}が画面外から迷い込んできた。`); + this.log(`${t.name}\u304c\u753b\u9762\u5916\u304b\u3089\u8ff7\u3044\u8fbc\u3093\u3067\u304d\u305f\u3002`); } @@ -1677,6 +1786,23 @@ class World { this.log(`\u5929\u6c17: ${weatherLabel(this.weather)}`); } + fencePlacementBlocked(item) { + if (!item || !this.isFenceType(item.type)) return false; + const rect = this.fenceRect(item); + if (!rect) return false; + const margin = Math.max(6, (item.r || 42) * 0.24); + if (rect.left < CONFIG.worldPadding || rect.top < CONFIG.worldPadding || rect.right > this.w - CONFIG.worldPadding || rect.bottom > this.h - CONFIG.worldPadding) return true; + const searchRadius = Math.max(rect.right - rect.left, rect.bottom - rect.top) * 0.5 + 72; + for (const it of this.nearbyItems(item.x, item.y, searchRadius)) { + if (!it || it.dead || !this.isFenceType(it.type)) continue; + const r = this.fenceRect(it); + if (!r) continue; + const separated = rect.right + margin < r.left || rect.left - margin > r.right || rect.bottom + margin < r.top || rect.top - margin > r.bottom; + if (!separated) return true; + } + return false; + } + findGrabTargetAt(x, y) { let foundT = null; for (let i = this.tarinai.length - 1; i >= 0; i--) { @@ -1702,9 +1828,21 @@ class World { placeItem(item, dropped = true) { if (!item) return null; if (item.type === "grass" && (this.itemCounts?.grass || 0) >= (this.grassLimit ? this.grassLimit() : (CONFIG.grassLimit ?? 96))) { - showToast("このフィールドの草の上限に達しています。"); + showToast("\u3053\u306e\u30d5\u30a3\u30fc\u30eb\u30c9\u306e\u8349\u306e\u4e0a\u9650\u306b\u9054\u3057\u3066\u3044\u307e\u3059\u3002"); return null; } + if (this.isFenceType(item.type)) { + this.compactItems(); + this.updateItemCounts(); + if (this.liveFenceCount() >= this.fenceLimit()) { + showToast("\u67f5\u304c\u591a\u3059\u304e\u307e\u3059\u3002\u5148\u306b\u4e00\u90e8\u3092\u524a\u9664\u3057\u3066\u304f\u3060\u3055\u3044\u3002"); + return null; + } + if (this.fencePlacementBlocked(item)) { + showToast("\u305d\u3053\u306b\u306f\u67f5\u3092\u8ffd\u52a0\u3067\u304d\u307e\u305b\u3093\u3002"); + return null; + } + } if (dropped) { item.dropMax = item.type === "stone" ? 0.72 : 0.58; item.dropTimer = item.dropMax; @@ -1743,9 +1881,9 @@ class World { const target = [...this.tarinai].reverse().find(t => t.contains(x, y)); if (target) { target.poke(); - if (!target.dead) this.log(`${target.name}はつつかれ、たりないことを覚えた。`); + if (!target.dead) this.log(`${target.name}\u306f\u3064\u3064\u304b\u308c\u3001\u305f\u308a\u306a\u3044\u3053\u3068\u3092\u899a\u3048\u305f\u3002`); } else { - this.log("空気をつついた。そこには何もたりなかった。"); + this.log("\u7a7a\u6c17\u3092\u3064\u3064\u3044\u305f\u3002\u305d\u3053\u306b\u306f\u4f55\u3082\u305f\u308a\u306a\u304b\u3063\u305f\u3002"); } return; } @@ -1774,7 +1912,7 @@ class World { this.compactItems(); this.updateItemCounts(); this.rebuildSpatial(); - const label = { zunchi: "\u305a\u3093\u3061", food: "\u98df\u3079\u7269", sweet: "\u305a\u3093\u3060\u9905", love_mochi: "\u5a9a\u85ac\u9905", fight_mochi: "\u55a7\u5629\u9905", water: "\u6c34", grass: "\u8349", stone: "\u77f3", bed: "\u5e72\u8349\u5bdd\u5e8a", ball: "\u30dc\u30fc\u30eb", firecracker: "\u7206\u7af9", fence_v: "\u7e26\u306e\u67f5", fence_h: "\u6a2a\u306e\u67f5", trace: "\u8db3\u8de1", splat: "\u3057\u3076\u304d" }[best.type] || best.type; + const label = { zunchi: "\u305a\u3093\u3061", food: "\u98df\u3079\u7269", sweet: "\u305a\u3093\u3060\u9905", love_mochi: "\u3078\u3053\u9905", fight_mochi: "\u3051\u3093\u304b\u9905", water: "\u6c34", grass: "\u8349", stone: "\u77f3", bed: "\u5e72\u8349\u5bdd\u5e8a", ball: "\u30dc\u30fc\u30eb", firecracker: "\u7206\u7af9", fence_v: "\u7e26\u306e\u67f5", fence_h: "\u6a2a\u306e\u67f5", trace: "\u8db3\u8de1", splat: "\u3057\u3076\u304d" }[best.type] || best.type; this.log(`${label}\u3092\u524a\u9664\u3057\u305f\u3002`, "observe"); return; } @@ -1795,10 +1933,20 @@ class World { px = spot.x; py = spot.y; } - const placed = this.placeItem(this.applyToolSize(new Item(itemType, px, py)), true); + const rawItem = this.applyToolSize(new Item(itemType, px, py)); + if (this.isFenceType(rawItem.type)) { + const rect = this.fenceRect(rawItem); + if (rect) { + const dx = Math.max(CONFIG.worldPadding - rect.left, 0) - Math.max(rect.right - (this.w - CONFIG.worldPadding), 0); + const dy = Math.max(CONFIG.worldPadding - rect.top, 0) - Math.max(rect.bottom - (this.h - CONFIG.worldPadding), 0); + rawItem.x += dx; + rawItem.y += dy; + } + } + const placed = this.placeItem(rawItem, true); if (!placed) return; - const label = { zunchi: "ずんち", food: "食べ物", sweet: "ずんだ餅", love_mochi: "媚薬餅", fight_mochi: "喧嘩餅", water: "水", grass: "草", stone: "石", bed: "干草寝床", ball: "ボール", firecracker: "爆竹", fence_v: "縦の柵", fence_h: "横の柵" }[itemType]; - this.log(`${label}を配置した。`); + const label = { zunchi: "\u305a\u3093\u3061", food: "\u98df\u3079\u7269", sweet: "\u305a\u3093\u3060\u9905", love_mochi: "\u3078\u3053\u9905", fight_mochi: "\u3051\u3093\u304b\u9905", water: "\u6c34", grass: "\u8349", stone: "\u77f3", bed: "\u5e72\u8349\u5bdd\u5e8a", ball: "\u30dc\u30fc\u30eb", firecracker: "\u7206\u7af9", fence_v: "\u7e26\u306e\u67f5", fence_h: "\u6a2a\u306e\u67f5" }[itemType]; + this.log(`${label}\u3092\u914d\u7f6e\u3057\u305f\u3002`); } } @@ -1807,7 +1955,7 @@ class World { if (s.includes("firecracker") || s.includes("accident") || s.includes("drop") || s.includes("\u7206\u7af9") || s.includes("\u843d\u3061") || s.includes("\u4e8b\u6545") || s.includes("\u5439\u304d\u98db")) return "accident"; if (s.includes("fight") || s.includes("lost to") || s.includes("poked") || s.includes("\u55a7\u5629") || s.includes("\u8ca0\u3051") || s.includes("\u3064\u3064\u304b")) return "fight"; if (s.includes("appeared") || s.includes("wandered in") || s.includes("birth") || s.includes("\u73fe\u308c") || s.includes("\u8ff7\u3044\u8fbc")) return "birth"; - if (s.includes("dead") || s.includes("trace") || s.includes("reason:") || s.includes("\u6b7b\u4ea1") || s.includes("\u75d5\u8de1") || s.includes("\u7406\u7531:")) return "death"; + if (s.includes("dead") || s.includes("trace") || s.includes("reason:") || s.includes("\u6b7b\u4ea1") || s.includes("\u75d5\u8de1") || s.includes("\u7406\u7531:") || s.includes("\u6b7b\u56e0:")) return "death"; if (s.includes("weather") || s.includes("day") || s.includes("night") || s.includes("morning") || s.includes("evening") || s.includes("\u5929\u6c17") || s.includes("\u65e5\u76ee") || s.includes("\u591c") || s.includes("\u671d") || s.includes("\u5915")) return "weather"; if (s.includes("zunda") || s.includes("food") || s.includes("eat") || s.includes("appreciated") || s.includes("\u305a\u3093\u3060") || s.includes("\u98df\u3079\u7269") || s.includes("\u5473\u308f")) return "food"; if (s.includes("grass") || s.includes("soil") || s.includes("\u8349") || s.includes("\u571f")) return "grass"; @@ -1828,7 +1976,7 @@ class World { serialize() { return { w: this.w, h: this.h, time: this.time, day: this.day, deadCount: this.deadCount, - maxGeneration: this.maxGeneration, + maxGeneration: this.maxGeneration, lastBirthAt: this.lastBirthAt || -999, tarinai: this.tarinai.map(t => t.serialize()), items: this.items.map(i => i.serialize()), logs: this.logs.slice(0, 30), @@ -1853,6 +2001,7 @@ class World { this.toolSize = data.toolSize || this.toolSize || "medium"; this.toolSizes = data.toolSizes || this.toolSizes || {}; this.maxGeneration = data.maxGeneration || 1; + this.lastBirthAt = Number.isFinite(data.lastBirthAt) ? data.lastBirthAt : -999; this.fieldType = FIELD_TYPES?.[data.fieldType]?.id || "garden"; this.fieldZoom = Number(data.fieldZoom) || 1; this.cameraX = Number(data.cameraX) || 0;