This commit is contained in:
33333-33333 2026-06-19 02:27:19 +09:00
commit 0e8ece2dc0
23 changed files with 2030 additions and 395 deletions

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.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 981 B

Before After
Before After

View file

@ -1,3 +1,5 @@
@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);
@ -9,13 +11,14 @@
--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: ui-rounded, "Hiragino Maru Gothic ProN", "Yu Gothic", "Meiryo", system-ui, sans-serif;
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%),
@ -50,7 +53,7 @@ button {
h1 {
margin: 0 0 3px;
font-size: clamp(24px, 3.4vw, 42px);
letter-spacing: 0.02em;
letter-spacing: 0.03em;
}
.subtitle {
@ -134,12 +137,15 @@ h1 {
display: flex;
flex-direction: column;
gap: 12px;
height: calc(100vh - 120px);
max-height: calc(100vh - 120px);
overflow: auto;
overflow-y: auto;
overflow-x: hidden;
padding-right: 3px;
}
.card {
position: relative;
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
@ -152,7 +158,7 @@ h1 {
.card h2 {
margin: 0 0 12px;
font-size: 17px;
letter-spacing: 0.02em;
letter-spacing: 0.03em;
}
.tool-grid {
@ -202,8 +208,12 @@ h1 {
.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);
@ -218,6 +228,53 @@ h1 {
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);
@ -275,9 +332,11 @@ meter {
}
.info-row span:first-child { color: var(--muted); }
.log-card { min-height: 210px; }
.log-card { min-height: 260px; flex: 0 0 auto; display: flex; flex-direction: column; }
.log {
max-height: 280px;
max-height: 360px;
min-height: 170px;
flex: 1 1 auto;
overflow: auto;
display: flex;
flex-direction: column;
@ -345,7 +404,7 @@ meter {
.lineage-main-card {
margin-top: 16px;
min-height: 360px;
min-height: 0;
}
.lineage-main-card h2 {
@ -353,8 +412,8 @@ meter {
}
.lineage-main-content {
max-height: min(64vh, 620px);
min-height: 260px;
max-height: 560px;
min-height: 320px;
overflow: auto;
overscroll-behavior: contain;
padding: 8px 4px 16px;
@ -366,6 +425,7 @@ meter {
.lineage-main-content .lineage-tree {
min-width: 360px;
overflow: visible;
padding-right: 2px;
}
@ -475,6 +535,8 @@ body::before {
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: 26px; height: 26px; object-fit: contain; }
@ -485,6 +547,13 @@ body::before {
text-overflow: ellipsis;
white-space: nowrap;
}
.family-copy .death-reason {
white-space: normal;
overflow: visible;
text-overflow: clip;
line-height: 1.18;
color: rgba(126, 80, 64, 0.92);
}
.family-copy strong { font-size: 12px; }
.family-copy span { color: var(--muted); font-size: 9px; margin-top: 2px; }
@media (max-width: 760px) {
@ -493,3 +562,153 @@ body::before {
}
.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: 154px;
min-height: 76px;
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: 154px;
min-height: 76px;
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; }

View file

@ -30,34 +30,57 @@
</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>
<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;&#x3057;&#x8349;&#x30D9;&#x30C3;&#x30C9;</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>
<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">
<section class="card colony-card">
<h2>&#x30B3;&#x30ED;&#x30CB;&#x30FC;</h2>
<div class="stats">
<div class="stats colony-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>
<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">

View file

@ -2,6 +2,83 @@
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;
@ -53,4 +130,4 @@ function getImageMetrics(idOrImg) {
}
window.TarinaiAssets = { images, imageBoundsCache, imageMetrics, loadImages, imageAlphaBounds, getImageMetrics };
window.TarinaiAssets = { images, imageBoundsCache, imageMetrics, scaledSpriteCache, stainOverlayCache, loadImages, imageAlphaBounds, getImageMetrics, getCachedSpriteCanvas, getCachedStainOverlayCanvas };

View file

@ -2,18 +2,53 @@
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();
@ -31,13 +66,23 @@ const audio = {
osc.start(t0);
osc.stop(t0 + dur + 0.02);
},
eat() { this.tone(660 + Math.random() * 140, 0.055, "triangle", 0.026, 120); },
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;

View file

@ -26,6 +26,7 @@ const SPRITES = [
{ 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" },
@ -57,7 +58,7 @@ const ALPHA_BOUNDS = {
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 },
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 },
};
@ -67,15 +68,15 @@ const NAMES = ["ta", "ri", "na", "i", "po", "yo", "nu", "ma", "fu", "he", "shi",
const CONFIG = {
initialPopulation: 18,
maxPopulation: 96,
itemLimit: 110,
saveKey: "tarinai-colony-save-v18",
maxPopulation: Number.MAX_SAFE_INTEGER,
itemLimit: Number.MAX_SAFE_INTEGER,
saveKey: "tarinai-colony-save-v37",
worldPadding: 30,
hungerPerMinute: 11.2,
lonelinessPerMinute: 7.6,
energyPerMinute: 8.2,
stressDecayPerMinute: 9,
reproductionCooldown: 28,
reproductionCooldown: 18,
dayLength: 120,
childGrowTime: 58,
fightCooldown: 8,
@ -83,6 +84,9 @@ const CONFIG = {
weatherChangeMin: 46,
weatherChangeMax: 96,
bubbleLimit: 42,
grassLimit: 96,
highPopulationMode: 70,
ultraPopulationMode: 120,
};
const LIGHTING_TUNING = {
rayAlpha: 0.040,

View file

@ -19,7 +19,9 @@ function loop() {
world.update(dt);
render();
statsTimer += dt;
if (statsTimer > 0.2) {
const perf = world.performanceLevel ? world.performanceLevel() : 0;
const statsInterval = perf >= 3 ? 1.20 : perf === 2 ? 0.92 : perf === 1 ? 0.68 : 0.48;
if (statsTimer > statsInterval) {
renderStats();
statsTimer = 0;
}

View file

@ -95,45 +95,11 @@ function drawImageProjectedShadow(ctx, img, x, groundY, drawW, drawH, params, op
}
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.globalAlpha = clamp(params.alpha || 0.10, 0.035, 0.16);
ctx.fillStyle = params.color || "rgba(78, 65, 43, 0.24)";
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.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();
}
@ -154,46 +120,23 @@ function drawRadialGlow(ctx, x, y, radius, color, alpha) {
}
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);
}
// \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 (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);
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) {
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();
return;
}
// Cached static garden layer: expensive soil/decor loops are rendered only when
@ -210,7 +153,11 @@ const backgroundCache = {
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)}`;
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 `${weather}:${progressBucket}:${weatherBucket}`;
}
function ensureBackgroundCache(w, h, lighting) {
@ -516,8 +463,9 @@ function render() {
drawGardenBackdrop(w, h, lighting);
drawGardenBed(w, h, lighting);
}
drawLightRays(ctx, w, h, lighting);
drawRain(ctx, w, h, world.time);
const perf = world.performanceLevel ? world.performanceLevel() : 0;
if (perf < 2) drawLightRays(ctx, w, h, lighting);
if (perf < 3 || world.weather === "light_rain") drawRain(ctx, w, h, world.time);
for (const it of world.items) {
if (it.type !== "zunchi") it.draw(ctx, world.time, lighting);
@ -541,25 +489,13 @@ function render() {
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);
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);
// 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;
@ -626,7 +562,8 @@ function drawSelectedCard(ctx, world, lighting) {
const child = t.juvenile && t.parentToFollow?.() ? " / \u5b50" : "";
const zHint = t.digest > 4.9 ? " / \u305a\u3093\u3061" : "";
ctx.fillText(`${stateLabel(t.state)}${child}${zHint}`, x + 10, y + 35);
ctx.fillText(`\u6c17\u5206 ${fmt(t.mood)} \u4e16\u4ee3 ${t.generation} \u3064\u3064\u304d ${t.pokeCount}/9`, x + 10, y + 52);
const genLabel = (t.displayGeneration && t.displayGeneration()) ? ` \u4e16\u4ee3 ${t.generation}` : "";
ctx.fillText(`\u6c17\u5206 ${fmt(t.mood)}${genLabel} \u4f53\u529b ${fmt(t.energy)}`, x + 10, y + 52);
ctx.restore();
}

1307
js/sim.js

File diff suppressed because it is too large Load diff

559
js/ui.js
View file

@ -10,17 +10,15 @@ const ui = {
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"),
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"),
colonyChart: document.getElementById("colonyChart"),
colonyChartLegend: document.getElementById("colonyChartLegend"),
archiveContent: document.getElementById("archiveContent"),
};
@ -33,6 +31,13 @@ const uiCache = {
archiveRows: [],
archiveScrollScheduled: false,
lastArchiveWindow: "",
chartHistory: [],
chartBucketStart: -Infinity,
chartBucketEnd: -Infinity,
chartBucketAccum: null,
lastChartSample: -Infinity,
lastChartDraw: "",
chartMode: "population",
};
function renderLog(logs) {
@ -70,10 +75,23 @@ function formatLogTime(timeValue) {
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"
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)) {
@ -89,18 +107,18 @@ function renderSelected() {
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),
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.pokeCount,
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.fear?.id || "", rel.fear?.score || 0, fightRecord
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><span class="badge">\u4e16\u4ee3 ${t.generation}</span></div>
<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>
@ -111,9 +129,8 @@ function renderSelected() {
<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 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>
@ -135,6 +152,7 @@ function stateLabel(s) {
eat: "\u98df\u4e8b\u4e2d",
panic: "\u30d1\u30cb\u30c3\u30af",
intimidate: "\u5a01\u5687\u4e2d",
birth_ritual: "\u8a95\u751f\u524d",
}[s] || s;
}
@ -145,6 +163,7 @@ function reasonLabel(t) {
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";
}
@ -152,7 +171,7 @@ function reasonLabel(t) {
function targetLabel(target) {
if (!target) return "\u306a\u3057";
if (target.name) return target.name;
if (target.type) return { sweet: "\u305a\u3093\u3060\u9905", 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 (target.type) return { sweet: "\u305a\u3093\u3060\u9905", food: "\u98df\u4e8b", grass: "\u8349", water: "\u6c34", bed: "\u5e72\u3057\u8349\u30d9\u30c3\u30c9", 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";
}
@ -169,19 +188,190 @@ function relationFightRecord(t) {
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));
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));
uiCache.lastChartDraw = "";
}
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 rows = chartRows(values);
const mode = uiCache.chartMode || "population";
const snapshot = `${mode}:${rows.length}:${Math.round((rows[rows.length - 1]?.pop || values.pop) * 10)}:${JSON.stringify(values.events || {})}:${JSON.stringify(values.objects || {})}:${chart.clientWidth}x${chart.clientHeight}`;
if (uiCache.lastChartDraw === snapshot) return;
uiCache.lastChartDraw = snapshot;
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 series = chartSeries(mode);
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));
}
function setTextIfChanged(el, key, value) {
if (!el) return;
const next = String(value);
if (uiCache.stats[key] === next) return;
uiCache.stats[key] = next;
@ -189,6 +379,7 @@ function setTextIfChanged(el, key, value) {
}
function setValueIfChanged(el, key, value) {
if (!el) return;
if (uiCache.stats[key] === value) return;
uiCache.stats[key] = value;
el.value = value;
@ -205,19 +396,52 @@ function escapeHtml(value) {
}
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",
}));
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) {
@ -225,53 +449,180 @@ function familyStatusText(n) {
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 ?";
function lineageNodeHtml(n, family, x = 0, y = 0) {
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 cls = n ? (n.alive ? "alive" : "dead") : "missing";
return `<div class="family-node ${role} ${cls}">
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 tree-node ${cls}"${data} style="left:${x}px;top:${y}px">
<img src="${img}" alt="">
<div class="family-copy">
<strong title="${name}">${name}</strong>
<span>${escapeHtml(gen)} / ${status}</span>
<span>${gen ? `${escapeHtml(gen)} / ${status}` : `${status}`}</span>${reason}
</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 lineageFamilyHtml(component, index, family) {
const byGen = new Map();
for (const n of component) {
const g = n.generation || 1;
if (!byGen.has(g)) byGen.set(g, []);
byGen.get(g).push(n);
}
const gens = Array.from(byGen.keys()).sort((a, b) => a - b);
const nodeW = 154;
const nodeH = 76;
const colGap = 34;
const rowGap = 82;
const padX = 72;
const padY = 18;
const maxCount = Math.max(1, ...gens.map(g => byGen.get(g).length));
const width = Math.max(460, padX * 2 + maxCount * nodeW + Math.max(0, maxCount - 1) * colGap + 120);
const rowH = nodeH + rowGap;
const height = Math.max(190, padY * 2 + gens.length * nodeH + Math.max(0, gens.length - 1) * rowGap + 34);
const pos = new Map();
const nodesHtml = [];
const genLabels = [];
const rowIds = new Map();
const parentCenterFor = (nodes) => {
const centers = [];
for (const n of nodes) {
for (const p of n.parents || []) {
const pp = pos.get(p);
if (pp) centers.push(pp.x + nodeW / 2);
}
}
return centers.length ? centers.reduce((a, b) => a + b, 0) / centers.length : null;
};
gens.forEach((g, row) => {
const source = byGen.get(g).sort((a, b) => (a.birthTime || 0) - (b.birthTime || 0) || String(a.name).localeCompare(String(b.name)));
const y = padY + row * rowH;
genLabels.push(`<div class="tree-generation-label" style="top:${y + 15}px">\u4e16\u4ee3 ${g}</div>`);
let ordered = [];
if (row === 0) {
ordered = source.map(n => ({ nodes: [n], desired: null }));
} else {
const groups = new Map();
for (const n of source) {
const key = (n.parents || []).slice().sort().join("+") || `solo:${n.id}`;
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(n);
}
ordered = Array.from(groups.values()).map(nodes => ({ nodes, desired: parentCenterFor(nodes) }));
ordered.sort((a, b) => (a.desired ?? 1e9) - (b.desired ?? 1e9) || (a.nodes[0].birthTime || 0) - (b.nodes[0].birthTime || 0));
}
const groupsW = ordered.map(gp => gp.nodes.length * nodeW + Math.max(0, gp.nodes.length - 1) * colGap);
let x = padX;
if (ordered.length && ordered.every(gp => gp.desired == null)) {
const totalW = groupsW.reduce((a, b) => a + b, 0) + Math.max(0, groupsW.length - 1) * colGap;
x = Math.max(padX, (width - totalW) / 2);
}
ordered.forEach((gp, gi) => {
const groupW = groupsW[gi];
if (gp.desired != null) x = Math.max(x, gp.desired - groupW / 2);
if (x + groupW > width - padX) x = Math.max(padX, width - padX - groupW);
gp.nodes.forEach((n, ci) => {
const nx = x + ci * (nodeW + colGap);
pos.set(n.id, { x: nx, y, row });
if (!rowIds.has(row)) rowIds.set(row, []);
rowIds.get(row).push(n.id);
});
x += groupW + colGap;
});
});
const allIds = Array.from(rowIds.values()).flat();
const allXs = allIds.map(id => pos.get(id)?.x).filter(v => Number.isFinite(v));
if (allXs.length) {
const treeMin = Math.min(...allXs);
const treeMax = Math.max(...allIds.map(id => (pos.get(id)?.x || 0) + nodeW));
const shift = width / 2 - (treeMin + treeMax) / 2;
for (const id of allIds) {
const p = pos.get(id);
if (p) p.x += shift;
}
}
for (const g of gens) {
const row = gens.indexOf(g);
const ids = rowIds.get(row) || [];
for (const id of ids) {
const n = family[id];
const p = pos.get(id);
if (!n || !p) continue;
nodesHtml.push(lineageNodeHtml(n, family, p.x, p.y));
}
}
const links = [];
const pairGroups = new Map();
for (const child of component) {
const parents = (child.parents || []).filter(id => pos.has(id));
if (!parents.length || !pos.has(child.id)) continue;
const key = parents.slice().sort().join("+");
if (!pairGroups.has(key)) pairGroups.set(key, { parents, children: [] });
pairGroups.get(key).children.push(child.id);
}
for (const group of pairGroups.values()) {
const parentCenters = group.parents.map(id => {
const p = pos.get(id);
return { id, x: p.x + nodeW / 2, y: p.y + nodeH };
}).sort((a, b) => a.x - b.x);
const childCenters = group.children.map(id => {
const p = pos.get(id);
return { id, x: p.x + nodeW / 2, y: p.y };
}).sort((a, b) => a.x - b.x);
if (!parentCenters.length || !childCenters.length) continue;
const pMinX = Math.min(...parentCenters.map(p => p.x));
const pMaxX = Math.max(...parentCenters.map(p => p.x));
const pBottom = Math.max(...parentCenters.map(p => p.y));
const cTop = Math.min(...childCenters.map(c => c.y));
const mY = Math.min(cTop - 28, pBottom + 22);
const trunkX = (pMinX + pMaxX) / 2;
const childRailY = cTop - 18;
if (parentCenters.length >= 2) {
links.push(`<path class="partner-link" d="M ${pMinX.toFixed(1)} ${mY.toFixed(1)} H ${pMaxX.toFixed(1)}"/>`);
for (const p of parentCenters) links.push(`<path class="child-link" d="M ${p.x.toFixed(1)} ${p.y.toFixed(1)} V ${mY.toFixed(1)}"/>`);
} else {
links.push(`<path class="child-link" d="M ${parentCenters[0].x.toFixed(1)} ${parentCenters[0].y.toFixed(1)} V ${mY.toFixed(1)}"/>`);
}
links.push(`<path class="child-link" d="M ${trunkX.toFixed(1)} ${mY.toFixed(1)} V ${childRailY.toFixed(1)}"/>`);
if (childCenters.length > 1) {
const cMinX = Math.min(...childCenters.map(c => c.x));
const cMaxX = Math.max(...childCenters.map(c => c.x));
links.push(`<path class="child-link" d="M ${cMinX.toFixed(1)} ${childRailY.toFixed(1)} H ${cMaxX.toFixed(1)}"/>`);
}
for (const c of childCenters) links.push(`<path class="child-link" d="M ${c.x.toFixed(1)} ${childRailY.toFixed(1)} V ${c.y.toFixed(1)}"/>`);
}
const aliveCount = component.filter(n => n.alive).length;
return `<section class="lineage-family"><h3>\u5bb6\u7cfb ${index + 1} / ${component.length}\u5339 / \u751f\u5b58 ${aliveCount}</h3><div class="lineage-tree-panel" style="width:${width}px;height:${height}px"><svg class="lineage-links" viewBox="0 0 ${width} ${height}" aria-hidden="true">${links.join("")}</svg>${genLabels.join("")}${nodesHtml.join("")}</div></section>`;
}
function renderArchive() {
if (!ui.archiveContent) return;
const family = world.family || {};
const ids = Object.keys(family);
const version = `${world.familyVersion || 0}:${ids.length}`;
const liveSpriteVersion = world.tarinai.map(t => `${t.id}:${t.spriteId ? t.spriteId() : t.type}`).join(",");
const version = `${world.familyVersion || 0}:${ids.length}:${liveSpriteVersion}`;
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>`;
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 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>`;
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 renderArchiveWindow() {
@ -290,6 +641,7 @@ function resizeCanvas() {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
world.w = rect.width;
world.h = rect.height;
uiCache.lastChartDraw = "";
}
function screenToWorld(e) {
@ -303,7 +655,8 @@ function selectTool(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");
syncToolSizeBadges();
canvas.style.cursor = tool === "observe" ? "default" : (tool === "poke" || tool === "delete" ? "pointer" : "crosshair");
}
function showToast(text) {
@ -336,9 +689,73 @@ function syncTopButtons() {
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 = "";
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;
@ -362,9 +779,19 @@ function bindUI() {
});
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;
selectTool(btn.dataset.tool);
const tool = btn.dataset.tool;
if (world.tool === tool && cycleToolSize(tool)) return;
selectTool(tool);
});
canvas.addEventListener("click", (e) => {