Compare commits

...

3 commits

Author SHA1 Message Date
e6507288ce ? 2026-06-19 14:00:48 +09:00
0e8ece2dc0 sprites 2026-06-19 02:27:19 +09:00
6d2537e3e3 fiiiirst 2026-06-18 18:59:56 +09:00
73 changed files with 7146 additions and 2 deletions

View file

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 389 KiB

View 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
}
]

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

BIN
assets/ui/tool_bed.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 527 B

BIN
assets/ui/tool_delete.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 321 B

BIN
assets/ui/tool_fence_h.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 786 B

BIN
assets/ui/tool_fence_v.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 KiB

BIN
assets/ui/tool_grass.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 452 B

BIN
assets/ui/tool_new.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

BIN
assets/ui/tool_observe.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 981 B

BIN
assets/ui/tool_poke.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 770 B

BIN
assets/ui/tool_stone.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 404 B

BIN
assets/ui/tool_sweet.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 489 B

BIN
assets/ui/tool_water.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 439 B

BIN
assets/ui/tool_zunchi.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

870
css/styles.css Normal file
View 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
View 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
View 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
View 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>&#x305F;&#x308A;&#x306A;&#x3044;&#x89B3;&#x5BDF;&#x7BB1;</title>
<link rel="stylesheet" href="css/styles.css" />
</head>
<body>
<div id="app">
<header class="topbar">
<div>
<h1>&#x305F;&#x308A;&#x306A;&#x3044;&#x89B3;&#x5BDF;&#x7BB1;</h1>
<p class="subtitle">&#x305F;&#x308A;&#x306A;&#x3044;&#x304C;&#x66AE;&#x3089;&#x3059;&#x5C0F;&#x3055;&#x306A;&#x5EAD;&#x3092;&#x89B3;&#x5BDF;&#x3057;&#x307E;&#x3059;&#x3002;&#x98DF;&#x3079;&#x3001;&#x7720;&#x308A;&#x3001;&#x95A2;&#x4FC2;&#x3092;&#x5909;&#x3048;&#x307E;&#x3059;&#x3002;</p>
</div>
<div class="top-actions">
<button id="pauseBtn" class="btn primary">&#x4E00;&#x6642;&#x505C;&#x6B62;</button>
<button id="speedBtn" class="btn">&#x901F;&#x5EA6; x1</button>
<button id="soundBtn" class="btn">&#x97F3; OFF</button>
<button id="saveBtn" class="btn">&#x4FDD;&#x5B58;</button>
<button id="loadBtn" class="btn">&#x8AAD;&#x8FBC;</button>
<button id="resetBtn" class="btn danger">&#x30EA;&#x30BB;&#x30C3;&#x30C8;</button>
</div>
</header>
<main class="layout">
<section class="stage-wrap">
<canvas id="gameCanvas" aria-label="&#x305F;&#x308A;&#x306A;&#x3044;&#x89B3;&#x5BDF;&#x30AD;&#x30E3;&#x30F3;&#x30D0;&#x30B9;"></canvas>
<div id="toast" class="toast hidden"></div>
</section>
<aside class="panel">
<section id="toolCard" class="card tool-card">
<div class="tool-head"><h2>&#x9053;&#x5177;</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">&#x89B3;&#x5BDF;</button>
<div class="tool-grid tool-category-body">
<button class="tool selected" data-tool="observe"><span>O</span>&#x89B3;&#x5BDF;</button>
<button class="tool" data-tool="delete"><span>D</span>&#x524A;&#x9664;</button>
<button class="tool" data-tool="poke"><span>P</span>&#x3064;&#x3064;&#x304F;</button>
<button class="tool" data-tool="new"><span>N</span>&#x8FFD;&#x52A0;</button>
</div>
</section>
<section class="tool-category" data-tool-category="food">
<button class="tool-category-toggle" type="button" aria-expanded="true">&#x98DF;&#x3079;&#x7269;&#x30FB;&#x81EA;&#x7136;</button>
<div class="tool-grid tool-category-body">
<button class="tool" data-tool="zunchi"><span>Z</span>&#x305A;&#x3093;&#x3061;</button>
<button class="tool" data-tool="sweet"><span>S</span>&#x305A;&#x3093;&#x3060;&#x9905;</button>
<button class="tool" data-tool="water"><span>W</span>&#x6C34;</button>
<button class="tool" data-tool="grass"><span>G</span>&#x8349;</button>
</div>
</section>
<section class="tool-category" data-tool-category="object">
<button class="tool-category-toggle" type="button" aria-expanded="true">&#x30AA;&#x30D6;&#x30B8;&#x30A7;&#x30AF;&#x30C8;</button>
<div class="tool-grid tool-category-body">
<button class="tool" data-tool="stone"><span>R</span>&#x77F3;</button>
<button class="tool" data-tool="bed"><span>B</span>&#x5E72;&#x8349;&#x5BDD;&#x5E8A;</button>
<button class="tool" data-tool="firecracker"><span>F</span>&#x7206;&#x7AF9;</button>
</div>
</section>
<section class="tool-category" data-tool-category="fence">
<button class="tool-category-toggle" type="button" aria-expanded="true">&#x67F5;</button>
<div class="tool-grid tool-category-body">
<button class="tool" data-tool="fence_v"><span>V</span>&#x7E26;&#x306E;&#x67F5;</button>
<button class="tool" data-tool="fence_h"><span>H</span>&#x6A2A;&#x306E;&#x67F5;</button>
</div>
</section>
</div>
<p class="hint">&#x540C;&#x3058;&#x9053;&#x5177;&#x30DC;&#x30BF;&#x30F3;&#x3092;&#x3082;&#x3046;&#x4E00;&#x5EA6;&#x62BC;&#x3059;&#x3068;&#x3001;&#x5BFE;&#x5FDC;&#x9053;&#x5177;&#x306E;&#x5927;&#x304D;&#x3055;&#x304C;&#x5C0F;&#x30FB;&#x4E2D;&#x30FB;&#x5927;&#x3067;&#x5207;&#x308A;&#x66FF;&#x308F;&#x308A;&#x307E;&#x3059;&#x3002;</p>
</div>
</section>
<section class="card colony-card">
<h2>&#x30B3;&#x30ED;&#x30CB;&#x30FC;</h2>
<div class="stats colony-stats">
<div class="stat"><span>&#x500B;&#x4F53;&#x6570;</span><strong id="statPop">0</strong></div>
<div class="stat"><span>&#x6B7B;&#x4EA1;&#x6570;</span><strong id="statDead">0</strong></div>
</div>
<div id="colonyChartTabs" class="chart-tabs"><button data-chart="population" class="active">&#x4EBA;&#x53E3;</button><button data-chart="life">&#x751F;&#x6D3B;</button><button data-chart="events">&#x4E8B;&#x4EF6;</button><button data-chart="objects">&#x30AA;&#x30D6;&#x30B8;&#x30A7;&#x30AF;&#x30C8;</button></div>
<div id="colonyChartLegend" class="colony-chart-legend"></div>
<div id="colonyChart" class="colony-chart" aria-label="&#x30B3;&#x30ED;&#x30CB;&#x30FC;&#x306E;&#x63A8;&#x79FB;&#x30B0;&#x30E9;&#x30D5;" role="img"></div>
</section>
<section class="card selected-card">
<h2>&#x9078;&#x629E;&#x4E2D;</h2>
<div id="selectedInfo" class="selected-info empty">&#x672A;&#x9078;&#x629E;</div>
</section>
<section class="card log-card">
<h2>&#x8A18;&#x9332;</h2>
<div id="log" class="log"></div>
</section>
</aside>
</main>
<section class="card lineage-main-card">
<h2>&#x5BB6;&#x7CFB;&#x56F3;</h2>
<p class="hint">&#x5B50;&#x3069;&#x3082;&#x306F;&#x6210;&#x9577;&#x3059;&#x308B;&#x307E;&#x3067;&#x89AA;&#x306B;&#x3064;&#x3044;&#x3066;&#x3044;&#x304D;&#x307E;&#x3059;&#x3002;&#x3053;&#x306E;&#x9818;&#x57DF;&#x3092;&#x30B9;&#x30AF;&#x30ED;&#x30FC;&#x30EB;&#x3057;&#x3066;&#x5BB6;&#x7CFB;&#x3092;&#x78BA;&#x8A8D;&#x3067;&#x304D;&#x307E;&#x3059;&#x3002;</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">&#x30D5;&#x30A3;&#x30FC;&#x30EB;&#x30C9;&#x3092;&#x9078;&#x629E;</h2>
<div class="field-choice-grid">
<button class="field-choice" data-field-type="park" type="button"><strong>&#x516C;&#x5712;</strong><span>&#x5927;</span></button>
<button class="field-choice" data-field-type="garden" type="button"><strong>&#x5EAD;</strong><span>&#x4E2D;</span></button>
<button class="field-choice" data-field-type="cage" type="button"><strong>&#x30B1;&#x30FC;&#x30B8;</strong><span>&#x5C0F;</span></button>
</div>
<div class="field-dialog-actions">
<button id="fieldCancelBtn" class="btn" type="button">&#x30AD;&#x30E3;&#x30F3;&#x30BB;&#x30EB;</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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load diff

528
js/ui.js Normal file
View 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 => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;"
}[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
View 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
View 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
View 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;

1525
js/world.js Normal file

File diff suppressed because it is too large Load diff