This commit is contained in:
33333-33333 2026-06-18 18:59:56 +09:00
commit 6d2537e3e3
53 changed files with 4719 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.

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: 69 KiB

BIN
assets/ui/tool_bed.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 527 B

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: 5.2 KiB

BIN
assets/ui/tool_poke.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 648 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

495
css/styles.css Normal file
View file

@ -0,0 +1,495 @@
: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;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: 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.02em;
}
.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: 620px;
}
#gameCanvas {
width: 100%;
height: calc(100vh - 140px);
min-height: 620px;
display: block;
cursor: crosshair;
}
.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;
max-height: calc(100vh - 120px);
overflow: auto;
padding-right: 3px;
}
.card {
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.02em;
}
.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="bed"] { --tool-icon: url("../assets/ui/tool_bed.png"); }
.tool[data-tool="poke"] { --tool-icon: url("../assets/ui/tool_poke.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;
}
.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: 210px; }
.log {
max-height: 280px;
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: 66vh; min-height: 460px; }
.stage-wrap { min-height: 460px; }
}
.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: 360px;
}
.lineage-main-card h2 {
margin-bottom: 6px;
}
.lineage-main-content {
max-height: min(64vh, 620px);
min-height: 260px;
overflow: auto;
overscroll-behavior: contain;
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;
}
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: 26px minmax(0, 1fr);
align-items: center;
gap: 6px;
min-width: 0;
min-height: 42px;
padding: 5px 7px;
border-radius: 11px;
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.dead { opacity: 0.68; filter: grayscale(0.35); }
.family-node.missing { opacity: 0.48; border-style: dashed; }
.family-node img { width: 26px; height: 26px; 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 strong { font-size: 12px; }
.family-copy span { color: var(--muted); font-size: 9px; margin-top: 2px; }
@media (max-width: 760px) {
.family-grid { grid-template-columns: 1fr; }
.family-cluster { min-height: 102px; }
}

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 を連続的なアルファ値へ反映し、数分かけてフェードアウトする。

91
index.html Normal file
View file

@ -0,0 +1,91 @@
<!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 class="card">
<h2>&#x9053;&#x5177;</h2>
<div id="toolPalette" class="tool-grid">
<button class="tool selected" data-tool="observe"><span>O</span>&#x89B3;&#x5BDF;</button>
<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>
<button class="tool" data-tool="stone"><span>R</span>&#x3042;&#x305F;&#x305F;&#x304B;&#x3044;&#x77F3;</button>
<button class="tool" data-tool="bed"><span>B</span>&#x5E72;&#x3057;&#x8349;&#x30D9;&#x30C3;&#x30C9;</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>
<p class="hint">&#x30AD;&#x30E3;&#x30F3;&#x30D0;&#x30B9;&#x3092;&#x30AF;&#x30EA;&#x30C3;&#x30AF;&#x3057;&#x3066;&#x9053;&#x5177;&#x3092;&#x7F6E;&#x304D;&#x307E;&#x3059;&#x3002;&#x89B3;&#x5BDF;&#x30E2;&#x30FC;&#x30C9;&#x3067;&#x306F;&#x305F;&#x308A;&#x306A;&#x3044;&#x3092;&#x9078;&#x629E;&#x3057;&#x307E;&#x3059;&#x3002;</p>
</section>
<section class="card">
<h2>&#x30B3;&#x30ED;&#x30CB;&#x30FC;</h2>
<div class="stats">
<div class="stat"><span>&#x500B;&#x4F53;&#x6570;</span><strong id="statPop">0</strong></div>
<div class="stat"><span>&#x4E16;&#x4EE3;</span><strong id="statGen">1</strong></div>
<div class="stat"><span>&#x6B7B;&#x4EA1;&#x6570;</span><strong id="statDead">0</strong></div>
<div class="stat"><span>&#x65E5;</span><strong id="statDay">1</strong></div>
</div>
<div class="meter-row"><span>&#x7A7A;&#x8179;&#x5E73;&#x5747;</span><meter id="meterHunger" min="0" max="100" value="0"></meter></div>
<div class="meter-row"><span>&#x5BC2;&#x3057;&#x3055;&#x5E73;&#x5747;</span><meter id="meterLonely" min="0" max="100" value="0"></meter></div>
<div class="meter-row"><span>&#x30B9;&#x30C8;&#x30EC;&#x30B9;&#x5E73;&#x5747;</span><meter id="meterStress" min="0" max="100" value="0"></meter></div>
<div class="meter-row"><span>&#x305F;&#x308A;&#x306A;&#x3044;&#x5E73;&#x5747;</span><meter id="meterLack" min="0" max="100" value="0"></meter></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>
<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.js"></script>
<script src="js/ui.js"></script>
<script src="js/main.js"></script>
</body>
</html>

56
js/assets.js Normal file
View file

@ -0,0 +1,56 @@
"use strict";
const images = new Map();
const imageBoundsCache = new WeakMap();
const imageMetrics = new Map();
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, loadImages, imageAlphaBounds, getImageMetrics };

43
js/audio.js Normal file
View file

@ -0,0 +1,43 @@
"use strict";
const audio = {
enabled: false,
ctx: null,
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();
return this.ctx;
},
toggle() {
this.enabled = !this.enabled;
if (this.enabled) this.ensure();
return this.enabled;
},
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);
},
eat() { 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); },
phase() { this.tone(440, 0.06, "sine", 0.018, 60); },
};
window.TarinaiAudio = audio;

94
js/data.js Normal file
View file

@ -0,0 +1,94 @@
"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 },
];
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: 99, y: 20, w: 313, h: 434, 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: 96,
itemLimit: 110,
saveKey: "tarinai-colony-save-v18",
worldPadding: 30,
hungerPerMinute: 11.2,
lonelinessPerMinute: 7.6,
energyPerMinute: 8.2,
stressDecayPerMinute: 9,
reproductionCooldown: 28,
dayLength: 120,
childGrowTime: 58,
fightCooldown: 8,
lightingQuality: "cinematic",
weatherChangeMin: 46,
weatherChangeMax: 96,
bubbleLimit: 42,
};
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, LIGHTING_TUNING };

40
js/main.js Normal file
View file

@ -0,0 +1,40 @@
"use strict";
let last = nowSec();
let statsTimer = 0;
let fpsTimer = 0;
let fpsFrames = 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;
if (statsTimer > 0.2) {
renderStats();
statsTimer = 0;
}
requestAnimationFrame(loop);
}
loadImages().then(() => {
bindUI();
resizeCanvas();
world.reset();
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 };

644
js/render.js Normal file
View file

@ -0,0 +1,644 @@
"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) {
const pixelEllipse = (cx, cy, ew, eh, angle, steps = 10) => {
const cos = Math.cos(angle);
const sin = Math.sin(angle);
ctx.beginPath();
for (let i = 0; i < steps; i++) {
const a = (i / steps) * Math.PI * 2;
const px = Math.cos(a) * ew;
const py = Math.sin(a) * eh;
const sx = Math.round(cx + px * cos - py * sin);
const sy = Math.round(cy + px * sin + py * cos);
if (i === 0) ctx.moveTo(sx, sy);
else ctx.lineTo(sx, sy);
}
ctx.closePath();
};
ctx.save();
ctx.translate(x, y);
ctx.imageSmoothingEnabled = false;
ctx.fillStyle = params.color;
const dx = Math.round(params.dx + rx * 0.10);
const dy = Math.round(params.dy + ry * 0.10);
const angle = Math.atan2(dy, dx) * 0.42;
ctx.globalAlpha = Math.max(0.04, params.alpha * 0.50);
ctx.beginPath();
ctx.moveTo(Math.round(-rx * 0.28), Math.round(-ry * 0.50));
ctx.lineTo(Math.round(dx + rx * 1.18), Math.round(dy - ry * 0.36));
ctx.lineTo(Math.round(dx + rx * 1.42), Math.round(dy + ry * 0.42));
ctx.lineTo(Math.round(-rx * 0.08), Math.round(ry * 0.72));
ctx.closePath();
ctx.fill();
ctx.globalAlpha = Math.max(0.055, params.alpha * 0.78);
pixelEllipse(dx * 0.42, dy * 0.42, Math.max(3, rx * 0.90), Math.max(1.8, ry * 0.86), angle, 10);
ctx.fill();
ctx.globalAlpha = Math.max(0.07, params.alpha * 1.12);
pixelEllipse(0, 0, Math.max(3, rx * 0.76), Math.max(1.6, ry * 0.72), 0, 10);
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) {
if (lighting.quality !== "cinematic") return;
const nightBoost = lighting.nightStrength;
const goldenBoost = lighting.goldenStrength;
if (item.type === "food" || item.type === "sweet") {
const sweet = item.type === "sweet";
drawRadialGlow(ctx, item.x, item.y, item.r * (2.35 + goldenBoost * 0.55), sweet ? "rgba(128, 190, 88, ALPHA)" : "rgba(255, 218, 120, ALPHA)", visibleAlpha * (0.045 + goldenBoost * 0.045 + nightBoost * 0.055));
} else if (item.type === "water") {
drawRadialGlow(ctx, item.x, item.y, item.r * (2.5 + nightBoost * 0.7), "rgba(116, 183, 255, ALPHA)", visibleAlpha * (0.045 + nightBoost * 0.09));
} else if (item.type === "grass" && goldenBoost > 0.35) {
drawRadialGlow(ctx, item.x, item.y - item.r * 0.2, item.r * 2.1, "rgba(183, 255, 99, ALPHA)", visibleAlpha * goldenBoost * 0.04);
}
}
function drawCreatureGlow(ctx, tarinai, drawW, drawH, lighting) {
if (lighting.quality !== "cinematic" || tarinai.dead) return;
const selected = tarinai.world.selected === tarinai;
const happy = (tarinai.goodMode === "smile" || tarinai.state === "cursor_friend") && tarinai.mood > 58 && tarinai.stress < 44 && tarinai.state !== "sleep";
const nightAlpha = lighting.nightStrength * (happy ? 0.10 : 0.032);
const selectedAlpha = selected ? 0.13 + lighting.bloom * 0.055 : 0;
const mealAlpha = tarinai.eatTimer > 0.02 ? tarinai.eatTimer * 0.12 : 0;
const alpha = clamp(nightAlpha + selectedAlpha + mealAlpha + lighting.goldenStrength * (happy ? 0.04 : 0.012), 0, 0.22);
if (alpha <= 0.01) return;
const color = selected ? "rgba(178, 236, 96, ALPHA)" : (lighting.nightStrength > 0.22 ? "rgba(180, 213, 255, ALPHA)" : "rgba(255, 222, 132, ALPHA)");
drawRadialGlow(ctx, tarinai.x, tarinai.y - drawH * 0.10, Math.max(drawW, drawH) * (1.0 + lighting.bloom * 0.34), color, alpha);
}
function drawDawnRimLight(ctx, drawW, drawH, lighting) {
const alpha = clamp(lighting.dawnStrength * (0.10 + lighting.nightStrength * 0.08), 0, 0.16);
if (alpha <= 0.01) return;
ctx.save();
ctx.globalCompositeOperation = "screen";
const g = ctx.createLinearGradient(-drawW * 0.55, -drawH * 0.55, drawW * 0.18, drawH * 0.16);
g.addColorStop(0, `rgba(255, 229, 170, ${alpha})`);
g.addColorStop(0.46, `rgba(255, 213, 142, ${alpha * 0.30})`);
g.addColorStop(1, "rgba(255, 213, 142, 0)");
ctx.fillStyle = g;
ctx.beginPath();
ctx.ellipse(-drawW * 0.06, -drawH * 0.06, drawW * 0.60, drawH * 0.42, -0.18, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
// 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";
return `${weather}:${lighting.progress.toFixed(3)}:${lighting.light.toFixed(3)}:${lighting.goldenStrength.toFixed(3)}:${lighting.nightStrength.toFixed(3)}`;
}
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 x = 18, y = 22, bw = w - 36, bh = h - 44;
const border = activeCtx.createLinearGradient(0, y, 0, y + bh);
border.addColorStop(0, light > 0.42 ? (lighting.goldenStrength > 0.25 ? "#bf8f5e" : "#a88c66") : "#6f6c72");
border.addColorStop(1, light > 0.42 ? (lighting.goldenStrength > 0.25 ? "#9c7043" : "#8a6f4b") : "#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);
soil.addColorStop(0, light > 0.42 ? (lighting.goldenStrength > 0.25 ? "#f1dfbc" : "#ece6d2") : "#bbbcc6");
soil.addColorStop(0.38, light > 0.42 ? (lighting.goldenStrength > 0.25 ? "#e5d4b5" : "#dfdccb") : "#afb1bd");
soil.addColorStop(1, light > 0.42 ? (lighting.goldenStrength > 0.25 ? "#d3c5a5" : "#d6d2bf") : "#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();
for (let i = 0; i < 46; 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();
}
for (let i = 0; i < 8; 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
for (let i = 0; i < 14; 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();
}
for (let i = 0; i < 9; 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();
}
}
for (let i = 0; i < 10; 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 w = world.w, h = world.h;
ctx.clearRect(0, 0, w, h);
const lighting = getLightingState(world);
const light = lighting.light;
const cachedBackground = ensureBackgroundCache(w, h, lighting);
if (cachedBackground) {
ctx.drawImage(cachedBackground, 0, 0, w, h);
} else {
drawGardenBackdrop(w, h, lighting);
drawGardenBed(w, h, lighting);
}
drawLightRays(ctx, w, h, lighting);
drawRain(ctx, w, h, world.time);
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) / 2, Math.min(t.y, p.y) - 10, p.x, p.y);
ctx.stroke();
}
ctx.setLineDash([]);
ctx.restore();
const sorted = world.drawList || world.tarinai;
sorted.length = 0;
for (const t of world.tarinai) sorted.push(t);
sorted.sort((a, b) => a.y - b.y);
for (const t of sorted) t.draw(ctx, world.time, lighting);
for (const ef of world.effects) ef.draw(ctx);
// Light affecting the whole scene, including characters and ground.
drawAtmosphericLighting(ctx, w, h, lighting);
const pressure = clamp((world.tarinai.length - 44) / 24, 0, 1);
if (pressure > 0) {
ctx.save();
ctx.globalAlpha = pressure * 0.06;
ctx.fillStyle = "#b64545";
ctx.fillRect(0, 0, w, h);
ctx.restore();
}
// Time HUD
ctx.save();
const hudW = 150;
const hudH = 56;
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();
drawSelectedCard(ctx, world, lighting);
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.w, h = 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);
ctx.fillText(`\u6c17\u5206 ${fmt(t.mood)} \u4e16\u4ee3 ${t.generation} \u3064\u3064\u304d ${t.pokeCount}/9`, 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 };

2471
js/sim.js Normal file

File diff suppressed because it is too large Load diff

389
js/ui.js Normal file
View file

@ -0,0 +1,389 @@
"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"),
selectedInfo: document.getElementById("selectedInfo"),
log: document.getElementById("log"),
toast: document.getElementById("toast"),
statPop: document.getElementById("statPop"),
statGen: document.getElementById("statGen"),
statDead: document.getElementById("statDead"),
statDay: document.getElementById("statDay"),
meterHunger: document.getElementById("meterHunger"),
meterLonely: document.getElementById("meterLonely"),
meterStress: document.getElementById("meterStress"),
meterLack: document.getElementById("meterLack"),
archiveContent: document.getElementById("archiveContent"),
};
const uiCache = {
stats: {},
selectedSnapshot: "",
selectedEmpty: false,
toolButtons: [],
archiveVersion: "",
archiveRows: [],
archiveScrollScheduled: false,
lastArchiveWindow: "",
};
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", observe: "\u89b3\u5bdf", note: "\u8a18\u9332"
}[kind] || "\u8a18\u9332";
}
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.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.pokeCount,
t.parentNames?.join("+") || "", t.children?.length || 0, fmt(t.lack),
rel.friend?.id || "", rel.friend?.score || 0, rel.fear?.id || "", rel.fear?.score || 0, 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><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"><span>\u3064\u3064\u304d</span><strong>${t.pokeCount} / 9</strong></div>
<div class="info-row"><span>\u4ef2\u826f\u3057</span><strong>${escapeHtml(rel.friend ? `${rel.friend.name} +${fmt(rel.friend.score)}` : "\u306a\u3057")}</strong></div>
<div class="info-row"><span>\u907f\u3051\u308b\u76f8\u624b</span><strong>${escapeHtml(rel.fear ? `${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\u3057\u8349\u30d9\u30c3\u30c9\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",
}[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 === "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\u3057\u8349\u30d9\u30c3\u30c9", stone: "\u3042\u305f\u305f\u304b\u3044\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 renderStats() {
const alive = world.tarinai;
const avg = (fn) => alive.length ? alive.reduce((a, t) => a + fn(t), 0) / alive.length : 0;
setTextIfChanged(ui.statPop, "pop", alive.length);
setTextIfChanged(ui.statGen, "gen", world.maxGeneration);
setTextIfChanged(ui.statDead, "dead", world.deadCount);
setTextIfChanged(ui.statDay, "day", `${world.day}\u65e5`);
setValueIfChanged(ui.meterHunger, "hunger", avg(t => t.hunger).toFixed(1));
setValueIfChanged(ui.meterLonely, "lonely", avg(t => t.loneliness).toFixed(1));
setValueIfChanged(ui.meterStress, "stress", avg(t => t.stress).toFixed(1));
setValueIfChanged(ui.meterLack, "lack", avg(t => t.lack).toFixed(1));
renderSelected();
renderArchive();
}
function setTextIfChanged(el, key, value) {
const next = String(value);
if (uiCache.stats[key] === next) return;
uiCache.stats[key] = next;
el.textContent = next;
}
function setValueIfChanged(el, key, value) {
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 buildArchiveRows(family) {
const nodes = Object.values(family || {})
.filter(Boolean)
.filter(n => (n.parents || []).length > 0 || (n.parentNames || []).length > 0)
.sort((a, b) => (a.generation || 1) - (b.generation || 1) || (a.birthTime || 0) - (b.birthTime || 0));
return nodes.map(n => ({
kind: "\u8a95\u751f",
id: n.id,
child: n,
leftParent: family[n.parents?.[0]],
rightParent: family[n.parents?.[1]],
leftName: n.parentNames?.[0] || "\u4e0d\u660e",
rightName: n.parentNames?.[1] || "\u4e0d\u660e",
}));
}
function familyStatusText(n) {
if (!n) return "\u4e0d\u660e";
return n.alive ? "\u751f\u5b58" : "\u6b7b\u4ea1";
}
function familyNodeHtml(n, role, fallbackName = "\u4e0d\u660e") {
const img = n ? (SPRITES.find(s => s.id === n.type)?.path || SPRITES[0].path) : SPRITES[0].path;
const name = escapeHtml(n?.name || fallbackName);
const gen = n?.generation ? `\u4e16\u4ee3 ${n.generation}` : "\u4e16\u4ee3 ?";
const status = escapeHtml(familyStatusText(n));
const cls = n ? (n.alive ? "alive" : "dead") : "missing";
return `<div class="family-node ${role} ${cls}">
<img src="${img}" alt="">
<div class="family-copy">
<strong title="${name}">${name}</strong>
<span>${escapeHtml(gen)} / ${status}</span>
</div>
</div>`;
}
function familyClusterHtml(row) {
return `<div class="family-cluster">
<div class="family-parents">
${familyNodeHtml(row.leftParent, "parent", row.leftName)}
${familyNodeHtml(row.rightParent, "parent", row.rightName)}
</div>
<div class="family-connector" aria-hidden="true"><span></span></div>
<div class="family-child-slot">${familyNodeHtml(row.child, "child")}</div>
</div>`;
}
function renderArchive() {
if (!ui.archiveContent) return;
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 = "";
uiCache.archiveRows = buildArchiveRows(family);
const rows = uiCache.archiveRows;
if (!rows.length) {
ui.archiveContent.innerHTML = `<div class="selected-info empty">\u5bb6\u65cf\u95a2\u4fc2\u3092\u6301\u3064\u500b\u4f53\u306f\u307e\u3060\u3044\u307e\u305b\u3093\u3002</div>`;
return;
}
const limit = 240;
const visibleRows = rows.slice(Math.max(0, rows.length - limit));
const omitted = rows.length > limit ? rows.length - limit : 0;
const note = omitted
? `<div class="tree-note"><strong>${visibleRows.length}</strong> \u4ef6\u3092\u8868\u793a\u4e2d\u3002\u53e4\u3044\u95a2\u4fc2 ${omitted} \u4ef6\u306f\u7701\u7565\u3057\u307e\u3057\u305f\u3002</div>`
: `<div class="tree-note"><strong>${visibleRows.length}</strong> \u4ef6\u306e\u5bb6\u65cf\u95a2\u4fc2\u3002</div>`;
ui.archiveContent.innerHTML = `${note}<div class="family-grid">${visibleRows.map(familyClusterHtml).join("")}</div>`;
}
function renderArchiveWindow() {
renderArchive();
}
function scheduleArchiveWindowRender() {
renderArchive();
}
function resizeCanvas() {
const rect = canvas.getBoundingClientRect();
const dpr = Math.max(1, Math.min(window.devicePixelRatio || 1, 2));
canvas.width = Math.floor(rect.width * dpr);
canvas.height = Math.floor(rect.height * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
world.w = rect.width;
world.h = rect.height;
}
function screenToWorld(e) {
const rect = canvas.getBoundingClientRect();
const touch = e.touches ? e.touches[0] : e;
return { x: touch.clientX - rect.left, y: touch.clientY - rect.top };
}
function selectTool(tool) {
world.tool = tool;
for (const btn of uiCache.toolButtons) {
btn.classList.toggle("selected", btn.dataset.tool === tool);
}
canvas.style.cursor = tool === "observe" ? "default" : (tool === "poke" ? "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 {
world.load(JSON.parse(raw));
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";
}
function bindUI() {
uiCache.toolButtons = Array.from(ui.toolPalette.querySelectorAll(".tool"));
ui.archiveContent?.addEventListener("scroll", scheduleArchiveWindowRender, { passive: true });
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", () => {
if (confirm("\u73fe\u5728\u306e\u89b3\u5bdf\u3092\u30ea\u30bb\u30c3\u30c8\u3057\u307e\u3059\u304b\uff1f \u30bb\u30fc\u30d6\u30c7\u30fc\u30bf\u306f\u6b8b\u308a\u307e\u3059\u3002")) world.reset();
});
ui.toolPalette.addEventListener("click", (e) => {
const btn = e.target.closest(".tool");
if (!btn) return;
selectTool(btn.dataset.tool);
});
canvas.addEventListener("click", (e) => {
const p = screenToWorld(e);
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 = true;
});
canvas.addEventListener("mouseleave", () => {
world.pointer.inside = false;
});
window.addEventListener("resize", resizeCanvas);
}
window.TarinaiUI = { canvas, ctx, ui, renderLog, renderSelected, stateLabel, renderStats, escapeHtml, renderArchive, resizeCanvas, screenToWorld, selectTool, showToast, saveGame, loadGame, syncTopButtons, bindUI };