sprites
BIN
assets/sounds/firecracker_explosion.mp3
Normal file
BIN
assets/sounds/voice_001_hau.wav
Normal file
BIN
assets/sounds/voice_002_flee.wav
Normal file
BIN
assets/sounds/voice_003_po.wav
Normal file
BIN
assets/sounds/voice_004_eat.wav
Normal file
BIN
assets/sounds/voice_005_stress.wav
Normal file
BIN
assets/sounds/voice_006_sleep.wav
Normal file
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 78 KiB |
BIN
assets/sprites/tarinai_27_birth_ritual.png
Normal file
|
After Width: | Height: | Size: 145 KiB |
BIN
assets/ui/tool_delete.png
Normal file
|
After Width: | Height: | Size: 321 B |
BIN
assets/ui/tool_fence_h.png
Normal file
|
After Width: | Height: | Size: 786 B |
BIN
assets/ui/tool_fence_v.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
assets/ui/tool_firecracker.png
Normal file
|
After Width: | Height: | Size: 1 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 981 B |
237
css/styles.css
|
|
@ -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; }
|
||||
|
|
|
|||
65
index.html
|
|
@ -30,34 +30,57 @@
|
|||
</section>
|
||||
|
||||
<aside class="panel">
|
||||
<section class="card">
|
||||
<h2>道具</h2>
|
||||
<div id="toolPalette" class="tool-grid">
|
||||
<button class="tool selected" data-tool="observe"><span>O</span>観察</button>
|
||||
<button class="tool" data-tool="zunchi"><span>Z</span>ずんち</button>
|
||||
<button class="tool" data-tool="sweet"><span>S</span>ずんだ餅</button>
|
||||
<button class="tool" data-tool="water"><span>W</span>水</button>
|
||||
<button class="tool" data-tool="grass"><span>G</span>草</button>
|
||||
<button class="tool" data-tool="stone"><span>R</span>あたたかい石</button>
|
||||
<button class="tool" data-tool="bed"><span>B</span>干し草ベッド</button>
|
||||
<button class="tool" data-tool="poke"><span>P</span>つつく</button>
|
||||
<button class="tool" data-tool="new"><span>N</span>追加</button>
|
||||
<section id="toolCard" class="card tool-card">
|
||||
<div class="tool-head"><h2>道具</h2></div>
|
||||
<div class="tool-body">
|
||||
<div id="toolPalette" class="tool-palette">
|
||||
<section class="tool-category" data-tool-category="observe">
|
||||
<button class="tool-category-toggle" type="button" aria-expanded="true">観察</button>
|
||||
<div class="tool-grid tool-category-body">
|
||||
<button class="tool selected" data-tool="observe"><span>O</span>観察</button>
|
||||
<button class="tool" data-tool="delete"><span>D</span>削除</button>
|
||||
<button class="tool" data-tool="poke"><span>P</span>つつく</button>
|
||||
<button class="tool" data-tool="new"><span>N</span>追加</button>
|
||||
</div>
|
||||
</section>
|
||||
<section class="tool-category" data-tool-category="food">
|
||||
<button class="tool-category-toggle" type="button" aria-expanded="true">食べ物・自然</button>
|
||||
<div class="tool-grid tool-category-body">
|
||||
<button class="tool" data-tool="zunchi"><span>Z</span>ずんち</button>
|
||||
<button class="tool" data-tool="sweet"><span>S</span>ずんだ餅</button>
|
||||
<button class="tool" data-tool="water"><span>W</span>水</button>
|
||||
<button class="tool" data-tool="grass"><span>G</span>草</button>
|
||||
</div>
|
||||
</section>
|
||||
<section class="tool-category" data-tool-category="object">
|
||||
<button class="tool-category-toggle" type="button" aria-expanded="true">オブジェクト</button>
|
||||
<div class="tool-grid tool-category-body">
|
||||
<button class="tool" data-tool="stone"><span>R</span>石</button>
|
||||
<button class="tool" data-tool="bed"><span>B</span>干し草ベッド</button>
|
||||
<button class="tool" data-tool="firecracker"><span>F</span>爆竹</button>
|
||||
</div>
|
||||
</section>
|
||||
<section class="tool-category" data-tool-category="fence">
|
||||
<button class="tool-category-toggle" type="button" aria-expanded="true">柵</button>
|
||||
<div class="tool-grid tool-category-body">
|
||||
<button class="tool" data-tool="fence_v"><span>V</span>縦の柵</button>
|
||||
<button class="tool" data-tool="fence_h"><span>H</span>横の柵</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<p class="hint">同じ道具ボタンをもう一度押すと、対応道具の大きさが小・中・大で切り替わります。</p>
|
||||
</div>
|
||||
<p class="hint">キャンバスをクリックして道具を置きます。観察モードではたりないを選択します。</p>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<section class="card colony-card">
|
||||
<h2>コロニー</h2>
|
||||
<div class="stats">
|
||||
<div class="stats colony-stats">
|
||||
<div class="stat"><span>個体数</span><strong id="statPop">0</strong></div>
|
||||
<div class="stat"><span>世代</span><strong id="statGen">1</strong></div>
|
||||
<div class="stat"><span>死亡数</span><strong id="statDead">0</strong></div>
|
||||
<div class="stat"><span>日</span><strong id="statDay">1</strong></div>
|
||||
</div>
|
||||
<div class="meter-row"><span>空腹平均</span><meter id="meterHunger" min="0" max="100" value="0"></meter></div>
|
||||
<div class="meter-row"><span>寂しさ平均</span><meter id="meterLonely" min="0" max="100" value="0"></meter></div>
|
||||
<div class="meter-row"><span>ストレス平均</span><meter id="meterStress" min="0" max="100" value="0"></meter></div>
|
||||
<div class="meter-row"><span>たりない平均</span><meter id="meterLack" min="0" max="100" value="0"></meter></div>
|
||||
<div id="colonyChartTabs" class="chart-tabs"><button data-chart="population" class="active">人口</button><button data-chart="life">生活</button><button data-chart="events">事件</button><button data-chart="objects">オブジェクト</button></div>
|
||||
<div id="colonyChartLegend" class="colony-chart-legend"></div>
|
||||
<div id="colonyChart" class="colony-chart" aria-label="コロニーの推移グラフ" role="img"></div>
|
||||
</section>
|
||||
|
||||
<section class="card selected-card">
|
||||
|
|
|
|||
79
js/assets.js
|
|
@ -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 };
|
||||
|
|
|
|||
49
js/audio.js
|
|
@ -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;
|
||||
|
|
|
|||
14
js/data.js
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
113
js/render.js
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
559
js/ui.js
|
|
@ -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) => {
|
||||
|
|
|
|||