Compare commits
3 commits
9b84ea95d6
...
e6507288ce
| Author | SHA1 | Date | |
|---|---|---|---|
| e6507288ce | |||
| 0e8ece2dc0 | |||
| 6d2537e3e3 |
47
README.md
|
|
@ -13,7 +13,7 @@ Chrome / Edge / Firefox / Safari の近年版を想定しています。
|
|||
## ゲーム内容
|
||||
|
||||
「たりない」たちが観察皿の中で勝手に暮らします。
|
||||
基本は眺めるだけですが、餌・水・草・石・寝床・鏡などを配置して軽く介入できます。
|
||||
基本は眺めるだけですが、餌・水・草・石・寝床・などを配置して軽く介入できます。
|
||||
|
||||
## 主な要素
|
||||
|
||||
|
|
@ -29,7 +29,7 @@ Chrome / Edge / Firefox / Safari の近年版を想定しています。
|
|||
## 操作
|
||||
|
||||
- 観察:個体クリックで詳細表示
|
||||
- ふつうの餌 / あまい餌 / 水滴 / 草 / ぬくい石 / ふわふわ / 鏡:キャンバスクリックで配置
|
||||
- ふつうの餌 / あまい餌 / 水滴 / 草 / ぬくい石 / ふわふわ:キャンバスクリックで配置
|
||||
- つつく:個体をクリックして一時的に不安を上げる
|
||||
- 迷い込み:指定位置に新しいたりないを追加
|
||||
|
||||
|
|
@ -165,3 +165,46 @@ tarinai_colony_game/
|
|||
- 食事パーティクル頻度を抑制
|
||||
- 死亡後の緑のしぶきが時間経過でゆっくり薄く消えるように変更
|
||||
- 保存キーは `tarinai-colony-save-v9`
|
||||
|
||||
## 更新内容(v10)
|
||||
|
||||
- 新規スプライト5枚を追加
|
||||
- `10_hurt`: 痛い時・喧嘩中に打たれた時
|
||||
- `11_weak`: 低体力時
|
||||
- `12_back`: 奥方向へ進む時
|
||||
- `13_flee`: 喧嘩に負けて敗走する時
|
||||
- `14_zunda_eat`: ずんだ餅を食べる時
|
||||
- 追加画像はすべて透明PNG化し、`512x468` のキャンバスへ正規化
|
||||
- 追加スプライトは行動専用扱いにし、通常個体の基本タイプとしてはランダム出現しないように調整
|
||||
|
||||
|
||||
## 更新内容(v11)
|
||||
|
||||
- 個体ごとの性格を追加
|
||||
- 臆病、食いしん坊、眠たがり、寂しがり、怒りっぽい、マイペース
|
||||
- 性格は睡眠、喧嘩、ずんだ餅反応、恐怖、対個体行動へ影響
|
||||
- 選択パネルに観察理由を追加
|
||||
- 現在の行動理由
|
||||
- 対象
|
||||
- 仲の良い個体
|
||||
- 避けている個体
|
||||
- 喧嘩記憶
|
||||
- 関係性と喧嘩記憶を追加
|
||||
- 近くで過ごす個体同士は親和度が上がる
|
||||
- 喧嘩に負けた相手を記憶し、しばらく避けやすくなる
|
||||
- 勝敗はログと選択パネルに反映
|
||||
- hay bed に快適度・混雑ペナルティを追加
|
||||
- 空いているベッドを選びやすくなる
|
||||
- 混雑時は回復効率が下がり、ストレスが上がる
|
||||
- 草の生態系を強化
|
||||
- 雨、水、ずんち由来の肥沃地で育ちやすくなる
|
||||
- 強い晴天で水分がないと弱りやすくなる
|
||||
- 草の周辺ではストレス・恐怖が少し下がる
|
||||
- 表情スプライトの優先順位を整理
|
||||
- ずんだ餅、被ダメージ、敗走、恐怖、低体力、睡眠、喧嘩、奥移動、通常の順に競合解決
|
||||
- ログを種類別に色分け
|
||||
- 誕生、死亡、喧嘩、食事、草、環境、関係、観察
|
||||
- 喧嘩で転ぶ演出を追加
|
||||
- 打撃時に低確率で転ぶ
|
||||
- 敗北時に転倒し、土煙が出る
|
||||
- 保存キーは `tarinai-colony-save-v11`
|
||||
|
|
|
|||
BIN
assets/objects/zunchi.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
assets/objects/zunchi_02.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
assets/sounds/firecracker_explosion.mp3
Normal file
BIN
assets/sounds/voice_001_hau.wav
Normal file
BIN
assets/sounds/voice_002_flee.wav
Normal file
BIN
assets/sounds/voice_003_po.wav
Normal file
BIN
assets/sounds/voice_004_eat.wav
Normal file
BIN
assets/sounds/voice_005_stress.wav
Normal file
BIN
assets/sounds/voice_006_sleep.wav
Normal file
BIN
assets/sprites/_contact_sheet.png
Normal file
|
After Width: | Height: | Size: 389 KiB |
175
assets/sprites/manifest.json
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
[
|
||||
{
|
||||
"id": "smile",
|
||||
"file": "assets/sprites/tarinai_01_smile.png",
|
||||
"width": 512,
|
||||
"height": 468
|
||||
},
|
||||
{
|
||||
"id": "angry",
|
||||
"file": "assets/sprites/tarinai_02_angry.png",
|
||||
"width": 512,
|
||||
"height": 464
|
||||
},
|
||||
{
|
||||
"id": "teary",
|
||||
"file": "assets/sprites/tarinai_03_teary.png",
|
||||
"width": 512,
|
||||
"height": 472
|
||||
},
|
||||
{
|
||||
"id": "jito",
|
||||
"file": "assets/sprites/tarinai_04_jito.png",
|
||||
"width": 512,
|
||||
"height": 487
|
||||
},
|
||||
{
|
||||
"id": "drool",
|
||||
"file": "assets/sprites/tarinai_05_drool.png",
|
||||
"width": 512,
|
||||
"height": 461
|
||||
},
|
||||
{
|
||||
"id": "cry",
|
||||
"file": "assets/sprites/tarinai_06_cry.png",
|
||||
"width": 512,
|
||||
"height": 478
|
||||
},
|
||||
{
|
||||
"id": "sleep",
|
||||
"file": "assets/sprites/tarinai_07_sleep.png",
|
||||
"width": 511,
|
||||
"height": 475
|
||||
},
|
||||
{
|
||||
"id": "pokan",
|
||||
"file": "assets/sprites/tarinai_08_pokan.png",
|
||||
"width": 512,
|
||||
"height": 469
|
||||
},
|
||||
{
|
||||
"id": "stretch",
|
||||
"file": "assets/sprites/tarinai_09_stretch.png",
|
||||
"width": 512,
|
||||
"height": 384
|
||||
},
|
||||
{
|
||||
"id": "hurt",
|
||||
"file": "assets/sprites/tarinai_10_hurt.png",
|
||||
"width": 512,
|
||||
"height": 468,
|
||||
"actionOnly": true
|
||||
},
|
||||
{
|
||||
"id": "weak",
|
||||
"file": "assets/sprites/tarinai_11_weak.png",
|
||||
"width": 512,
|
||||
"height": 468,
|
||||
"actionOnly": true
|
||||
},
|
||||
{
|
||||
"id": "back",
|
||||
"file": "assets/sprites/tarinai_12_back.png",
|
||||
"width": 512,
|
||||
"height": 468,
|
||||
"actionOnly": true
|
||||
},
|
||||
{
|
||||
"id": "flee",
|
||||
"file": "assets/sprites/tarinai_13_flee.png",
|
||||
"width": 512,
|
||||
"height": 468,
|
||||
"actionOnly": true
|
||||
},
|
||||
{
|
||||
"id": "zunda_eat",
|
||||
"file": "assets/sprites/tarinai_14_zunda_eat.png",
|
||||
"width": 512,
|
||||
"height": 468,
|
||||
"actionOnly": true
|
||||
},
|
||||
{
|
||||
"id": "hurt2",
|
||||
"file": "assets/sprites/tarinai_15_hurt2.png",
|
||||
"width": 512,
|
||||
"height": 468,
|
||||
"actionOnly": true
|
||||
},
|
||||
{
|
||||
"id": "normal_smirk",
|
||||
"file": "assets/sprites/tarinai_16_normal_smirk.png",
|
||||
"width": 512,
|
||||
"height": 468,
|
||||
"displayOnly": true
|
||||
},
|
||||
{
|
||||
"id": "normal_tongue",
|
||||
"file": "assets/sprites/tarinai_17_normal_tongue.png",
|
||||
"width": 512,
|
||||
"height": 468,
|
||||
"displayOnly": true
|
||||
},
|
||||
{
|
||||
"id": "fear",
|
||||
"file": "assets/sprites/tarinai_18_fear.png",
|
||||
"width": 512,
|
||||
"height": 468,
|
||||
"actionOnly": true
|
||||
},
|
||||
{
|
||||
"id": "flee_fear2",
|
||||
"file": "assets/sprites/tarinai_19_flee_fear2.png",
|
||||
"width": 512,
|
||||
"height": 468,
|
||||
"actionOnly": true
|
||||
},
|
||||
{
|
||||
"id": "sleep2",
|
||||
"file": "assets/sprites/tarinai_20_sleep2.png",
|
||||
"width": 512,
|
||||
"height": 468,
|
||||
"actionOnly": true
|
||||
},
|
||||
{
|
||||
"id": "normal_happy",
|
||||
"file": "assets/sprites/tarinai_21_normal_happy.png",
|
||||
"width": 512,
|
||||
"height": 468,
|
||||
"displayOnly": true
|
||||
},
|
||||
{
|
||||
"id": "fear_blue",
|
||||
"file": "assets/sprites/tarinai_22_fear_blue.png",
|
||||
"width": 512,
|
||||
"height": 468,
|
||||
"actionOnly": true
|
||||
},
|
||||
{
|
||||
"id": "fear_cry",
|
||||
"file": "assets/sprites/tarinai_23_fear_cry.png",
|
||||
"width": 512,
|
||||
"height": 468,
|
||||
"actionOnly": true
|
||||
},
|
||||
{
|
||||
"id": "stress_dizzy",
|
||||
"file": "assets/sprites/tarinai_24_stress_dizzy.png",
|
||||
"width": 512,
|
||||
"height": 468,
|
||||
"actionOnly": true
|
||||
},
|
||||
{
|
||||
"id": "stress_sweat",
|
||||
"file": "assets/sprites/tarinai_25_stress_sweat.png",
|
||||
"width": 512,
|
||||
"height": 468,
|
||||
"actionOnly": true
|
||||
},
|
||||
{
|
||||
"id": "intimidate",
|
||||
"file": "assets/sprites/tarinai_26_intimidate.png",
|
||||
"width": 512,
|
||||
"height": 468,
|
||||
"actionOnly": true
|
||||
}
|
||||
]
|
||||
BIN
assets/sprites/tarinai_01_smile.png
Normal file
|
After Width: | Height: | Size: 163 KiB |
BIN
assets/sprites/tarinai_02_angry.png
Normal file
|
After Width: | Height: | Size: 162 KiB |
BIN
assets/sprites/tarinai_03_teary.png
Normal file
|
After Width: | Height: | Size: 169 KiB |
BIN
assets/sprites/tarinai_04_jito.png
Normal file
|
After Width: | Height: | Size: 166 KiB |
BIN
assets/sprites/tarinai_05_drool.png
Normal file
|
After Width: | Height: | Size: 162 KiB |
BIN
assets/sprites/tarinai_06_cry.png
Normal file
|
After Width: | Height: | Size: 191 KiB |
BIN
assets/sprites/tarinai_07_sleep.png
Normal file
|
After Width: | Height: | Size: 162 KiB |
BIN
assets/sprites/tarinai_08_pokan.png
Normal file
|
After Width: | Height: | Size: 166 KiB |
BIN
assets/sprites/tarinai_09_stretch.png
Normal file
|
After Width: | Height: | Size: 137 KiB |
BIN
assets/sprites/tarinai_10_hurt.png
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
assets/sprites/tarinai_11_weak.png
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
assets/sprites/tarinai_12_back.png
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
assets/sprites/tarinai_13_flee.png
Normal file
|
After Width: | Height: | Size: 89 KiB |
BIN
assets/sprites/tarinai_14_zunda_eat.png
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
assets/sprites/tarinai_15_hurt2.png
Normal file
|
After Width: | Height: | Size: 91 KiB |
BIN
assets/sprites/tarinai_16_normal_smirk.png
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
assets/sprites/tarinai_17_normal_tongue.png
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
assets/sprites/tarinai_18_fear.png
Normal file
|
After Width: | Height: | Size: 97 KiB |
BIN
assets/sprites/tarinai_19_flee_fear2.png
Normal file
|
After Width: | Height: | Size: 99 KiB |
BIN
assets/sprites/tarinai_20_sleep2.png
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
assets/sprites/tarinai_21_normal_happy.png
Normal file
|
After Width: | Height: | Size: 90 KiB |
BIN
assets/sprites/tarinai_22_fear_blue.png
Normal file
|
After Width: | Height: | Size: 93 KiB |
BIN
assets/sprites/tarinai_22_stress_blue.png
Normal file
|
After Width: | Height: | Size: 106 KiB |
BIN
assets/sprites/tarinai_23_fear_cry.png
Normal file
|
After Width: | Height: | Size: 96 KiB |
BIN
assets/sprites/tarinai_23_stress_cry.png
Normal file
|
After Width: | Height: | Size: 109 KiB |
BIN
assets/sprites/tarinai_24_stress_dizzy.png
Normal file
|
After Width: | Height: | Size: 98 KiB |
BIN
assets/sprites/tarinai_25_stress_sweat.png
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
assets/sprites/tarinai_26_intimidate.png
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
assets/sprites/tarinai_27_birth_ritual.png
Normal file
|
After Width: | Height: | Size: 145 KiB |
BIN
assets/ui/tool_bed.png
Normal file
|
After Width: | Height: | Size: 527 B |
BIN
assets/ui/tool_delete.png
Normal file
|
After Width: | Height: | Size: 321 B |
BIN
assets/ui/tool_fence_h.png
Normal file
|
After Width: | Height: | Size: 786 B |
BIN
assets/ui/tool_fence_v.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
assets/ui/tool_firecracker.png
Normal file
|
After Width: | Height: | Size: 1 KiB |
BIN
assets/ui/tool_grass.png
Normal file
|
After Width: | Height: | Size: 452 B |
BIN
assets/ui/tool_new.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
assets/ui/tool_observe.png
Normal file
|
After Width: | Height: | Size: 981 B |
BIN
assets/ui/tool_poke.png
Normal file
|
After Width: | Height: | Size: 770 B |
BIN
assets/ui/tool_stone.png
Normal file
|
After Width: | Height: | Size: 404 B |
BIN
assets/ui/tool_sweet.png
Normal file
|
After Width: | Height: | Size: 489 B |
BIN
assets/ui/tool_water.png
Normal file
|
After Width: | Height: | Size: 439 B |
BIN
assets/ui/tool_zunchi.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
870
css/styles.css
Normal file
|
|
@ -0,0 +1,870 @@
|
|||
@import url("https://fonts.googleapis.com/css2?family=Zen+Maru+Gothic:wght@400;500;700&family=M+PLUS+Rounded+1c:wght@400;500;700&family=Yomogi&display=swap");
|
||||
|
||||
:root {
|
||||
--bg: #f7f5ec;
|
||||
--panel: rgba(255,251,245,0.88);
|
||||
--ink: #2a241d;
|
||||
--muted: #756a5e;
|
||||
--line: rgba(84, 68, 48, 0.18);
|
||||
--accent: #9ce84b;
|
||||
--accent-2: #f5a8b7;
|
||||
--danger: #b64545;
|
||||
--shadow: 0 18px 50px rgba(64, 48, 32, 0.14);
|
||||
--radius: 18px;
|
||||
--bubble-font: "Yomogi", "Zen Maru Gothic", "M PLUS Rounded 1c", ui-rounded, sans-serif;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Zen Maru Gothic", "M PLUS Rounded 1c", ui-rounded, "Hiragino Maru Gothic ProN", "Yu Gothic", "Meiryo", system-ui, sans-serif;
|
||||
background:
|
||||
radial-gradient(circle at 12% 0%, rgba(168, 228, 113, 0.28), transparent 33%),
|
||||
radial-gradient(circle at 88% 8%, rgba(255, 222, 147, 0.24), transparent 28%),
|
||||
radial-gradient(circle at 82% 90%, rgba(126, 181, 120, 0.16), transparent 34%),
|
||||
linear-gradient(180deg, #dff0ff 0%, #edf8ff 12%, #f9f6eb 46%, var(--bg) 100%);
|
||||
color: var(--ink);
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
#app {
|
||||
width: min(1480px, 100vw);
|
||||
margin: 0 auto;
|
||||
padding: 18px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 3px;
|
||||
font-size: clamp(24px, 3.4vw, 42px);
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.top-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.btn, .tool {
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255,255,255,0.76);
|
||||
border-radius: 999px;
|
||||
padding: 9px 13px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 14px rgba(80,60,40,0.06);
|
||||
transition: transform 130ms ease, background 130ms ease, border-color 130ms ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.btn:hover, .tool:hover { transform: translateY(-1px); background: #fff; }
|
||||
.btn:active, .tool:active { transform: translateY(1px); }
|
||||
|
||||
.btn.primary {
|
||||
background: linear-gradient(180deg, #a9ef5d, #8bdc3f);
|
||||
border-color: rgba(89, 128, 47, 0.28);
|
||||
}
|
||||
|
||||
.btn.danger {
|
||||
color: #7a2828;
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(520px, 1fr) 380px;
|
||||
gap: 16px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.stage-wrap {
|
||||
position: relative;
|
||||
background: linear-gradient(180deg, rgba(255,255,255,0.46), rgba(246, 240, 228, 0.56));
|
||||
border: 1px solid rgba(84, 68, 48, 0.12);
|
||||
border-radius: calc(var(--radius) + 8px);
|
||||
overflow: hidden;
|
||||
box-shadow: 0 16px 42px rgba(42, 45, 80, 0.12), inset 0 0 0 1px rgba(255,255,255,0.24);
|
||||
min-height: var(--field-min-height, 620px);
|
||||
}
|
||||
|
||||
#gameCanvas {
|
||||
width: 100%;
|
||||
height: var(--field-height, calc(100vh - 140px));
|
||||
min-height: var(--field-min-height, 620px);
|
||||
display: block;
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.field-dialog {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 30;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 18px;
|
||||
background: rgba(34, 29, 22, 0.38);
|
||||
backdrop-filter: blur(3px);
|
||||
}
|
||||
.field-dialog.hidden { display: none; }
|
||||
.field-dialog-panel {
|
||||
width: min(440px, 100%);
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
background: rgba(255, 252, 245, 0.96);
|
||||
border: 1px solid rgba(84,68,48,0.16);
|
||||
box-shadow: 0 18px 46px rgba(40, 32, 24, 0.22);
|
||||
}
|
||||
.field-dialog-panel h2 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 17px;
|
||||
}
|
||||
.field-choice-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
.field-choice {
|
||||
min-height: 74px;
|
||||
border: 1px solid rgba(84,68,48,0.16);
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(180deg, rgba(255,255,255,0.86), rgba(241,235,221,0.72));
|
||||
cursor: pointer;
|
||||
color: var(--ink);
|
||||
}
|
||||
.field-choice:hover { border-color: rgba(112,178,66,0.45); }
|
||||
.field-choice strong, .field-choice span { display: block; }
|
||||
.field-choice strong { font-size: 15px; }
|
||||
.field-choice span { margin-top: 4px; color: var(--muted); font-size: 12px; }
|
||||
.field-dialog-actions {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 22px;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(42,36,29,0.86);
|
||||
color: white;
|
||||
padding: 10px 16px;
|
||||
border-radius: 999px;
|
||||
font-size: 14px;
|
||||
pointer-events: none;
|
||||
opacity: 1;
|
||||
transition: opacity 200ms ease, transform 200ms ease;
|
||||
}
|
||||
.toast.hidden { opacity: 0; transform: translateX(-50%) translateY(8px); }
|
||||
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
height: calc(100vh - 120px);
|
||||
max-height: calc(100vh - 120px);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding-right: 3px;
|
||||
}
|
||||
|
||||
.card {
|
||||
position: relative;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 14px;
|
||||
box-shadow: 0 10px 30px rgba(64, 48, 32, 0.08);
|
||||
backdrop-filter: blur(10px);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 17px;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.tool-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0,1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tool {
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
justify-content: flex-start;
|
||||
padding: 8px 8px 8px 42px;
|
||||
min-height: 46px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background-repeat: no-repeat;
|
||||
background-position: 7px center, center;
|
||||
background-size: 30px 30px, cover;
|
||||
}
|
||||
.tool::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 7px;
|
||||
top: 50%;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
transform: translateY(-50%);
|
||||
background: var(--tool-icon) center / contain no-repeat;
|
||||
opacity: 0.92;
|
||||
filter: drop-shadow(0 2px 3px rgba(52,40,26,0.12));
|
||||
}
|
||||
.tool::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--tool-icon) right -8px center / 54px 54px no-repeat;
|
||||
opacity: 0.10;
|
||||
pointer-events: none;
|
||||
}
|
||||
.tool span { min-width: 16px; text-align: center; color: rgba(72,58,43,0.64); font-size: 10px; }
|
||||
.tool[data-tool="observe"] { --tool-icon: url("../assets/ui/tool_observe.png"); }
|
||||
.tool[data-tool="zunchi"] { --tool-icon: url("../assets/ui/tool_zunchi.png"); }
|
||||
.tool[data-tool="sweet"] { --tool-icon: url("../assets/ui/tool_sweet.png"); }
|
||||
.tool[data-tool="water"] { --tool-icon: url("../assets/ui/tool_water.png"); }
|
||||
.tool[data-tool="grass"] { --tool-icon: url("../assets/ui/tool_grass.png"); }
|
||||
.tool[data-tool="stone"] { --tool-icon: url("../assets/ui/tool_stone.png"); }
|
||||
.tool[data-tool="firecracker"] { --tool-icon: url("../assets/ui/tool_firecracker.png"); }
|
||||
.tool[data-tool="fence_v"] { --tool-icon: url("../assets/ui/tool_fence_v.png"); }
|
||||
.tool[data-tool="fence_h"] { --tool-icon: url("../assets/ui/tool_fence_h.png"); }
|
||||
.tool[data-tool="bed"] { --tool-icon: url("../assets/ui/tool_bed.png"); }
|
||||
.tool[data-tool="poke"] { --tool-icon: url("../assets/ui/tool_poke.png"); }
|
||||
.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 {
|
||||
background: linear-gradient(180deg, #ffffff, #efffde);
|
||||
border-color: rgba(103, 178, 44, 0.52);
|
||||
box-shadow: inset 0 0 0 1px rgba(156,232,75,0.38), 0 8px 20px rgba(117, 166, 64, 0.13);
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
margin: 10px 0 0;
|
||||
}
|
||||
|
||||
.tool-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.tool-head h2 { margin-bottom: 0; }
|
||||
.btn.mini { padding: 6px 10px; font-size: 12px; }
|
||||
.tool-body { margin-top: 12px; }
|
||||
.tool-palette { display: flex; flex-direction: column; gap: 9px; }
|
||||
.tool-category {
|
||||
border: 1px solid rgba(84,68,48,0.10);
|
||||
border-radius: 14px;
|
||||
background: rgba(255,255,255,0.34);
|
||||
overflow: hidden;
|
||||
}
|
||||
.tool-category-toggle {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
border-bottom: 1px solid rgba(84,68,48,0.08);
|
||||
background: rgba(255,252,244,0.66);
|
||||
padding: 8px 10px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
}
|
||||
.tool-category-toggle::before { content: "▾"; display: inline-block; width: 16px; color: var(--muted); }
|
||||
.tool-category.collapsed .tool-category-toggle::before { content: "▸"; }
|
||||
.tool-category-body { padding: 8px; }
|
||||
.tool-category.collapsed .tool-category-body { display: none; }
|
||||
.tool-size-badge {
|
||||
position: absolute;
|
||||
right: 7px;
|
||||
bottom: 6px;
|
||||
min-width: 20px;
|
||||
padding: 2px 5px;
|
||||
border-radius: 999px;
|
||||
background: rgba(42,36,29,0.68);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
text-align: center;
|
||||
box-shadow: 0 1px 4px rgba(42,36,29,0.16);
|
||||
pointer-events: none;
|
||||
}
|
||||
.chart-tabs { display: grid; grid-template-columns: repeat(4, 1fr); gap: 5px; margin: 3px 0 8px; }
|
||||
.chart-tabs button { border: 1px solid var(--line); background: rgba(255,255,255,0.62); border-radius: 999px; padding: 5px 4px; font-size: 11px; cursor: pointer; }
|
||||
.chart-tabs button.active { background: #efffde; border-color: rgba(103,178,44,0.46); font-weight: 700; }
|
||||
.log-accident { border-left-color: rgba(226, 136, 54, 0.76); }
|
||||
|
||||
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.stat {
|
||||
background: linear-gradient(180deg, rgba(255,255,255,0.58), rgba(246, 240, 228, 0.72));
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 13px;
|
||||
padding: 9px 8px;
|
||||
text-align: center;
|
||||
}
|
||||
.stat span { display: block; font-size: 11px; color: var(--muted); }
|
||||
.stat strong { font-size: 18px; }
|
||||
|
||||
.meter-row {
|
||||
display: grid;
|
||||
grid-template-columns: 72px 1fr;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
margin: 6px 0;
|
||||
}
|
||||
meter {
|
||||
width: 100%;
|
||||
height: 12px;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.selected-info {
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.selected-info.empty {
|
||||
color: var(--muted);
|
||||
}
|
||||
.badge {
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
border-radius: 999px;
|
||||
padding: 5px 8px;
|
||||
background: #f1f8e9;
|
||||
border: 1px solid rgba(103, 178, 44, 0.28);
|
||||
margin: 0 4px 4px 0;
|
||||
}
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
border-bottom: 1px dashed rgba(84,68,48,0.16);
|
||||
padding: 5px 0;
|
||||
}
|
||||
.info-row span:first-child { color: var(--muted); }
|
||||
|
||||
.log-card { min-height: 260px; flex: 0 0 auto; display: flex; flex-direction: column; }
|
||||
.log {
|
||||
max-height: 360px;
|
||||
min-height: 170px;
|
||||
flex: 1 1 auto;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.log-entry {
|
||||
padding: 8px 9px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255,252,246,0.82);
|
||||
border: 1px solid rgba(84,68,48,0.12);
|
||||
border-left: 4px solid rgba(118, 101, 74, 0.24);
|
||||
}
|
||||
.log-tag {
|
||||
display: inline-block;
|
||||
min-width: 34px;
|
||||
margin-right: 6px;
|
||||
padding: 2px 5px;
|
||||
border-radius: 999px;
|
||||
font-size: 9px;
|
||||
line-height: 1.1;
|
||||
text-align: center;
|
||||
background: rgba(84,68,48,0.08);
|
||||
color: rgba(72,58,43,0.72);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.log-entry .time { color: var(--muted); margin-right: 6px; }
|
||||
.log-birth { border-left-color: rgba(115, 185, 75, 0.66); }
|
||||
.log-death { border-left-color: rgba(91, 83, 75, 0.60); }
|
||||
.log-fight { border-left-color: rgba(214, 94, 70, 0.68); }
|
||||
.log-food { border-left-color: rgba(118, 185, 77, 0.68); }
|
||||
.log-grass { border-left-color: rgba(72, 148, 67, 0.68); }
|
||||
.log-weather { border-left-color: rgba(92, 142, 210, 0.62); }
|
||||
.log-relation { border-left-color: rgba(204, 126, 176, 0.62); }
|
||||
.log-observe { border-left-color: rgba(175, 146, 86, 0.62); }
|
||||
|
||||
@media (max-width: 980px) {
|
||||
#app { padding: 10px; }
|
||||
.topbar { align-items: flex-start; flex-direction: column; }
|
||||
.layout { grid-template-columns: 1fr; }
|
||||
.panel { max-height: none; overflow: visible; }
|
||||
#gameCanvas { height: var(--field-mobile-height, 66vh); min-height: var(--field-mobile-min-height, 460px); }
|
||||
.stage-wrap { min-height: var(--field-mobile-min-height, 460px); }
|
||||
.field-choice-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
.tree-note {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
margin: 0 0 10px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
background: rgba(255,255,255,0.54);
|
||||
border: 1px solid rgba(84,68,48,0.10);
|
||||
}
|
||||
|
||||
.lineage-tree {
|
||||
overflow: auto;
|
||||
padding: 8px 10px 18px;
|
||||
contain: content;
|
||||
}
|
||||
|
||||
.lineage-main-card {
|
||||
margin-top: 16px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.lineage-main-card h2 {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.lineage-main-content {
|
||||
max-height: 560px;
|
||||
min-height: 320px;
|
||||
overflow: auto;
|
||||
overscroll-behavior: auto;
|
||||
padding: 8px 4px 16px;
|
||||
background: rgba(255,250,244,0.48);
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(84,68,48,0.10);
|
||||
}
|
||||
|
||||
.lineage-main-content .lineage-tree {
|
||||
min-width: 360px;
|
||||
overflow: visible;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background:
|
||||
radial-gradient(circle at 8% 20%, rgba(255,255,255,0.62), transparent 16%),
|
||||
radial-gradient(circle at 92% 18%, rgba(255,255,255,0.45), transparent 18%),
|
||||
linear-gradient(180deg, transparent 0%, rgba(114, 152, 78, 0.05) 100%);
|
||||
}
|
||||
|
||||
|
||||
.stage-wrap::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background:
|
||||
radial-gradient(circle at 18% 14%, rgba(255,255,255,0.12), transparent 18%),
|
||||
radial-gradient(circle at 84% 10%, rgba(255,255,255,0.09), transparent 16%),
|
||||
linear-gradient(180deg, rgba(255,255,255,0.07), transparent 24%);
|
||||
z-index: 1;
|
||||
}
|
||||
#gameCanvas {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.card::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
border-radius: inherit;
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.55);
|
||||
}
|
||||
|
||||
/* Compact family board: parent ┳ parent / child, without unrelated founders. */
|
||||
.family-board { padding: 0; overflow: visible; }
|
||||
.family-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(246px, 1fr));
|
||||
gap: 10px 12px;
|
||||
align-items: start;
|
||||
padding: 2px 2px 10px;
|
||||
}
|
||||
.family-cluster {
|
||||
position: relative;
|
||||
min-height: 106px;
|
||||
padding: 7px 7px 8px;
|
||||
border-radius: 14px;
|
||||
background: rgba(255,253,248,0.56);
|
||||
border: 1px solid rgba(84,68,48,0.09);
|
||||
contain: layout paint;
|
||||
}
|
||||
.family-parents {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.family-child-slot {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.family-node {
|
||||
display: grid;
|
||||
grid-template-columns: 22px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
min-height: 34px;
|
||||
padding: 4px 6px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(180deg, rgba(255,253,248,0.96), rgba(248,241,229,0.90));
|
||||
border: 1px solid rgba(84,68,48,0.14);
|
||||
box-shadow: 0 3px 8px rgba(64,48,32,0.045);
|
||||
}
|
||||
.family-child-slot .family-node { width: min(150px, 68%); }
|
||||
.family-connector {
|
||||
position: relative;
|
||||
height: 12px;
|
||||
margin: 0 42px;
|
||||
}
|
||||
.family-connector::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 7px;
|
||||
height: 2px;
|
||||
background: rgba(107, 88, 61, 0.55);
|
||||
}
|
||||
.family-connector span {
|
||||
position: absolute;
|
||||
left: calc(50% - 1px);
|
||||
top: 7px;
|
||||
width: 2px;
|
||||
height: 16px;
|
||||
background: rgba(107, 88, 61, 0.55);
|
||||
}
|
||||
.family-node.child {
|
||||
border-color: rgba(103, 178, 44, 0.38);
|
||||
box-shadow: inset 0 0 0 1px rgba(156,232,75,0.17), 0 3px 8px rgba(64,48,32,0.045);
|
||||
}
|
||||
.family-node[data-family-tarinai-id] { cursor: pointer; transition: transform .12s ease, border-color .12s ease, box-shadow .12s ease; }
|
||||
.family-node[data-family-tarinai-id]:hover { transform: translateY(-1px); border-color: rgba(112,178,66,0.42); box-shadow: 0 5px 12px rgba(64,48,32,0.08); }
|
||||
.family-node.dead { opacity: 0.68; filter: grayscale(0.35); }
|
||||
.family-node.missing { opacity: 0.48; border-style: dashed; }
|
||||
.family-node img { width: 22px; height: 22px; object-fit: contain; }
|
||||
.family-copy { min-width: 0; line-height: 1.12; }
|
||||
.family-copy strong, .family-copy span {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.family-copy .death-reason {
|
||||
white-space: normal;
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
line-height: 1.12;
|
||||
margin-top: 1px;
|
||||
color: rgba(126, 80, 64, 0.92);
|
||||
}
|
||||
.family-copy strong { font-size: 11px; }
|
||||
.family-copy span { color: var(--muted); font-size: 8.5px; margin-top: 1px; }
|
||||
@media (max-width: 760px) {
|
||||
.family-grid { grid-template-columns: 1fr; }
|
||||
.family-cluster { min-height: 102px; }
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
.colony-card { overflow: visible; min-height: 0; flex: 0 0 auto; }
|
||||
.colony-stats {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.colony-chart {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 188px;
|
||||
min-height: 188px;
|
||||
border: 1px solid rgba(84, 68, 48, 0.14);
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(180deg, rgba(255,255,255,0.70), rgba(246,240,228,0.54));
|
||||
overflow: hidden;
|
||||
}
|
||||
.colony-card h2 { line-height: 1.45; margin-bottom: 10px; }
|
||||
.colony-chart svg { display: block; width: 100%; height: 100%; }
|
||||
.colony-chart-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 8px;
|
||||
align-items: center;
|
||||
margin: 4px 0 8px;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
line-height: 1.25;
|
||||
}
|
||||
.colony-chart-legend span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.colony-chart-legend i {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
display: inline-block;
|
||||
box-shadow: 0 0 0 1px rgba(70, 52, 36, 0.10);
|
||||
}
|
||||
|
||||
.relation-row strong { display: inline-flex; align-items: center; justify-content: flex-end; gap: 6px; }
|
||||
.relation-face {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
object-fit: contain;
|
||||
border-radius: 999px;
|
||||
background: rgba(255,255,255,0.66);
|
||||
border: 1px solid rgba(84,68,48,0.12);
|
||||
vertical-align: middle;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
|
||||
.lineage-family {
|
||||
padding: 10px;
|
||||
margin: 0 0 12px;
|
||||
border-radius: 15px;
|
||||
background: rgba(255,253,248,0.62);
|
||||
border: 1px solid rgba(84,68,48,0.12);
|
||||
}
|
||||
.lineage-family h3 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 13px;
|
||||
color: rgba(64,52,38,0.86);
|
||||
}
|
||||
.lineage-generation {
|
||||
display: grid;
|
||||
grid-template-columns: 62px 1fr;
|
||||
gap: 8px;
|
||||
align-items: start;
|
||||
padding: 8px 0;
|
||||
border-top: 1px dashed rgba(84,68,48,0.13);
|
||||
}
|
||||
.lineage-generation:first-of-type { border-top: 0; }
|
||||
.lineage-generation-label {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
padding-top: 8px;
|
||||
}
|
||||
.lineage-generation-nodes {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: stretch;
|
||||
}
|
||||
.family-node.lineage-node {
|
||||
width: 132px;
|
||||
min-height: 54px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.lineage-children {
|
||||
display: block;
|
||||
margin-top: 3px;
|
||||
font-size: 9px;
|
||||
color: rgba(117,106,94,0.84);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.lineage-empty-family {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
|
||||
.lineage-tree-panel {
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
min-width: 360px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.lineage-links {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
pointer-events: none;
|
||||
}
|
||||
.lineage-links path {
|
||||
fill: none;
|
||||
stroke: rgba(107, 88, 61, 0.58);
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
.lineage-links .partner-link { stroke-width: 2.4; stroke: rgba(98, 80, 56, 0.66); }
|
||||
.lineage-links .child-link { stroke-width: 1.9; stroke: rgba(107, 88, 61, 0.54); }
|
||||
.tree-node {
|
||||
position: absolute;
|
||||
width: 132px;
|
||||
min-height: 54px;
|
||||
align-items: flex-start;
|
||||
z-index: 2;
|
||||
}
|
||||
.tree-generation-label {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
width: 54px;
|
||||
font-size: 10px;
|
||||
color: var(--muted);
|
||||
z-index: 1;
|
||||
}
|
||||
.lineage-family { overflow-x: auto; }
|
||||
|
||||
.lineage-family-readable {
|
||||
overflow: visible;
|
||||
}
|
||||
.lineage-readable-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(300px, 1.05fr) minmax(280px, 0.95fr);
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
}
|
||||
.lineage-member-column,
|
||||
.lineage-reproduction-column {
|
||||
min-width: 0;
|
||||
}
|
||||
.lineage-generation-block {
|
||||
padding: 8px 0;
|
||||
border-top: 1px dashed rgba(84,68,48,0.12);
|
||||
}
|
||||
.lineage-generation-block:first-child { border-top: 0; padding-top: 0; }
|
||||
.lineage-generation-block > b,
|
||||
.lineage-column-title {
|
||||
display: block;
|
||||
margin: 0 0 7px;
|
||||
color: rgba(84,68,48,0.72);
|
||||
font-size: 11px;
|
||||
}
|
||||
.lineage-person-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(132px, 1fr));
|
||||
gap: 7px;
|
||||
}
|
||||
.family-node.lineage-person {
|
||||
width: auto;
|
||||
min-height: 46px;
|
||||
}
|
||||
.lineage-pair-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.lineage-pair-card {
|
||||
padding: 8px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(84,68,48,0.12);
|
||||
background: rgba(255,253,248,0.70);
|
||||
}
|
||||
.lineage-pair-parents,
|
||||
.lineage-pair-children {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
}
|
||||
.lineage-pair-parents {
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
.lineage-pair-children {
|
||||
padding-top: 5px;
|
||||
border-top: 1px solid rgba(84,68,48,0.08);
|
||||
}
|
||||
.lineage-plus {
|
||||
color: rgba(84,68,48,0.44);
|
||||
font-weight: 700;
|
||||
line-height: 28px;
|
||||
}
|
||||
.lineage-birth-arrow {
|
||||
color: rgba(107, 88, 61, 0.62);
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
padding: 1px 0 3px 13px;
|
||||
}
|
||||
.lineage-mini {
|
||||
display: inline-grid;
|
||||
grid-template-columns: 20px minmax(0, 1fr);
|
||||
grid-template-rows: auto auto;
|
||||
gap: 0 4px;
|
||||
align-items: center;
|
||||
max-width: 132px;
|
||||
min-width: 88px;
|
||||
padding: 4px 6px;
|
||||
border-radius: 9px;
|
||||
border: 1px solid rgba(84,68,48,0.12);
|
||||
background: rgba(255,255,255,0.64);
|
||||
}
|
||||
.lineage-mini.child {
|
||||
border-color: rgba(103,178,44,0.28);
|
||||
background: rgba(246,255,235,0.62);
|
||||
}
|
||||
.lineage-mini.dead { opacity: 0.64; filter: grayscale(0.25); }
|
||||
.lineage-mini img {
|
||||
grid-row: 1 / span 2;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
object-fit: contain;
|
||||
}
|
||||
.lineage-mini b,
|
||||
.lineage-mini small {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.lineage-mini b { font-size: 10px; line-height: 1.1; }
|
||||
.lineage-mini small { color: var(--muted); font-size: 8px; line-height: 1.1; }
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.lineage-readable-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
29
docs/code_map.md
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# Code Map
|
||||
|
||||
Use this map to open the smallest relevant file first.
|
||||
|
||||
## Simulation
|
||||
- `js/sim_core.js`: shared simulation helpers, personality definitions, `Effect`, `SpatialGrid`, and small drawing helpers used by simulation classes.
|
||||
- `js/items.js`: `Item` lifecycle, item save/load restore, grass and zunchi behavior, and item drawing.
|
||||
- `js/tarinai.js`: `Tarinai` state, AI, movement, eating, fighting, panic, reproduction-adjacent behavior, and drawing.
|
||||
- `js/world.js`: `World` state, save/load, spawning, family registry normalization, spatial queries, tools, weather, and update loop coordination.
|
||||
- `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`.
|
||||
- Change item growth, zunchi lifecycle, grass spread, or item visuals: start with `js/items.js`.
|
||||
- Change save/load, population, spawning, weather, tools, or family data correctness: start with `js/world.js`.
|
||||
- Change family tree display only: start with `js/ui_family.js`.
|
||||
- Change colony charts: start with `js/ui_charts.js`.
|
||||
- Change buttons, selected info, logs, or input handling: start with `js/ui.js`.
|
||||
- Change the garden canvas visuals: start with `js/render.js`.
|
||||
|
||||
## Loading Order
|
||||
`index.html` loads plain browser scripts in dependency order. Do not convert to ES modules or add a build step unless the project intentionally adopts one later.
|
||||
163
docs/design_notes.md
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
# たりないコロニー観察所:仕様メモ
|
||||
|
||||
## コンセプト
|
||||
|
||||
「不足を解決するゲーム」ではなく、
|
||||
「不足を抱えた生き物が、それでも勝手に暮らす様子を眺めるゲーム」。
|
||||
|
||||
プレイヤーは神ではなく、観察者兼飼育係程度の立場。
|
||||
すべてを最適化するより、少し足りない状態の揺らぎを観察することを主目的にする。
|
||||
|
||||
## 現在実装しているループ
|
||||
|
||||
1. 個体が空腹・孤独・眠気・不安を内部状態として持つ
|
||||
2. 状態に応じて餌・仲間・寝床を探す
|
||||
3. プレイヤーは環境にアイテムを置いて介入する
|
||||
4. 介入と個体行動により、繁殖・死亡・観察ログが生まれる
|
||||
5. 死亡個体は「たりない跡」を残し、環境に記録として残る
|
||||
|
||||
## 個体状態
|
||||
|
||||
| 状態 | 内容 |
|
||||
|---|---|
|
||||
| 空腹 | 高いほど餌・草を探す。上がりすぎると死亡 |
|
||||
| 孤独 | 高いほど仲間に寄る。近くに仲間がいると下がる |
|
||||
| 眠気残量 | 低いほど寝る。寝床があると回復が速い |
|
||||
| 不安 | 高いほど慌てる。水・石・寝床などで下がる |
|
||||
| たりない度 | 上記の総合的な不安定さ |
|
||||
|
||||
## 個体タイプ
|
||||
|
||||
9枚のスプライトに対応。
|
||||
|
||||
- にこたりない
|
||||
- いかりたりない
|
||||
- うるうるたりない
|
||||
- じとたりない
|
||||
- よだれたりない
|
||||
- ぐしゃたりない
|
||||
- すやたりない
|
||||
- ぽかんたりない
|
||||
- のびたりない
|
||||
|
||||
各タイプは、空腹速度・社交性・睡眠傾向・不安耐性・移動速度に差がある。
|
||||
|
||||
## 介入アイテム
|
||||
|
||||
| アイテム | 役割 |
|
||||
|---|---|
|
||||
| ふつうの餌 | 空腹を直接下げる |
|
||||
| あまい餌 | 空腹と不安を下げるが依存的な挙動を生みやすい |
|
||||
| 水滴 | 不安を下げる。時間で蒸発する |
|
||||
| 草 | 軽い餌・落ち着き場・死後の循環要素 |
|
||||
| ぬくい石 | 周辺温度を上げ、不安を下げる |
|
||||
| ふわふわ | 寝床。睡眠回復を強める |
|
||||
| つつく | 個体に一時的な不安と速度を与える |
|
||||
| 迷い込み | 新規個体を追加する |
|
||||
|
||||
## 生死
|
||||
|
||||
死亡条件は現在、主に以下。
|
||||
|
||||
- 空腹過多
|
||||
- 寿命
|
||||
- 不安過多
|
||||
- 空腹と眠気の複合
|
||||
|
||||
死亡時には「たりない跡」が生成され、一定確率で草も発生する。
|
||||
これは観察ゲームとして「失敗」ではなく、生態系の記録として扱う。
|
||||
|
||||
## 繁殖
|
||||
|
||||
近距離にいる2個体が、以下を満たすと低確率で新個体が発生する。
|
||||
|
||||
- 空腹が高すぎない
|
||||
- 眠気残量が十分ある
|
||||
- 繁殖クールダウンが終わっている
|
||||
- コロニーが過密でない
|
||||
|
||||
子は親のタイプ・足りないものを一部継承し、低確率で変異する。
|
||||
|
||||
## 設計上の注意
|
||||
|
||||
- 完全な最適化ゲームに寄せすぎない
|
||||
- 数値を見せつつ、ログ文で生態観察感を出す
|
||||
- 介入は強いが万能ではない
|
||||
- 死亡や衰退も観察価値として扱う
|
||||
- 「むらさき」は画像作成者名のため、ゲーム内の異常現象名には使用していない
|
||||
|
||||
|
||||
## v3設計メモ
|
||||
|
||||
- 観察時間を伸ばすため、空腹・孤独の上昇をやや抑制し、寿命を長めにした。
|
||||
- 図鑑は「達成」ではなく観察記録として扱う。スプライト、イベント、死因をゆるく解放する。
|
||||
- 系譜は全個体を複雑なツリーで描画せず、出生ログとして読みやすさを優先した。
|
||||
- 音は外部素材を使わず、Web Audioの短い合成音で実装した。ブラウザ仕様上、音 ON を押すまで鳴らない。
|
||||
|
||||
|
||||
## v4 仕様追記
|
||||
|
||||
### 子の追従
|
||||
繁殖で生まれた子は `childGrowTime` の間、親のどちらか近い方を追従する。成長すると通常AIへ移行する。
|
||||
|
||||
### 系譜
|
||||
系譜は `family` レジストリで保持し、親子関係を樹状図で表示する。表示時は同じ個体を重複させず、共有された親子関係は複数の接続線で表す。
|
||||
|
||||
### 食事エフェクト
|
||||
食事パーティクルは表示頻度を抑え、対象アイテムの色を反映する。
|
||||
|
||||
### 喧嘩
|
||||
近距離、低気分、怒り個体、過密などを条件に小さな喧嘩が発生する。喧嘩中は 02_angry を優先表示し、互いにノックバックする。
|
||||
|
||||
### 昼夜
|
||||
夜間・夕方の色を背景だけでなく、観察皿・アイテム・たりない本体にも重ねる。
|
||||
|
||||
|
||||
## v5 仕様追記
|
||||
|
||||
### 草
|
||||
草はコロニー内に自動で発生する。草アイテムは `amount` を草量として持ち、時間でゆっくり伸びる。食べられると `amount` が減り、描画本数も減る。
|
||||
|
||||
### 昼夜の描画修正
|
||||
スプライト本体への色補正は `source-atop` の矩形塗りを廃止し、`ctx.filter` に変更した。これにより透過部分へ矩形状の色が乗る問題を避ける。
|
||||
|
||||
### 系譜
|
||||
図鑑UIは削除し、系譜のみをメイン画面下部に配置する。
|
||||
|
||||
|
||||
## v6 仕様追記
|
||||
|
||||
### 頭突き喧嘩
|
||||
喧嘩中は一定周期で相手へ突進し、近距離でノックバックと喧嘩エフェクトを発生させる。
|
||||
|
||||
### 睡眠
|
||||
睡眠状態では速度をゼロにし、移動処理を行わない。寝床が近くても寝ながら移動しない。
|
||||
|
||||
### カーソル反応
|
||||
カーソルが近い場合、気分の良い個体は笑顔で寄り、不安・怒り・つつき記憶のある個体は逃げる。
|
||||
|
||||
|
||||
## v7 ビジュアル改修
|
||||
|
||||
- 背景をシャーレ風から庭の観察区画風へ変更。
|
||||
- 地面は木枠に囲まれた明るい土床とし、細かな土粒、苔色のムラ、縁の草、野花を加えた。
|
||||
- たりないとアイテムの接地影を追加し、庭の上に実在している感覚を強めた。
|
||||
- 右上HUDと下部ラベルを観察札のような見た目へ調整。
|
||||
- 親子の追従を視認しやすくするため、近距離では薄い点線ガイドを描く。
|
||||
|
||||
|
||||
## v8 ビジュアル / 実装改修
|
||||
|
||||
- 直射日光・月光の方向をもとに、たりないとアイテムへ投影影を付与。
|
||||
- 背景では天体本体は描かず、空色と影方向のみで昼夜を表現する。
|
||||
- 食事の蓄積で「ずんち」を排泄する消化処理を追加。
|
||||
- 09_stretch は通常描画でも横にやや広く扱い、視認性を上げた。
|
||||
- 系譜ツリーの接続線は擬似要素を再設計し、途切れにくい連結表現へ変更。
|
||||
- 旧図鑑用の状態管理(codex)と未使用スタイルを整理。
|
||||
|
||||
|
||||
## v9 修正
|
||||
|
||||
- `ctx.filter = blur(...)` を使った影を廃止し、低負荷な楕円描画へ変更。
|
||||
- 背景装飾のループ数を削減し、毎フレームの描画負荷を下げた。
|
||||
- 死亡時の `splat` は amount を連続的なアルファ値へ反映し、数分かけてフェードアウトする。
|
||||
135
index.html
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
<!doctype html>
|
||||
<html lang="ja">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>たりない観察箱</title>
|
||||
<link rel="stylesheet" href="css/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<h1>たりない観察箱</h1>
|
||||
<p class="subtitle">たりないが暮らす小さな庭を観察します。食べ、眠り、関係を変えます。</p>
|
||||
</div>
|
||||
<div class="top-actions">
|
||||
<button id="pauseBtn" class="btn primary">一時停止</button>
|
||||
<button id="speedBtn" class="btn">速度 x1</button>
|
||||
<button id="soundBtn" class="btn">音 OFF</button>
|
||||
<button id="saveBtn" class="btn">保存</button>
|
||||
<button id="loadBtn" class="btn">読込</button>
|
||||
<button id="resetBtn" class="btn danger">リセット</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="layout">
|
||||
<section class="stage-wrap">
|
||||
<canvas id="gameCanvas" aria-label="たりない観察キャンバス"></canvas>
|
||||
<div id="toast" class="toast hidden"></div>
|
||||
</section>
|
||||
|
||||
<aside class="panel">
|
||||
<section id="toolCard" class="card tool-card">
|
||||
<div class="tool-head"><h2>道具</h2></div>
|
||||
<div class="tool-body">
|
||||
<div id="toolPalette" class="tool-palette">
|
||||
<section class="tool-category" data-tool-category="observe">
|
||||
<button class="tool-category-toggle" type="button" aria-expanded="true">観察</button>
|
||||
<div class="tool-grid tool-category-body">
|
||||
<button class="tool selected" data-tool="observe"><span>O</span>観察</button>
|
||||
<button class="tool" data-tool="delete"><span>D</span>削除</button>
|
||||
<button class="tool" data-tool="poke"><span>P</span>つつく</button>
|
||||
<button class="tool" data-tool="new"><span>N</span>追加</button>
|
||||
</div>
|
||||
</section>
|
||||
<section class="tool-category" data-tool-category="food">
|
||||
<button class="tool-category-toggle" type="button" aria-expanded="true">食べ物・自然</button>
|
||||
<div class="tool-grid tool-category-body">
|
||||
<button class="tool" data-tool="zunchi"><span>Z</span>ずんち</button>
|
||||
<button class="tool" data-tool="sweet"><span>S</span>ずんだ餅</button>
|
||||
<button class="tool" data-tool="water"><span>W</span>水</button>
|
||||
<button class="tool" data-tool="grass"><span>G</span>草</button>
|
||||
</div>
|
||||
</section>
|
||||
<section class="tool-category" data-tool-category="object">
|
||||
<button class="tool-category-toggle" type="button" aria-expanded="true">オブジェクト</button>
|
||||
<div class="tool-grid tool-category-body">
|
||||
<button class="tool" data-tool="stone"><span>R</span>石</button>
|
||||
<button class="tool" data-tool="bed"><span>B</span>干草寝床</button>
|
||||
<button class="tool" data-tool="firecracker"><span>F</span>爆竹</button>
|
||||
</div>
|
||||
</section>
|
||||
<section class="tool-category" data-tool-category="fence">
|
||||
<button class="tool-category-toggle" type="button" aria-expanded="true">柵</button>
|
||||
<div class="tool-grid tool-category-body">
|
||||
<button class="tool" data-tool="fence_v"><span>V</span>縦の柵</button>
|
||||
<button class="tool" data-tool="fence_h"><span>H</span>横の柵</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<p class="hint">同じ道具ボタンをもう一度押すと、対応道具の大きさが小・中・大で切り替わります。</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card colony-card">
|
||||
<h2>コロニー</h2>
|
||||
<div class="stats colony-stats">
|
||||
<div class="stat"><span>個体数</span><strong id="statPop">0</strong></div>
|
||||
<div class="stat"><span>死亡数</span><strong id="statDead">0</strong></div>
|
||||
</div>
|
||||
<div id="colonyChartTabs" class="chart-tabs"><button data-chart="population" class="active">人口</button><button data-chart="life">生活</button><button data-chart="events">事件</button><button data-chart="objects">オブジェクト</button></div>
|
||||
<div id="colonyChartLegend" class="colony-chart-legend"></div>
|
||||
<div id="colonyChart" class="colony-chart" aria-label="コロニーの推移グラフ" role="img"></div>
|
||||
</section>
|
||||
|
||||
<section class="card selected-card">
|
||||
<h2>選択中</h2>
|
||||
<div id="selectedInfo" class="selected-info empty">未選択</div>
|
||||
</section>
|
||||
|
||||
<section class="card log-card">
|
||||
<h2>記録</h2>
|
||||
<div id="log" class="log"></div>
|
||||
</section>
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
<section class="card lineage-main-card">
|
||||
<h2>家系図</h2>
|
||||
<p class="hint">子どもは成長するまで親についていきます。この領域をスクロールして家系を確認できます。</p>
|
||||
<div id="archiveContent" class="archive-content lineage-main-content"></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="fieldDialog" class="field-dialog hidden" role="dialog" aria-modal="true" aria-labelledby="fieldDialogTitle">
|
||||
<div class="field-dialog-panel">
|
||||
<h2 id="fieldDialogTitle">フィールドを選択</h2>
|
||||
<div class="field-choice-grid">
|
||||
<button class="field-choice" data-field-type="park" type="button"><strong>公園</strong><span>大</span></button>
|
||||
<button class="field-choice" data-field-type="garden" type="button"><strong>庭</strong><span>中</span></button>
|
||||
<button class="field-choice" data-field-type="cage" type="button"><strong>ケージ</strong><span>小</span></button>
|
||||
</div>
|
||||
<div class="field-dialog-actions">
|
||||
<button id="fieldCancelBtn" class="btn" type="button">キャンセル</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="js/data.js"></script>
|
||||
<script src="js/math.js"></script>
|
||||
<script src="js/assets.js"></script>
|
||||
<script src="js/audio.js"></script>
|
||||
<script src="js/render.js"></script>
|
||||
<script src="js/sim_core.js"></script>
|
||||
<script src="js/items.js"></script>
|
||||
<script src="js/tarinai.js"></script>
|
||||
<script src="js/world.js"></script>
|
||||
<script src="js/sim.js"></script>
|
||||
<script src="js/ui.js"></script>
|
||||
<script src="js/ui_charts.js"></script>
|
||||
<script src="js/ui_family.js"></script>
|
||||
<script src="js/ui_exports.js"></script>
|
||||
<script src="js/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
133
js/assets.js
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
"use strict";
|
||||
const images = new Map();
|
||||
const imageBoundsCache = new WeakMap();
|
||||
const imageMetrics = new Map();
|
||||
|
||||
const scaledSpriteCache = new Map();
|
||||
const stainOverlayCache = new Map();
|
||||
|
||||
function trimCanvasCache(cache, limit = 420) {
|
||||
while (cache.size > limit) {
|
||||
const first = cache.keys().next().value;
|
||||
if (first === undefined) break;
|
||||
cache.delete(first);
|
||||
}
|
||||
}
|
||||
|
||||
function quantizedCanvasSize(v, step = 16) {
|
||||
return Math.max(1, Math.round(v / step) * step);
|
||||
}
|
||||
|
||||
const SPRITE_CACHE_SCALE = 2.0;
|
||||
|
||||
function getCachedSpriteCanvas(id, img, w, h) {
|
||||
if (!img || !w || !h) return img;
|
||||
const qw = quantizedCanvasSize(w, 16);
|
||||
const qh = quantizedCanvasSize(h, 16);
|
||||
const key = `${id}:${qw}x${qh}`;
|
||||
let canvas = scaledSpriteCache.get(key);
|
||||
if (canvas) return canvas;
|
||||
canvas = document.createElement("canvas");
|
||||
canvas.width = qw;
|
||||
canvas.height = qh;
|
||||
const c = canvas.getContext("2d");
|
||||
c.imageSmoothingEnabled = true;
|
||||
if ("imageSmoothingQuality" in c) c.imageSmoothingQuality = "high";
|
||||
c.clearRect(0, 0, qw, qh);
|
||||
c.drawImage(img, 0, 0, qw, qh);
|
||||
scaledSpriteCache.set(key, canvas);
|
||||
trimCanvasCache(scaledSpriteCache, 520);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
function getCachedStainOverlayCanvas(id, img, w, h, stain = 0, seed = 0.5) {
|
||||
if (!img || stain <= 0.01 || !w || !h) return null;
|
||||
const qw = quantizedCanvasSize(w, 16);
|
||||
const qh = quantizedCanvasSize(h, 16);
|
||||
const bucket = Math.max(1, Math.min(8, Math.ceil(stain * 8)));
|
||||
const seedBucket = Math.round((seed || 0) * 16);
|
||||
const key = `${id}:${qw}x${qh}:s${bucket}:r${seedBucket}`;
|
||||
let canvas = stainOverlayCache.get(key);
|
||||
if (canvas) return canvas;
|
||||
canvas = document.createElement("canvas");
|
||||
canvas.width = qw;
|
||||
canvas.height = qh;
|
||||
const c = canvas.getContext("2d");
|
||||
c.imageSmoothingEnabled = true;
|
||||
if ("imageSmoothingQuality" in c) c.imageSmoothingQuality = "high";
|
||||
c.clearRect(0, 0, qw, qh);
|
||||
c.drawImage(img, 0, 0, qw, qh);
|
||||
c.globalCompositeOperation = "source-in";
|
||||
c.globalAlpha = 0.34 + bucket * 0.075;
|
||||
c.fillStyle = "rgba(28, 92, 22, 0.96)";
|
||||
const s = (seedBucket % 16) / 16;
|
||||
const spots = [
|
||||
[0.40 + s * 0.035, 0.82, 0.14, 0.040, -0.10],
|
||||
[0.59 - s * 0.030, 0.87, 0.15, 0.044, 0.12],
|
||||
[0.50, 0.91, 0.20, 0.036, 0.02],
|
||||
[0.48 + s * 0.020, 0.78, 0.10, 0.030, -0.18],
|
||||
];
|
||||
for (const [sx, sy, rw, rh, rot] of spots) {
|
||||
c.beginPath();
|
||||
c.ellipse(sx * qw, sy * qh, rw * qw, rh * qh, rot, 0, Math.PI * 2);
|
||||
c.fill();
|
||||
}
|
||||
c.globalAlpha = 1;
|
||||
c.globalCompositeOperation = "source-over";
|
||||
stainOverlayCache.set(key, canvas);
|
||||
trimCanvasCache(stainOverlayCache, 360);
|
||||
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 imageAlphaBounds(img) {
|
||||
if (!img) return null;
|
||||
if (img.alphaBounds) return img.alphaBounds;
|
||||
if (imageBoundsCache.has(img)) return imageBoundsCache.get(img);
|
||||
|
||||
const w = img.naturalWidth || img.width || 0;
|
||||
const h = img.naturalHeight || img.height || 0;
|
||||
if (!w || !h) return null;
|
||||
const bounds = { x: 0, y: 0, w, h, imageW: w, imageH: h };
|
||||
imageBoundsCache.set(img, bounds);
|
||||
return bounds;
|
||||
}
|
||||
|
||||
function getImageMetrics(idOrImg) {
|
||||
if (typeof idOrImg === "string") return imageMetrics.get(idOrImg) || null;
|
||||
if (!idOrImg) return null;
|
||||
for (const [id, img] of images) {
|
||||
if (img === idOrImg) return imageMetrics.get(id) || null;
|
||||
}
|
||||
const w = idOrImg.naturalWidth || idOrImg.width || 0;
|
||||
const h = idOrImg.naturalHeight || idOrImg.height || 0;
|
||||
return w && h ? { w, h, ratio: h / w, visibleBottomRatio: 1 } : null;
|
||||
}
|
||||
|
||||
|
||||
window.TarinaiAssets = { images, imageBoundsCache, imageMetrics, scaledSpriteCache, stainOverlayCache, loadImages, imageAlphaBounds, getImageMetrics, getCachedSpriteCanvas, getCachedStainOverlayCanvas };
|
||||
88
js/audio.js
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
"use strict";
|
||||
const audio = {
|
||||
enabled: false,
|
||||
ctx: null,
|
||||
samples: {},
|
||||
sampleLast: {},
|
||||
samplePaths: {
|
||||
hau: "assets/sounds/voice_001_hau.wav",
|
||||
flee: "assets/sounds/voice_002_flee.wav",
|
||||
poop: "assets/sounds/voice_003_po.wav",
|
||||
eat: "assets/sounds/voice_004_eat.wav",
|
||||
stress: "assets/sounds/voice_005_stress.wav",
|
||||
sleep: "assets/sounds/voice_006_sleep.wav",
|
||||
explosion: "assets/sounds/firecracker_explosion.mp3",
|
||||
},
|
||||
ensure() {
|
||||
const AudioClass = window.AudioContext || window.webkitAudioContext;
|
||||
if (!AudioClass) return null;
|
||||
if (!this.ctx) this.ctx = new AudioClass();
|
||||
if (this.ctx.state === "suspended") this.ctx.resume();
|
||||
this.initSamples();
|
||||
return this.ctx;
|
||||
},
|
||||
initSamples() {
|
||||
if (this._samplesReady) return;
|
||||
this._samplesReady = true;
|
||||
for (const [key, path] of Object.entries(this.samplePaths)) {
|
||||
const a = new Audio(path);
|
||||
a.preload = "auto";
|
||||
a.volume = 0.72;
|
||||
this.samples[key] = a;
|
||||
}
|
||||
},
|
||||
toggle() {
|
||||
this.enabled = !this.enabled;
|
||||
if (this.enabled) this.ensure();
|
||||
return this.enabled;
|
||||
},
|
||||
playSample(key, minGap = 0.16) {
|
||||
if (!this.enabled) return false;
|
||||
this.ensure();
|
||||
const base = this.samples[key];
|
||||
if (!base) return false;
|
||||
const t = performance.now() / 1000;
|
||||
if (t - (this.sampleLast[key] || 0) < minGap) return true;
|
||||
this.sampleLast[key] = t;
|
||||
const node = base.cloneNode(true);
|
||||
node.volume = base.volume;
|
||||
node.play().catch(() => {});
|
||||
return true;
|
||||
},
|
||||
tone(freq, dur = 0.08, type = "sine", gain = 0.035, slide = 0) {
|
||||
if (!this.enabled) return;
|
||||
const ctx = this.ensure();
|
||||
if (!ctx) return;
|
||||
const t0 = ctx.currentTime;
|
||||
const osc = ctx.createOscillator();
|
||||
const g = ctx.createGain();
|
||||
osc.type = type;
|
||||
osc.frequency.setValueAtTime(freq, t0);
|
||||
if (slide) osc.frequency.linearRampToValueAtTime(Math.max(40, freq + slide), t0 + dur);
|
||||
g.gain.setValueAtTime(0.0001, t0);
|
||||
g.gain.exponentialRampToValueAtTime(gain, t0 + 0.01);
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
|
||||
osc.connect(g).connect(ctx.destination);
|
||||
osc.start(t0);
|
||||
osc.stop(t0 + dur + 0.02);
|
||||
},
|
||||
bubble(text) {
|
||||
const s = String(text || "");
|
||||
if (s.includes("\u3082\u3050")) return this.playSample("eat", 0.20);
|
||||
if (s.includes("\u3076\u308a")) return this.playSample("poop", 0.28);
|
||||
if (s.includes("\u306f\u3063")) return this.playSample("stress", 0.80);
|
||||
if (s.includes("Zzz")) return this.playSample("sleep", 1.25);
|
||||
if (s.includes("\u306f\u3041") || s.includes("\u306f\u3042")) return this.playSample("flee", 0.70);
|
||||
if (s.includes("\u306f\u3046") || s.includes("\u306f\u3045")) return this.playSample("hau", 0.45);
|
||||
return false;
|
||||
},
|
||||
eat() { if (!this.playSample("eat", 0.20)) this.tone(660 + Math.random() * 140, 0.055, "triangle", 0.026, 120); },
|
||||
poke() { this.tone(180, 0.09, "sawtooth", 0.022, -70); },
|
||||
birth() { this.tone(520, 0.08, "sine", 0.028, 100); setTimeout(() => this.tone(760, 0.10, "triangle", 0.024, 80), 70); },
|
||||
death() { this.tone(150, 0.22, "sine", 0.028, -70); },
|
||||
fight() { this.tone(110, 0.055, "square", 0.018, 70); setTimeout(() => this.tone(90, 0.045, "sawtooth", 0.014, -40), 35); },
|
||||
explode() { if (!this.playSample("explosion", 0.30)) { this.tone(90, 0.12, "sawtooth", 0.06, -30); setTimeout(() => this.tone(55, 0.20, "square", 0.04, -20), 40); } },
|
||||
phase() { this.tone(440, 0.06, "sine", 0.018, 60); },
|
||||
};
|
||||
|
||||
window.TarinaiAudio = audio;
|
||||
107
js/data.js
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
"use strict";
|
||||
const SPRITES = [
|
||||
{ id: "smile", label: "\u901a\u5e38\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_01_smile.png" },
|
||||
{ id: "angry", label: "\u6012\u308a\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_02_angry.png" },
|
||||
{ id: "teary", label: "\u6d99\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_03_teary.png" },
|
||||
{ id: "jito", label: "\u3058\u3068\u76ee\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_04_jito.png" },
|
||||
{ id: "drool", label: "\u3088\u3060\u308c\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_05_drool.png" },
|
||||
{ id: "cry", label: "\u6ce3\u304d\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_06_cry.png" },
|
||||
{ id: "sleep", label: "\u7720\u308a\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_07_sleep.png" },
|
||||
{ id: "pokan", label: "\u307d\u304b\u3093\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_08_pokan.png" },
|
||||
{ id: "stretch", label: "\u4f38\u3073\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_09_stretch.png" },
|
||||
{ id: "hurt", label: "\u75db\u304c\u308a\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_10_hurt.png", actionOnly: true },
|
||||
{ id: "weak", label: "\u5f31\u308a\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_11_weak.png", actionOnly: true },
|
||||
{ id: "back", label: "\u5965\u5411\u304d\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_12_back.png", actionOnly: true },
|
||||
{ id: "flee", label: "\u6557\u8d70\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_13_flee.png", actionOnly: true },
|
||||
{ id: "zunda_eat", label: "\u305a\u3093\u3060\u9905\u98df\u3079\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_14_zunda_eat.png", actionOnly: true },
|
||||
{ id: "hurt2", label: "\u75db\u304c\u308a\u305f\u308a\u306a\u30442", path: "assets/sprites/tarinai_15_hurt2.png", actionOnly: true },
|
||||
{ id: "normal_smirk", label: "\u901a\u5e38\u305f\u308a\u306a\u3044\u30fb\u306b\u3084\u308a", path: "assets/sprites/tarinai_16_normal_smirk.png", displayOnly: true },
|
||||
{ id: "normal_tongue", label: "\u4f4e\u30b9\u30c8\u30ec\u30b9\u305f\u308a\u306a\u3044\u30fb\u820c", path: "assets/sprites/tarinai_17_normal_tongue.png", displayOnly: true },
|
||||
{ id: "fear", label: "\u6050\u6016\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_18_fear.png", actionOnly: true },
|
||||
{ id: "flee_fear2", label: "\u6557\u8d70\u6050\u6016\u305f\u308a\u306a\u30442", path: "assets/sprites/tarinai_19_flee_fear2.png", actionOnly: true },
|
||||
{ id: "sleep2", label: "\u7720\u308a\u305f\u308a\u306a\u30442", path: "assets/sprites/tarinai_20_sleep2.png", actionOnly: true },
|
||||
{ id: "normal_happy", label: "\u4f4e\u30b9\u30c8\u30ec\u30b9\u305f\u308a\u306a\u3044\u30fb\u3054\u304d\u3052\u3093", path: "assets/sprites/tarinai_21_normal_happy.png", displayOnly: true },
|
||||
{ id: "fear_blue", label: "\u6050\u6016\u305f\u308a\u306a\u3044\u30fb\u9752\u3056\u3081", path: "assets/sprites/tarinai_22_fear_blue.png", actionOnly: true },
|
||||
{ id: "fear_cry", label: "\u6050\u6016\u305f\u308a\u306a\u3044\u30fb\u6d99", path: "assets/sprites/tarinai_23_fear_cry.png", actionOnly: true },
|
||||
{ id: "stress_dizzy", label: "\u9ad8\u30b9\u30c8\u30ec\u30b9\u305f\u308a\u306a\u3044\u30fb\u3050\u308b\u3050\u308b", path: "assets/sprites/tarinai_24_stress_dizzy.png", actionOnly: true },
|
||||
{ id: "stress_sweat", label: "\u9ad8\u30b9\u30c8\u30ec\u30b9\u305f\u308a\u306a\u3044\u30fb\u6c57", path: "assets/sprites/tarinai_25_stress_sweat.png", actionOnly: true },
|
||||
{ id: "intimidate", label: "\u5a01\u5687\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_26_intimidate.png", actionOnly: true },
|
||||
{ id: "birth_ritual", label: "\u7e41\u6b96\u524d\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_27_birth_ritual.png", actionOnly: true },
|
||||
];
|
||||
const DECOR_ASSETS = [
|
||||
{ id: "zunchi", path: "assets/objects/zunchi.png" },
|
||||
{ id: "zunchi_02", path: "assets/objects/zunchi_02.png" },
|
||||
];
|
||||
const ALPHA_BOUNDS = {
|
||||
smile: { x: 18, y: 18, w: 476, h: 432, imageW: 512, imageH: 468 },
|
||||
angry: { x: 16, y: 16, w: 480, h: 432, imageW: 512, imageH: 464 },
|
||||
teary: { x: 17, y: 17, w: 478, h: 438, imageW: 512, imageH: 472 },
|
||||
jito: { x: 18, y: 18, w: 476, h: 451, imageW: 512, imageH: 487 },
|
||||
drool: { x: 18, y: 18, w: 476, h: 425, imageW: 512, imageH: 461 },
|
||||
cry: { x: 15, y: 15, w: 482, h: 448, imageW: 512, imageH: 478 },
|
||||
sleep: { x: 18, y: 18, w: 475, h: 439, imageW: 511, imageH: 475 },
|
||||
pokan: { x: 18, y: 18, w: 476, h: 433, imageW: 512, imageH: 469 },
|
||||
stretch: { x: 15, y: 15, w: 482, h: 354, imageW: 512, imageH: 384 },
|
||||
hurt: { x: 17, y: 21, w: 478, h: 432, imageW: 512, imageH: 468 },
|
||||
weak: { x: 53, y: 18, w: 405, h: 435, imageW: 512, imageH: 468 },
|
||||
back: { x: 17, y: 21, w: 478, h: 432, imageW: 512, imageH: 468 },
|
||||
flee: { x: 17, y: 21, w: 478, h: 432, imageW: 512, imageH: 468 },
|
||||
zunda_eat: { x: 17, y: 21, w: 478, h: 432, imageW: 512, imageH: 468 },
|
||||
hurt2: { x: 16, y: 20, w: 480, h: 434, imageW: 512, imageH: 468 },
|
||||
normal_smirk: { x: 16, y: 20, w: 480, h: 434, imageW: 512, imageH: 468 },
|
||||
normal_tongue: { x: 16, y: 20, w: 480, h: 434, imageW: 512, imageH: 468 },
|
||||
fear: { x: 16, y: 20, w: 480, h: 434, imageW: 512, imageH: 468 },
|
||||
flee_fear2: { x: 16, y: 26, w: 480, h: 428, imageW: 512, imageH: 468 },
|
||||
sleep2: { x: 16, y: 21, w: 480, h: 433, imageW: 512, imageH: 468 },
|
||||
normal_happy: { x: 16, y: 21, w: 480, h: 433, imageW: 512, imageH: 468 },
|
||||
fear_blue: { x: 16, y: 21, w: 480, h: 433, imageW: 512, imageH: 468 },
|
||||
fear_cry: { x: 16, y: 21, w: 480, h: 433, imageW: 512, imageH: 468 },
|
||||
stress_dizzy: { x: 16, y: 21, w: 480, h: 433, imageW: 512, imageH: 468 },
|
||||
stress_sweat: { x: 16, y: 26, w: 480, h: 428, imageW: 512, imageH: 468 },
|
||||
intimidate: { x: 98, y: 16, w: 316, h: 438, imageW: 512, imageH: 468 },
|
||||
zunchi: { x: 0, y: 0, w: 236, h: 178, imageW: 236, imageH: 178 },
|
||||
zunchi_02: { x: 8, y: 63, w: 220, h: 105, imageW: 236, imageH: 178 },
|
||||
};
|
||||
|
||||
const NEEDS = ["\u98df\u3079\u7269", "\u4ef2\u9593", "\u7720\u308a", "\u52c7\u6c17", "\u6c34", "\u9759\u3051\u3055", "\u3042\u305f\u305f\u304b\u3055", "\u610f\u5473", "\u7a7a\u9593", "\u81ea\u4fe1"];
|
||||
const NAMES = ["ta", "ri", "na", "i", "po", "yo", "nu", "ma", "fu", "he", "shi", "ro", "mu", "a"];
|
||||
|
||||
const CONFIG = {
|
||||
initialPopulation: 18,
|
||||
maxPopulation: Number.MAX_SAFE_INTEGER,
|
||||
itemLimit: 260,
|
||||
zunchiLimit: 56,
|
||||
traceLimit: 64,
|
||||
splatLimit: 48,
|
||||
effectLimit: 96,
|
||||
saveKey: "tarinai-colony-save-v38",
|
||||
worldPadding: 30,
|
||||
hungerPerMinute: 11.2,
|
||||
lonelinessPerMinute: 7.6,
|
||||
energyPerMinute: 8.2,
|
||||
stressDecayPerMinute: 9,
|
||||
reproductionCooldown: 18,
|
||||
dayLength: 120,
|
||||
childGrowTime: 58,
|
||||
fightCooldown: 8,
|
||||
lightingQuality: "cinematic",
|
||||
weatherChangeMin: 46,
|
||||
weatherChangeMax: 96,
|
||||
bubbleLimit: 42,
|
||||
grassLimit: 96,
|
||||
highPopulationMode: 70,
|
||||
ultraPopulationMode: 120,
|
||||
};
|
||||
const FIELD_TYPES = {
|
||||
cage: { id: "cage", label: "\u30b1\u30fc\u30b8", sizeLabel: "\u5c0f", height: "calc(100vh - 140px)", minHeight: "620px", mobileHeight: "66vh", mobileMinHeight: "460px", worldScale: 0.46, population: 12, grass: 9 },
|
||||
garden: { id: "garden", label: "\u5ead", sizeLabel: "\u4e2d", height: "calc(100vh - 140px)", minHeight: "620px", mobileHeight: "66vh", mobileMinHeight: "460px", worldScale: 1.0, population: CONFIG.initialPopulation, grass: 18 },
|
||||
park: { id: "park", label: "\u516c\u5712", sizeLabel: "\u5927", height: "calc(100vh - 140px)", minHeight: "620px", mobileHeight: "66vh", mobileMinHeight: "460px", worldScale: 2.35, population: 24, grass: 28 },
|
||||
};
|
||||
const LIGHTING_TUNING = {
|
||||
rayAlpha: 0.040,
|
||||
nightOverlayAlpha: 0.135,
|
||||
goldenOverlayAlpha: 0.045,
|
||||
bloomWashAlpha: 0.040,
|
||||
};
|
||||
|
||||
window.TarinaiData = { SPRITES, DECOR_ASSETS, ALPHA_BOUNDS, NEEDS, NAMES, CONFIG, FIELD_TYPES, LIGHTING_TUNING };
|
||||
491
js/items.js
Normal file
|
|
@ -0,0 +1,491 @@
|
|||
"use strict";
|
||||
|
||||
class Item {
|
||||
constructor(type, x, y) {
|
||||
this.id = crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`;
|
||||
this.type = type;
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.r = {
|
||||
food: 14, sweet: 13, water: 12, grass: 17, stone: 20, bed: 37, firecracker: 15, fence_v: 42, fence_h: 42, trace: 16, splat: 26, zunchi: 14
|
||||
}[type] || 12;
|
||||
this.amount = {
|
||||
food: 85, sweet: 62, water: 64, grass: 120, stone: 999, bed: 999, firecracker: 999, fence_v: 999, fence_h: 999, trace: 220, splat: 260, zunchi: 240
|
||||
}[type] || 80;
|
||||
this.age = 0;
|
||||
this.seed = Math.random() * 1000;
|
||||
this.dropTimer = 0;
|
||||
this.dropMax = 0;
|
||||
this.dropImpactDone = false;
|
||||
this.lifecycleTimer = rand(0, type === "grass" ? 1.15 : 0.55);
|
||||
this.lifecycleInterval = type === "grass" ? (1.75 + stableUnit(this.id, "grass-life") * 0.90) : (type === "zunchi" ? (0.95 + stableUnit(this.id, "zunchi-life") * 0.45) : 0);
|
||||
if (type === "grass") {
|
||||
this.growth = rand(0.18, 0.42);
|
||||
this.health = 1;
|
||||
this.seedTimer = rand(18, 38);
|
||||
this.eatenAmount = 0;
|
||||
this.fertilityBoost = 0;
|
||||
}
|
||||
if (type === "bed") {
|
||||
this.comfort = rand(0.86, 1.14);
|
||||
this.wear = 0;
|
||||
}
|
||||
if (type === "zunchi") {
|
||||
this.stage = "fresh";
|
||||
this.stageTimer = 0;
|
||||
this.fertility = 0;
|
||||
this.freshness = 1;
|
||||
this.zunchiVariant = stableUnit(this.id, "zunchi-variant") < 0.5 ? "zunchi" : "zunchi_02";
|
||||
}
|
||||
if (type === "firecracker") {
|
||||
this.fuseTimer = 5;
|
||||
this.fuseMax = 5;
|
||||
}
|
||||
}
|
||||
update(dt, worldRef = world) {
|
||||
this.age += dt;
|
||||
if (this.dropTimer > 0) {
|
||||
const beforeDrop = this.dropTimer;
|
||||
this.dropTimer = Math.max(0, this.dropTimer - dt);
|
||||
if (beforeDrop > 0 && this.dropTimer <= 0 && !this.dropImpactDone) {
|
||||
this.dropImpactDone = true;
|
||||
if (worldRef.itemDropImpact) worldRef.itemDropImpact(this);
|
||||
}
|
||||
}
|
||||
if (this.type === "water") this.amount -= dt * 0.9;
|
||||
if (this.type === "sweet") this.amount -= dt * 0.12;
|
||||
if (this.type === "trace") this.amount -= dt * 1.35;
|
||||
if (this.type === "splat") this.amount -= dt * 1.05;
|
||||
if (this.type === "firecracker") {
|
||||
this.fuseTimer = Math.max(0, (this.fuseTimer ?? 5) - dt);
|
||||
if (this.fuseTimer <= 0) {
|
||||
if (worldRef.explodeFirecracker) worldRef.explodeFirecracker(this);
|
||||
else this.amount = 0;
|
||||
}
|
||||
}
|
||||
if (this.type === "grass") {
|
||||
this.lifecycleTimer += dt;
|
||||
const factor = worldRef.grassUpdateFactor ? worldRef.grassUpdateFactor() : 1;
|
||||
const interval = (this.lifecycleInterval || 1.9) * factor;
|
||||
if (this.lifecycleTimer >= interval) {
|
||||
const lifecycleDt = Math.min(this.lifecycleTimer, 4.8);
|
||||
this.lifecycleTimer = 0;
|
||||
this.updateGrass(lifecycleDt, worldRef);
|
||||
}
|
||||
} else if (this.type === "zunchi") {
|
||||
this.lifecycleTimer += dt;
|
||||
const interval = this.lifecycleInterval || (0.95 + stableUnit(this.id, "zunchi-life") * 0.45);
|
||||
if (this.lifecycleTimer >= interval) {
|
||||
const lifecycleDt = Math.min(this.lifecycleTimer, 2.4);
|
||||
this.lifecycleTimer = 0;
|
||||
this.updateZunchi(lifecycleDt, worldRef);
|
||||
}
|
||||
}
|
||||
}
|
||||
updateGrass(dt, worldRef) {
|
||||
const light = worldRef.lightLevel ? worldRef.lightLevel() : 0.7;
|
||||
const weather = worldRef.weather || "sunny";
|
||||
const grassLoad = worldRef.grassLoadLevel ? worldRef.grassLoadLevel() : 0;
|
||||
if (worldRef.grassBlockedAt && worldRef.grassBlockedAt(this.x, this.y, this)) {
|
||||
this.growth = Math.max(0, (this.growth ?? 0.25) - dt * 0.28);
|
||||
this.amount -= dt * 34;
|
||||
return;
|
||||
}
|
||||
let fertility = 0;
|
||||
let fertileSource = null;
|
||||
let bestFertility = 0;
|
||||
let waterNear = weather === "light_rain" ? 0.35 : 0;
|
||||
const nearbyRadius = grassLoad >= 3 ? 190 : grassLoad >= 2 ? 230 : grassLoad >= 1 ? 290 : 360;
|
||||
const nearby = worldRef.nearbyItems ? worldRef.nearbyItems(this.x, this.y, nearbyRadius) : (worldRef.items || []);
|
||||
let scanned = 0;
|
||||
const scanLimit = grassLoad >= 3 ? 18 : grassLoad >= 2 ? 26 : grassLoad >= 1 ? 40 : 9999;
|
||||
for (const it of nearby) {
|
||||
if (++scanned > scanLimit) break;
|
||||
if (it === this) continue;
|
||||
const d = distXY(this.x, this.y, it.x, it.y);
|
||||
if ((it.type === "trace" || it.type === "splat") && d < 170) {
|
||||
const influence = clamp(1 - d / 170, 0, 1) * (it.type === "splat" ? 0.92 : 0.55);
|
||||
fertility += influence;
|
||||
if (influence > bestFertility) { bestFertility = influence; fertileSource = it; }
|
||||
}
|
||||
if (it.type === "zunchi" && (it.stage === "decomposing" || it.stage === "fertile_soil")) {
|
||||
const influence = clamp(1 - d / nearbyRadius, 0, 1) * (it.stage === "fertile_soil" ? 1.25 : 0.72);
|
||||
fertility += influence;
|
||||
if (influence > bestFertility) {
|
||||
bestFertility = influence;
|
||||
fertileSource = it;
|
||||
}
|
||||
}
|
||||
if (it.type === "water") waterNear += clamp(1 - d / 90, 0, 1) * 0.25;
|
||||
}
|
||||
this.fertilityBoost = clamp(fertility, 0, 1.4);
|
||||
const weatherBoost = weather === "sunny" ? 0.09 : weather === "light_rain" ? 0.42 : 0.08;
|
||||
const dryStress = weather === "sunny" && light > 0.78 && waterNear < 0.04 && this.fertilityBoost < 0.12 ? 0.012 : 0;
|
||||
const growthRate = (0.006 + light * 0.004 + weatherBoost * 0.01 + waterNear * 0.014 + this.fertilityBoost * 0.014) * dt * 0.5;
|
||||
this.growth = clamp((this.growth ?? 0.25) + growthRate - dryStress * dt, 0, 0.88);
|
||||
this.health = clamp((this.health ?? 1) + dt * (waterNear > 0.05 || weather === "light_rain" ? 0.017 : -0.0015 - dryStress * 0.28), 0.18, 1);
|
||||
const grassLimit = CONFIG.grassLimit ?? 96;
|
||||
if (this.growth >= 0.86 && (worldRef.itemCounts?.grass || 0) < grassLimit) {
|
||||
const spawned = this.seedAround(worldRef, fertileSource, this.fertilityBoost > 0.25, 2);
|
||||
if (spawned > 0) this.growth = rand(0.52, 0.68);
|
||||
this.seedTimer = rand(18, 34);
|
||||
}
|
||||
this.amount = clamp(this.growth * 120 * this.health, 3, 130);
|
||||
this.seedTimer -= dt * (this.growth > 0.72 ? 1 : 0.25) * 0.5;
|
||||
if (this.seedTimer <= 0 && this.growth > 0.64 && (worldRef.itemCounts?.grass || 0) < grassLimit) {
|
||||
this.seedTimer = rand(24, 54);
|
||||
if (Math.random() < 0.42 + this.fertilityBoost * 0.18) this.seedAround(worldRef, fertileSource, this.fertilityBoost > 0.25, 1);
|
||||
}
|
||||
}
|
||||
seedAround(worldRef, fertileSource = null, fertile = false, maxNew = 1) {
|
||||
let made = 0;
|
||||
const limit = CONFIG.grassLimit ?? 96;
|
||||
for (let n = 0; n < maxNew && (worldRef.itemCounts?.grass || 0) < limit; n++) {
|
||||
const source = fertile && fertileSource ? fertileSource : this;
|
||||
const spot = worldRef.findGrassSproutSpot
|
||||
? worldRef.findGrassSproutSpot(source, Boolean(fertile && fertileSource))
|
||||
: { x: clamp(this.x + rand(-58, 58), 44, worldRef.w - 44), y: clamp(this.y + rand(-42, 42), 44, worldRef.h - 44) };
|
||||
if (!spot) break;
|
||||
const sprout = new Item("grass", spot.x, spot.y);
|
||||
sprout.growth = rand(0.08, 0.18);
|
||||
sprout.amount = sprout.growth * 120;
|
||||
worldRef.items.push(sprout);
|
||||
if (worldRef.itemCounts) worldRef.itemCounts.grass = (worldRef.itemCounts.grass || 0) + 1;
|
||||
made += 1;
|
||||
}
|
||||
return made;
|
||||
}
|
||||
|
||||
updateZunchi(dt, worldRef) {
|
||||
const rainBoost = worldRef.weather === "light_rain" ? 1.35 : 1;
|
||||
let waterBoost = 0;
|
||||
const nearby = worldRef.nearbyItems ? worldRef.nearbyItems(this.x, this.y, 84) : (worldRef.items || []);
|
||||
for (const it of nearby) {
|
||||
if (it.type !== "water") continue;
|
||||
waterBoost += clamp(1 - distXY(this.x, this.y, it.x, it.y) / 80, 0, 1);
|
||||
}
|
||||
this.stageTimer += dt * 2.0 * (rainBoost + waterBoost * 1.3);
|
||||
if (this.stage === "fresh") {
|
||||
this.freshness = clamp(1 - this.stageTimer / 110, 0, 1);
|
||||
if (this.stageTimer > 110) { this.stage = "dry"; this.stageTimer = 0; }
|
||||
} else if (this.stage === "dry") {
|
||||
this.freshness = clamp(0.55 - this.stageTimer / 220, 0.20, 0.55);
|
||||
if (this.stageTimer > 150) { this.stage = "decomposing"; this.stageTimer = 0; this.fertility = 0.35; }
|
||||
} else if (this.stage === "decomposing") {
|
||||
this.fertility = clamp(this.fertility + dt * 0.015, 0, 1);
|
||||
this.amount -= dt * 0.12;
|
||||
if (this.stageTimer > 190) { this.stage = "fertile_soil"; this.stageTimer = 0; this.amount = Math.min(this.amount, 140); }
|
||||
} else if (this.stage === "fertile_soil") {
|
||||
this.fertility = clamp(1 - this.stageTimer / 360, 0, 1);
|
||||
this.amount -= dt * 0.26;
|
||||
}
|
||||
}
|
||||
get dead() {
|
||||
return this.amount <= 0;
|
||||
}
|
||||
serialize() {
|
||||
return {
|
||||
type: this.type, x: this.x, y: this.y, amount: this.amount, age: this.age, seed: this.seed,
|
||||
stage: this.stage, stageTimer: this.stageTimer, fertility: this.fertility, freshness: this.freshness,
|
||||
growth: this.growth, health: this.health, seedTimer: this.seedTimer, eatenAmount: this.eatenAmount, fertilityBoost: this.fertilityBoost,
|
||||
comfort: this.comfort, wear: this.wear, zunchiVariant: this.zunchiVariant, toolSize: this.toolSize, blastScale: this.blastScale, fuseTimer: this.fuseTimer, fuseMax: this.fuseMax, dropTimer: this.dropTimer, dropMax: this.dropMax
|
||||
};
|
||||
}
|
||||
static from(o) {
|
||||
const type = o?.type || "grass";
|
||||
const it = Object.create(Item.prototype);
|
||||
Item._loadId = (Item._loadId || 0) + 1;
|
||||
it.id = o.id || `loaded-${Item._loadId}`;
|
||||
it.type = type;
|
||||
it.x = o.x;
|
||||
it.y = o.y;
|
||||
it.r = {
|
||||
food: 14, sweet: 13, water: 12, grass: 17, stone: 20, bed: 37, firecracker: 15, fence_v: 42, fence_h: 42, trace: 16, splat: 26, zunchi: 14
|
||||
}[type] || 12;
|
||||
it.amount = o.amount ?? ({
|
||||
food: 85, sweet: 62, water: 64, grass: 120, stone: 999, bed: 999, firecracker: 999, fence_v: 999, fence_h: 999, trace: 220, splat: 260, zunchi: 240
|
||||
}[type] || 80);
|
||||
it.age = o.age || 0;
|
||||
it.seed = o.seed || Math.random() * 1000;
|
||||
it.dropTimer = 0;
|
||||
it.dropMax = 0;
|
||||
it.dropImpactDone = false;
|
||||
it.lifecycleTimer = o.lifecycleTimer ?? rand(0, type === "grass" ? 1.15 : 0.55);
|
||||
it.lifecycleInterval = o.lifecycleInterval ?? (type === "grass" ? (1.75 + stableUnit(it.id, "grass-life") * 0.90) : (type === "zunchi" ? (0.95 + stableUnit(it.id, "zunchi-life") * 0.45) : 0));
|
||||
if (type === "grass") {
|
||||
it.growth = 0.25;
|
||||
it.health = 1;
|
||||
it.seedTimer = rand(18, 38);
|
||||
it.eatenAmount = 0;
|
||||
it.fertilityBoost = 0;
|
||||
}
|
||||
if (type === "bed") {
|
||||
it.comfort = 1;
|
||||
it.wear = 0;
|
||||
}
|
||||
if (type === "zunchi") {
|
||||
it.stage = "fresh";
|
||||
it.stageTimer = 0;
|
||||
it.fertility = 0;
|
||||
it.freshness = 1;
|
||||
it.zunchiVariant = "zunchi";
|
||||
}
|
||||
if (type === "firecracker") {
|
||||
it.fuseTimer = 5;
|
||||
it.fuseMax = 5;
|
||||
}
|
||||
it.stage = o.stage || it.stage;
|
||||
it.stageTimer = o.stageTimer || 0;
|
||||
it.fertility = o.fertility || 0;
|
||||
it.freshness = o.freshness ?? it.freshness;
|
||||
it.growth = o.growth ?? it.growth;
|
||||
it.health = o.health ?? it.health;
|
||||
it.seedTimer = o.seedTimer ?? it.seedTimer;
|
||||
it.eatenAmount = o.eatenAmount || 0;
|
||||
it.fertilityBoost = o.fertilityBoost || 0;
|
||||
it.comfort = o.comfort ?? it.comfort;
|
||||
it.wear = o.wear ?? it.wear;
|
||||
it.zunchiVariant = o.zunchiVariant || it.zunchiVariant || "zunchi";
|
||||
it.toolSize = o.toolSize || it.toolSize || "medium";
|
||||
it.blastScale = o.blastScale || it.blastScale || 1;
|
||||
it.fuseTimer = o.fuseTimer ?? it.fuseTimer;
|
||||
it.fuseMax = o.fuseMax ?? it.fuseMax;
|
||||
it.dropTimer = 0;
|
||||
it.dropMax = 0;
|
||||
it.dropImpactDone = true;
|
||||
return it;
|
||||
}
|
||||
draw(ctx, t, lighting = null) {
|
||||
const lightState = lighting || getLightingState(world);
|
||||
const night = clamp(lightState.nightStrength * 1.35, 0, 1);
|
||||
const warm = lightState.warmth;
|
||||
const styleNight = lightState.light < 0.42;
|
||||
const styleWarm = !styleNight && (lightState.goldenStrength > 0.22 || warm > 0.55);
|
||||
const visibleAlpha = clamp(this.amount / 40, 0.24, 1);
|
||||
const dropT = this.dropMax > 0 ? clamp(this.dropTimer / this.dropMax, 0, 1) : 0;
|
||||
const dropY = dropT > 0 ? -170 * Math.pow(dropT, 0.42) : 0;
|
||||
const shadow = projectedShadowParams(lightState, Math.max(0.4, this.r / 18));
|
||||
if (this.type !== "trace" && this.type !== "splat") {
|
||||
const zunchiId = this.type === "zunchi" ? (this.zunchiVariant || "zunchi") : null;
|
||||
const zunchiImg = zunchiId ? (images.get(zunchiId) || images.get("zunchi")) : null;
|
||||
if (zunchiImg) {
|
||||
const metrics = getImageMetrics(zunchiId || "zunchi") || getImageMetrics("zunchi");
|
||||
const ratio = metrics?.ratio || 178 / 236;
|
||||
const zunchiH = this.r * 1.66 * ratio;
|
||||
drawImageProjectedShadow(ctx, zunchiImg, this.x, this.y + imageVisibleBottomFromTop(zunchiImg, -this.r * 1.35, zunchiH), this.r * 2.2, zunchiH, shadow, {
|
||||
alpha: shadow.alpha * visibleAlpha * 1.18,
|
||||
widthScale: 0.92,
|
||||
heightScale: 0.92,
|
||||
});
|
||||
} else if (this.type === "grass") {
|
||||
ctx.save();
|
||||
ctx.globalAlpha = clamp(shadow.alpha * visibleAlpha * 0.62, 0.035, 0.13);
|
||||
ctx.fillStyle = shadow.color;
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(this.x, this.y + this.r * 0.46, this.r * 0.62, this.r * 0.12, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
} else {
|
||||
drawProjectedShadow(ctx, this.x, this.y + this.r * 0.52, this.r * 0.82, this.r * 0.26, { ...shadow, alpha: shadow.alpha * visibleAlpha * 1.25 });
|
||||
}
|
||||
}
|
||||
drawItemGlow(ctx, this, lightState, visibleAlpha);
|
||||
if (world.pointer?.inside && this.type !== "trace" && this.type !== "splat" && distXY(this.x, this.y, world.pointer.x, world.pointer.y) < this.r * 2.2) {
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.32;
|
||||
ctx.strokeStyle = "rgba(255, 250, 220, 0.82)";
|
||||
ctx.lineWidth = 1.4;
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(this.x, this.y + this.r * 0.1, this.r * 1.35, this.r * 0.92, 0, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
ctx.save();
|
||||
ctx.translate(this.x, this.y + dropY);
|
||||
ctx.globalAlpha = visibleAlpha;
|
||||
if (this.type === "food" || this.type === "sweet") {
|
||||
const sweet = this.type === "sweet";
|
||||
ctx.fillStyle = sweet ? "#fffaf1" : "#f1dfb7";
|
||||
ctx.strokeStyle = sweet ? "#9a8a72" : "#8d6f48";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
const bob = 0;
|
||||
ctx.ellipse(0, bob, this.r * 1.25, this.r * 0.75, Math.sin(this.seed) * 0.4, 0, Math.PI * 2);
|
||||
ctx.fill(); ctx.stroke();
|
||||
if (sweet) {
|
||||
ctx.beginPath();
|
||||
ctx.fillStyle = "#76b94d";
|
||||
ctx.ellipse(-1, -this.r * 0.24, this.r * 0.76, this.r * 0.26, Math.sin(this.seed) * 0.25, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = "rgba(255,255,255,0.62)";
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(-this.r * 0.32, -this.r * 0.16, this.r * 0.24, this.r * 0.10, -0.35, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
} else {
|
||||
ctx.fillStyle = "#fff7dc";
|
||||
for (let i = 0; i < 5; i++) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(randSeed(this.seed + i, -8, 8), randSeed(this.seed + i + 7, -5, 5), 1.6, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
} else if (this.type === "water") {
|
||||
ctx.fillStyle = "rgba(86, 157, 224, 0.50)";
|
||||
ctx.strokeStyle = "rgba(62, 113, 178, 0.42)";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(0, Math.sin(t * 1.7 + this.seed) * 1.5, this.r * 1.05, this.r * 0.8, 0, 0, Math.PI * 2);
|
||||
ctx.fill(); ctx.stroke();
|
||||
ctx.fillStyle = "rgba(255,255,255,0.55)";
|
||||
ctx.beginPath(); ctx.arc(-4, -4, 3, 0, Math.PI * 2); ctx.fill();
|
||||
} else if (this.type === "grass") {
|
||||
const growth = this.growth ?? clamp(this.amount / 120, 0, 1);
|
||||
const stage = growth < 0.22 ? "sprout" : growth < 0.48 ? "young" : "mature";
|
||||
const eaten = this.eatenAmount > 14 || growth < 0.16;
|
||||
const grassLoad = 1;
|
||||
ctx.strokeStyle = eaten ? "#7b8755" : (styleNight ? "#294b40" : (styleWarm ? "#64ad3f" : "#4f8438"));
|
||||
ctx.shadowColor = "transparent";
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineWidth = grassLoad >= 2 ? (styleNight ? 1.3 : 1.5) : (styleNight ? 1.7 : 2);
|
||||
const matureBlades = grassLoad >= 3 ? 2 : grassLoad >= 2 ? 3 : grassLoad >= 1 ? 5 : 7;
|
||||
const bladeCount = eaten ? 2 : (stage === "sprout" ? 2 : stage === "young" ? (grassLoad >= 2 ? 2 : 3) : matureBlades);
|
||||
for (let i = 0; i < bladeCount; i++) {
|
||||
const spread = stage === "mature" ? (grassLoad >= 2 ? 0.92 : 1.18) : 0.70;
|
||||
const a = -Math.PI / 2 + randSeed(this.seed + i, -spread, spread);
|
||||
const len = this.r * randSeed(this.seed + i + 10, 0.45, stage === "mature" ? (grassLoad >= 2 ? 1.08 : 1.30) : 1.05) * clamp(growth, 0.18, 1.00) * (eaten ? 0.42 : 1);
|
||||
const rootX = stage === "mature" ? randSeed(this.seed + i + 31, -this.r * (grassLoad >= 2 ? 0.24 : 0.32), this.r * (grassLoad >= 2 ? 0.24 : 0.32)) : 0;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(rootX, 8);
|
||||
ctx.quadraticCurveTo(rootX + Math.cos(a) * len * 0.55, 8 - len * 0.24, rootX + Math.cos(a) * len, Math.sin(a) * len * 0.78);
|
||||
ctx.stroke();
|
||||
}
|
||||
if (stage === "mature" && !eaten && grassLoad <= 1) {
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.16;
|
||||
ctx.fillStyle = styleNight ? "rgba(95,132,114,0.65)" : "rgba(138,189,108,0.72)";
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(0, 1, this.r * 0.72, this.r * 0.22, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
} else if (this.type === "stone") {
|
||||
ctx.fillStyle = styleNight ? "#8f94a6" : (styleWarm ? "#e4d6b5" : "#d9d0b8");
|
||||
ctx.strokeStyle = styleNight ? "#5f6478" : "#8a7c63";
|
||||
ctx.shadowColor = "transparent";
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.lineWidth = 2;
|
||||
roundedBlob(ctx, 0, 0, this.r * 1.25, this.r * 0.85, 8);
|
||||
ctx.fill(); ctx.stroke();
|
||||
ctx.fillStyle = "rgba(255,255,255,0.34)";
|
||||
ctx.beginPath(); ctx.ellipse(-6, -5, 6, 3, -0.3, 0, Math.PI * 2); ctx.fill();
|
||||
} else if (this.type === "bed") {
|
||||
ctx.fillStyle = styleWarm ? "rgba(224, 190, 94, 0.90)" : (styleNight ? "rgba(177, 156, 95, 0.84)" : "rgba(205, 169, 86, 0.88)" );
|
||||
ctx.strokeStyle = styleWarm ? "rgba(135, 101, 45, 0.62)" : (styleNight ? "rgba(102, 88, 56, 0.68)" : "rgba(117, 91, 47, 0.68)" );
|
||||
ctx.lineWidth = 2.2;
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(0, 3, this.r * 1.45, this.r * 0.72, -0.08, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
ctx.strokeStyle = "rgba(116, 89, 42, 0.45)";
|
||||
ctx.lineWidth = 1.5;
|
||||
for (let i = 0; i < 13; i++) {
|
||||
const x = randSeed(this.seed + i, -this.r * 1.10, this.r * 1.08);
|
||||
const y = randSeed(this.seed + i + 20, -this.r * 0.36, this.r * 0.46);
|
||||
const len = randSeed(this.seed + i + 40, this.r * 0.34, this.r * 0.70);
|
||||
const a = randSeed(this.seed + i + 60, -0.75, 0.75);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x - Math.cos(a) * len * 0.5, y - Math.sin(a) * len * 0.5);
|
||||
ctx.lineTo(x + Math.cos(a) * len * 0.5, y + Math.sin(a) * len * 0.5);
|
||||
ctx.stroke();
|
||||
}
|
||||
} else if (this.type === "firecracker") {
|
||||
const fuse = clamp((this.fuseTimer ?? 5) / Math.max(this.fuseMax || 5, 0.1), 0, 1);
|
||||
const flash = fuse < 0.35 ? (0.5 + Math.sin(t * 20 + this.seed) * 0.5) : 0;
|
||||
ctx.rotate(-0.22 + Math.sin(this.seed) * 0.12);
|
||||
ctx.fillStyle = flash > 0.65 ? "#ffed77" : "#d94b43";
|
||||
ctx.strokeStyle = "rgba(96,48,34,0.78)";
|
||||
ctx.lineWidth = 2;
|
||||
roundedRect(ctx, -this.r * 0.72, -this.r * 0.86, this.r * 1.44, this.r * 1.72, 5);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = "#f4d15b";
|
||||
ctx.fillRect(-this.r * 0.55, -this.r * 0.54, this.r * 1.1, this.r * 0.20);
|
||||
ctx.fillRect(-this.r * 0.55, this.r * 0.34, this.r * 1.1, this.r * 0.20);
|
||||
ctx.strokeStyle = "rgba(74,55,39,0.82)";
|
||||
ctx.lineWidth = 2.2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, -this.r * 0.88);
|
||||
ctx.quadraticCurveTo(this.r * 0.32, -this.r * 1.34, this.r * 0.92, -this.r * 1.42);
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = flash > 0.1 ? "rgba(255,221,82,0.92)" : "rgba(255,162,62,0.78)";
|
||||
ctx.beginPath();
|
||||
ctx.arc(this.r * 0.98, -this.r * 1.42, 2.5 + flash * 2.2, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = "rgba(42,36,29,0.66)";
|
||||
ctx.font = "bold 10px ui-rounded, sans-serif";
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText(String(Math.ceil(this.fuseTimer ?? 5)), 0, this.r * 1.42);
|
||||
} else if (this.type === "fence_v" || this.type === "fence_h") {
|
||||
const vertical = this.type === "fence_v";
|
||||
const len = Math.max(112, this.r * 3.55);
|
||||
const thick = Math.max(10, this.r * 0.31);
|
||||
ctx.save();
|
||||
ctx.rotate(vertical ? 0 : Math.PI / 2);
|
||||
const wood = styleNight ? "#7a5b3c" : (styleWarm ? "#a96f35" : "#965f31");
|
||||
const edge = styleNight ? "#4d3d2f" : "#5f3f25";
|
||||
const hi = styleNight ? "rgba(198,166,116,0.20)" : "rgba(232,176,94,0.32)";
|
||||
ctx.fillStyle = wood;
|
||||
ctx.strokeStyle = edge;
|
||||
ctx.lineWidth = 2.4;
|
||||
ctx.beginPath();
|
||||
roundedRect(ctx, -thick / 2, -len / 2, thick, len, thick / 2);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = hi;
|
||||
ctx.beginPath();
|
||||
roundedRect(ctx, -thick * 0.26, -len / 2 + 6, thick * 0.20, len - 12, thick / 3);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
} else if (this.type === "zunchi") {
|
||||
const zunchiId = this.zunchiVariant || "zunchi";
|
||||
const img = images.get(zunchiId) || images.get("zunchi");
|
||||
if (img) {
|
||||
const metrics = getImageMetrics(zunchiId) || getImageMetrics("zunchi");
|
||||
const ratio = metrics?.ratio || 178 / 236;
|
||||
const stageAlpha = this.stage === "fresh" ? clamp(this.freshness ?? 1, 0.45, 1) : this.stage === "dry" ? 0.82 : this.stage === "decomposing" ? 0.62 : clamp(this.fertility ?? 0.28, 0.24, 0.46);
|
||||
ctx.globalAlpha *= stageAlpha;
|
||||
ctx.drawImage(img, -this.r * 1.1, -this.r * 1.35, this.r * 2.2, this.r * 1.66 * ratio);
|
||||
}
|
||||
} else if (this.type === "trace") {
|
||||
ctx.fillStyle = "rgba(122, 100, 76, 0.12)";
|
||||
ctx.strokeStyle = "rgba(122, 100, 76, 0.16)";
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(0, 0, this.r * 1.3, this.r * 0.75, Math.sin(this.seed) * 0.7, 0, Math.PI * 2);
|
||||
ctx.fill(); ctx.stroke();
|
||||
} else if (this.type === "splat") {
|
||||
const fade = clamp(this.amount / 260, 0, 1);
|
||||
ctx.globalAlpha = visibleAlpha * fade;
|
||||
ctx.fillStyle = `rgba(123, 214, 52, ${0.34 * fade})`;
|
||||
ctx.strokeStyle = `rgba(85, 160, 38, ${0.22 * fade})`;
|
||||
ctx.lineWidth = 1.8;
|
||||
const blobs = [
|
||||
[-12, -2, 10, 8], [0, 0, 16, 10], [13, 5, 10, 8], [-2, -12, 9, 7], [7, -8, 7, 5], [-18, 8, 6, 5]
|
||||
];
|
||||
for (const [bx, by, bw, bh] of blobs) {
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(bx, by, bw, bh, randSeed(this.seed + bx * 0.1, -0.5, 0.5), 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
}
|
||||
for (let i = 0; i < 7; i++) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(randSeed(this.seed + i, -28, 24), randSeed(this.seed + i + 9, -18, 18), randSeed(this.seed + i + 19, 2.5, 4.8), 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
52
js/main.js
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
"use strict";
|
||||
let last = nowSec();
|
||||
let statsTimer = 0;
|
||||
let fpsTimer = 0;
|
||||
let fpsFrames = 0;
|
||||
let perfResizeTimer = 0;
|
||||
let lastPerfLevel = 0;
|
||||
window.__tarinaiFps = 0;
|
||||
function loop() {
|
||||
const t = nowSec();
|
||||
const rawDt = t - last;
|
||||
const dt = clamp(rawDt, 0, 0.05);
|
||||
last = t;
|
||||
fpsTimer += rawDt;
|
||||
fpsFrames += 1;
|
||||
if (fpsTimer >= 0.5) {
|
||||
window.__tarinaiFps = Math.round(fpsFrames / Math.max(fpsTimer, 0.001));
|
||||
fpsTimer = 0;
|
||||
fpsFrames = 0;
|
||||
}
|
||||
world.update(dt);
|
||||
render();
|
||||
statsTimer += dt;
|
||||
const perf = world.performanceLevel ? world.performanceLevel() : 0;
|
||||
perfResizeTimer += rawDt;
|
||||
if (perfResizeTimer > 1.5) {
|
||||
if (perf !== lastPerfLevel) {
|
||||
lastPerfLevel = perf;
|
||||
resizeCanvas();
|
||||
}
|
||||
perfResizeTimer = 0;
|
||||
}
|
||||
const statsInterval = perf >= 3 ? 1.20 : perf === 2 ? 0.92 : perf === 1 ? 0.68 : 0.48;
|
||||
if (statsTimer > statsInterval) {
|
||||
renderStats();
|
||||
statsTimer = 0;
|
||||
}
|
||||
requestAnimationFrame(loop);
|
||||
}
|
||||
|
||||
loadImages().then(() => {
|
||||
bindUI();
|
||||
applyFieldLayout("garden");
|
||||
world.reset(null, "garden");
|
||||
selectTool("observe");
|
||||
renderLog(world.logs);
|
||||
renderArchive();
|
||||
render();
|
||||
renderStats();
|
||||
syncTopButtons();
|
||||
loop();
|
||||
});
|
||||
13
js/math.js
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
"use strict";
|
||||
const rand = (min, max) => min + Math.random() * (max - min);
|
||||
const randi = (min, max) => Math.floor(rand(min, max + 1));
|
||||
const pick = (arr) => arr[Math.floor(Math.random() * arr.length)];
|
||||
const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
|
||||
const lerp = (a, b, t) => a + (b - a) * t;
|
||||
const dist = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
|
||||
const distXY = (ax, ay, bx, by) => Math.hypot(ax - bx, ay - by);
|
||||
const nowSec = () => performance.now() / 1000;
|
||||
const fmt = (v) => Math.round(v).toString();
|
||||
|
||||
|
||||
window.TarinaiMath = { rand, randi, pick, clamp, lerp, dist, distXY, nowSec, fmt };
|
||||
652
js/render.js
Normal file
|
|
@ -0,0 +1,652 @@
|
|||
"use strict";
|
||||
function randSeed(seed, min, max) {
|
||||
const s = Math.sin(seed * 12.9898) * 43758.5453;
|
||||
return min + (s - Math.floor(s)) * (max - min);
|
||||
}
|
||||
|
||||
function roundedBlob(ctx, x, y, w, h, wobble = 4) {
|
||||
ctx.beginPath();
|
||||
const steps = 28;
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const a = (i / steps) * Math.PI * 2;
|
||||
const rr = 1 + Math.sin(a * 3.1) * 0.025 + Math.cos(a * 5.7) * 0.018;
|
||||
const px = x + Math.cos(a) * w * rr + Math.sin(a * 2.0) * wobble * 0.08;
|
||||
const py = y + Math.sin(a) * h * rr + Math.cos(a * 1.7) * wobble * 0.08;
|
||||
if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
|
||||
}
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
function getLightingState(targetWorld) {
|
||||
const progress = targetWorld.dayProgress();
|
||||
const light = targetWorld.lightLevel();
|
||||
const dawnStrength = clamp(1 - Math.abs(progress - 0.22) / 0.14, 0, 1);
|
||||
const duskStrength = clamp(1 - Math.abs(progress - 0.58) / 0.16, 0, 1);
|
||||
const goldenStrength = Math.max(dawnStrength * 0.62, duskStrength);
|
||||
const nightStrength = clamp(1 - light * 1.62, 0, 0.54);
|
||||
const noonStrength = clamp((light - 0.72) / 0.28, 0, 1) * clamp(1 - goldenStrength * 0.72, 0, 1);
|
||||
const elevation = clamp(Math.sin(progress * Math.PI), 0.10, 1);
|
||||
const horizon = Math.cos(progress * Math.PI * 2 - Math.PI / 2);
|
||||
const shadowLength = lerp(1.58, 0.52, elevation);
|
||||
const warmth = clamp(0.28 + goldenStrength * 0.70 + noonStrength * 0.18 - nightStrength * 0.12, 0, 1);
|
||||
const bloom = CONFIG.lightingQuality === "cinematic" ? clamp(goldenStrength * 0.42 + nightStrength * 0.18 + noonStrength * 0.14, 0, 0.58) : 0;
|
||||
return {
|
||||
quality: CONFIG.lightingQuality,
|
||||
progress,
|
||||
light,
|
||||
dawnStrength,
|
||||
duskStrength,
|
||||
goldenStrength,
|
||||
nightStrength,
|
||||
noonStrength,
|
||||
elevation,
|
||||
warmth,
|
||||
bloom,
|
||||
sunDirX: -horizon,
|
||||
sunDirY: 0.42 + (1 - elevation) * 0.32,
|
||||
shadowLength,
|
||||
shadowColor: nightStrength > 0.22 ? "rgba(40, 50, 91, 0.36)" : (goldenStrength > 0.15 ? "rgba(116, 76, 48, 0.28)" : "rgba(83, 72, 54, 0.27)"),
|
||||
shadowAlpha: (nightStrength > 0.22 ? 0.135 : lerp(0.13, 0.22, 1 - elevation) + goldenStrength * 0.035),
|
||||
ambientTint: nightStrength > 0.2 ? "#253a8e" : (goldenStrength > 0.2 ? "#ffad6d" : "#fff7d6"),
|
||||
vignetteAlpha: nightStrength > 0.18 ? 0.13 + nightStrength * 0.13 : 0.06 + goldenStrength * 0.045,
|
||||
};
|
||||
}
|
||||
|
||||
function projectedShadowParams(lighting, scale = 1) {
|
||||
const edgeLight = clamp((1 - lighting.elevation) * 0.60 + lighting.goldenStrength * 0.40 + lighting.nightStrength * 0.55, 0, 1);
|
||||
const moonLit = lighting.nightStrength > 0.18;
|
||||
const lightEdge = moonLit
|
||||
? (lighting.progress < 0.5 ? 1 : -1)
|
||||
: (lighting.progress < 0.5 ? -1 : 1);
|
||||
const castDir = -lightEdge;
|
||||
const weather = world.weather || "sunny";
|
||||
const weatherAlpha = weather === "sunny" ? 1.12 : weather === "cloudy" ? 0.72 : 0.38;
|
||||
const length = lerp(0.62, 1.82, edgeLight) * scale;
|
||||
return {
|
||||
dx: castDir * 12 * length,
|
||||
dy: 5.2 * length,
|
||||
skew: castDir * lerp(0.08, 0.34, edgeLight),
|
||||
flatness: lerp(0.42, 0.24, edgeLight),
|
||||
spread: lerp(0.98, 1.52, edgeLight),
|
||||
alpha: clamp((0.16 + edgeLight * 0.09 - lighting.nightStrength * 0.04) * (0.92 + scale * 0.12) * weatherAlpha, 0.05, 0.32),
|
||||
color: lighting.nightStrength > 0.22 ? "rgba(42, 48, 72, 0.34)" : "rgba(78, 65, 43, 0.31)",
|
||||
};
|
||||
}
|
||||
|
||||
function imageVisibleBottomFromTop(img, top, drawH) {
|
||||
const b = imageAlphaBounds(img);
|
||||
if (!b) return top + drawH;
|
||||
return top + ((b.y + b.h) / b.imageH) * drawH;
|
||||
}
|
||||
|
||||
function drawImageProjectedShadow(ctx, img, x, groundY, drawW, drawH, params, options = {}) {
|
||||
if (!img) return false;
|
||||
const alpha = options.alpha ?? params.alpha;
|
||||
const rx = Math.max(5, drawW * (options.widthScale ?? 1) * 0.30);
|
||||
const ry = Math.max(2, drawH * (options.heightScale ?? 1) * 0.055);
|
||||
ctx.save();
|
||||
ctx.globalAlpha = clamp(alpha, 0.08, 0.22);
|
||||
ctx.fillStyle = params.color;
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(Math.round(x), Math.round(groundY), rx, ry, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
return true;
|
||||
}
|
||||
|
||||
function drawProjectedShadow(ctx, x, y, rx, ry, params) {
|
||||
ctx.save();
|
||||
ctx.globalAlpha = clamp(params.alpha || 0.10, 0.035, 0.16);
|
||||
ctx.fillStyle = params.color || "rgba(78, 65, 43, 0.24)";
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(Math.round(x), Math.round(y), Math.max(3, rx * 0.82), Math.max(1.5, ry * 0.46), 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawRadialGlow(ctx, x, y, radius, color, alpha) {
|
||||
if (alpha <= 0 || radius <= 0) return;
|
||||
ctx.save();
|
||||
ctx.globalCompositeOperation = "lighter";
|
||||
const g = ctx.createRadialGradient(x, y, 0, x, y, radius);
|
||||
g.addColorStop(0, color.replace("ALPHA", alpha.toFixed(3)));
|
||||
g.addColorStop(0.52, color.replace("ALPHA", (alpha * 0.34).toFixed(3)));
|
||||
g.addColorStop(1, color.replace("ALPHA", "0"));
|
||||
ctx.fillStyle = g;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, radius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawItemGlow(ctx, item, lighting, visibleAlpha = 1) {
|
||||
// \u500b\u5225\u306e\u767a\u5149\u30b0\u30e9\u30c7\u30fc\u30b7\u30e7\u30f3\u306f\u500b\u4f53\u6570\u5897\u52a0\u6642\u306e\u8ca0\u8377\u304c\u9ad8\u3044\u305f\u3081\u3001\u901a\u5e38\u63cf\u753b\u306b\u7d71\u5408\u3059\u308b\u3002
|
||||
return;
|
||||
}
|
||||
|
||||
function drawCreatureGlow(ctx, tarinai, drawW, drawH, lighting) {
|
||||
if (tarinai.dead || tarinai.world.selected !== tarinai) return;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.10;
|
||||
ctx.fillStyle = "rgba(178, 236, 96, 0.42)";
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(tarinai.x, tarinai.y - drawH * 0.06, drawW * 0.58, drawH * 0.42, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawDawnRimLight(ctx, drawW, drawH, lighting) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Cached static garden layer: expensive soil/decor loops are rendered only when
|
||||
// canvas size, coarse light phase, or weather changes.
|
||||
let activeCtx = null;
|
||||
|
||||
const backgroundCache = {
|
||||
canvas: null,
|
||||
ctx: null,
|
||||
w: 0,
|
||||
h: 0,
|
||||
key: "",
|
||||
};
|
||||
|
||||
function backgroundCacheKey(world, lighting) {
|
||||
const weather = world.weather || "sunny";
|
||||
const field = world.fieldType || "garden";
|
||||
const perf = world.performanceLevel ? world.performanceLevel() : 0;
|
||||
const step = perf >= 3 ? 64 : perf === 2 ? 96 : 180;
|
||||
const progressBucket = Math.round((lighting.progress || 0) * step);
|
||||
const weatherBucket = Math.floor((world.time || 0) / (perf >= 2 ? 4 : 2));
|
||||
return `${field}:${weather}:${progressBucket}:${weatherBucket}`;
|
||||
}
|
||||
|
||||
function ensureBackgroundCache(w, h, lighting) {
|
||||
const key = backgroundCacheKey(world, lighting);
|
||||
if (!backgroundCache.canvas) {
|
||||
backgroundCache.canvas = document.createElement("canvas");
|
||||
backgroundCache.ctx = backgroundCache.canvas.getContext("2d");
|
||||
}
|
||||
if (backgroundCache.w === w && backgroundCache.h === h && backgroundCache.key === key) {
|
||||
return backgroundCache.canvas;
|
||||
}
|
||||
|
||||
backgroundCache.w = w;
|
||||
backgroundCache.h = h;
|
||||
backgroundCache.key = key;
|
||||
backgroundCache.canvas.width = Math.max(1, Math.floor(w));
|
||||
backgroundCache.canvas.height = Math.max(1, Math.floor(h));
|
||||
|
||||
const mainCtx = activeCtx;
|
||||
try {
|
||||
activeCtx = backgroundCache.ctx;
|
||||
drawGardenBackdrop(w, h, lighting);
|
||||
drawGardenBed(w, h, lighting);
|
||||
return backgroundCache.canvas;
|
||||
} catch (err) {
|
||||
console.error("background cache redraw failed", err);
|
||||
backgroundCache.key = "";
|
||||
return null;
|
||||
} finally {
|
||||
activeCtx = mainCtx;
|
||||
}
|
||||
}
|
||||
|
||||
function drawGardenBackdrop(w, h, lighting) {
|
||||
const light = lighting.light;
|
||||
const sky = activeCtx.createLinearGradient(0, 0, 0, h);
|
||||
const topColor = light > 0.5 ? `rgba(${Math.round(142 + light * 62 + lighting.goldenStrength * 24)}, ${Math.round(198 + light * 34 + lighting.goldenStrength * 10)}, ${Math.round(238 + light * 15 - lighting.goldenStrength * 24)}, 1)` : `rgba(${Math.round(42 + light * 78)}, ${Math.round(55 + light * 98)}, ${Math.round(95 + light * 124)}, 1)`;
|
||||
const midColor = light > 0.5 ? `rgba(${Math.round(214 + light * 28 + lighting.goldenStrength * 24)}, ${Math.round(235 + light * 12 + lighting.goldenStrength * 4)}, ${Math.round(246 - light * 8 - lighting.goldenStrength * 30)}, 1)` : `rgba(${Math.round(78 + light * 90)}, ${Math.round(88 + light * 108)}, ${Math.round(128 + light * 104)}, 1)`;
|
||||
const bottomColor = light > 0.5 ? `rgba(${Math.round(230 + lighting.goldenStrength * 18)}, ${Math.round(226 - lighting.goldenStrength * 10)}, ${Math.round(202 - lighting.goldenStrength * 22)}, 1)` : "rgba(74, 84, 119, 1)";
|
||||
sky.addColorStop(0, topColor);
|
||||
sky.addColorStop(0.48, midColor);
|
||||
sky.addColorStop(1, bottomColor);
|
||||
activeCtx.fillStyle = sky;
|
||||
activeCtx.fillRect(0, 0, w, h);
|
||||
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const cx = randSeed(300 + i, 80, w - 80);
|
||||
const cy = randSeed(420 + i, 28, h * 0.24);
|
||||
const alpha = light > 0.4 ? 0.09 + lighting.noonStrength * 0.04 : 0.04;
|
||||
activeCtx.save();
|
||||
activeCtx.fillStyle = `rgba(255,255,255,${alpha})`;
|
||||
for (let j = 0; j < 3; j++) {
|
||||
activeCtx.beginPath();
|
||||
activeCtx.ellipse(cx + j * 22, cy + Math.sin(j) * 5, 34, 18, 0, 0, Math.PI * 2);
|
||||
activeCtx.fill();
|
||||
}
|
||||
activeCtx.restore();
|
||||
}
|
||||
|
||||
if (light < 0.22) {
|
||||
activeCtx.save();
|
||||
activeCtx.fillStyle = `rgba(255,255,255,${0.62 + lighting.nightStrength * 0.30})`;
|
||||
for (let i = 0; i < 26; i++) {
|
||||
const sx = randSeed(i + Math.floor(world.time * 0.1), 40, w - 40);
|
||||
const sy = randSeed(i + Math.floor(world.time * 0.3) + 12, 26, h * 0.34);
|
||||
const size = randSeed(i + 910, 1.2, 2.4);
|
||||
activeCtx.fillRect(sx, sy, size, size);
|
||||
}
|
||||
activeCtx.restore();
|
||||
}
|
||||
|
||||
if (lighting.bloom > 0.08) {
|
||||
const sunX = w * (0.15 + (1 - lighting.progress) * 0.70);
|
||||
const sunY = h * (0.12 + (1 - lighting.elevation) * 0.22);
|
||||
drawRadialGlow(activeCtx, sunX, sunY, Math.max(w, h) * (0.24 + lighting.goldenStrength * 0.10), "rgba(255, 220, 138, ALPHA)", lighting.bloom * 0.10);
|
||||
}
|
||||
|
||||
if (world.weather === "cloudy") {
|
||||
activeCtx.fillStyle = "rgba(218, 223, 214, 0.16)";
|
||||
activeCtx.fillRect(0, 0, w, h);
|
||||
} else if (world.weather === "light_rain") {
|
||||
activeCtx.fillStyle = "rgba(178, 197, 208, 0.22)";
|
||||
activeCtx.fillRect(0, 0, w, h);
|
||||
}
|
||||
}
|
||||
|
||||
function drawGardenBed(w, h, lighting) {
|
||||
const light = lighting.light;
|
||||
const fieldType = world.fieldType || "garden";
|
||||
const x = 18, y = 22, bw = w - 36, bh = h - 44;
|
||||
const border = activeCtx.createLinearGradient(0, y, 0, y + bh);
|
||||
const borderTop = fieldType === "park" ? "#6f8f55" : fieldType === "cage" ? "#8b8f98" : (lighting.goldenStrength > 0.25 ? "#bf8f5e" : "#a88c66");
|
||||
const borderBottom = fieldType === "park" ? "#486d3d" : fieldType === "cage" ? "#5f6670" : (lighting.goldenStrength > 0.25 ? "#9c7043" : "#8a6f4b");
|
||||
border.addColorStop(0, light > 0.42 ? borderTop : "#6f6c72");
|
||||
border.addColorStop(1, light > 0.42 ? borderBottom : "#565a68");
|
||||
activeCtx.fillStyle = border;
|
||||
roundedRect(activeCtx, x, y, bw, bh, 34);
|
||||
activeCtx.fill();
|
||||
|
||||
activeCtx.save();
|
||||
activeCtx.globalAlpha = 0.14;
|
||||
activeCtx.strokeStyle = "rgba(255,255,255,0.85)";
|
||||
activeCtx.lineWidth = 2;
|
||||
roundedRect(activeCtx, x + 6, y + 6, bw - 12, bh - 12, 28);
|
||||
activeCtx.stroke();
|
||||
activeCtx.restore();
|
||||
|
||||
const soil = activeCtx.createLinearGradient(0, y + 14, 0, y + bh - 14);
|
||||
const palette = fieldType === "park"
|
||||
? ["#cfe5a8", "#b8d990", "#91bd74"]
|
||||
: fieldType === "cage"
|
||||
? ["#d7d2c5", "#c7c2b6", "#aaa79e"]
|
||||
: [lighting.goldenStrength > 0.25 ? "#f1dfbc" : "#ece6d2", lighting.goldenStrength > 0.25 ? "#e5d4b5" : "#dfdccb", lighting.goldenStrength > 0.25 ? "#d3c5a5" : "#d6d2bf"];
|
||||
soil.addColorStop(0, light > 0.42 ? palette[0] : "#bbbcc6");
|
||||
soil.addColorStop(0.38, light > 0.42 ? palette[1] : "#afb1bd");
|
||||
soil.addColorStop(1, light > 0.42 ? palette[2] : "#a0a3af");
|
||||
activeCtx.fillStyle = soil;
|
||||
roundedRect(activeCtx, x + 16, y + 14, bw - 32, bh - 30, 28);
|
||||
activeCtx.fill();
|
||||
|
||||
activeCtx.save();
|
||||
activeCtx.beginPath();
|
||||
roundedRect(activeCtx, x + 16, y + 14, bw - 32, bh - 30, 28);
|
||||
activeCtx.clip();
|
||||
|
||||
const speckCount = fieldType === "park" ? 72 : fieldType === "cage" ? 34 : 46;
|
||||
for (let i = 0; i < speckCount; i++) {
|
||||
const px = randSeed(900 + i, x + 24, x + bw - 24);
|
||||
const py = randSeed(1200 + i, y + 22, y + bh - 24);
|
||||
const r = randSeed(1600 + i, 1.2, 3.8);
|
||||
const a = randSeed(1900 + i, 0.03, 0.09) * (light > 0.4 ? 1.05 + lighting.goldenStrength * 0.35 : 0.70);
|
||||
activeCtx.fillStyle = `rgba(${Math.round(randSeed(2000 + i, 166, 196))}, ${Math.round(randSeed(2300 + i, 156, 182))}, ${Math.round(randSeed(2600 + i, 128, 150))}, ${a})`;
|
||||
activeCtx.beginPath();
|
||||
activeCtx.ellipse(px, py, r * 1.5, r, randSeed(3000 + i, -0.8, 0.8), 0, Math.PI * 2);
|
||||
activeCtx.fill();
|
||||
}
|
||||
|
||||
if (fieldType === "cage") {
|
||||
activeCtx.save();
|
||||
activeCtx.strokeStyle = light > 0.42 ? "rgba(98, 102, 108, 0.20)" : "rgba(220, 225, 235, 0.16)";
|
||||
activeCtx.lineWidth = 1.1;
|
||||
for (let gx = x + 44; gx < x + bw - 26; gx += 44) {
|
||||
activeCtx.beginPath(); activeCtx.moveTo(gx, y + 20); activeCtx.lineTo(gx, y + bh - 20); activeCtx.stroke();
|
||||
}
|
||||
for (let gy = y + 44; gy < y + bh - 24; gy += 44) {
|
||||
activeCtx.beginPath(); activeCtx.moveTo(x + 22, gy); activeCtx.lineTo(x + bw - 22, gy); activeCtx.stroke();
|
||||
}
|
||||
activeCtx.restore();
|
||||
} else if (fieldType === "park") {
|
||||
activeCtx.save();
|
||||
activeCtx.globalAlpha = light > 0.42 ? 0.24 : 0.14;
|
||||
for (let i = 0; i < 24; i++) {
|
||||
activeCtx.fillStyle = i % 2 ? "rgba(74, 132, 58, 0.35)" : "rgba(238, 222, 112, 0.32)";
|
||||
activeCtx.beginPath();
|
||||
activeCtx.ellipse(randSeed(5400 + i, x + 34, x + bw - 34), randSeed(5600 + i, y + 36, y + bh - 36), randSeed(5800 + i, 3, 8), randSeed(6000 + i, 2, 5), randSeed(6200 + i, -0.8, 0.8), 0, Math.PI * 2);
|
||||
activeCtx.fill();
|
||||
}
|
||||
activeCtx.restore();
|
||||
}
|
||||
|
||||
const patchCount = fieldType === "park" ? 14 : fieldType === "cage" ? 3 : 8;
|
||||
for (let i = 0; i < patchCount; i++) {
|
||||
const px = randSeed(3300 + i, x + 40, x + bw - 40);
|
||||
const py = randSeed(3600 + i, y + 44, y + bh - 46);
|
||||
const rw = randSeed(3900 + i, 24, 64);
|
||||
const rh = randSeed(4200 + i, 12, 28);
|
||||
activeCtx.fillStyle = light > 0.42 ? `rgba(161, 186, 118, ${0.07 + lighting.goldenStrength * 0.05})` : "rgba(118, 141, 132, 0.06)";
|
||||
activeCtx.beginPath();
|
||||
activeCtx.ellipse(px, py, rw, rh, randSeed(4500 + i, -1, 1), 0, Math.PI * 2);
|
||||
activeCtx.fill();
|
||||
}
|
||||
|
||||
// edge grasses and tiny flowers
|
||||
const edgeGrassCount = fieldType === "park" ? 24 : fieldType === "cage" ? 5 : 14;
|
||||
for (let i = 0; i < edgeGrassCount; i++) {
|
||||
const side = i % 2;
|
||||
const px = side === 0 ? randSeed(5000 + i, x + 36, x + bw - 36) : randSeed(5400 + i, x + 30, x + bw - 30);
|
||||
const py = side === 0 ? y + 20 + randSeed(5700 + i, -4, 8) : y + bh - 24 + randSeed(6000 + i, -8, 4);
|
||||
activeCtx.save();
|
||||
activeCtx.translate(px, py);
|
||||
activeCtx.strokeStyle = light > 0.34 ? `rgba(101, 157, 68, ${0.38 + lighting.goldenStrength * 0.16})` : "rgba(97, 126, 128, 0.25)";
|
||||
activeCtx.lineWidth = 1.4;
|
||||
for (let j = 0; j < 3; j++) {
|
||||
const len = randSeed(6300 + i * 5 + j, 7, 14);
|
||||
const dir = randSeed(6600 + i * 5 + j, -0.65, 0.65);
|
||||
activeCtx.beginPath();
|
||||
activeCtx.moveTo(0, 0);
|
||||
activeCtx.quadraticCurveTo(Math.sin(dir) * len * 0.35, -len * 0.45, Math.sin(dir) * len, -len);
|
||||
activeCtx.stroke();
|
||||
}
|
||||
activeCtx.restore();
|
||||
}
|
||||
|
||||
const flowerCount = fieldType === "park" ? 18 : fieldType === "cage" ? 0 : 9;
|
||||
for (let i = 0; i < flowerCount; i++) {
|
||||
const px = randSeed(7000 + i, x + 44, x + bw - 44);
|
||||
const py = randSeed(7400 + i, y + 46, y + bh - 44);
|
||||
if (i % 3 === 0) {
|
||||
activeCtx.save();
|
||||
activeCtx.translate(px, py);
|
||||
activeCtx.globalAlpha = 0.30;
|
||||
for (let p = 0; p < 4; p++) {
|
||||
activeCtx.fillStyle = p === 3 ? "rgba(248, 204, 82, 0.75)" : "rgba(255,255,255,0.80)";
|
||||
activeCtx.beginPath();
|
||||
if (p === 3) activeCtx.arc(0, 0, 1.6, 0, Math.PI * 2);
|
||||
else activeCtx.ellipse(Math.cos((p / 3) * Math.PI * 2) * 2.2, Math.sin((p / 3) * Math.PI * 2) * 2.2, 1.8, 1.3, 0, 0, Math.PI * 2);
|
||||
activeCtx.fill();
|
||||
}
|
||||
activeCtx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
const leafCount = fieldType === "park" ? 18 : fieldType === "cage" ? 4 : 10;
|
||||
for (let i = 0; i < leafCount; i++) {
|
||||
const px = randSeed(7800 + i, x + 36, x + bw - 36);
|
||||
const py = randSeed(8200 + i, y + 34, y + bh - 34);
|
||||
if (i % 2 === 0) {
|
||||
activeCtx.save();
|
||||
activeCtx.translate(px, py);
|
||||
activeCtx.rotate(randSeed(8500 + i, -1.3, 1.3));
|
||||
activeCtx.fillStyle = light > 0.45 ? `rgba(160, 179, 126, ${0.22 + lighting.goldenStrength * 0.12})` : "rgba(126, 144, 139, 0.16)";
|
||||
activeCtx.beginPath();
|
||||
activeCtx.ellipse(-4, 0, 4.8, 2.2, -0.4, 0, Math.PI * 2);
|
||||
activeCtx.ellipse(4, 0, 4.8, 2.2, 0.4, 0, Math.PI * 2);
|
||||
activeCtx.ellipse(0, -4, 4.8, 2.2, 0, 0, Math.PI * 2);
|
||||
activeCtx.fill();
|
||||
activeCtx.restore();
|
||||
} else {
|
||||
activeCtx.save();
|
||||
activeCtx.translate(px, py);
|
||||
activeCtx.rotate(randSeed(8800 + i, -1.1, 1.1));
|
||||
activeCtx.fillStyle = "rgba(158, 140, 108, 0.22)";
|
||||
activeCtx.beginPath();
|
||||
activeCtx.ellipse(0, 0, randSeed(9000 + i, 5, 9), randSeed(9300 + i, 2.2, 3.6), 0, 0, Math.PI * 2);
|
||||
activeCtx.fill();
|
||||
activeCtx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
activeCtx.restore();
|
||||
|
||||
// subtle vignette
|
||||
const vignette = activeCtx.createRadialGradient(w * 0.5, h * 0.5, Math.min(w, h) * 0.22, w * 0.5, h * 0.5, Math.max(w, h) * 0.66);
|
||||
vignette.addColorStop(0, "rgba(0,0,0,0)");
|
||||
vignette.addColorStop(1, light > 0.4 ? `rgba(94, 70, 48, ${0.09 + lighting.goldenStrength * 0.06})` : `rgba(16, 22, 52, ${0.17 + lighting.nightStrength * 0.14})`);
|
||||
activeCtx.fillStyle = vignette;
|
||||
activeCtx.fillRect(0, 0, w, h);
|
||||
}
|
||||
|
||||
function drawLightRays(ctx, w, h, lighting) {
|
||||
if (lighting.quality !== "cinematic" || lighting.goldenStrength <= 0.08) return;
|
||||
ctx.save();
|
||||
ctx.globalCompositeOperation = "screen";
|
||||
const rayAlpha = lighting.goldenStrength * LIGHTING_TUNING.rayAlpha;
|
||||
const originX = w * (lighting.dawnStrength > lighting.duskStrength ? 0.08 : 0.90);
|
||||
const originY = h * (0.02 + (1 - lighting.elevation) * 0.18);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const spread = (i - 1) * 0.20;
|
||||
const g = ctx.createLinearGradient(originX, originY, w * (0.50 + spread), h * (0.58 + i * 0.06));
|
||||
g.addColorStop(0, `rgba(255, 230, 170, ${rayAlpha * (1 - i * 0.10)})`);
|
||||
g.addColorStop(0.30, `rgba(255, 198, 120, ${rayAlpha * 0.18})`);
|
||||
g.addColorStop(1, "rgba(255, 188, 108, 0)");
|
||||
ctx.fillStyle = g;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(originX, originY);
|
||||
ctx.lineTo(w * (0.10 + i * 0.28), h);
|
||||
ctx.lineTo(w * (0.42 + i * 0.28), h);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawAtmosphericLighting(ctx, w, h, lighting) {
|
||||
ctx.save();
|
||||
if (lighting.nightStrength > 0) {
|
||||
ctx.globalAlpha = lighting.nightStrength * LIGHTING_TUNING.nightOverlayAlpha;
|
||||
ctx.fillStyle = "#223683";
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
}
|
||||
if (lighting.goldenStrength > 0) {
|
||||
ctx.globalAlpha = lighting.goldenStrength * LIGHTING_TUNING.goldenOverlayAlpha;
|
||||
ctx.fillStyle = "#ffad68";
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
}
|
||||
if (lighting.noonStrength > 0) {
|
||||
ctx.globalAlpha = lighting.noonStrength * 0.035;
|
||||
ctx.fillStyle = "#fff8cf";
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
}
|
||||
ctx.restore();
|
||||
|
||||
if (lighting.quality === "cinematic") {
|
||||
drawRadialGlow(ctx, w * 0.5, h * 0.46, Math.max(w, h) * 0.50, lighting.nightStrength > 0.18 ? "rgba(91, 121, 255, ALPHA)" : "rgba(255, 236, 175, ALPHA)", lighting.bloom * LIGHTING_TUNING.bloomWashAlpha);
|
||||
}
|
||||
|
||||
ctx.save();
|
||||
const vignette = ctx.createRadialGradient(w * 0.5, h * 0.46, Math.min(w, h) * 0.18, w * 0.5, h * 0.48, Math.max(w, h) * 0.76);
|
||||
vignette.addColorStop(0, "rgba(0,0,0,0)");
|
||||
vignette.addColorStop(0.72, "rgba(0,0,0,0)");
|
||||
vignette.addColorStop(1, `rgba(13, 17, 34, ${lighting.vignetteAlpha})`);
|
||||
ctx.fillStyle = vignette;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawRain(ctx, w, h, t) {
|
||||
if (world.weather !== "light_rain") return;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.28;
|
||||
ctx.strokeStyle = "rgba(197, 220, 230, 0.70)";
|
||||
ctx.lineWidth = 1;
|
||||
for (let i = 0; i < 34; i++) {
|
||||
const x = (randSeed(18000 + i, 0, w) + t * 26) % w;
|
||||
const y = (randSeed(18400 + i, 0, h) + t * 72) % h;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, y);
|
||||
ctx.lineTo(x - 4, y + 10);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function weatherLabel(weather) {
|
||||
return { sunny: "\u6674\u308c", cloudy: "\u66c7\u308a", light_rain: "\u5c0f\u96e8" }[weather] || "\u6674\u308c";
|
||||
}
|
||||
|
||||
function render() {
|
||||
activeCtx = ctx;
|
||||
const sceneW = world.w, sceneH = world.h;
|
||||
const screenW = world.viewportW || sceneW;
|
||||
const screenH = world.viewportH || sceneH;
|
||||
const fieldOffset = world.fieldScreenOffset ? world.fieldScreenOffset() : { x: 0, y: 0 };
|
||||
const cameraX = world.cameraX || 0;
|
||||
const cameraY = world.cameraY || 0;
|
||||
const viewScale = world.viewScale ? world.viewScale() : 1;
|
||||
ctx.clearRect(0, 0, screenW, screenH);
|
||||
|
||||
const lighting = getLightingState(world);
|
||||
const light = lighting.light;
|
||||
const cachedBackground = ensureBackgroundCache(sceneW, sceneH, lighting);
|
||||
|
||||
const beginFieldTransform = () => {
|
||||
ctx.save();
|
||||
ctx.translate(fieldOffset.x, fieldOffset.y);
|
||||
ctx.scale(viewScale, viewScale);
|
||||
ctx.translate(-cameraX, -cameraY);
|
||||
};
|
||||
|
||||
beginFieldTransform();
|
||||
if (cachedBackground) {
|
||||
ctx.drawImage(cachedBackground, 0, 0, sceneW, sceneH);
|
||||
} else {
|
||||
drawGardenBackdrop(sceneW, sceneH, lighting);
|
||||
drawGardenBed(sceneW, sceneH, lighting);
|
||||
}
|
||||
ctx.restore();
|
||||
|
||||
const perf = world.performanceLevel ? world.performanceLevel() : 0;
|
||||
if (perf < 2) drawLightRays(ctx, screenW, screenH, lighting);
|
||||
if (perf < 3 || world.weather === "light_rain") drawRain(ctx, screenW, screenH, world.time);
|
||||
|
||||
beginFieldTransform();
|
||||
for (const it of world.items) {
|
||||
if (it.type !== "zunchi") it.draw(ctx, world.time, lighting);
|
||||
}
|
||||
for (const it of world.items) {
|
||||
if (it.type === "zunchi") it.draw(ctx, world.time, lighting);
|
||||
}
|
||||
|
||||
ctx.save();
|
||||
ctx.lineWidth = 1.15;
|
||||
ctx.setLineDash([4, 4]);
|
||||
ctx.strokeStyle = light > 0.42 ? "rgba(115, 151, 86, 0.32)" : "rgba(202, 220, 255, 0.22)";
|
||||
for (const t of world.tarinai) {
|
||||
const p = t.parentToFollow?.();
|
||||
if (!p || !t.juvenile || t.dead || p.dead || dist(t, p) > 96) continue;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(t.x, t.y);
|
||||
ctx.quadraticCurveTo((t.x + p.x) * 0.5, Math.min(t.y, p.y) - 10, p.x, p.y);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.setLineDash([]);
|
||||
ctx.restore();
|
||||
|
||||
const sorted = world.sortedDrawList ? world.sortedDrawList() : world.tarinai;
|
||||
for (const t of sorted) t.draw(ctx, world.time, lighting);
|
||||
for (const ef of world.effects) ef.draw(ctx);
|
||||
ctx.restore();
|
||||
|
||||
// Light affecting the whole visible scene, including characters and ground.
|
||||
drawAtmosphericLighting(ctx, screenW, screenH, lighting);
|
||||
|
||||
const drawAtScreenPosition = (entity, drawFn) => {
|
||||
if (!entity) return;
|
||||
const ox = entity.x;
|
||||
const oy = entity.y;
|
||||
const screen = world.worldToScreen ? world.worldToScreen(ox, oy) : { x: ox, y: oy };
|
||||
entity.x = screen.x;
|
||||
entity.y = screen.y;
|
||||
try {
|
||||
drawFn();
|
||||
} finally {
|
||||
entity.x = ox;
|
||||
entity.y = oy;
|
||||
}
|
||||
};
|
||||
drawAtScreenPosition(world.selected, () => drawSelectedCard(ctx, world, lighting));
|
||||
|
||||
// Time HUD
|
||||
ctx.save();
|
||||
const hudW = 150;
|
||||
const hudH = 56;
|
||||
const w = screenW, h = screenH;
|
||||
const hudX = w - hudW - 22;
|
||||
const hudY = 16;
|
||||
ctx.fillStyle = light > 0.42 ? "rgba(255,251,244,0.76)" : "rgba(247,246,255,0.62)";
|
||||
roundedRect(ctx, hudX, hudY, hudW, hudH, 18);
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = light > 0.42 ? "rgba(118,103,83,0.12)" : "rgba(206,213,242,0.15)";
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = "#2a241d";
|
||||
ctx.font = "bold 16px ui-rounded, sans-serif";
|
||||
ctx.textAlign = "left";
|
||||
ctx.fillText(`${world.day}\u65e5\u76ee`, hudX + 24, hudY + 25);
|
||||
ctx.font = "12px ui-rounded, sans-serif";
|
||||
ctx.fillStyle = "rgba(111,101,88,0.86)";
|
||||
ctx.fillText(`${world.phaseName()} / ${world.clockString()} / ${weatherLabel(world.weather)}`, hudX + 24, hudY + 45);
|
||||
ctx.restore();
|
||||
|
||||
ctx.save();
|
||||
ctx.textAlign = "right";
|
||||
ctx.font = "10px ui-rounded, sans-serif";
|
||||
ctx.fillStyle = light > 0.42 ? "rgba(84,72,55,0.42)" : "rgba(230,235,255,0.48)";
|
||||
ctx.fillText(`${window.__tarinaiFps || 0} fps`, w - 14, h - 12);
|
||||
ctx.restore();
|
||||
|
||||
|
||||
if (world.paused) {
|
||||
ctx.save();
|
||||
ctx.fillStyle = "rgba(255,255,255,0.50)";
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
ctx.fillStyle = "rgba(42,36,29,0.70)";
|
||||
ctx.textAlign = "center";
|
||||
ctx.font = "bold 28px ui-rounded, sans-serif";
|
||||
ctx.fillText("\u4e00\u6642\u505c\u6b62\u4e2d", w / 2, h / 2);
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
function drawSelectedCard(ctx, world, lighting) {
|
||||
const t = world.selected;
|
||||
if (!t || t.dead || !world.tarinai.includes(t)) return;
|
||||
const w = world.viewportW || world.w;
|
||||
const h = world.viewportH || world.h;
|
||||
const cardW = 152;
|
||||
const cardH = 66;
|
||||
let x = t.x + 18;
|
||||
let y = t.y - 86;
|
||||
x = clamp(x, 10, w - cardW - 10);
|
||||
y = clamp(y, 10, h - cardH - 10);
|
||||
ctx.save();
|
||||
ctx.fillStyle = lighting.light > 0.42 ? "rgba(255,250,239,0.82)" : "rgba(239,243,255,0.74)";
|
||||
roundedRect(ctx, x, y, cardW, cardH, 14);
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = "rgba(84,68,48,0.12)";
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = "rgba(42,36,29,0.86)";
|
||||
ctx.font = "bold 11px ui-rounded, sans-serif";
|
||||
ctx.fillText(t.name, x + 10, y + 18);
|
||||
ctx.font = "10px ui-rounded, sans-serif";
|
||||
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 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.restore();
|
||||
}
|
||||
|
||||
function roundedRect(ctx, x, y, w, h, r) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + r, y);
|
||||
ctx.arcTo(x + w, y, x + w, y + h, r);
|
||||
ctx.arcTo(x + w, y + h, x, y + h, r);
|
||||
ctx.arcTo(x, y + h, x, y, r);
|
||||
ctx.arcTo(x, y, x + w, y, r);
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
|
||||
window.TarinaiRender = { randSeed, roundedBlob, getLightingState, projectedShadowParams, imageVisibleBottomFromTop, drawImageProjectedShadow, drawProjectedShadow, drawRadialGlow, drawItemGlow, drawCreatureGlow, drawDawnRimLight, drawGardenBackdrop, drawGardenBed, drawLightRays, drawAtmosphericLighting, drawRain, drawSelectedCard, render, roundedRect };
|
||||
3
js/sim.js
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
"use strict";
|
||||
|
||||
window.TarinaiSim = { makeName, makeTrait, PERSONALITIES, SpatialGrid, Effect, Item, Tarinai, World, world };
|
||||
306
js/sim_core.js
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
"use strict";
|
||||
function makeName(worldRef = null) {
|
||||
const existing = new Set((worldRef?.tarinai || []).filter(t => !t.dead).map(t => t.name));
|
||||
const build = () => {
|
||||
const len = randi(2, 7);
|
||||
const hasStop = len >= 2 && Math.random() < 0.30;
|
||||
const middleLen = Math.max(0, len - 1 - (hasStop ? 1 : 0));
|
||||
let name = "\u306f";
|
||||
for (let i = 0; i < middleLen; i++) name += Math.random() < 0.64 ? "\u3046" : "\u3045";
|
||||
if (hasStop) name += "\u3063";
|
||||
return name;
|
||||
};
|
||||
for (let i = 0; i < 80; i++) {
|
||||
const candidate = build();
|
||||
if (!existing.has(candidate)) return candidate;
|
||||
}
|
||||
return build();
|
||||
}
|
||||
|
||||
function spawnableSprites() {
|
||||
return SPRITES.filter(s => !s.actionOnly && !s.displayOnly);
|
||||
}
|
||||
|
||||
function displayNormalSprites() {
|
||||
return ["smile", "jito", "normal_smirk"].filter(id => SPRITES.some(s => s.id === id));
|
||||
}
|
||||
|
||||
function lowStressSprites() {
|
||||
return ["normal_tongue", "normal_happy"].filter(id => SPRITES.some(s => s.id === id));
|
||||
}
|
||||
|
||||
function stableChoice(seed, salt, options) {
|
||||
if (!options?.length) return null;
|
||||
const i = Math.min(options.length - 1, Math.floor(stableUnit(seed, salt) * options.length));
|
||||
return options[i];
|
||||
}
|
||||
|
||||
function baseSpriteId() {
|
||||
return pick(spawnableSprites()).id;
|
||||
}
|
||||
|
||||
function makeTrait(type) {
|
||||
const base = {
|
||||
smile: { hunger: 0.92, social: 1.14, sleep: 1.00, stress: 0.82, speed: 1.02 },
|
||||
angry: { hunger: 1.05, social: 0.84, sleep: 0.94, stress: 1.24, speed: 1.15 },
|
||||
teary: { hunger: 0.96, social: 1.22, sleep: 1.04, stress: 1.12, speed: 0.96 },
|
||||
jito: { hunger: 0.88, social: 0.80, sleep: 0.96, stress: 0.80, speed: 0.98 },
|
||||
drool: { hunger: 1.30, social: 1.02, sleep: 1.04, stress: 0.98, speed: 1.00 },
|
||||
cry: { hunger: 1.00, social: 1.30, sleep: 1.12, stress: 1.36, speed: 0.90 },
|
||||
sleep: { hunger: 0.78, social: 0.88, sleep: 1.44, stress: 0.72, speed: 0.70 },
|
||||
pokan: { hunger: 1.02, social: 1.02, sleep: 1.02, stress: 1.06, speed: 0.88 },
|
||||
stretch: { hunger: 1.08, social: 0.90, sleep: 0.92, stress: 1.08, speed: 1.26 },
|
||||
}[type] || { hunger: 1, social: 1, sleep: 1, stress: 1, speed: 1 };
|
||||
return { ...base };
|
||||
}
|
||||
|
||||
function stableUnit(seed, salt = "") {
|
||||
const str = `${seed}:${salt}`;
|
||||
let h = 2166136261;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
h ^= str.charCodeAt(i);
|
||||
h = Math.imul(h, 16777619);
|
||||
}
|
||||
return ((h >>> 0) % 100000) / 100000;
|
||||
}
|
||||
|
||||
const PERSONALITIES = {
|
||||
timid: { label: "\u81c6\u75c5", desc: "\u9003\u3052\u3084\u3059\u304f\u3001\u55a7\u5629\u3092\u907f\u3051\u308b", fear: 1.42, fight: 0.48, social: 0.96, relation: 1.04, sleep: 1.00, zunda: 1.00 },
|
||||
glutton: { label: "\u98df\u3044\u3057\u3093\u574a", desc: "\u305a\u3093\u3060\u9905\u3092\u512a\u5148\u3057\u3084\u3059\u3044", fear: 0.92, fight: 0.82, social: 1.00, relation: 1.00, sleep: 0.95, zunda: 1.42 },
|
||||
sleepy: { label: "\u7720\u305f\u304c\u308a", desc: "\u65e9\u3081\u306b\u5bdd\u5e8a\u3078\u5411\u304b\u3046", fear: 0.96, fight: 0.72, social: 0.92, relation: 0.96, sleep: 1.34, zunda: 0.92 },
|
||||
lonely: { label: "\u5bc2\u3057\u304c\u308a", desc: "\u4ed6\u306e\u305f\u308a\u306a\u3044\u3078\u5bc4\u308a\u3084\u3059\u3044", fear: 1.02, fight: 0.70, social: 1.34, relation: 1.38, sleep: 0.96, zunda: 1.00 },
|
||||
irritable: { label: "\u6012\u308a\u3063\u307d\u3044", desc: "\u55a7\u5629\u304c\u8d77\u304d\u3084\u3059\u3044", fear: 0.82, fight: 1.82, social: 0.78, relation: 0.78, sleep: 0.90, zunda: 0.94 },
|
||||
calm: { label: "\u30de\u30a4\u30da\u30fc\u30b9", desc: "\u53cd\u5fdc\u304c\u7a4f\u3084\u304b\u3067\u5b89\u5b9a\u3057\u3084\u3059\u3044", fear: 0.82, fight: 0.58, social: 1.02, relation: 1.08, sleep: 1.02, zunda: 0.94 },
|
||||
};
|
||||
const PERSONALITY_IDS = Object.keys(PERSONALITIES);
|
||||
|
||||
function personalityForSeed(seed) {
|
||||
return stableChoice(seed, "personality", PERSONALITY_IDS) || "calm";
|
||||
}
|
||||
|
||||
function relationDefaults() {
|
||||
return { affinity: 0, fear: 0, fightsWon: 0, fightsLost: 0, lastEvent: "", lastTime: 0 };
|
||||
}
|
||||
|
||||
function isParentChild(a, b) {
|
||||
if (!a || !b) return false;
|
||||
return Boolean(a.parents?.includes(b.id) || b.parents?.includes(a.id));
|
||||
}
|
||||
|
||||
function relationDisplayName(worldRef, id) {
|
||||
if (!id) return "\u306a\u3057";
|
||||
const live = worldRef?.tarinai?.find(t => t.id === id);
|
||||
if (live) return live.name;
|
||||
const fam = worldRef?.family?.[id];
|
||||
return fam?.name || "\u4e0d\u660e";
|
||||
}
|
||||
|
||||
class Effect {
|
||||
constructor(type, x, y, options = {}) {
|
||||
this.type = type;
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.vx = options.vx ?? rand(-10, 10);
|
||||
this.vy = options.vy ?? rand(-10, 10);
|
||||
this.size = options.size ?? rand(3, 8);
|
||||
this.life = options.life ?? 0.7;
|
||||
this.maxLife = this.life;
|
||||
this.color = options.color || "#fff4a8";
|
||||
this.seed = Math.random() * 1000;
|
||||
this.text = options.text || "";
|
||||
}
|
||||
|
||||
update(dt) {
|
||||
this.life -= dt;
|
||||
this.x += this.vx * dt;
|
||||
this.y += this.vy * dt;
|
||||
this.vx *= Math.pow(0.92, dt * 60);
|
||||
this.vy *= Math.pow(0.92, dt * 60);
|
||||
}
|
||||
get dead() { return this.life <= 0; }
|
||||
|
||||
draw(ctx) {
|
||||
const alpha = clamp(this.life / this.maxLife, 0, 1);
|
||||
ctx.save();
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.translate(this.x, this.y);
|
||||
if (this.type === "eat") {
|
||||
ctx.fillStyle = this.color;
|
||||
ctx.strokeStyle = "rgba(143, 111, 43, 0.7)";
|
||||
ctx.lineWidth = 1.2;
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, this.size, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
} else if (this.type === "bleed") {
|
||||
ctx.fillStyle = this.color;
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(0, 0, this.size * 1.25, this.size * 0.72, this.seed, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
} else if (this.type === "ring") {
|
||||
ctx.strokeStyle = this.color;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, this.size + (1 - alpha) * 18, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
} else if (this.type === "fight") {
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineJoin = "round";
|
||||
const puffColor = this.color || "rgba(105, 62, 34, 0.86)";
|
||||
ctx.fillStyle = puffColor;
|
||||
ctx.strokeStyle = puffColor;
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const a = this.seed + i * 1.55;
|
||||
const px = Math.cos(a) * this.size * randSeed(this.seed + i, 0.10, 0.72);
|
||||
const py = Math.sin(a) * this.size * randSeed(this.seed + i + 8, 0.08, 0.48);
|
||||
ctx.globalAlpha = alpha * (0.26 + i * 0.08);
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(px, py, this.size * randSeed(this.seed + i + 20, 0.18, 0.34), this.size * randSeed(this.seed + i + 40, 0.10, 0.22), a, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.globalAlpha = alpha * 0.78;
|
||||
ctx.lineWidth = 2.0;
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const dir = i === 0 ? -1 : 1;
|
||||
const y = dir * this.size * 0.14;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-this.size * 0.62, y);
|
||||
ctx.quadraticCurveTo(-this.size * 0.12, y - dir * this.size * 0.42, this.size * 0.54, y + dir * this.size * 0.06);
|
||||
ctx.stroke();
|
||||
}
|
||||
} else if (this.type === "fall") {
|
||||
ctx.fillStyle = this.color || "rgba(154, 124, 80, 0.62)";
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const a = this.seed + i * 1.37;
|
||||
const rr = this.size * randSeed(this.seed + i, 0.18, 0.82);
|
||||
const px = Math.cos(a) * rr;
|
||||
const py = Math.sin(a) * rr * 0.42;
|
||||
ctx.globalAlpha = alpha * randSeed(this.seed + i + 9, 0.18, 0.42);
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(px, py, this.size * randSeed(this.seed + i + 20, 0.16, 0.34), this.size * randSeed(this.seed + i + 30, 0.08, 0.18), a, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
} else if (this.type === "explosion") {
|
||||
const burst = 1 - alpha;
|
||||
ctx.globalAlpha = alpha * 0.95;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
ctx.strokeStyle = i === 0 ? "rgba(255,238,145,0.92)" : (i === 1 ? "rgba(255,128,66,0.68)" : "rgba(96,62,42,0.42)");
|
||||
ctx.lineWidth = Math.max(2, this.size * (0.09 - i * 0.018));
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, this.size * (0.28 + burst * (0.78 + i * 0.22)), 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.fillStyle = "rgba(255,214,86,0.88)";
|
||||
for (let i = 0; i < 14; i++) {
|
||||
const a = this.seed + i * 0.73;
|
||||
const r = this.size * burst * randSeed(this.seed + i, 0.28, 1.00);
|
||||
ctx.globalAlpha = alpha * randSeed(this.seed + i + 4, 0.36, 0.88);
|
||||
ctx.beginPath();
|
||||
ctx.arc(Math.cos(a) * r, Math.sin(a) * r, randSeed(this.seed + i + 12, 2.2, 5.2), 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
} else if (this.type === "bubble") {
|
||||
const w = Math.max(30, 14 + this.text.length * 7);
|
||||
const h = 20;
|
||||
ctx.globalAlpha = Math.min(1, alpha * 1.25);
|
||||
ctx.fillStyle = "rgba(255, 252, 240, 0.92)";
|
||||
ctx.strokeStyle = "rgba(78, 64, 48, 0.22)";
|
||||
ctx.lineWidth = 1.2;
|
||||
roundedRect(ctx, -w / 2, -h, w, h, 9);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = "rgba(255, 252, 240, 0.92)";
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-4, -1);
|
||||
ctx.lineTo(2, 5);
|
||||
ctx.lineTo(6, -2);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = this.color || "rgba(42,36,29,0.78)";
|
||||
ctx.font = "bold 12px Yomogi, ui-rounded, sans-serif";
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText(this.text, 0, -10);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
class SpatialGrid {
|
||||
constructor(cellSize = 96) {
|
||||
this.cellSize = cellSize;
|
||||
this.itemCells = new Map();
|
||||
this.tarinaiCells = new Map();
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.itemCells.clear();
|
||||
this.tarinaiCells.clear();
|
||||
}
|
||||
|
||||
keyFor(x, y) {
|
||||
const cx = Math.floor(x / this.cellSize);
|
||||
const cy = Math.floor(y / this.cellSize);
|
||||
return cx + cy * 100000;
|
||||
}
|
||||
|
||||
add(map, entity) {
|
||||
const key = this.keyFor(entity.x, entity.y);
|
||||
let bucket = map.get(key);
|
||||
if (!bucket) {
|
||||
bucket = [];
|
||||
map.set(key, bucket);
|
||||
}
|
||||
bucket.push(entity);
|
||||
}
|
||||
|
||||
rebuild(items, tarinai) {
|
||||
this.clear();
|
||||
for (const it of items) {
|
||||
if (!it.dead) this.add(this.itemCells, it);
|
||||
}
|
||||
for (const t of tarinai) {
|
||||
if (!t.dead) this.add(this.tarinaiCells, t);
|
||||
}
|
||||
}
|
||||
|
||||
nearby(map, x, y, radius, out = []) {
|
||||
out.length = 0;
|
||||
const r = Number.isFinite(radius) ? radius : Math.max(1200, this.cellSize * 12);
|
||||
const minX = Math.floor((x - r) / this.cellSize);
|
||||
const maxX = Math.floor((x + r) / this.cellSize);
|
||||
const minY = Math.floor((y - r) / this.cellSize);
|
||||
const maxY = Math.floor((y + r) / this.cellSize);
|
||||
for (let cy = minY; cy <= maxY; cy++) {
|
||||
for (let cx = minX; cx <= maxX; cx++) {
|
||||
const bucket = map.get(cx + cy * 100000);
|
||||
if (!bucket) continue;
|
||||
for (const entity of bucket) out.push(entity);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
function drawHeartShape(ctx, x, y, size, opts = {}) {
|
||||
const fill = opts.fill || "rgba(240,91,135,0.90)";
|
||||
const stroke = opts.stroke || "rgba(255,252,246,0.92)";
|
||||
const alpha = opts.alpha == null ? 1 : opts.alpha;
|
||||
const rotation = opts.rotation || 0;
|
||||
ctx.save();
|
||||
ctx.translate(x, y);
|
||||
if (rotation) ctx.rotate(rotation);
|
||||
ctx.globalAlpha *= alpha;
|
||||
ctx.fillStyle = fill;
|
||||
ctx.strokeStyle = stroke;
|
||||
ctx.lineWidth = Math.max(1.2, size * 0.14);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, size * 0.72);
|
||||
ctx.bezierCurveTo(-size * 1.55, -size * 0.22, -size * 1.16, -size * 1.36, 0, -size * 0.52);
|
||||
ctx.bezierCurveTo(size * 1.16, -size * 1.36, size * 1.55, -size * 0.22, 0, size * 0.72);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
1448
js/tarinai.js
Normal file
528
js/ui.js
Normal file
|
|
@ -0,0 +1,528 @@
|
|||
"use strict";
|
||||
|
||||
const canvas = document.getElementById("gameCanvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
const ui = {
|
||||
pauseBtn: document.getElementById("pauseBtn"),
|
||||
speedBtn: document.getElementById("speedBtn"),
|
||||
soundBtn: document.getElementById("soundBtn"),
|
||||
saveBtn: document.getElementById("saveBtn"),
|
||||
loadBtn: document.getElementById("loadBtn"),
|
||||
resetBtn: document.getElementById("resetBtn"),
|
||||
toolPalette: document.getElementById("toolPalette"),
|
||||
toolCard: document.getElementById("toolCard"),
|
||||
colonyChartTabs: document.getElementById("colonyChartTabs"),
|
||||
selectedInfo: document.getElementById("selectedInfo"),
|
||||
log: document.getElementById("log"),
|
||||
toast: document.getElementById("toast"),
|
||||
statPop: document.getElementById("statPop"),
|
||||
statDead: document.getElementById("statDead"),
|
||||
colonyChart: document.getElementById("colonyChart"),
|
||||
colonyChartLegend: document.getElementById("colonyChartLegend"),
|
||||
archiveContent: document.getElementById("archiveContent"),
|
||||
fieldDialog: document.getElementById("fieldDialog"),
|
||||
fieldCancelBtn: document.getElementById("fieldCancelBtn"),
|
||||
};
|
||||
|
||||
const uiCache = {
|
||||
stats: {},
|
||||
selectedSnapshot: "",
|
||||
selectedEmpty: false,
|
||||
toolButtons: [],
|
||||
archiveVersion: "",
|
||||
archiveRows: [],
|
||||
archiveScrollScheduled: false,
|
||||
deferArchiveUntil: 0,
|
||||
lastArchiveWindow: "",
|
||||
chartHistory: [],
|
||||
chartBucketStart: -Infinity,
|
||||
chartBucketEnd: -Infinity,
|
||||
chartBucketAccum: null,
|
||||
lastChartSample: -Infinity,
|
||||
lastChartDraw: "",
|
||||
lastChartDrawAt: 0,
|
||||
chartMode: "population",
|
||||
canvasDpr: 0,
|
||||
panning: false,
|
||||
panLastX: 0,
|
||||
panLastY: 0,
|
||||
panMoved: false,
|
||||
};
|
||||
|
||||
function renderLog(logs) {
|
||||
ui.log.innerHTML = "";
|
||||
const frag = document.createDocumentFragment();
|
||||
for (const raw of logs.slice(0, 32)) {
|
||||
const entry = typeof raw === "string" ? { time: world.time, text: raw, kind: "note" } : raw;
|
||||
const kind = entry.kind || "note";
|
||||
const div = document.createElement("div");
|
||||
div.className = `log-entry log-${kind}`;
|
||||
const tag = document.createElement("span");
|
||||
tag.className = "log-tag";
|
||||
tag.textContent = logKindLabel(kind);
|
||||
const time = document.createElement("span");
|
||||
time.className = "time";
|
||||
time.textContent = formatLogTime(entry.time || 0);
|
||||
div.appendChild(tag);
|
||||
div.appendChild(time);
|
||||
div.append(document.createTextNode(entry.text || ""));
|
||||
frag.appendChild(div);
|
||||
}
|
||||
ui.log.appendChild(frag);
|
||||
}
|
||||
|
||||
function formatLogTime(timeValue) {
|
||||
const t = Math.max(0, Number(timeValue) || 0);
|
||||
const day = Math.floor(t / CONFIG.dayLength) + 1;
|
||||
const dayT = ((t % CONFIG.dayLength) + CONFIG.dayLength) % CONFIG.dayLength;
|
||||
const totalMinutes = Math.floor(dayT / CONFIG.dayLength * 24 * 60);
|
||||
const hh = String(Math.floor(totalMinutes / 60)).padStart(2, "0");
|
||||
const mm = String(totalMinutes % 60).padStart(2, "0");
|
||||
return `${day}\u65e5 ${hh}\u6642${mm}\u5206`;
|
||||
}
|
||||
|
||||
function logKindLabel(kind) {
|
||||
return {
|
||||
birth: "\u8a95\u751f", death: "\u6b7b\u4ea1", fight: "\u55a7\u5629", food: "\u98df\u4e8b", grass: "\u8349",
|
||||
weather: "\u74b0\u5883", relation: "\u95a2\u4fc2", accident: "\u4e8b\u6545", observe: "\u89b3\u5bdf", note: "\u8a18\u9332"
|
||||
}[kind] || "\u8a18\u9332";
|
||||
}
|
||||
|
||||
function tarinaiSpritePathForId(id) {
|
||||
const live = world?.tarinai?.find(t => t.id === id && !t.dead);
|
||||
const sid = live?.spriteId ? live.spriteId() : live?.type;
|
||||
const fam = world?.family?.[id];
|
||||
const fallback = fam?.type;
|
||||
return (SPRITES.find(s => s.id === (sid || fallback)) || SPRITES[0]).path;
|
||||
}
|
||||
|
||||
function relationIconHtml(id) {
|
||||
if (!id) return "";
|
||||
return `<img class="relation-face" src="${tarinaiSpritePathForId(id)}" alt="">`;
|
||||
}
|
||||
|
||||
function renderSelected() {
|
||||
const t = world.selected;
|
||||
if (!t || !world.tarinai.includes(t)) {
|
||||
if (uiCache.selectedEmpty) return;
|
||||
uiCache.selectedSnapshot = "";
|
||||
uiCache.selectedEmpty = true;
|
||||
ui.selectedInfo.className = "selected-info empty";
|
||||
ui.selectedInfo.textContent = "\u672a\u9078\u629e";
|
||||
return;
|
||||
}
|
||||
|
||||
uiCache.selectedEmpty = false;
|
||||
const rel = t.relationSummary ? t.relationSummary() : {};
|
||||
const fightRecord = relationFightRecord(t);
|
||||
const snapshot = [
|
||||
t.id, t.type, t.name, (t.displayGeneration ? t.displayGeneration() : ((t.hasPaired || t.parents?.length || t.children?.length) ? t.generation : "")), t.state, t.personality, t.thought, targetLabel(t.target),
|
||||
fmt(t.age), fmt(t.lifeSpan), fmt(t.hunger), fmt(t.loneliness),
|
||||
fmt(t.energy), fmt(t.stress), fmt(t.mood),
|
||||
t.parentNames?.join("+") || "", t.children?.length || 0, fmt(t.lack),
|
||||
rel.friend?.id || "", rel.friend?.score || 0, rel.friend?.id ? tarinaiSpritePathForId(rel.friend.id) : "", rel.fear?.id || "", rel.fear?.score || 0, rel.fear?.id ? tarinaiSpritePathForId(rel.fear.id) : "", fightRecord
|
||||
].join("|");
|
||||
if (uiCache.selectedSnapshot === snapshot) return;
|
||||
uiCache.selectedSnapshot = snapshot;
|
||||
|
||||
ui.selectedInfo.className = "selected-info";
|
||||
ui.selectedInfo.innerHTML = `
|
||||
<div><span class="badge">${escapeHtml(t.label())}</span><span class="badge">${escapeHtml(t.name)}</span>${(t.displayGeneration ? t.displayGeneration() : ((t.hasPaired || t.parents?.length || t.children?.length) ? t.generation : null)) ? `<span class="badge">\u4e16\u4ee3 ${t.generation}</span>` : ""}</div>
|
||||
<div class="info-row"><span>\u6027\u683c</span><strong>${escapeHtml(t.personalityLabel ? t.personalityLabel() : t.personality || "\u4e0d\u660e")}</strong></div>
|
||||
<div class="info-row"><span>\u72b6\u614b</span><strong>${escapeHtml(stateLabel(t.state))}</strong></div>
|
||||
<div class="info-row"><span>\u7406\u7531</span><strong>${escapeHtml(reasonLabel(t))}</strong></div>
|
||||
<div class="info-row"><span>\u5bfe\u8c61</span><strong>${escapeHtml(targetLabel(t.target))}</strong></div>
|
||||
<div class="info-row"><span>\u5e74\u9f62</span><strong>${fmt(t.age)} / ${fmt(t.lifeSpan)}</strong></div>
|
||||
<div class="info-row"><span>\u7a7a\u8179</span><strong>${fmt(t.hunger)}</strong></div>
|
||||
<div class="info-row"><span>\u5bc2\u3057\u3055</span><strong>${fmt(t.loneliness)}</strong></div>
|
||||
<div class="info-row"><span>\u4f53\u529b</span><strong>${fmt(t.energy)}</strong></div>
|
||||
<div class="info-row"><span>\u30b9\u30c8\u30ec\u30b9</span><strong>${fmt(t.stress)}</strong></div>
|
||||
<div class="info-row"><span>\u6c17\u5206</span><strong>${fmt(t.mood)}</strong></div>
|
||||
<div class="info-row relation-row"><span>\u4ef2\u826f\u3057</span><strong>${rel.friend ? `${relationIconHtml(rel.friend.id)}${escapeHtml(`${rel.friend.name} +${fmt(rel.friend.score)}`)}` : "\u306a\u3057"}</strong></div>
|
||||
<div class="info-row relation-row"><span>\u907f\u3051\u308b\u76f8\u624b</span><strong>${rel.fear ? `${relationIconHtml(rel.fear.id)}${escapeHtml(`${rel.fear.name} ${fmt(rel.fear.score)}`)}` : "\u306a\u3057"}</strong></div>
|
||||
<div class="info-row"><span>\u55a7\u5629\u8a18\u61b6</span><strong>${escapeHtml(fightRecord)}</strong></div>
|
||||
<div class="info-row"><span>\u89aa</span><strong>${escapeHtml(t.parentNames?.length ? t.parentNames.join(" + ") : "\u4e0d\u660e")}</strong></div>
|
||||
<div class="info-row"><span>\u5b50</span><strong>${t.children?.length || 0}</strong></div>
|
||||
<div class="info-row"><span>\u305f\u308a\u306a\u3044</span><strong>${fmt(t.lack)}</strong></div>
|
||||
`;
|
||||
}
|
||||
|
||||
function stateLabel(s) {
|
||||
return {
|
||||
idle: "\u5f85\u6a5f",
|
||||
seek_food: "\u98df\u3079\u7269\u3078\u5411\u304b\u3046",
|
||||
seek_bed: "\u5e72\u8349\u5bdd\u5e8a\u3078\u5411\u304b\u3046",
|
||||
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",
|
||||
cursor_enemy: "\u30ab\u30fc\u30bd\u30eb\u3092\u907f\u3051\u308b",
|
||||
fight: "\u55a7\u5629\u4e2d",
|
||||
sleep: "\u7761\u7720\u4e2d",
|
||||
eat: "\u98df\u4e8b\u4e2d",
|
||||
panic: "\u30d1\u30cb\u30c3\u30af",
|
||||
intimidate: "\u5a01\u5687\u4e2d",
|
||||
birth_ritual: "\u8a95\u751f\u524d",
|
||||
}[s] || s;
|
||||
}
|
||||
|
||||
function reasonLabel(t) {
|
||||
if (!t) return "\u306a\u3057";
|
||||
const thought = String(t.thought || "").trim();
|
||||
if (thought) return thought;
|
||||
if (t.state === "sleep") return "\u4f53\u529b\u3092\u56de\u5fa9\u3057\u3066\u3044\u308b";
|
||||
if (t.state === "panic") return "\u6016\u304c\u3063\u3066\u3044\u308b";
|
||||
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";
|
||||
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", food: "\u98df\u4e8b", grass: "\u8349", water: "\u6c34", bed: "\u5e72\u8349\u5bdd\u5e8a", stone: "\u77f3", zunchi: "\u305a\u3093\u3061", 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";
|
||||
}
|
||||
|
||||
function relationFightRecord(t) {
|
||||
let won = 0, lost = 0;
|
||||
for (const rel of Object.values(t.relationships || {})) {
|
||||
won += rel.fightsWon || 0;
|
||||
lost += rel.fightsLost || 0;
|
||||
}
|
||||
return won || lost ? `\u52dd${won} / \u8ca0${lost}` : "\u306a\u3057";
|
||||
}
|
||||
|
||||
function setTextIfChanged(el, key, value) {
|
||||
if (!el) return;
|
||||
const next = String(value);
|
||||
if (uiCache.stats[key] === next) return;
|
||||
uiCache.stats[key] = next;
|
||||
el.textContent = next;
|
||||
}
|
||||
|
||||
function setValueIfChanged(el, key, value) {
|
||||
if (!el) return;
|
||||
if (uiCache.stats[key] === value) return;
|
||||
uiCache.stats[key] = value;
|
||||
el.value = value;
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? "").replace(/[&<>"']/g, c => ({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'"
|
||||
}[c]));
|
||||
}
|
||||
function resizeCanvas() {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const perf = world?.performanceLevel ? world.performanceLevel() : 0;
|
||||
const maxDpr = perf >= 2 ? 1 : (perf === 1 ? 1.5 : 2);
|
||||
const dpr = Math.max(1, Math.min(window.devicePixelRatio || 1, maxDpr));
|
||||
const nextW = Math.floor(rect.width * dpr);
|
||||
const nextH = Math.floor(rect.height * dpr);
|
||||
if (canvas.width === nextW && canvas.height === nextH && uiCache.canvasDpr === dpr) return;
|
||||
uiCache.canvasDpr = dpr;
|
||||
canvas.width = nextW;
|
||||
canvas.height = nextH;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
world.setViewportSize(rect.width, rect.height);
|
||||
uiCache.lastChartDraw = "";
|
||||
}
|
||||
|
||||
function applyFieldLayout(fieldType = world.fieldType || "garden") {
|
||||
const field = FIELD_TYPES?.[fieldType] || FIELD_TYPES.garden;
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty("--field-height", field.height);
|
||||
root.style.setProperty("--field-min-height", field.minHeight || field.height);
|
||||
root.style.setProperty("--field-mobile-height", field.mobileHeight || field.height);
|
||||
root.style.setProperty("--field-mobile-min-height", field.mobileMinHeight || field.mobileHeight || "460px");
|
||||
world.fieldType = field.id;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
world.setViewportSize(rect.width, rect.height);
|
||||
resizeCanvas();
|
||||
}
|
||||
|
||||
function openFieldDialog() {
|
||||
ui.fieldDialog?.classList.remove("hidden");
|
||||
}
|
||||
|
||||
function closeFieldDialog() {
|
||||
ui.fieldDialog?.classList.add("hidden");
|
||||
}
|
||||
|
||||
function resetWithField(fieldType) {
|
||||
const field = FIELD_TYPES?.[fieldType] || FIELD_TYPES.garden;
|
||||
closeFieldDialog();
|
||||
applyFieldLayout(field.id);
|
||||
world.reset(null, field.id);
|
||||
uiCache.archiveVersion = "";
|
||||
renderLog(world.logs);
|
||||
renderArchive();
|
||||
renderStats();
|
||||
render();
|
||||
}
|
||||
|
||||
function screenToWorld(e) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const touch = e.touches ? e.touches[0] : e;
|
||||
const sx = touch.clientX - rect.left;
|
||||
const sy = touch.clientY - rect.top;
|
||||
return world.screenToWorld ? world.screenToWorld(sx, sy) : { x: sx, y: sy };
|
||||
}
|
||||
|
||||
function resizeFieldFromWheel(e) {
|
||||
if (!world.setFieldZoom) return;
|
||||
e.preventDefault();
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const sx = e.clientX - rect.left;
|
||||
const sy = e.clientY - rect.top;
|
||||
const focus = world.screenToWorld ? world.screenToWorld(sx, sy) : { x: sx, y: sy };
|
||||
const factor = e.deltaY > 0 ? 1.08 : 1 / 1.08;
|
||||
if (!world.setFieldZoom((world.fieldZoom || 1) * factor)) return;
|
||||
const off = world.fieldScreenOffset ? world.fieldScreenOffset() : { x: 0, y: 0 };
|
||||
const scale = world.viewScale ? world.viewScale() : 1;
|
||||
world.cameraX = focus.x - (sx - off.x) / scale;
|
||||
world.cameraY = focus.y - (sy - off.y) / scale;
|
||||
world.clampCamera?.();
|
||||
render();
|
||||
}
|
||||
|
||||
function selectTool(tool) {
|
||||
world.tool = tool;
|
||||
for (const btn of uiCache.toolButtons) {
|
||||
btn.classList.toggle("selected", btn.dataset.tool === tool);
|
||||
}
|
||||
syncToolSizeBadges();
|
||||
canvas.style.cursor = tool === "observe" ? "default" : (tool === "poke" || tool === "delete" ? "pointer" : "crosshair");
|
||||
}
|
||||
|
||||
function showToast(text) {
|
||||
ui.toast.textContent = text;
|
||||
ui.toast.classList.remove("hidden");
|
||||
clearTimeout(showToast._timer);
|
||||
showToast._timer = setTimeout(() => ui.toast.classList.add("hidden"), 1400);
|
||||
}
|
||||
|
||||
function saveGame() {
|
||||
localStorage.setItem(CONFIG.saveKey, JSON.stringify(world.serialize()));
|
||||
showToast("\u4fdd\u5b58\u3057\u307e\u3057\u305f\u3002");
|
||||
}
|
||||
|
||||
function loadGame() {
|
||||
const raw = localStorage.getItem(CONFIG.saveKey);
|
||||
if (!raw) { showToast("\u30bb\u30fc\u30d6\u30c7\u30fc\u30bf\u304c\u3042\u308a\u307e\u305b\u3093\u3002"); return; }
|
||||
try {
|
||||
const t0 = performance.now();
|
||||
world.load(JSON.parse(raw));
|
||||
applyFieldLayout(world.fieldType || "garden");
|
||||
uiCache.archiveVersion = "";
|
||||
uiCache.deferArchiveUntil = performance.now() + 80;
|
||||
if (ui.archiveContent) ui.archiveContent.innerHTML = `<div class="selected-info empty">\u5bb6\u7cfb\u56f3\u3092\u8aad\u307f\u8fbc\u3093\u3067\u3044\u307e\u3059\u3002</div>`;
|
||||
renderSelected();
|
||||
renderStats();
|
||||
requestAnimationFrame(() => {
|
||||
uiCache.deferArchiveUntil = 0;
|
||||
renderArchive();
|
||||
});
|
||||
if (typeof console !== "undefined") console.debug?.(`loaded save in ${Math.round(performance.now() - t0)}ms`);
|
||||
showToast("\u8aad\u307f\u8fbc\u307f\u307e\u3057\u305f\u3002");
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showToast("\u8aad\u307f\u8fbc\u307f\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002");
|
||||
}
|
||||
}
|
||||
|
||||
function syncTopButtons() {
|
||||
ui.pauseBtn.textContent = world.paused ? "\u518d\u958b" : "\u4e00\u6642\u505c\u6b62";
|
||||
ui.speedBtn.textContent = `\u901f\u5ea6 x${world.speed}`;
|
||||
if (ui.soundBtn) ui.soundBtn.textContent = audio.enabled ? "\u97f3 ON" : "\u97f3 OFF";
|
||||
}
|
||||
|
||||
const SCALABLE_TOOLS = new Set(["zunchi", "sweet", "grass", "stone", "firecracker", "fence_v", "fence_h"]);
|
||||
const SIZE_ORDER = ["small", "medium", "large"];
|
||||
const SIZE_LABELS = { small: "\u5c0f", medium: "\u4e2d", large: "\u5927" };
|
||||
|
||||
function toolSizeFor(tool) {
|
||||
if (!SCALABLE_TOOLS.has(tool)) return "";
|
||||
return (world.toolSizes && world.toolSizes[tool]) || world.toolSize || "medium";
|
||||
}
|
||||
|
||||
function cycleToolSize(tool) {
|
||||
if (!SCALABLE_TOOLS.has(tool)) return false;
|
||||
if (!world.toolSizes) world.toolSizes = {};
|
||||
const current = toolSizeFor(tool);
|
||||
const idx = SIZE_ORDER.indexOf(current);
|
||||
const next = SIZE_ORDER[(idx + 1 + SIZE_ORDER.length) % SIZE_ORDER.length];
|
||||
world.toolSizes[tool] = next;
|
||||
world.toolSize = next;
|
||||
syncToolSizeBadges();
|
||||
showToast(`\u30b5\u30a4\u30ba: ${SIZE_LABELS[next] || next}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
function syncToolSizeBadges() {
|
||||
for (const btn of uiCache.toolButtons || []) {
|
||||
const tool = btn.dataset.tool || "";
|
||||
const old = btn.querySelector(".tool-size-badge");
|
||||
if (!SCALABLE_TOOLS.has(tool)) {
|
||||
btn.removeAttribute("data-size");
|
||||
if (old) old.remove();
|
||||
continue;
|
||||
}
|
||||
const size = toolSizeFor(tool);
|
||||
btn.dataset.size = size;
|
||||
let badge = old;
|
||||
if (!badge) {
|
||||
badge = document.createElement("b");
|
||||
badge.className = "tool-size-badge";
|
||||
btn.appendChild(badge);
|
||||
}
|
||||
badge.textContent = SIZE_LABELS[size] || size;
|
||||
}
|
||||
}
|
||||
|
||||
function bindUI() {
|
||||
uiCache.toolButtons = Array.from(ui.toolPalette.querySelectorAll(".tool"));
|
||||
syncToolSizeBadges();
|
||||
ui.colonyChartTabs?.addEventListener("click", (e) => {
|
||||
const btn = e.target.closest("button[data-chart]");
|
||||
if (!btn) return;
|
||||
uiCache.chartMode = btn.dataset.chart || "population";
|
||||
for (const b of ui.colonyChartTabs.querySelectorAll("button")) b.classList.toggle("active", b === btn);
|
||||
uiCache.lastChartDraw = "";
|
||||
uiCache.lastChartDrawAt = 0;
|
||||
renderStats();
|
||||
});
|
||||
syncToolSizeBadges();
|
||||
ui.archiveContent?.addEventListener("scroll", scheduleArchiveWindowRender, { passive: true });
|
||||
ui.archiveContent?.addEventListener("click", (e) => {
|
||||
const node = e.target.closest("[data-family-tarinai-id]");
|
||||
if (!node) return;
|
||||
const id = node.getAttribute("data-family-tarinai-id");
|
||||
const t = world.tarinai.find(x => x.id === id && !x.dead);
|
||||
if (!t) { showToast("\u305d\u306e\u500b\u4f53\u306f\u3082\u3046\u9078\u629e\u3067\u304d\u307e\u305b\u3093\u3002"); return; }
|
||||
world.selected = t;
|
||||
selectTool("observe");
|
||||
renderStats();
|
||||
showToast(`${t.name}\u3092\u9078\u629e\u3057\u307e\u3057\u305f\u3002`);
|
||||
});
|
||||
|
||||
ui.pauseBtn.addEventListener("click", () => {
|
||||
world.paused = !world.paused;
|
||||
syncTopButtons();
|
||||
});
|
||||
ui.speedBtn.addEventListener("click", () => {
|
||||
const speeds = [1, 2, 4];
|
||||
const i = speeds.indexOf(world.speed);
|
||||
world.speed = speeds[(i + 1) % speeds.length];
|
||||
syncTopButtons();
|
||||
});
|
||||
ui.soundBtn?.addEventListener("click", () => {
|
||||
const on = audio.toggle();
|
||||
ui.soundBtn.textContent = on ? "\u97f3 ON" : "\u97f3 OFF";
|
||||
showToast(on ? "\u97f3\u3092\u6709\u52b9\u306b\u3057\u307e\u3057\u305f\u3002" : "\u97f3\u3092\u7121\u52b9\u306b\u3057\u307e\u3057\u305f\u3002");
|
||||
});
|
||||
ui.saveBtn.addEventListener("click", saveGame);
|
||||
ui.loadBtn.addEventListener("click", loadGame);
|
||||
ui.resetBtn.addEventListener("click", () => {
|
||||
openFieldDialog();
|
||||
});
|
||||
ui.fieldCancelBtn?.addEventListener("click", closeFieldDialog);
|
||||
ui.fieldDialog?.addEventListener("click", (e) => {
|
||||
if (e.target === ui.fieldDialog) { closeFieldDialog(); return; }
|
||||
const btn = e.target.closest("[data-field-type]");
|
||||
if (!btn) return;
|
||||
resetWithField(btn.dataset.fieldType || "garden");
|
||||
});
|
||||
|
||||
ui.toolPalette.addEventListener("click", (e) => {
|
||||
const toggle = e.target.closest(".tool-category-toggle");
|
||||
if (toggle) {
|
||||
const category = toggle.closest(".tool-category");
|
||||
if (!category) return;
|
||||
category.classList.toggle("collapsed");
|
||||
toggle.setAttribute("aria-expanded", category.classList.contains("collapsed") ? "false" : "true");
|
||||
return;
|
||||
}
|
||||
const btn = e.target.closest(".tool");
|
||||
if (!btn) return;
|
||||
const tool = btn.dataset.tool;
|
||||
if (world.tool === tool && cycleToolSize(tool)) return;
|
||||
selectTool(tool);
|
||||
});
|
||||
|
||||
const chainScroll = (el) => {
|
||||
if (!el) return;
|
||||
el.addEventListener("wheel", (e) => {
|
||||
if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return;
|
||||
const atTop = el.scrollTop <= 0;
|
||||
const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 1;
|
||||
if ((e.deltaY < 0 && atTop) || (e.deltaY > 0 && atBottom)) {
|
||||
const page = document.scrollingElement || document.documentElement;
|
||||
page.scrollTop += e.deltaY;
|
||||
}
|
||||
}, { passive: true });
|
||||
};
|
||||
chainScroll(document.querySelector(".panel"));
|
||||
chainScroll(ui.archiveContent);
|
||||
chainScroll(ui.log);
|
||||
|
||||
canvas.addEventListener("contextmenu", (e) => e.preventDefault());
|
||||
canvas.addEventListener("mousedown", (e) => {
|
||||
if (e.button !== 2) return;
|
||||
e.preventDefault();
|
||||
uiCache.panning = true;
|
||||
uiCache.panMoved = false;
|
||||
uiCache.panLastX = e.clientX;
|
||||
uiCache.panLastY = e.clientY;
|
||||
canvas.style.cursor = "grabbing";
|
||||
});
|
||||
window.addEventListener("mouseup", (e) => {
|
||||
if (e.button !== 2 && !uiCache.panning) return;
|
||||
if (uiCache.panning) {
|
||||
uiCache.panning = false;
|
||||
canvas.style.cursor = world.tool === "observe" ? "default" : (world.tool === "poke" || world.tool === "delete" ? "pointer" : "crosshair");
|
||||
}
|
||||
});
|
||||
window.addEventListener("mousemove", (e) => {
|
||||
if (!uiCache.panning) return;
|
||||
const dx = e.clientX - uiCache.panLastX;
|
||||
const dy = e.clientY - uiCache.panLastY;
|
||||
uiCache.panLastX = e.clientX;
|
||||
uiCache.panLastY = e.clientY;
|
||||
if (Math.abs(dx) + Math.abs(dy) > 0.5) {
|
||||
world.panCamera?.(-dx, -dy);
|
||||
uiCache.panMoved = true;
|
||||
render();
|
||||
}
|
||||
});
|
||||
|
||||
canvas.addEventListener("click", (e) => {
|
||||
if (uiCache.panMoved) {
|
||||
uiCache.panMoved = false;
|
||||
return;
|
||||
}
|
||||
const p = screenToWorld(e);
|
||||
if (!p.inside) return;
|
||||
world.handleClick(p.x, p.y);
|
||||
renderStats();
|
||||
});
|
||||
|
||||
canvas.addEventListener("mousemove", (e) => {
|
||||
const p = screenToWorld(e);
|
||||
world.pointer.x = p.x;
|
||||
world.pointer.y = p.y;
|
||||
world.pointer.inside = !!p.inside;
|
||||
});
|
||||
canvas.addEventListener("wheel", resizeFieldFromWheel, { passive: false });
|
||||
canvas.addEventListener("mouseleave", () => {
|
||||
world.pointer.inside = false;
|
||||
});
|
||||
|
||||
window.addEventListener("resize", resizeCanvas);
|
||||
}
|
||||
193
js/ui_charts.js
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
"use strict";
|
||||
|
||||
function renderStats() {
|
||||
const alive = world.tarinai;
|
||||
const avg = (fn) => alive.length ? alive.reduce((a, t) => a + fn(t), 0) / alive.length : 0;
|
||||
const values = {
|
||||
pop: alive.length,
|
||||
dead: world.deadCount,
|
||||
hunger: avg(t => t.hunger),
|
||||
lonely: avg(t => t.loneliness),
|
||||
stress: avg(t => t.stress),
|
||||
lack: avg(t => t.lack),
|
||||
events: { ...(world.eventCounters || {}) },
|
||||
objects: { ...(world.itemCounts || {}) },
|
||||
};
|
||||
setTextIfChanged(ui.statPop, "pop", values.pop);
|
||||
setTextIfChanged(ui.statDead, "dead", values.dead);
|
||||
updateColonyHistory(values);
|
||||
drawColonyChart(values);
|
||||
renderSelected();
|
||||
renderArchive();
|
||||
}
|
||||
|
||||
function updateColonyHistory(values) {
|
||||
const t = Math.max(0, world.time || 0);
|
||||
const history = uiCache.chartHistory;
|
||||
const interval = Math.max(1, (CONFIG.dayLength || 120) / 12);
|
||||
const maxPoints = 7 * 12;
|
||||
if ((history.length && t < history[history.length - 1].t) || (Number.isFinite(uiCache.chartBucketStart) && t < uiCache.chartBucketStart)) {
|
||||
history.length = 0;
|
||||
uiCache.chartBucketStart = -Infinity;
|
||||
uiCache.chartBucketEnd = -Infinity;
|
||||
uiCache.chartBucketAccum = null;
|
||||
uiCache.lastChartDraw = "";
|
||||
}
|
||||
const eventKeys = ["birth", "death", "fight", "accident", "food", "grass"];
|
||||
const objectKeys = ["grass", "zunchi"];
|
||||
const makeBucket = (start) => ({
|
||||
start,
|
||||
end: start + interval,
|
||||
count: 0,
|
||||
sums: { pop: 0, hunger: 0, lonely: 0, stress: 0, lack: 0 },
|
||||
objects: Object.fromEntries(objectKeys.map(k => [k, 0])),
|
||||
eventsStart: Object.fromEntries(eventKeys.map(k => [k, (values.events || {})[k] || 0])),
|
||||
events: Object.fromEntries(eventKeys.map(k => [k, 0])),
|
||||
});
|
||||
if (!uiCache.chartBucketAccum) {
|
||||
const start = Math.floor(t / interval) * interval;
|
||||
uiCache.chartBucketStart = start;
|
||||
uiCache.chartBucketEnd = start + interval;
|
||||
uiCache.chartBucketAccum = makeBucket(start);
|
||||
}
|
||||
const pushBucket = (bucket) => {
|
||||
if (!bucket || bucket.count <= 0) return;
|
||||
const row = {
|
||||
t: bucket.end,
|
||||
pop: bucket.sums.pop / bucket.count,
|
||||
hunger: bucket.sums.hunger / bucket.count,
|
||||
lonely: bucket.sums.lonely / bucket.count,
|
||||
stress: bucket.sums.stress / bucket.count,
|
||||
lack: bucket.sums.lack / bucket.count,
|
||||
...Object.fromEntries(objectKeys.map(k => [`obj_${k}`, bucket.objects[k] / bucket.count])),
|
||||
...Object.fromEntries(eventKeys.map(k => [`ev_${k}`, bucket.events[k] || 0])),
|
||||
};
|
||||
history.push(row);
|
||||
if (history.length > maxPoints) history.splice(0, history.length - maxPoints);
|
||||
};
|
||||
while (t >= uiCache.chartBucketEnd) {
|
||||
const b = uiCache.chartBucketAccum;
|
||||
for (const k of eventKeys) b.events[k] = Math.max(0, ((values.events || {})[k] || 0) - (b.eventsStart[k] || 0));
|
||||
pushBucket(b);
|
||||
uiCache.chartBucketStart = uiCache.chartBucketEnd;
|
||||
uiCache.chartBucketEnd = uiCache.chartBucketStart + interval;
|
||||
uiCache.chartBucketAccum = makeBucket(uiCache.chartBucketStart);
|
||||
}
|
||||
const bucket = uiCache.chartBucketAccum;
|
||||
bucket.count += 1;
|
||||
bucket.sums.pop += values.pop || 0;
|
||||
bucket.sums.hunger += values.hunger || 0;
|
||||
bucket.sums.lonely += values.lonely || 0;
|
||||
bucket.sums.stress += values.stress || 0;
|
||||
bucket.sums.lack += values.lack || 0;
|
||||
for (const k of objectKeys) bucket.objects[k] += (values.objects || {})[k] || 0;
|
||||
for (const k of eventKeys) bucket.events[k] = Math.max(0, ((values.events || {})[k] || 0) - (bucket.eventsStart[k] || 0));
|
||||
}
|
||||
|
||||
function chartRows(values) {
|
||||
const bucket = uiCache.chartBucketAccum;
|
||||
const eventKeys = ["birth", "death", "fight", "accident", "food", "grass"];
|
||||
const objectKeys = ["grass", "zunchi"];
|
||||
const preview = bucket && bucket.count > 0 ? {
|
||||
t: Math.max(world.time || 0, bucket.start || 0),
|
||||
pop: bucket.sums.pop / bucket.count,
|
||||
hunger: bucket.sums.hunger / bucket.count,
|
||||
lonely: bucket.sums.lonely / bucket.count,
|
||||
stress: bucket.sums.stress / bucket.count,
|
||||
lack: bucket.sums.lack / bucket.count,
|
||||
...Object.fromEntries(objectKeys.map(k => [`obj_${k}`, bucket.objects[k] / bucket.count])),
|
||||
...Object.fromEntries(eventKeys.map(k => [`ev_${k}`, bucket.events[k] || 0])),
|
||||
} : null;
|
||||
return uiCache.chartHistory.length ? [...uiCache.chartHistory, ...(preview ? [preview] : [])] : [preview || { t: world.time || 0, ...values }];
|
||||
}
|
||||
|
||||
function drawColonyChart(values) {
|
||||
const chart = ui.colonyChart;
|
||||
if (!chart) return;
|
||||
const now = performance.now();
|
||||
const perf = world.performanceLevel ? world.performanceLevel() : 0;
|
||||
const minInterval = perf >= 3 ? 2600 : perf === 2 ? 1800 : perf === 1 ? 950 : 0;
|
||||
if (minInterval && uiCache.lastChartDrawAt && now - uiCache.lastChartDrawAt < minInterval) return;
|
||||
const rows = chartRows(values);
|
||||
const mode = uiCache.chartMode || "population";
|
||||
const last = rows[rows.length - 1] || values;
|
||||
const series = chartSeries(mode);
|
||||
const seriesSnapshot = series.map(s => `${s.key}:${Math.round(Number(last[s.key] || 0) * 10)}`).join(",");
|
||||
const snapshot = `${mode}:${rows.length}:${seriesSnapshot}:${chart.clientWidth}x${chart.clientHeight}`;
|
||||
if (uiCache.lastChartDraw === snapshot) return;
|
||||
uiCache.lastChartDraw = snapshot;
|
||||
uiCache.lastChartDrawAt = now;
|
||||
const cssW = Math.max(280, Math.floor(chart.clientWidth || chart.offsetWidth || 320));
|
||||
const cssH = Math.max(160, Math.floor(chart.clientHeight || chart.offsetHeight || 188));
|
||||
if (mode === "events") return drawEventsChart(chart, rows, cssW, cssH);
|
||||
const pad = { l: 30, r: 12, t: 12, b: 24 };
|
||||
const w = cssW - pad.l - pad.r;
|
||||
const h = cssH - pad.t - pad.b;
|
||||
const maxVal = Math.max(10, ...series.flatMap(s => rows.map(r => Number(r[s.key] || 0))));
|
||||
const xFor = (i) => rows.length <= 1 ? pad.l + w : pad.l + (i / (rows.length - 1)) * w;
|
||||
const yFor = (row, s) => {
|
||||
const raw = Number(row[s.key] || 0);
|
||||
const scale = s.percent ? 100 : maxVal;
|
||||
const v = clamp(raw / Math.max(scale, 1) * 100, 0, 100);
|
||||
return pad.t + h - h * v / 100;
|
||||
};
|
||||
const grid = [0, 25, 50, 75, 100].map(yv => {
|
||||
const y = pad.t + h - h * yv / 100;
|
||||
return `<line x1="${pad.l}" y1="${y.toFixed(1)}" x2="${(pad.l + w).toFixed(1)}" y2="${y.toFixed(1)}" stroke="rgba(84,68,48,0.13)" stroke-width="1"/><text x="${pad.l - 6}" y="${(y + 3).toFixed(1)}" text-anchor="end" font-size="10" fill="rgba(117,106,94,0.78)">${yv}</text>`;
|
||||
}).join("");
|
||||
const lines = series.map(s => {
|
||||
const pts = rows.map((row, i) => `${xFor(i).toFixed(1)},${yFor(row, s).toFixed(1)}`).join(" ");
|
||||
const last = rows[rows.length - 1];
|
||||
const cx = xFor(rows.length - 1).toFixed(1);
|
||||
const cy = yFor(last, s).toFixed(1);
|
||||
return `<polyline points="${pts}" fill="none" stroke="${s.color}" stroke-width="${s.key === "pop" ? 2.4 : 1.8}" stroke-linecap="round" stroke-linejoin="round"/><circle cx="${cx}" cy="${cy}" r="2.7" fill="${s.color}"/>`;
|
||||
}).join("");
|
||||
chart.innerHTML = `<svg viewBox="0 0 ${cssW} ${cssH}" preserveAspectRatio="none" aria-hidden="true"><rect x="0" y="0" width="${cssW}" height="${cssH}" rx="14" fill="rgba(255,255,255,0.18)"/>${grid}${lines}<text x="${pad.l}" y="${cssH - 8}" font-size="10" fill="rgba(117,106,94,0.76)">2\u6642\u9593\u3054\u3068 / 1\u9031\u9593</text></svg>`;
|
||||
if (ui.colonyChartLegend) ui.colonyChartLegend.innerHTML = series.map(s => `<span><i style="background:${s.color}"></i>${s.label} ${formatChartValue(s.key, rows[rows.length - 1] || values)}</span>`).join("");
|
||||
}
|
||||
|
||||
function drawEventsChart(chart, rows, cssW, cssH) {
|
||||
const series = chartSeries("events");
|
||||
const weekRows = rows.slice(-7 * 12);
|
||||
const counts = series.map(s => ({ ...s, value: weekRows.reduce((sum, row) => sum + Math.max(0, Number(row[s.key] || 0)), 0) }));
|
||||
const total = Math.max(1, counts.reduce((a, b) => a + b.value, 0));
|
||||
const pad = { l: 68, r: 16, t: 12, b: 16 };
|
||||
const barW = cssW - pad.l - pad.r;
|
||||
const rowH = Math.max(18, (cssH - pad.t - pad.b) / counts.length);
|
||||
const bars = counts.map((s, i) => {
|
||||
const y = pad.t + i * rowH + 4;
|
||||
const pct = s.value / total;
|
||||
const bw = Math.max(2, barW * pct);
|
||||
return `<text x="${pad.l - 8}" y="${(y + 11).toFixed(1)}" text-anchor="end" font-size="10" fill="rgba(84,68,48,0.76)">${s.label}</text><rect x="${pad.l}" y="${y.toFixed(1)}" width="${barW}" height="${Math.max(8, rowH - 8).toFixed(1)}" rx="6" fill="rgba(84,68,48,0.08)"/><rect x="${pad.l}" y="${y.toFixed(1)}" width="${bw.toFixed(1)}" height="${Math.max(8, rowH - 8).toFixed(1)}" rx="6" fill="${s.color}"/><text x="${(pad.l + Math.min(barW - 4, bw + 6)).toFixed(1)}" y="${(y + 11).toFixed(1)}" font-size="10" fill="rgba(42,36,29,0.82)">${s.value}\u4ef6 / ${Math.round(pct * 100)}%</text>`;
|
||||
}).join("");
|
||||
chart.innerHTML = `<svg viewBox="0 0 ${cssW} ${cssH}" preserveAspectRatio="none" aria-hidden="true"><rect x="0" y="0" width="${cssW}" height="${cssH}" rx="14" fill="rgba(255,255,255,0.18)"/>${bars}</svg>`;
|
||||
if (ui.colonyChartLegend) ui.colonyChartLegend.innerHTML = `<span>1\u9031\u9593\u306e\u5272\u5408 / \u4ef6\u6570</span>`;
|
||||
}
|
||||
|
||||
function chartSeries(mode = "population") {
|
||||
if (mode === "life") return [
|
||||
{ key: "hunger", label: "\u7a7a\u8179", color: "#e4a33f", percent: true },
|
||||
{ key: "lonely", label: "\u5bc2\u3057\u3055", color: "#9b78d4", percent: true },
|
||||
{ key: "stress", label: "\u30b9\u30c8\u30ec\u30b9", color: "#d96262", percent: true },
|
||||
{ key: "lack", label: "\u305f\u308a\u306a\u3044", color: "#5b9d61", percent: true },
|
||||
];
|
||||
if (mode === "objects") return [
|
||||
{ key: "obj_grass", label: "\u8349", color: "#5b9d61" },
|
||||
{ key: "obj_zunchi", label: "\u305a\u3093\u3061", color: "#6f8a3f" },
|
||||
];
|
||||
if (mode === "events") return [
|
||||
{ key: "ev_birth", label: "\u8a95\u751f", color: "#73b94b" },
|
||||
{ key: "ev_death", label: "\u6b7b\u4ea1", color: "#5b534b" },
|
||||
{ key: "ev_fight", label: "\u55a7\u5629", color: "#d65e46" },
|
||||
{ key: "ev_accident", label: "\u4e8b\u6545", color: "#e28836" },
|
||||
{ key: "ev_food", label: "\u98df\u4e8b", color: "#76b94d" },
|
||||
{ key: "ev_grass", label: "\u8349", color: "#489443" },
|
||||
];
|
||||
return [{ key: "pop", label: "\u500b\u4f53", color: "#5d8ee6" }];
|
||||
}
|
||||
|
||||
function formatChartValue(key, values) {
|
||||
const v = values[key] ?? 0;
|
||||
return String(Math.round(v));
|
||||
}
|
||||
|
||||
3
js/ui_exports.js
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
"use strict";
|
||||
|
||||
window.TarinaiUI = { canvas, ctx, ui, renderLog, renderSelected, stateLabel, renderStats, escapeHtml, renderArchive, validateFamilyTree, resizeCanvas, applyFieldLayout, screenToWorld, selectTool, showToast, saveGame, loadGame, syncTopButtons, bindUI };
|
||||
187
js/ui_family.js
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
"use strict";
|
||||
|
||||
function buildArchiveRows(family) {
|
||||
return liveFamilyComponents(family);
|
||||
}
|
||||
|
||||
function liveFamilyComponents(family) {
|
||||
const nodes = Object.values(family || {}).filter(Boolean).filter(n => n.hasPaired || (n.parents || []).length || (n.children || []).length);
|
||||
const ids = new Set(nodes.map(n => n.id));
|
||||
const graph = new Map();
|
||||
const addEdge = (a, b) => {
|
||||
if (!a || !b || !ids.has(a) || !ids.has(b)) return;
|
||||
if (!graph.has(a)) graph.set(a, new Set());
|
||||
if (!graph.has(b)) graph.set(b, new Set());
|
||||
graph.get(a).add(b);
|
||||
graph.get(b).add(a);
|
||||
};
|
||||
for (const n of nodes) {
|
||||
if (!graph.has(n.id)) graph.set(n.id, new Set());
|
||||
for (const p of n.parents || []) addEdge(n.id, p);
|
||||
for (const c of n.children || []) addEdge(n.id, c);
|
||||
}
|
||||
const seen = new Set();
|
||||
const comps = [];
|
||||
for (const n of nodes) {
|
||||
if (seen.has(n.id)) continue;
|
||||
const stack = [n.id];
|
||||
const comp = [];
|
||||
seen.add(n.id);
|
||||
while (stack.length) {
|
||||
const id = stack.pop();
|
||||
const item = family[id];
|
||||
if (item) comp.push(item);
|
||||
for (const next of graph.get(id) || []) {
|
||||
if (seen.has(next)) continue;
|
||||
seen.add(next);
|
||||
stack.push(next);
|
||||
}
|
||||
}
|
||||
if (comp.some(x => x.alive)) comps.push(comp);
|
||||
}
|
||||
comps.sort((a, b) => {
|
||||
const ag = Math.min(...a.map(n => n.generation || 1));
|
||||
const bg = Math.min(...b.map(n => n.generation || 1));
|
||||
const at = Math.min(...a.map(n => n.birthTime || 0));
|
||||
const bt = Math.min(...b.map(n => n.birthTime || 0));
|
||||
return ag - bg || at - bt || b.length - a.length;
|
||||
});
|
||||
return comps;
|
||||
}
|
||||
|
||||
function familyStatusText(n) {
|
||||
if (!n) return "\u4e0d\u660e";
|
||||
return n.alive ? "\u751f\u5b58" : "\u6b7b\u4ea1";
|
||||
}
|
||||
|
||||
function lineagePersonHtml(n) {
|
||||
const live = n?.id ? world.tarinai.find(t => t.id === n.id && !t.dead) : null;
|
||||
const img = n ? tarinaiSpritePathForId(n.id) : SPRITES[0].path;
|
||||
const name = escapeHtml(n?.name || "\u4e0d\u660e");
|
||||
const joined = Boolean(n?.hasPaired || (n?.parents || []).length || (n?.children || []).length);
|
||||
const gen = joined && n?.generation ? `\u4e16\u4ee3 ${n.generation}` : "";
|
||||
const status = escapeHtml(familyStatusText(n));
|
||||
const reason = !n?.alive && n?.deathReason ? `<span class="death-reason">\u7406\u7531: ${escapeHtml(n.deathReason)}</span>` : "";
|
||||
const cls = n ? ((live || n.alive) ? "alive" : "dead") : "missing";
|
||||
const data = n?.id ? ` data-family-tarinai-id="${escapeHtml(n.id)}"` : "";
|
||||
return `<div class="family-node lineage-person ${cls}"${data}>
|
||||
<img src="${img}" alt="">
|
||||
<div class="family-copy">
|
||||
<strong title="${name}">${name}</strong>
|
||||
<span>${gen ? `${escapeHtml(gen)} / ${status}` : `${status}`}</span>${reason}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function lineageMiniHtml(n, role = "") {
|
||||
if (!n) return `<span class="lineage-mini missing">\u4e0d\u660e</span>`;
|
||||
const img = tarinaiSpritePathForId(n.id);
|
||||
const name = escapeHtml(n.name || n.id);
|
||||
const gen = n.generation ? `G${n.generation}` : "";
|
||||
const status = n.alive ? "\u751f" : "\u6b7b";
|
||||
return `<span class="lineage-mini ${n.alive ? "alive" : "dead"} ${role}">
|
||||
<img src="${img}" alt="">
|
||||
<b title="${name}">${name}</b>
|
||||
<small>${gen} / ${status}</small>
|
||||
</span>`;
|
||||
}
|
||||
|
||||
function lineageFamilyHtml(component, index, family) {
|
||||
const nodeSort = (a, b) =>
|
||||
(a.generation || 1) - (b.generation || 1) ||
|
||||
(a.birthTime || 0) - (b.birthTime || 0) ||
|
||||
String(a.name || "").localeCompare(String(b.name || "")) ||
|
||||
String(a.id || "").localeCompare(String(b.id || ""));
|
||||
const uniqueComponent = Array.from(new Map(component.filter(Boolean).map(n => [n.id, n])).values()).sort(nodeSort);
|
||||
const byGeneration = new Map();
|
||||
for (const n of uniqueComponent) {
|
||||
const key = n.generation || 1;
|
||||
if (!byGeneration.has(key)) byGeneration.set(key, []);
|
||||
byGeneration.get(key).push(n);
|
||||
}
|
||||
const generationBlocks = Array.from(byGeneration.entries()).sort((a, b) => a[0] - b[0]).map(([generation, nodes]) => `
|
||||
<div class="lineage-generation-block">
|
||||
<b>\u4e16\u4ee3 ${generation}</b>
|
||||
<div class="lineage-person-grid">${nodes.sort(nodeSort).map(lineagePersonHtml).join("")}</div>
|
||||
</div>
|
||||
`).join("");
|
||||
|
||||
const pairMap = new Map();
|
||||
for (const child of uniqueComponent) {
|
||||
const parents = (child.parents || []).filter(id => family[id]);
|
||||
if (!parents.length) continue;
|
||||
const key = parents.slice().sort().join("+");
|
||||
if (!pairMap.has(key)) pairMap.set(key, { parents: parents.slice().sort(), children: [] });
|
||||
pairMap.get(key).children.push(child.id);
|
||||
}
|
||||
const pairs = Array.from(pairMap.values()).sort((a, b) => {
|
||||
const achild = family[a.children[0]];
|
||||
const bchild = family[b.children[0]];
|
||||
return nodeSort(achild, bchild);
|
||||
});
|
||||
const pairCards = pairs.length ? pairs.map(pair => {
|
||||
const parents = pair.parents.map(id => family[id]).filter(Boolean).sort(nodeSort);
|
||||
const children = pair.children.map(id => family[id]).filter(Boolean).sort(nodeSort);
|
||||
return `<article class="lineage-pair-card">
|
||||
<div class="lineage-pair-parents">${parents.map(n => lineageMiniHtml(n, "parent")).join('<span class="lineage-plus">+</span>')}</div>
|
||||
<div class="lineage-birth-arrow">\u2193</div>
|
||||
<div class="lineage-pair-children">${children.map(n => lineageMiniHtml(n, "child")).join("")}</div>
|
||||
</article>`;
|
||||
}).join("") : `<div class="lineage-empty-family">\u307e\u3060\u89aa\u5b50\u95a2\u4fc2\u306f\u8a18\u9332\u3055\u308c\u3066\u3044\u307e\u305b\u3093\u3002</div>`;
|
||||
const aliveCount = uniqueComponent.filter(n => n.alive).length;
|
||||
return `<section class="lineage-family lineage-family-readable">
|
||||
<h3>\u5bb6\u7cfb ${index + 1} / ${uniqueComponent.length}\u5339 / \u751f\u5b58 ${aliveCount}</h3>
|
||||
<div class="lineage-readable-grid">
|
||||
<div class="lineage-member-column">${generationBlocks}</div>
|
||||
<div class="lineage-reproduction-column">
|
||||
<div class="lineage-column-title">\u7e41\u6b96\u30da\u30a2</div>
|
||||
<div class="lineage-pair-list">${pairCards}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function renderArchive() {
|
||||
if (!ui.archiveContent) return;
|
||||
if (uiCache.deferArchiveUntil && performance.now() < uiCache.deferArchiveUntil) return;
|
||||
uiCache.deferArchiveUntil = 0;
|
||||
if (world.normalizeFamily) world.normalizeFamily();
|
||||
const family = world.family || {};
|
||||
const ids = Object.keys(family);
|
||||
const version = `${world.familyVersion || 0}:${ids.length}`;
|
||||
if (uiCache.archiveVersion === version) return;
|
||||
uiCache.archiveVersion = version;
|
||||
uiCache.lastArchiveWindow = "";
|
||||
const components = liveFamilyComponents(family);
|
||||
uiCache.archiveRows = components;
|
||||
if (!components.length) {
|
||||
ui.archiveContent.innerHTML = `<div class="selected-info empty">\u751f\u304d\u3066\u3044\u308b\u69cb\u6210\u54e1\u3092\u6301\u3064\u5bb6\u7cfb\u306f\u307e\u3060\u3042\u308a\u307e\u305b\u3093\u3002</div>`;
|
||||
return;
|
||||
}
|
||||
const shown = components.reduce((a, c) => a + c.length, 0);
|
||||
const note = `<div class="tree-note"><strong>${components.length}</strong> \u4ef6\u306e\u5bb6\u7cfb / <strong>${shown}</strong> \u5339\u3092\u8868\u793a\u3002\u5168\u6ec5\u3057\u305f\u5bb6\u7cfb\u306f\u975e\u8868\u793a\u3067\u3059\u3002</div>`;
|
||||
ui.archiveContent.innerHTML = `${note}<div class="lineage-forest">${components.map((c, i) => lineageFamilyHtml(c, i, family)).join("")}</div>`;
|
||||
}
|
||||
|
||||
function validateFamilyTree() {
|
||||
const issues = world.validateFamily ? [...world.validateFamily()] : [];
|
||||
const rendered = new Map();
|
||||
document.querySelectorAll("[data-family-tarinai-id]").forEach(node => {
|
||||
const id = node.getAttribute("data-family-tarinai-id");
|
||||
rendered.set(id, (rendered.get(id) || 0) + 1);
|
||||
});
|
||||
for (const [id, count] of rendered.entries()) {
|
||||
if (count > 1) issues.push({ type: "duplicate-render-node", id, count });
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
function renderArchiveWindow() {
|
||||
renderArchive();
|
||||
}
|
||||
|
||||
function scheduleArchiveWindowRender() {
|
||||
renderArchive();
|
||||
}
|
||||
|
||||
window.validateFamilyTree = validateFamilyTree;
|
||||