This commit is contained in:
33333-33333 2026-06-19 14:00:48 +09:00
commit e6507288ce
17 changed files with 4644 additions and 3852 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 648 B

After

Width:  |  Height:  |  Size: 770 B

Before After
Before After

View file

@ -106,17 +106,63 @@ h1 {
border-radius: calc(var(--radius) + 8px);
overflow: hidden;
box-shadow: 0 16px 42px rgba(42, 45, 80, 0.12), inset 0 0 0 1px rgba(255,255,255,0.24);
min-height: 620px;
min-height: var(--field-min-height, 620px);
}
#gameCanvas {
width: 100%;
height: calc(100vh - 140px);
min-height: 620px;
height: var(--field-height, calc(100vh - 140px));
min-height: var(--field-min-height, 620px);
display: block;
cursor: crosshair;
}
.field-dialog {
position: fixed;
inset: 0;
z-index: 30;
display: grid;
place-items: center;
padding: 18px;
background: rgba(34, 29, 22, 0.38);
backdrop-filter: blur(3px);
}
.field-dialog.hidden { display: none; }
.field-dialog-panel {
width: min(440px, 100%);
border-radius: 16px;
padding: 16px;
background: rgba(255, 252, 245, 0.96);
border: 1px solid rgba(84,68,48,0.16);
box-shadow: 0 18px 46px rgba(40, 32, 24, 0.22);
}
.field-dialog-panel h2 {
margin: 0 0 12px;
font-size: 17px;
}
.field-choice-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.field-choice {
min-height: 74px;
border: 1px solid rgba(84,68,48,0.16);
border-radius: 12px;
background: linear-gradient(180deg, rgba(255,255,255,0.86), rgba(241,235,221,0.72));
cursor: pointer;
color: var(--ink);
}
.field-choice:hover { border-color: rgba(112,178,66,0.45); }
.field-choice strong, .field-choice span { display: block; }
.field-choice strong { font-size: 15px; }
.field-choice span { margin-top: 4px; color: var(--muted); font-size: 12px; }
.field-dialog-actions {
margin-top: 12px;
display: flex;
justify-content: flex-end;
}
.toast {
position: absolute;
left: 50%;
@ -378,8 +424,9 @@ meter {
.topbar { align-items: flex-start; flex-direction: column; }
.layout { grid-template-columns: 1fr; }
.panel { max-height: none; overflow: visible; }
#gameCanvas { height: 66vh; min-height: 460px; }
.stage-wrap { min-height: 460px; }
#gameCanvas { height: var(--field-mobile-height, 66vh); min-height: var(--field-mobile-min-height, 460px); }
.stage-wrap { min-height: var(--field-mobile-min-height, 460px); }
.field-choice-grid { grid-template-columns: 1fr; }
}
@ -415,7 +462,7 @@ meter {
max-height: 560px;
min-height: 320px;
overflow: auto;
overscroll-behavior: contain;
overscroll-behavior: auto;
padding: 8px 4px 16px;
background: rgba(255,250,244,0.48);
border-radius: 14px;
@ -497,13 +544,13 @@ body::before {
}
.family-node {
display: grid;
grid-template-columns: 26px minmax(0, 1fr);
grid-template-columns: 22px minmax(0, 1fr);
align-items: center;
gap: 6px;
gap: 5px;
min-width: 0;
min-height: 42px;
padding: 5px 7px;
border-radius: 11px;
min-height: 34px;
padding: 4px 6px;
border-radius: 10px;
background: linear-gradient(180deg, rgba(255,253,248,0.96), rgba(248,241,229,0.90));
border: 1px solid rgba(84,68,48,0.14);
box-shadow: 0 3px 8px rgba(64,48,32,0.045);
@ -539,7 +586,7 @@ body::before {
.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; }
.family-node img { width: 22px; height: 22px; object-fit: contain; }
.family-copy { min-width: 0; line-height: 1.12; }
.family-copy strong, .family-copy span {
display: block;
@ -551,11 +598,12 @@ body::before {
white-space: normal;
overflow: visible;
text-overflow: clip;
line-height: 1.18;
line-height: 1.12;
margin-top: 1px;
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; }
.family-copy strong { font-size: 11px; }
.family-copy span { color: var(--muted); font-size: 8.5px; margin-top: 1px; }
@media (max-width: 760px) {
.family-grid { grid-template-columns: 1fr; }
.family-cluster { min-height: 102px; }
@ -653,8 +701,8 @@ body::before {
align-items: stretch;
}
.family-node.lineage-node {
width: 154px;
min-height: 76px;
width: 132px;
min-height: 54px;
align-items: flex-start;
}
.lineage-children {
@ -698,8 +746,8 @@ body::before {
.lineage-links .child-link { stroke-width: 1.9; stroke: rgba(107, 88, 61, 0.54); }
.tree-node {
position: absolute;
width: 154px;
min-height: 76px;
width: 132px;
min-height: 54px;
align-items: flex-start;
z-index: 2;
}
@ -712,3 +760,111 @@ body::before {
z-index: 1;
}
.lineage-family { overflow-x: auto; }
.lineage-family-readable {
overflow: visible;
}
.lineage-readable-grid {
display: grid;
grid-template-columns: minmax(300px, 1.05fr) minmax(280px, 0.95fr);
gap: 12px;
align-items: start;
}
.lineage-member-column,
.lineage-reproduction-column {
min-width: 0;
}
.lineage-generation-block {
padding: 8px 0;
border-top: 1px dashed rgba(84,68,48,0.12);
}
.lineage-generation-block:first-child { border-top: 0; padding-top: 0; }
.lineage-generation-block > b,
.lineage-column-title {
display: block;
margin: 0 0 7px;
color: rgba(84,68,48,0.72);
font-size: 11px;
}
.lineage-person-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(132px, 1fr));
gap: 7px;
}
.family-node.lineage-person {
width: auto;
min-height: 46px;
}
.lineage-pair-list {
display: grid;
gap: 8px;
}
.lineage-pair-card {
padding: 8px;
border-radius: 12px;
border: 1px solid rgba(84,68,48,0.12);
background: rgba(255,253,248,0.70);
}
.lineage-pair-parents,
.lineage-pair-children {
display: flex;
flex-wrap: wrap;
gap: 5px;
align-items: center;
}
.lineage-pair-parents {
padding-bottom: 5px;
}
.lineage-pair-children {
padding-top: 5px;
border-top: 1px solid rgba(84,68,48,0.08);
}
.lineage-plus {
color: rgba(84,68,48,0.44);
font-weight: 700;
line-height: 28px;
}
.lineage-birth-arrow {
color: rgba(107, 88, 61, 0.62);
font-size: 14px;
line-height: 1;
padding: 1px 0 3px 13px;
}
.lineage-mini {
display: inline-grid;
grid-template-columns: 20px minmax(0, 1fr);
grid-template-rows: auto auto;
gap: 0 4px;
align-items: center;
max-width: 132px;
min-width: 88px;
padding: 4px 6px;
border-radius: 9px;
border: 1px solid rgba(84,68,48,0.12);
background: rgba(255,255,255,0.64);
}
.lineage-mini.child {
border-color: rgba(103,178,44,0.28);
background: rgba(246,255,235,0.62);
}
.lineage-mini.dead { opacity: 0.64; filter: grayscale(0.25); }
.lineage-mini img {
grid-row: 1 / span 2;
width: 20px;
height: 20px;
object-fit: contain;
}
.lineage-mini b,
.lineage-mini small {
display: block;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.lineage-mini b { font-size: 10px; line-height: 1.1; }
.lineage-mini small { color: var(--muted); font-size: 8px; line-height: 1.1; }
@media (max-width: 760px) {
.lineage-readable-grid { grid-template-columns: 1fr; }
}

29
docs/code_map.md Normal file
View file

@ -0,0 +1,29 @@
# Code Map
Use this map to open the smallest relevant file first.
## Simulation
- `js/sim_core.js`: shared simulation helpers, personality definitions, `Effect`, `SpatialGrid`, and small drawing helpers used by simulation classes.
- `js/items.js`: `Item` lifecycle, item save/load restore, grass and zunchi behavior, and item drawing.
- `js/tarinai.js`: `Tarinai` state, AI, movement, eating, fighting, panic, reproduction-adjacent behavior, and drawing.
- `js/world.js`: `World` state, save/load, spawning, family registry normalization, spatial queries, tools, weather, and update loop coordination.
- `js/sim.js`: compatibility export for `window.TarinaiSim`.
## UI And Rendering
- `js/render.js`: canvas scene rendering, lighting, weather visuals, HUD, and selected-card overlay.
- `js/ui.js`: DOM references, selected/log panels, labels, save/load buttons, tool controls, canvas input, and UI event binding.
- `js/ui_charts.js`: colony history aggregation and chart rendering.
- `js/ui_family.js`: family tree components, layout, archive rendering, and validation helper.
- `js/ui_exports.js`: compatibility export for `window.TarinaiUI`.
## Common Tasks
- Change hunger, sleep, fighting, panic, eating, or movement: start with `js/tarinai.js`.
- Change item growth, zunchi lifecycle, grass spread, or item visuals: start with `js/items.js`.
- Change save/load, population, spawning, weather, tools, or family data correctness: start with `js/world.js`.
- Change family tree display only: start with `js/ui_family.js`.
- Change colony charts: start with `js/ui_charts.js`.
- Change buttons, selected info, logs, or input handling: start with `js/ui.js`.
- Change the garden canvas visuals: start with `js/render.js`.
## Loading Order
`index.html` loads plain browser scripts in dependency order. Do not convert to ES modules or add a build step unless the project intentionally adopts one later.

View file

@ -101,7 +101,7 @@
繁殖で生まれた子は `childGrowTime` の間、親のどちらか近い方を追従する。成長すると通常AIへ移行する。
### 系譜
系譜は `family` レジストリで保持し、親子関係を樹状図で表示する。二親制のため、同じ子が複数枝に表示される場合がある
系譜は `family` レジストリで保持し、親子関係を樹状図で表示する。表示時は同じ個体を重複させず、共有された親子関係は複数の接続線で表す
### 食事エフェクト
食事パーティクルは表示頻度を抑え、対象アイテムの色を反映する。

View file

@ -56,7 +56,7 @@
<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="bed"><span>B</span>&#x5E72;&#x8349;&#x5BDD;&#x5E8A;</button>
<button class="tool" data-tool="firecracker"><span>F</span>&#x7206;&#x7AF9;</button>
</div>
</section>
@ -102,13 +102,34 @@
</section>
</div>
<div id="fieldDialog" class="field-dialog hidden" role="dialog" aria-modal="true" aria-labelledby="fieldDialogTitle">
<div class="field-dialog-panel">
<h2 id="fieldDialogTitle">&#x30D5;&#x30A3;&#x30FC;&#x30EB;&#x30C9;&#x3092;&#x9078;&#x629E;</h2>
<div class="field-choice-grid">
<button class="field-choice" data-field-type="park" type="button"><strong>&#x516C;&#x5712;</strong><span>&#x5927;</span></button>
<button class="field-choice" data-field-type="garden" type="button"><strong>&#x5EAD;</strong><span>&#x4E2D;</span></button>
<button class="field-choice" data-field-type="cage" type="button"><strong>&#x30B1;&#x30FC;&#x30B8;</strong><span>&#x5C0F;</span></button>
</div>
<div class="field-dialog-actions">
<button id="fieldCancelBtn" class="btn" type="button">&#x30AD;&#x30E3;&#x30F3;&#x30BB;&#x30EB;</button>
</div>
</div>
</div>
<script src="js/data.js"></script>
<script src="js/math.js"></script>
<script src="js/assets.js"></script>
<script src="js/audio.js"></script>
<script src="js/render.js"></script>
<script src="js/sim_core.js"></script>
<script src="js/items.js"></script>
<script src="js/tarinai.js"></script>
<script src="js/world.js"></script>
<script src="js/sim.js"></script>
<script src="js/ui.js"></script>
<script src="js/ui_charts.js"></script>
<script src="js/ui_family.js"></script>
<script src="js/ui_exports.js"></script>
<script src="js/main.js"></script>
</body>
</html>

View file

@ -69,8 +69,12 @@ const NAMES = ["ta", "ri", "na", "i", "po", "yo", "nu", "ma", "fu", "he", "shi",
const CONFIG = {
initialPopulation: 18,
maxPopulation: Number.MAX_SAFE_INTEGER,
itemLimit: Number.MAX_SAFE_INTEGER,
saveKey: "tarinai-colony-save-v37",
itemLimit: 260,
zunchiLimit: 56,
traceLimit: 64,
splatLimit: 48,
effectLimit: 96,
saveKey: "tarinai-colony-save-v38",
worldPadding: 30,
hungerPerMinute: 11.2,
lonelinessPerMinute: 7.6,
@ -88,6 +92,11 @@ const CONFIG = {
highPopulationMode: 70,
ultraPopulationMode: 120,
};
const FIELD_TYPES = {
cage: { id: "cage", label: "\u30b1\u30fc\u30b8", sizeLabel: "\u5c0f", height: "calc(100vh - 140px)", minHeight: "620px", mobileHeight: "66vh", mobileMinHeight: "460px", worldScale: 0.46, population: 12, grass: 9 },
garden: { id: "garden", label: "\u5ead", sizeLabel: "\u4e2d", height: "calc(100vh - 140px)", minHeight: "620px", mobileHeight: "66vh", mobileMinHeight: "460px", worldScale: 1.0, population: CONFIG.initialPopulation, grass: 18 },
park: { id: "park", label: "\u516c\u5712", sizeLabel: "\u5927", height: "calc(100vh - 140px)", minHeight: "620px", mobileHeight: "66vh", mobileMinHeight: "460px", worldScale: 2.35, population: 24, grass: 28 },
};
const LIGHTING_TUNING = {
rayAlpha: 0.040,
nightOverlayAlpha: 0.135,
@ -95,4 +104,4 @@ const LIGHTING_TUNING = {
bloomWashAlpha: 0.040,
};
window.TarinaiData = { SPRITES, DECOR_ASSETS, ALPHA_BOUNDS, NEEDS, NAMES, CONFIG, LIGHTING_TUNING };
window.TarinaiData = { SPRITES, DECOR_ASSETS, ALPHA_BOUNDS, NEEDS, NAMES, CONFIG, FIELD_TYPES, LIGHTING_TUNING };

491
js/items.js Normal file
View file

@ -0,0 +1,491 @@
"use strict";
class Item {
constructor(type, x, y) {
this.id = crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`;
this.type = type;
this.x = x;
this.y = y;
this.r = {
food: 14, sweet: 13, water: 12, grass: 17, stone: 20, bed: 37, firecracker: 15, fence_v: 42, fence_h: 42, trace: 16, splat: 26, zunchi: 14
}[type] || 12;
this.amount = {
food: 85, sweet: 62, water: 64, grass: 120, stone: 999, bed: 999, firecracker: 999, fence_v: 999, fence_h: 999, trace: 220, splat: 260, zunchi: 240
}[type] || 80;
this.age = 0;
this.seed = Math.random() * 1000;
this.dropTimer = 0;
this.dropMax = 0;
this.dropImpactDone = false;
this.lifecycleTimer = rand(0, type === "grass" ? 1.15 : 0.55);
this.lifecycleInterval = type === "grass" ? (1.75 + stableUnit(this.id, "grass-life") * 0.90) : (type === "zunchi" ? (0.95 + stableUnit(this.id, "zunchi-life") * 0.45) : 0);
if (type === "grass") {
this.growth = rand(0.18, 0.42);
this.health = 1;
this.seedTimer = rand(18, 38);
this.eatenAmount = 0;
this.fertilityBoost = 0;
}
if (type === "bed") {
this.comfort = rand(0.86, 1.14);
this.wear = 0;
}
if (type === "zunchi") {
this.stage = "fresh";
this.stageTimer = 0;
this.fertility = 0;
this.freshness = 1;
this.zunchiVariant = stableUnit(this.id, "zunchi-variant") < 0.5 ? "zunchi" : "zunchi_02";
}
if (type === "firecracker") {
this.fuseTimer = 5;
this.fuseMax = 5;
}
}
update(dt, worldRef = world) {
this.age += dt;
if (this.dropTimer > 0) {
const beforeDrop = this.dropTimer;
this.dropTimer = Math.max(0, this.dropTimer - dt);
if (beforeDrop > 0 && this.dropTimer <= 0 && !this.dropImpactDone) {
this.dropImpactDone = true;
if (worldRef.itemDropImpact) worldRef.itemDropImpact(this);
}
}
if (this.type === "water") this.amount -= dt * 0.9;
if (this.type === "sweet") this.amount -= dt * 0.12;
if (this.type === "trace") this.amount -= dt * 1.35;
if (this.type === "splat") this.amount -= dt * 1.05;
if (this.type === "firecracker") {
this.fuseTimer = Math.max(0, (this.fuseTimer ?? 5) - dt);
if (this.fuseTimer <= 0) {
if (worldRef.explodeFirecracker) worldRef.explodeFirecracker(this);
else this.amount = 0;
}
}
if (this.type === "grass") {
this.lifecycleTimer += dt;
const factor = worldRef.grassUpdateFactor ? worldRef.grassUpdateFactor() : 1;
const interval = (this.lifecycleInterval || 1.9) * factor;
if (this.lifecycleTimer >= interval) {
const lifecycleDt = Math.min(this.lifecycleTimer, 4.8);
this.lifecycleTimer = 0;
this.updateGrass(lifecycleDt, worldRef);
}
} else if (this.type === "zunchi") {
this.lifecycleTimer += dt;
const interval = this.lifecycleInterval || (0.95 + stableUnit(this.id, "zunchi-life") * 0.45);
if (this.lifecycleTimer >= interval) {
const lifecycleDt = Math.min(this.lifecycleTimer, 2.4);
this.lifecycleTimer = 0;
this.updateZunchi(lifecycleDt, worldRef);
}
}
}
updateGrass(dt, worldRef) {
const light = worldRef.lightLevel ? worldRef.lightLevel() : 0.7;
const weather = worldRef.weather || "sunny";
const grassLoad = worldRef.grassLoadLevel ? worldRef.grassLoadLevel() : 0;
if (worldRef.grassBlockedAt && worldRef.grassBlockedAt(this.x, this.y, this)) {
this.growth = Math.max(0, (this.growth ?? 0.25) - dt * 0.28);
this.amount -= dt * 34;
return;
}
let fertility = 0;
let fertileSource = null;
let bestFertility = 0;
let waterNear = weather === "light_rain" ? 0.35 : 0;
const nearbyRadius = grassLoad >= 3 ? 190 : grassLoad >= 2 ? 230 : grassLoad >= 1 ? 290 : 360;
const nearby = worldRef.nearbyItems ? worldRef.nearbyItems(this.x, this.y, nearbyRadius) : (worldRef.items || []);
let scanned = 0;
const scanLimit = grassLoad >= 3 ? 18 : grassLoad >= 2 ? 26 : grassLoad >= 1 ? 40 : 9999;
for (const it of nearby) {
if (++scanned > scanLimit) break;
if (it === this) continue;
const d = distXY(this.x, this.y, it.x, it.y);
if ((it.type === "trace" || it.type === "splat") && d < 170) {
const influence = clamp(1 - d / 170, 0, 1) * (it.type === "splat" ? 0.92 : 0.55);
fertility += influence;
if (influence > bestFertility) { bestFertility = influence; fertileSource = it; }
}
if (it.type === "zunchi" && (it.stage === "decomposing" || it.stage === "fertile_soil")) {
const influence = clamp(1 - d / nearbyRadius, 0, 1) * (it.stage === "fertile_soil" ? 1.25 : 0.72);
fertility += influence;
if (influence > bestFertility) {
bestFertility = influence;
fertileSource = it;
}
}
if (it.type === "water") waterNear += clamp(1 - d / 90, 0, 1) * 0.25;
}
this.fertilityBoost = clamp(fertility, 0, 1.4);
const weatherBoost = weather === "sunny" ? 0.09 : weather === "light_rain" ? 0.42 : 0.08;
const dryStress = weather === "sunny" && light > 0.78 && waterNear < 0.04 && this.fertilityBoost < 0.12 ? 0.012 : 0;
const growthRate = (0.006 + light * 0.004 + weatherBoost * 0.01 + waterNear * 0.014 + this.fertilityBoost * 0.014) * dt * 0.5;
this.growth = clamp((this.growth ?? 0.25) + growthRate - dryStress * dt, 0, 0.88);
this.health = clamp((this.health ?? 1) + dt * (waterNear > 0.05 || weather === "light_rain" ? 0.017 : -0.0015 - dryStress * 0.28), 0.18, 1);
const grassLimit = CONFIG.grassLimit ?? 96;
if (this.growth >= 0.86 && (worldRef.itemCounts?.grass || 0) < grassLimit) {
const spawned = this.seedAround(worldRef, fertileSource, this.fertilityBoost > 0.25, 2);
if (spawned > 0) this.growth = rand(0.52, 0.68);
this.seedTimer = rand(18, 34);
}
this.amount = clamp(this.growth * 120 * this.health, 3, 130);
this.seedTimer -= dt * (this.growth > 0.72 ? 1 : 0.25) * 0.5;
if (this.seedTimer <= 0 && this.growth > 0.64 && (worldRef.itemCounts?.grass || 0) < grassLimit) {
this.seedTimer = rand(24, 54);
if (Math.random() < 0.42 + this.fertilityBoost * 0.18) this.seedAround(worldRef, fertileSource, this.fertilityBoost > 0.25, 1);
}
}
seedAround(worldRef, fertileSource = null, fertile = false, maxNew = 1) {
let made = 0;
const limit = CONFIG.grassLimit ?? 96;
for (let n = 0; n < maxNew && (worldRef.itemCounts?.grass || 0) < limit; n++) {
const source = fertile && fertileSource ? fertileSource : this;
const spot = worldRef.findGrassSproutSpot
? worldRef.findGrassSproutSpot(source, Boolean(fertile && fertileSource))
: { x: clamp(this.x + rand(-58, 58), 44, worldRef.w - 44), y: clamp(this.y + rand(-42, 42), 44, worldRef.h - 44) };
if (!spot) break;
const sprout = new Item("grass", spot.x, spot.y);
sprout.growth = rand(0.08, 0.18);
sprout.amount = sprout.growth * 120;
worldRef.items.push(sprout);
if (worldRef.itemCounts) worldRef.itemCounts.grass = (worldRef.itemCounts.grass || 0) + 1;
made += 1;
}
return made;
}
updateZunchi(dt, worldRef) {
const rainBoost = worldRef.weather === "light_rain" ? 1.35 : 1;
let waterBoost = 0;
const nearby = worldRef.nearbyItems ? worldRef.nearbyItems(this.x, this.y, 84) : (worldRef.items || []);
for (const it of nearby) {
if (it.type !== "water") continue;
waterBoost += clamp(1 - distXY(this.x, this.y, it.x, it.y) / 80, 0, 1);
}
this.stageTimer += dt * 2.0 * (rainBoost + waterBoost * 1.3);
if (this.stage === "fresh") {
this.freshness = clamp(1 - this.stageTimer / 110, 0, 1);
if (this.stageTimer > 110) { this.stage = "dry"; this.stageTimer = 0; }
} else if (this.stage === "dry") {
this.freshness = clamp(0.55 - this.stageTimer / 220, 0.20, 0.55);
if (this.stageTimer > 150) { this.stage = "decomposing"; this.stageTimer = 0; this.fertility = 0.35; }
} else if (this.stage === "decomposing") {
this.fertility = clamp(this.fertility + dt * 0.015, 0, 1);
this.amount -= dt * 0.12;
if (this.stageTimer > 190) { this.stage = "fertile_soil"; this.stageTimer = 0; this.amount = Math.min(this.amount, 140); }
} else if (this.stage === "fertile_soil") {
this.fertility = clamp(1 - this.stageTimer / 360, 0, 1);
this.amount -= dt * 0.26;
}
}
get dead() {
return this.amount <= 0;
}
serialize() {
return {
type: this.type, x: this.x, y: this.y, amount: this.amount, age: this.age, seed: this.seed,
stage: this.stage, stageTimer: this.stageTimer, fertility: this.fertility, freshness: this.freshness,
growth: this.growth, health: this.health, seedTimer: this.seedTimer, eatenAmount: this.eatenAmount, fertilityBoost: this.fertilityBoost,
comfort: this.comfort, wear: this.wear, zunchiVariant: this.zunchiVariant, toolSize: this.toolSize, blastScale: this.blastScale, fuseTimer: this.fuseTimer, fuseMax: this.fuseMax, dropTimer: this.dropTimer, dropMax: this.dropMax
};
}
static from(o) {
const type = o?.type || "grass";
const it = Object.create(Item.prototype);
Item._loadId = (Item._loadId || 0) + 1;
it.id = o.id || `loaded-${Item._loadId}`;
it.type = type;
it.x = o.x;
it.y = o.y;
it.r = {
food: 14, sweet: 13, water: 12, grass: 17, stone: 20, bed: 37, firecracker: 15, fence_v: 42, fence_h: 42, trace: 16, splat: 26, zunchi: 14
}[type] || 12;
it.amount = o.amount ?? ({
food: 85, sweet: 62, water: 64, grass: 120, stone: 999, bed: 999, firecracker: 999, fence_v: 999, fence_h: 999, trace: 220, splat: 260, zunchi: 240
}[type] || 80);
it.age = o.age || 0;
it.seed = o.seed || Math.random() * 1000;
it.dropTimer = 0;
it.dropMax = 0;
it.dropImpactDone = false;
it.lifecycleTimer = o.lifecycleTimer ?? rand(0, type === "grass" ? 1.15 : 0.55);
it.lifecycleInterval = o.lifecycleInterval ?? (type === "grass" ? (1.75 + stableUnit(it.id, "grass-life") * 0.90) : (type === "zunchi" ? (0.95 + stableUnit(it.id, "zunchi-life") * 0.45) : 0));
if (type === "grass") {
it.growth = 0.25;
it.health = 1;
it.seedTimer = rand(18, 38);
it.eatenAmount = 0;
it.fertilityBoost = 0;
}
if (type === "bed") {
it.comfort = 1;
it.wear = 0;
}
if (type === "zunchi") {
it.stage = "fresh";
it.stageTimer = 0;
it.fertility = 0;
it.freshness = 1;
it.zunchiVariant = "zunchi";
}
if (type === "firecracker") {
it.fuseTimer = 5;
it.fuseMax = 5;
}
it.stage = o.stage || it.stage;
it.stageTimer = o.stageTimer || 0;
it.fertility = o.fertility || 0;
it.freshness = o.freshness ?? it.freshness;
it.growth = o.growth ?? it.growth;
it.health = o.health ?? it.health;
it.seedTimer = o.seedTimer ?? it.seedTimer;
it.eatenAmount = o.eatenAmount || 0;
it.fertilityBoost = o.fertilityBoost || 0;
it.comfort = o.comfort ?? it.comfort;
it.wear = o.wear ?? it.wear;
it.zunchiVariant = o.zunchiVariant || it.zunchiVariant || "zunchi";
it.toolSize = o.toolSize || it.toolSize || "medium";
it.blastScale = o.blastScale || it.blastScale || 1;
it.fuseTimer = o.fuseTimer ?? it.fuseTimer;
it.fuseMax = o.fuseMax ?? it.fuseMax;
it.dropTimer = 0;
it.dropMax = 0;
it.dropImpactDone = true;
return it;
}
draw(ctx, t, lighting = null) {
const lightState = lighting || getLightingState(world);
const night = clamp(lightState.nightStrength * 1.35, 0, 1);
const warm = lightState.warmth;
const styleNight = lightState.light < 0.42;
const styleWarm = !styleNight && (lightState.goldenStrength > 0.22 || warm > 0.55);
const visibleAlpha = clamp(this.amount / 40, 0.24, 1);
const dropT = this.dropMax > 0 ? clamp(this.dropTimer / this.dropMax, 0, 1) : 0;
const dropY = dropT > 0 ? -170 * Math.pow(dropT, 0.42) : 0;
const shadow = projectedShadowParams(lightState, Math.max(0.4, this.r / 18));
if (this.type !== "trace" && this.type !== "splat") {
const zunchiId = this.type === "zunchi" ? (this.zunchiVariant || "zunchi") : null;
const zunchiImg = zunchiId ? (images.get(zunchiId) || images.get("zunchi")) : null;
if (zunchiImg) {
const metrics = getImageMetrics(zunchiId || "zunchi") || getImageMetrics("zunchi");
const ratio = metrics?.ratio || 178 / 236;
const zunchiH = this.r * 1.66 * ratio;
drawImageProjectedShadow(ctx, zunchiImg, this.x, this.y + imageVisibleBottomFromTop(zunchiImg, -this.r * 1.35, zunchiH), this.r * 2.2, zunchiH, shadow, {
alpha: shadow.alpha * visibleAlpha * 1.18,
widthScale: 0.92,
heightScale: 0.92,
});
} else if (this.type === "grass") {
ctx.save();
ctx.globalAlpha = clamp(shadow.alpha * visibleAlpha * 0.62, 0.035, 0.13);
ctx.fillStyle = shadow.color;
ctx.beginPath();
ctx.ellipse(this.x, this.y + this.r * 0.46, this.r * 0.62, this.r * 0.12, 0, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
} else {
drawProjectedShadow(ctx, this.x, this.y + this.r * 0.52, this.r * 0.82, this.r * 0.26, { ...shadow, alpha: shadow.alpha * visibleAlpha * 1.25 });
}
}
drawItemGlow(ctx, this, lightState, visibleAlpha);
if (world.pointer?.inside && this.type !== "trace" && this.type !== "splat" && distXY(this.x, this.y, world.pointer.x, world.pointer.y) < this.r * 2.2) {
ctx.save();
ctx.globalAlpha = 0.32;
ctx.strokeStyle = "rgba(255, 250, 220, 0.82)";
ctx.lineWidth = 1.4;
ctx.beginPath();
ctx.ellipse(this.x, this.y + this.r * 0.1, this.r * 1.35, this.r * 0.92, 0, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
}
ctx.save();
ctx.translate(this.x, this.y + dropY);
ctx.globalAlpha = visibleAlpha;
if (this.type === "food" || this.type === "sweet") {
const sweet = this.type === "sweet";
ctx.fillStyle = sweet ? "#fffaf1" : "#f1dfb7";
ctx.strokeStyle = sweet ? "#9a8a72" : "#8d6f48";
ctx.lineWidth = 2;
ctx.beginPath();
const bob = 0;
ctx.ellipse(0, bob, this.r * 1.25, this.r * 0.75, Math.sin(this.seed) * 0.4, 0, Math.PI * 2);
ctx.fill(); ctx.stroke();
if (sweet) {
ctx.beginPath();
ctx.fillStyle = "#76b94d";
ctx.ellipse(-1, -this.r * 0.24, this.r * 0.76, this.r * 0.26, Math.sin(this.seed) * 0.25, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "rgba(255,255,255,0.62)";
ctx.beginPath();
ctx.ellipse(-this.r * 0.32, -this.r * 0.16, this.r * 0.24, this.r * 0.10, -0.35, 0, Math.PI * 2);
ctx.fill();
} else {
ctx.fillStyle = "#fff7dc";
for (let i = 0; i < 5; i++) {
ctx.beginPath();
ctx.arc(randSeed(this.seed + i, -8, 8), randSeed(this.seed + i + 7, -5, 5), 1.6, 0, Math.PI * 2);
ctx.fill();
}
}
} else if (this.type === "water") {
ctx.fillStyle = "rgba(86, 157, 224, 0.50)";
ctx.strokeStyle = "rgba(62, 113, 178, 0.42)";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.ellipse(0, Math.sin(t * 1.7 + this.seed) * 1.5, this.r * 1.05, this.r * 0.8, 0, 0, Math.PI * 2);
ctx.fill(); ctx.stroke();
ctx.fillStyle = "rgba(255,255,255,0.55)";
ctx.beginPath(); ctx.arc(-4, -4, 3, 0, Math.PI * 2); ctx.fill();
} else if (this.type === "grass") {
const growth = this.growth ?? clamp(this.amount / 120, 0, 1);
const stage = growth < 0.22 ? "sprout" : growth < 0.48 ? "young" : "mature";
const eaten = this.eatenAmount > 14 || growth < 0.16;
const grassLoad = 1;
ctx.strokeStyle = eaten ? "#7b8755" : (styleNight ? "#294b40" : (styleWarm ? "#64ad3f" : "#4f8438"));
ctx.shadowColor = "transparent";
ctx.shadowBlur = 0;
ctx.lineCap = "round";
ctx.lineWidth = grassLoad >= 2 ? (styleNight ? 1.3 : 1.5) : (styleNight ? 1.7 : 2);
const matureBlades = grassLoad >= 3 ? 2 : grassLoad >= 2 ? 3 : grassLoad >= 1 ? 5 : 7;
const bladeCount = eaten ? 2 : (stage === "sprout" ? 2 : stage === "young" ? (grassLoad >= 2 ? 2 : 3) : matureBlades);
for (let i = 0; i < bladeCount; i++) {
const spread = stage === "mature" ? (grassLoad >= 2 ? 0.92 : 1.18) : 0.70;
const a = -Math.PI / 2 + randSeed(this.seed + i, -spread, spread);
const len = this.r * randSeed(this.seed + i + 10, 0.45, stage === "mature" ? (grassLoad >= 2 ? 1.08 : 1.30) : 1.05) * clamp(growth, 0.18, 1.00) * (eaten ? 0.42 : 1);
const rootX = stage === "mature" ? randSeed(this.seed + i + 31, -this.r * (grassLoad >= 2 ? 0.24 : 0.32), this.r * (grassLoad >= 2 ? 0.24 : 0.32)) : 0;
ctx.beginPath();
ctx.moveTo(rootX, 8);
ctx.quadraticCurveTo(rootX + Math.cos(a) * len * 0.55, 8 - len * 0.24, rootX + Math.cos(a) * len, Math.sin(a) * len * 0.78);
ctx.stroke();
}
if (stage === "mature" && !eaten && grassLoad <= 1) {
ctx.save();
ctx.globalAlpha = 0.16;
ctx.fillStyle = styleNight ? "rgba(95,132,114,0.65)" : "rgba(138,189,108,0.72)";
ctx.beginPath();
ctx.ellipse(0, 1, this.r * 0.72, this.r * 0.22, 0, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
} else if (this.type === "stone") {
ctx.fillStyle = styleNight ? "#8f94a6" : (styleWarm ? "#e4d6b5" : "#d9d0b8");
ctx.strokeStyle = styleNight ? "#5f6478" : "#8a7c63";
ctx.shadowColor = "transparent";
ctx.shadowBlur = 0;
ctx.lineWidth = 2;
roundedBlob(ctx, 0, 0, this.r * 1.25, this.r * 0.85, 8);
ctx.fill(); ctx.stroke();
ctx.fillStyle = "rgba(255,255,255,0.34)";
ctx.beginPath(); ctx.ellipse(-6, -5, 6, 3, -0.3, 0, Math.PI * 2); ctx.fill();
} else if (this.type === "bed") {
ctx.fillStyle = styleWarm ? "rgba(224, 190, 94, 0.90)" : (styleNight ? "rgba(177, 156, 95, 0.84)" : "rgba(205, 169, 86, 0.88)" );
ctx.strokeStyle = styleWarm ? "rgba(135, 101, 45, 0.62)" : (styleNight ? "rgba(102, 88, 56, 0.68)" : "rgba(117, 91, 47, 0.68)" );
ctx.lineWidth = 2.2;
ctx.beginPath();
ctx.ellipse(0, 3, this.r * 1.45, this.r * 0.72, -0.08, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
ctx.strokeStyle = "rgba(116, 89, 42, 0.45)";
ctx.lineWidth = 1.5;
for (let i = 0; i < 13; i++) {
const x = randSeed(this.seed + i, -this.r * 1.10, this.r * 1.08);
const y = randSeed(this.seed + i + 20, -this.r * 0.36, this.r * 0.46);
const len = randSeed(this.seed + i + 40, this.r * 0.34, this.r * 0.70);
const a = randSeed(this.seed + i + 60, -0.75, 0.75);
ctx.beginPath();
ctx.moveTo(x - Math.cos(a) * len * 0.5, y - Math.sin(a) * len * 0.5);
ctx.lineTo(x + Math.cos(a) * len * 0.5, y + Math.sin(a) * len * 0.5);
ctx.stroke();
}
} else if (this.type === "firecracker") {
const fuse = clamp((this.fuseTimer ?? 5) / Math.max(this.fuseMax || 5, 0.1), 0, 1);
const flash = fuse < 0.35 ? (0.5 + Math.sin(t * 20 + this.seed) * 0.5) : 0;
ctx.rotate(-0.22 + Math.sin(this.seed) * 0.12);
ctx.fillStyle = flash > 0.65 ? "#ffed77" : "#d94b43";
ctx.strokeStyle = "rgba(96,48,34,0.78)";
ctx.lineWidth = 2;
roundedRect(ctx, -this.r * 0.72, -this.r * 0.86, this.r * 1.44, this.r * 1.72, 5);
ctx.fill();
ctx.stroke();
ctx.fillStyle = "#f4d15b";
ctx.fillRect(-this.r * 0.55, -this.r * 0.54, this.r * 1.1, this.r * 0.20);
ctx.fillRect(-this.r * 0.55, this.r * 0.34, this.r * 1.1, this.r * 0.20);
ctx.strokeStyle = "rgba(74,55,39,0.82)";
ctx.lineWidth = 2.2;
ctx.beginPath();
ctx.moveTo(0, -this.r * 0.88);
ctx.quadraticCurveTo(this.r * 0.32, -this.r * 1.34, this.r * 0.92, -this.r * 1.42);
ctx.stroke();
ctx.fillStyle = flash > 0.1 ? "rgba(255,221,82,0.92)" : "rgba(255,162,62,0.78)";
ctx.beginPath();
ctx.arc(this.r * 0.98, -this.r * 1.42, 2.5 + flash * 2.2, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "rgba(42,36,29,0.66)";
ctx.font = "bold 10px ui-rounded, sans-serif";
ctx.textAlign = "center";
ctx.fillText(String(Math.ceil(this.fuseTimer ?? 5)), 0, this.r * 1.42);
} else if (this.type === "fence_v" || this.type === "fence_h") {
const vertical = this.type === "fence_v";
const len = Math.max(112, this.r * 3.55);
const thick = Math.max(10, this.r * 0.31);
ctx.save();
ctx.rotate(vertical ? 0 : Math.PI / 2);
const wood = styleNight ? "#7a5b3c" : (styleWarm ? "#a96f35" : "#965f31");
const edge = styleNight ? "#4d3d2f" : "#5f3f25";
const hi = styleNight ? "rgba(198,166,116,0.20)" : "rgba(232,176,94,0.32)";
ctx.fillStyle = wood;
ctx.strokeStyle = edge;
ctx.lineWidth = 2.4;
ctx.beginPath();
roundedRect(ctx, -thick / 2, -len / 2, thick, len, thick / 2);
ctx.fill();
ctx.stroke();
ctx.fillStyle = hi;
ctx.beginPath();
roundedRect(ctx, -thick * 0.26, -len / 2 + 6, thick * 0.20, len - 12, thick / 3);
ctx.fill();
ctx.restore();
} else if (this.type === "zunchi") {
const zunchiId = this.zunchiVariant || "zunchi";
const img = images.get(zunchiId) || images.get("zunchi");
if (img) {
const metrics = getImageMetrics(zunchiId) || getImageMetrics("zunchi");
const ratio = metrics?.ratio || 178 / 236;
const stageAlpha = this.stage === "fresh" ? clamp(this.freshness ?? 1, 0.45, 1) : this.stage === "dry" ? 0.82 : this.stage === "decomposing" ? 0.62 : clamp(this.fertility ?? 0.28, 0.24, 0.46);
ctx.globalAlpha *= stageAlpha;
ctx.drawImage(img, -this.r * 1.1, -this.r * 1.35, this.r * 2.2, this.r * 1.66 * ratio);
}
} else if (this.type === "trace") {
ctx.fillStyle = "rgba(122, 100, 76, 0.12)";
ctx.strokeStyle = "rgba(122, 100, 76, 0.16)";
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.ellipse(0, 0, this.r * 1.3, this.r * 0.75, Math.sin(this.seed) * 0.7, 0, Math.PI * 2);
ctx.fill(); ctx.stroke();
} else if (this.type === "splat") {
const fade = clamp(this.amount / 260, 0, 1);
ctx.globalAlpha = visibleAlpha * fade;
ctx.fillStyle = `rgba(123, 214, 52, ${0.34 * fade})`;
ctx.strokeStyle = `rgba(85, 160, 38, ${0.22 * fade})`;
ctx.lineWidth = 1.8;
const blobs = [
[-12, -2, 10, 8], [0, 0, 16, 10], [13, 5, 10, 8], [-2, -12, 9, 7], [7, -8, 7, 5], [-18, 8, 6, 5]
];
for (const [bx, by, bw, bh] of blobs) {
ctx.beginPath();
ctx.ellipse(bx, by, bw, bh, randSeed(this.seed + bx * 0.1, -0.5, 0.5), 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
}
for (let i = 0; i < 7; i++) {
ctx.beginPath();
ctx.arc(randSeed(this.seed + i, -28, 24), randSeed(this.seed + i + 9, -18, 18), randSeed(this.seed + i + 19, 2.5, 4.8), 0, Math.PI * 2);
ctx.fill();
}
}
ctx.restore();
}
}

View file

@ -3,6 +3,8 @@ let last = nowSec();
let statsTimer = 0;
let fpsTimer = 0;
let fpsFrames = 0;
let perfResizeTimer = 0;
let lastPerfLevel = 0;
window.__tarinaiFps = 0;
function loop() {
const t = nowSec();
@ -20,6 +22,14 @@ function loop() {
render();
statsTimer += dt;
const perf = world.performanceLevel ? world.performanceLevel() : 0;
perfResizeTimer += rawDt;
if (perfResizeTimer > 1.5) {
if (perf !== lastPerfLevel) {
lastPerfLevel = perf;
resizeCanvas();
}
perfResizeTimer = 0;
}
const statsInterval = perf >= 3 ? 1.20 : perf === 2 ? 0.92 : perf === 1 ? 0.68 : 0.48;
if (statsTimer > statsInterval) {
renderStats();
@ -30,8 +40,8 @@ function loop() {
loadImages().then(() => {
bindUI();
resizeCanvas();
world.reset();
applyFieldLayout("garden");
world.reset(null, "garden");
selectTool("observe");
renderLog(world.logs);
renderArchive();

View file

@ -153,11 +153,12 @@ const backgroundCache = {
function backgroundCacheKey(world, lighting) {
const weather = world.weather || "sunny";
const field = world.fieldType || "garden";
const perf = world.performanceLevel ? world.performanceLevel() : 0;
const step = perf >= 3 ? 64 : perf === 2 ? 96 : 180;
const progressBucket = Math.round((lighting.progress || 0) * step);
const weatherBucket = Math.floor((world.time || 0) / (perf >= 2 ? 4 : 2));
return `${weather}:${progressBucket}:${weatherBucket}`;
return `${field}:${weather}:${progressBucket}:${weatherBucket}`;
}
function ensureBackgroundCache(w, h, lighting) {
@ -246,10 +247,13 @@ function drawGardenBackdrop(w, h, lighting) {
function drawGardenBed(w, h, lighting) {
const light = lighting.light;
const fieldType = world.fieldType || "garden";
const x = 18, y = 22, bw = w - 36, bh = h - 44;
const border = activeCtx.createLinearGradient(0, y, 0, y + bh);
border.addColorStop(0, light > 0.42 ? (lighting.goldenStrength > 0.25 ? "#bf8f5e" : "#a88c66") : "#6f6c72");
border.addColorStop(1, light > 0.42 ? (lighting.goldenStrength > 0.25 ? "#9c7043" : "#8a6f4b") : "#565a68");
const borderTop = fieldType === "park" ? "#6f8f55" : fieldType === "cage" ? "#8b8f98" : (lighting.goldenStrength > 0.25 ? "#bf8f5e" : "#a88c66");
const borderBottom = fieldType === "park" ? "#486d3d" : fieldType === "cage" ? "#5f6670" : (lighting.goldenStrength > 0.25 ? "#9c7043" : "#8a6f4b");
border.addColorStop(0, light > 0.42 ? borderTop : "#6f6c72");
border.addColorStop(1, light > 0.42 ? borderBottom : "#565a68");
activeCtx.fillStyle = border;
roundedRect(activeCtx, x, y, bw, bh, 34);
activeCtx.fill();
@ -263,9 +267,14 @@ function drawGardenBed(w, h, lighting) {
activeCtx.restore();
const soil = activeCtx.createLinearGradient(0, y + 14, 0, y + bh - 14);
soil.addColorStop(0, light > 0.42 ? (lighting.goldenStrength > 0.25 ? "#f1dfbc" : "#ece6d2") : "#bbbcc6");
soil.addColorStop(0.38, light > 0.42 ? (lighting.goldenStrength > 0.25 ? "#e5d4b5" : "#dfdccb") : "#afb1bd");
soil.addColorStop(1, light > 0.42 ? (lighting.goldenStrength > 0.25 ? "#d3c5a5" : "#d6d2bf") : "#a0a3af");
const palette = fieldType === "park"
? ["#cfe5a8", "#b8d990", "#91bd74"]
: fieldType === "cage"
? ["#d7d2c5", "#c7c2b6", "#aaa79e"]
: [lighting.goldenStrength > 0.25 ? "#f1dfbc" : "#ece6d2", lighting.goldenStrength > 0.25 ? "#e5d4b5" : "#dfdccb", lighting.goldenStrength > 0.25 ? "#d3c5a5" : "#d6d2bf"];
soil.addColorStop(0, light > 0.42 ? palette[0] : "#bbbcc6");
soil.addColorStop(0.38, light > 0.42 ? palette[1] : "#afb1bd");
soil.addColorStop(1, light > 0.42 ? palette[2] : "#a0a3af");
activeCtx.fillStyle = soil;
roundedRect(activeCtx, x + 16, y + 14, bw - 32, bh - 30, 28);
activeCtx.fill();
@ -275,7 +284,8 @@ function drawGardenBed(w, h, lighting) {
roundedRect(activeCtx, x + 16, y + 14, bw - 32, bh - 30, 28);
activeCtx.clip();
for (let i = 0; i < 46; i++) {
const speckCount = fieldType === "park" ? 72 : fieldType === "cage" ? 34 : 46;
for (let i = 0; i < speckCount; i++) {
const px = randSeed(900 + i, x + 24, x + bw - 24);
const py = randSeed(1200 + i, y + 22, y + bh - 24);
const r = randSeed(1600 + i, 1.2, 3.8);
@ -286,7 +296,31 @@ function drawGardenBed(w, h, lighting) {
activeCtx.fill();
}
for (let i = 0; i < 8; i++) {
if (fieldType === "cage") {
activeCtx.save();
activeCtx.strokeStyle = light > 0.42 ? "rgba(98, 102, 108, 0.20)" : "rgba(220, 225, 235, 0.16)";
activeCtx.lineWidth = 1.1;
for (let gx = x + 44; gx < x + bw - 26; gx += 44) {
activeCtx.beginPath(); activeCtx.moveTo(gx, y + 20); activeCtx.lineTo(gx, y + bh - 20); activeCtx.stroke();
}
for (let gy = y + 44; gy < y + bh - 24; gy += 44) {
activeCtx.beginPath(); activeCtx.moveTo(x + 22, gy); activeCtx.lineTo(x + bw - 22, gy); activeCtx.stroke();
}
activeCtx.restore();
} else if (fieldType === "park") {
activeCtx.save();
activeCtx.globalAlpha = light > 0.42 ? 0.24 : 0.14;
for (let i = 0; i < 24; i++) {
activeCtx.fillStyle = i % 2 ? "rgba(74, 132, 58, 0.35)" : "rgba(238, 222, 112, 0.32)";
activeCtx.beginPath();
activeCtx.ellipse(randSeed(5400 + i, x + 34, x + bw - 34), randSeed(5600 + i, y + 36, y + bh - 36), randSeed(5800 + i, 3, 8), randSeed(6000 + i, 2, 5), randSeed(6200 + i, -0.8, 0.8), 0, Math.PI * 2);
activeCtx.fill();
}
activeCtx.restore();
}
const patchCount = fieldType === "park" ? 14 : fieldType === "cage" ? 3 : 8;
for (let i = 0; i < patchCount; i++) {
const px = randSeed(3300 + i, x + 40, x + bw - 40);
const py = randSeed(3600 + i, y + 44, y + bh - 46);
const rw = randSeed(3900 + i, 24, 64);
@ -298,7 +332,8 @@ function drawGardenBed(w, h, lighting) {
}
// edge grasses and tiny flowers
for (let i = 0; i < 14; i++) {
const edgeGrassCount = fieldType === "park" ? 24 : fieldType === "cage" ? 5 : 14;
for (let i = 0; i < edgeGrassCount; i++) {
const side = i % 2;
const px = side === 0 ? randSeed(5000 + i, x + 36, x + bw - 36) : randSeed(5400 + i, x + 30, x + bw - 30);
const py = side === 0 ? y + 20 + randSeed(5700 + i, -4, 8) : y + bh - 24 + randSeed(6000 + i, -8, 4);
@ -317,7 +352,8 @@ function drawGardenBed(w, h, lighting) {
activeCtx.restore();
}
for (let i = 0; i < 9; i++) {
const flowerCount = fieldType === "park" ? 18 : fieldType === "cage" ? 0 : 9;
for (let i = 0; i < flowerCount; i++) {
const px = randSeed(7000 + i, x + 44, x + bw - 44);
const py = randSeed(7400 + i, y + 46, y + bh - 44);
if (i % 3 === 0) {
@ -335,7 +371,8 @@ function drawGardenBed(w, h, lighting) {
}
}
for (let i = 0; i < 10; i++) {
const leafCount = fieldType === "park" ? 18 : fieldType === "cage" ? 4 : 10;
for (let i = 0; i < leafCount; i++) {
const px = randSeed(7800 + i, x + 36, x + bw - 36);
const py = randSeed(8200 + i, y + 34, y + bh - 34);
if (i % 2 === 0) {
@ -451,22 +488,40 @@ function weatherLabel(weather) {
function render() {
activeCtx = ctx;
const w = world.w, h = world.h;
ctx.clearRect(0, 0, w, h);
const sceneW = world.w, sceneH = world.h;
const screenW = world.viewportW || sceneW;
const screenH = world.viewportH || sceneH;
const fieldOffset = world.fieldScreenOffset ? world.fieldScreenOffset() : { x: 0, y: 0 };
const cameraX = world.cameraX || 0;
const cameraY = world.cameraY || 0;
const viewScale = world.viewScale ? world.viewScale() : 1;
ctx.clearRect(0, 0, screenW, screenH);
const lighting = getLightingState(world);
const light = lighting.light;
const cachedBackground = ensureBackgroundCache(w, h, lighting);
if (cachedBackground) {
ctx.drawImage(cachedBackground, 0, 0, w, h);
} else {
drawGardenBackdrop(w, h, lighting);
drawGardenBed(w, h, lighting);
}
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);
const cachedBackground = ensureBackgroundCache(sceneW, sceneH, lighting);
const beginFieldTransform = () => {
ctx.save();
ctx.translate(fieldOffset.x, fieldOffset.y);
ctx.scale(viewScale, viewScale);
ctx.translate(-cameraX, -cameraY);
};
beginFieldTransform();
if (cachedBackground) {
ctx.drawImage(cachedBackground, 0, 0, sceneW, sceneH);
} else {
drawGardenBackdrop(sceneW, sceneH, lighting);
drawGardenBed(sceneW, sceneH, lighting);
}
ctx.restore();
const perf = world.performanceLevel ? world.performanceLevel() : 0;
if (perf < 2) drawLightRays(ctx, screenW, screenH, lighting);
if (perf < 3 || world.weather === "light_rain") drawRain(ctx, screenW, screenH, world.time);
beginFieldTransform();
for (const it of world.items) {
if (it.type !== "zunchi") it.draw(ctx, world.time, lighting);
}
@ -483,7 +538,7 @@ function render() {
if (!p || !t.juvenile || t.dead || p.dead || dist(t, p) > 96) continue;
ctx.beginPath();
ctx.moveTo(t.x, t.y);
ctx.quadraticCurveTo((t.x + p.x) / 2, Math.min(t.y, p.y) - 10, p.x, p.y);
ctx.quadraticCurveTo((t.x + p.x) * 0.5, Math.min(t.y, p.y) - 10, p.x, p.y);
ctx.stroke();
}
ctx.setLineDash([]);
@ -492,14 +547,32 @@ function render() {
const sorted = world.sortedDrawList ? world.sortedDrawList() : world.tarinai;
for (const t of sorted) t.draw(ctx, world.time, lighting);
for (const ef of world.effects) ef.draw(ctx);
ctx.restore();
// Light affecting the whole scene, including characters and ground.
drawAtmosphericLighting(ctx, w, h, lighting);
// Light affecting the whole visible scene, including characters and ground.
drawAtmosphericLighting(ctx, screenW, screenH, lighting);
const drawAtScreenPosition = (entity, drawFn) => {
if (!entity) return;
const ox = entity.x;
const oy = entity.y;
const screen = world.worldToScreen ? world.worldToScreen(ox, oy) : { x: ox, y: oy };
entity.x = screen.x;
entity.y = screen.y;
try {
drawFn();
} finally {
entity.x = ox;
entity.y = oy;
}
};
drawAtScreenPosition(world.selected, () => drawSelectedCard(ctx, world, lighting));
// Time HUD
ctx.save();
const hudW = 150;
const hudH = 56;
const w = screenW, h = screenH;
const hudX = w - hudW - 22;
const hudY = 16;
ctx.fillStyle = light > 0.42 ? "rgba(255,251,244,0.76)" : "rgba(247,246,255,0.62)";
@ -516,9 +589,6 @@ function render() {
ctx.fillText(`${world.phaseName()} / ${world.clockString()} / ${weatherLabel(world.weather)}`, hudX + 24, hudY + 45);
ctx.restore();
drawSelectedCard(ctx, world, lighting);
ctx.save();
ctx.textAlign = "right";
ctx.font = "10px ui-rounded, sans-serif";
@ -542,7 +612,8 @@ function render() {
function drawSelectedCard(ctx, world, lighting) {
const t = world.selected;
if (!t || t.dead || !world.tarinai.includes(t)) return;
const w = world.w, h = world.h;
const w = world.viewportW || world.w;
const h = world.viewportH || world.h;
const cardW = 152;
const cardH = 66;
let x = t.x + 18;

3371
js/sim.js

File diff suppressed because it is too large Load diff

306
js/sim_core.js Normal file
View file

@ -0,0 +1,306 @@
"use strict";
function makeName(worldRef = null) {
const existing = new Set((worldRef?.tarinai || []).filter(t => !t.dead).map(t => t.name));
const build = () => {
const len = randi(2, 7);
const hasStop = len >= 2 && Math.random() < 0.30;
const middleLen = Math.max(0, len - 1 - (hasStop ? 1 : 0));
let name = "\u306f";
for (let i = 0; i < middleLen; i++) name += Math.random() < 0.64 ? "\u3046" : "\u3045";
if (hasStop) name += "\u3063";
return name;
};
for (let i = 0; i < 80; i++) {
const candidate = build();
if (!existing.has(candidate)) return candidate;
}
return build();
}
function spawnableSprites() {
return SPRITES.filter(s => !s.actionOnly && !s.displayOnly);
}
function displayNormalSprites() {
return ["smile", "jito", "normal_smirk"].filter(id => SPRITES.some(s => s.id === id));
}
function lowStressSprites() {
return ["normal_tongue", "normal_happy"].filter(id => SPRITES.some(s => s.id === id));
}
function stableChoice(seed, salt, options) {
if (!options?.length) return null;
const i = Math.min(options.length - 1, Math.floor(stableUnit(seed, salt) * options.length));
return options[i];
}
function baseSpriteId() {
return pick(spawnableSprites()).id;
}
function makeTrait(type) {
const base = {
smile: { hunger: 0.92, social: 1.14, sleep: 1.00, stress: 0.82, speed: 1.02 },
angry: { hunger: 1.05, social: 0.84, sleep: 0.94, stress: 1.24, speed: 1.15 },
teary: { hunger: 0.96, social: 1.22, sleep: 1.04, stress: 1.12, speed: 0.96 },
jito: { hunger: 0.88, social: 0.80, sleep: 0.96, stress: 0.80, speed: 0.98 },
drool: { hunger: 1.30, social: 1.02, sleep: 1.04, stress: 0.98, speed: 1.00 },
cry: { hunger: 1.00, social: 1.30, sleep: 1.12, stress: 1.36, speed: 0.90 },
sleep: { hunger: 0.78, social: 0.88, sleep: 1.44, stress: 0.72, speed: 0.70 },
pokan: { hunger: 1.02, social: 1.02, sleep: 1.02, stress: 1.06, speed: 0.88 },
stretch: { hunger: 1.08, social: 0.90, sleep: 0.92, stress: 1.08, speed: 1.26 },
}[type] || { hunger: 1, social: 1, sleep: 1, stress: 1, speed: 1 };
return { ...base };
}
function stableUnit(seed, salt = "") {
const str = `${seed}:${salt}`;
let h = 2166136261;
for (let i = 0; i < str.length; i++) {
h ^= str.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return ((h >>> 0) % 100000) / 100000;
}
const PERSONALITIES = {
timid: { label: "\u81c6\u75c5", desc: "\u9003\u3052\u3084\u3059\u304f\u3001\u55a7\u5629\u3092\u907f\u3051\u308b", fear: 1.42, fight: 0.48, social: 0.96, relation: 1.04, sleep: 1.00, zunda: 1.00 },
glutton: { label: "\u98df\u3044\u3057\u3093\u574a", desc: "\u305a\u3093\u3060\u9905\u3092\u512a\u5148\u3057\u3084\u3059\u3044", fear: 0.92, fight: 0.82, social: 1.00, relation: 1.00, sleep: 0.95, zunda: 1.42 },
sleepy: { label: "\u7720\u305f\u304c\u308a", desc: "\u65e9\u3081\u306b\u5bdd\u5e8a\u3078\u5411\u304b\u3046", fear: 0.96, fight: 0.72, social: 0.92, relation: 0.96, sleep: 1.34, zunda: 0.92 },
lonely: { label: "\u5bc2\u3057\u304c\u308a", desc: "\u4ed6\u306e\u305f\u308a\u306a\u3044\u3078\u5bc4\u308a\u3084\u3059\u3044", fear: 1.02, fight: 0.70, social: 1.34, relation: 1.38, sleep: 0.96, zunda: 1.00 },
irritable: { label: "\u6012\u308a\u3063\u307d\u3044", desc: "\u55a7\u5629\u304c\u8d77\u304d\u3084\u3059\u3044", fear: 0.82, fight: 1.82, social: 0.78, relation: 0.78, sleep: 0.90, zunda: 0.94 },
calm: { label: "\u30de\u30a4\u30da\u30fc\u30b9", desc: "\u53cd\u5fdc\u304c\u7a4f\u3084\u304b\u3067\u5b89\u5b9a\u3057\u3084\u3059\u3044", fear: 0.82, fight: 0.58, social: 1.02, relation: 1.08, sleep: 1.02, zunda: 0.94 },
};
const PERSONALITY_IDS = Object.keys(PERSONALITIES);
function personalityForSeed(seed) {
return stableChoice(seed, "personality", PERSONALITY_IDS) || "calm";
}
function relationDefaults() {
return { affinity: 0, fear: 0, fightsWon: 0, fightsLost: 0, lastEvent: "", lastTime: 0 };
}
function isParentChild(a, b) {
if (!a || !b) return false;
return Boolean(a.parents?.includes(b.id) || b.parents?.includes(a.id));
}
function relationDisplayName(worldRef, id) {
if (!id) return "\u306a\u3057";
const live = worldRef?.tarinai?.find(t => t.id === id);
if (live) return live.name;
const fam = worldRef?.family?.[id];
return fam?.name || "\u4e0d\u660e";
}
class Effect {
constructor(type, x, y, options = {}) {
this.type = type;
this.x = x;
this.y = y;
this.vx = options.vx ?? rand(-10, 10);
this.vy = options.vy ?? rand(-10, 10);
this.size = options.size ?? rand(3, 8);
this.life = options.life ?? 0.7;
this.maxLife = this.life;
this.color = options.color || "#fff4a8";
this.seed = Math.random() * 1000;
this.text = options.text || "";
}
update(dt) {
this.life -= dt;
this.x += this.vx * dt;
this.y += this.vy * dt;
this.vx *= Math.pow(0.92, dt * 60);
this.vy *= Math.pow(0.92, dt * 60);
}
get dead() { return this.life <= 0; }
draw(ctx) {
const alpha = clamp(this.life / this.maxLife, 0, 1);
ctx.save();
ctx.globalAlpha = alpha;
ctx.translate(this.x, this.y);
if (this.type === "eat") {
ctx.fillStyle = this.color;
ctx.strokeStyle = "rgba(143, 111, 43, 0.7)";
ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.arc(0, 0, this.size, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
} else if (this.type === "bleed") {
ctx.fillStyle = this.color;
ctx.beginPath();
ctx.ellipse(0, 0, this.size * 1.25, this.size * 0.72, this.seed, 0, Math.PI * 2);
ctx.fill();
} else if (this.type === "ring") {
ctx.strokeStyle = this.color;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(0, 0, this.size + (1 - alpha) * 18, 0, Math.PI * 2);
ctx.stroke();
} else if (this.type === "fight") {
ctx.lineCap = "round";
ctx.lineJoin = "round";
const puffColor = this.color || "rgba(105, 62, 34, 0.86)";
ctx.fillStyle = puffColor;
ctx.strokeStyle = puffColor;
for (let i = 0; i < 4; i++) {
const a = this.seed + i * 1.55;
const px = Math.cos(a) * this.size * randSeed(this.seed + i, 0.10, 0.72);
const py = Math.sin(a) * this.size * randSeed(this.seed + i + 8, 0.08, 0.48);
ctx.globalAlpha = alpha * (0.26 + i * 0.08);
ctx.beginPath();
ctx.ellipse(px, py, this.size * randSeed(this.seed + i + 20, 0.18, 0.34), this.size * randSeed(this.seed + i + 40, 0.10, 0.22), a, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = alpha * 0.78;
ctx.lineWidth = 2.0;
for (let i = 0; i < 2; i++) {
const dir = i === 0 ? -1 : 1;
const y = dir * this.size * 0.14;
ctx.beginPath();
ctx.moveTo(-this.size * 0.62, y);
ctx.quadraticCurveTo(-this.size * 0.12, y - dir * this.size * 0.42, this.size * 0.54, y + dir * this.size * 0.06);
ctx.stroke();
}
} else if (this.type === "fall") {
ctx.fillStyle = this.color || "rgba(154, 124, 80, 0.62)";
for (let i = 0; i < 5; i++) {
const a = this.seed + i * 1.37;
const rr = this.size * randSeed(this.seed + i, 0.18, 0.82);
const px = Math.cos(a) * rr;
const py = Math.sin(a) * rr * 0.42;
ctx.globalAlpha = alpha * randSeed(this.seed + i + 9, 0.18, 0.42);
ctx.beginPath();
ctx.ellipse(px, py, this.size * randSeed(this.seed + i + 20, 0.16, 0.34), this.size * randSeed(this.seed + i + 30, 0.08, 0.18), a, 0, Math.PI * 2);
ctx.fill();
}
} else if (this.type === "explosion") {
const burst = 1 - alpha;
ctx.globalAlpha = alpha * 0.95;
for (let i = 0; i < 3; i++) {
ctx.strokeStyle = i === 0 ? "rgba(255,238,145,0.92)" : (i === 1 ? "rgba(255,128,66,0.68)" : "rgba(96,62,42,0.42)");
ctx.lineWidth = Math.max(2, this.size * (0.09 - i * 0.018));
ctx.beginPath();
ctx.arc(0, 0, this.size * (0.28 + burst * (0.78 + i * 0.22)), 0, Math.PI * 2);
ctx.stroke();
}
ctx.fillStyle = "rgba(255,214,86,0.88)";
for (let i = 0; i < 14; i++) {
const a = this.seed + i * 0.73;
const r = this.size * burst * randSeed(this.seed + i, 0.28, 1.00);
ctx.globalAlpha = alpha * randSeed(this.seed + i + 4, 0.36, 0.88);
ctx.beginPath();
ctx.arc(Math.cos(a) * r, Math.sin(a) * r, randSeed(this.seed + i + 12, 2.2, 5.2), 0, Math.PI * 2);
ctx.fill();
}
} else if (this.type === "bubble") {
const w = Math.max(30, 14 + this.text.length * 7);
const h = 20;
ctx.globalAlpha = Math.min(1, alpha * 1.25);
ctx.fillStyle = "rgba(255, 252, 240, 0.92)";
ctx.strokeStyle = "rgba(78, 64, 48, 0.22)";
ctx.lineWidth = 1.2;
roundedRect(ctx, -w / 2, -h, w, h, 9);
ctx.fill();
ctx.stroke();
ctx.fillStyle = "rgba(255, 252, 240, 0.92)";
ctx.beginPath();
ctx.moveTo(-4, -1);
ctx.lineTo(2, 5);
ctx.lineTo(6, -2);
ctx.closePath();
ctx.fill();
ctx.stroke();
ctx.fillStyle = this.color || "rgba(42,36,29,0.78)";
ctx.font = "bold 12px Yomogi, ui-rounded, sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(this.text, 0, -10);
}
ctx.restore();
}
}
class SpatialGrid {
constructor(cellSize = 96) {
this.cellSize = cellSize;
this.itemCells = new Map();
this.tarinaiCells = new Map();
}
clear() {
this.itemCells.clear();
this.tarinaiCells.clear();
}
keyFor(x, y) {
const cx = Math.floor(x / this.cellSize);
const cy = Math.floor(y / this.cellSize);
return cx + cy * 100000;
}
add(map, entity) {
const key = this.keyFor(entity.x, entity.y);
let bucket = map.get(key);
if (!bucket) {
bucket = [];
map.set(key, bucket);
}
bucket.push(entity);
}
rebuild(items, tarinai) {
this.clear();
for (const it of items) {
if (!it.dead) this.add(this.itemCells, it);
}
for (const t of tarinai) {
if (!t.dead) this.add(this.tarinaiCells, t);
}
}
nearby(map, x, y, radius, out = []) {
out.length = 0;
const r = Number.isFinite(radius) ? radius : Math.max(1200, this.cellSize * 12);
const minX = Math.floor((x - r) / this.cellSize);
const maxX = Math.floor((x + r) / this.cellSize);
const minY = Math.floor((y - r) / this.cellSize);
const maxY = Math.floor((y + r) / this.cellSize);
for (let cy = minY; cy <= maxY; cy++) {
for (let cx = minX; cx <= maxX; cx++) {
const bucket = map.get(cx + cy * 100000);
if (!bucket) continue;
for (const entity of bucket) out.push(entity);
}
}
return out;
}
}
function drawHeartShape(ctx, x, y, size, opts = {}) {
const fill = opts.fill || "rgba(240,91,135,0.90)";
const stroke = opts.stroke || "rgba(255,252,246,0.92)";
const alpha = opts.alpha == null ? 1 : opts.alpha;
const rotation = opts.rotation || 0;
ctx.save();
ctx.translate(x, y);
if (rotation) ctx.rotate(rotation);
ctx.globalAlpha *= alpha;
ctx.fillStyle = fill;
ctx.strokeStyle = stroke;
ctx.lineWidth = Math.max(1.2, size * 0.14);
ctx.beginPath();
ctx.moveTo(0, size * 0.72);
ctx.bezierCurveTo(-size * 1.55, -size * 0.22, -size * 1.16, -size * 1.36, 0, -size * 0.52);
ctx.bezierCurveTo(size * 1.16, -size * 1.36, size * 1.55, -size * 0.22, 0, size * 0.72);
ctx.fill();
ctx.stroke();
ctx.restore();
}

1448
js/tarinai.js Normal file

File diff suppressed because it is too large Load diff

584
js/ui.js
View file

@ -20,6 +20,8 @@ const ui = {
colonyChart: document.getElementById("colonyChart"),
colonyChartLegend: document.getElementById("colonyChartLegend"),
archiveContent: document.getElementById("archiveContent"),
fieldDialog: document.getElementById("fieldDialog"),
fieldCancelBtn: document.getElementById("fieldCancelBtn"),
};
const uiCache = {
@ -30,6 +32,7 @@ const uiCache = {
archiveVersion: "",
archiveRows: [],
archiveScrollScheduled: false,
deferArchiveUntil: 0,
lastArchiveWindow: "",
chartHistory: [],
chartBucketStart: -Infinity,
@ -37,7 +40,13 @@ const uiCache = {
chartBucketAccum: null,
lastChartSample: -Infinity,
lastChartDraw: "",
lastChartDrawAt: 0,
chartMode: "population",
canvasDpr: 0,
panning: false,
panLastX: 0,
panLastY: 0,
panMoved: false,
};
function renderLog(logs) {
@ -142,7 +151,7 @@ function stateLabel(s) {
return {
idle: "\u5f85\u6a5f",
seek_food: "\u98df\u3079\u7269\u3078\u5411\u304b\u3046",
seek_bed: "\u5e72\u3057\u8349\u30d9\u30c3\u30c9\u3078\u5411\u304b\u3046",
seek_bed: "\u5e72\u8349\u5bdd\u5e8a\u3078\u5411\u304b\u3046",
seek_friend: "\u4ef2\u9593\u3092\u63a2\u3059",
follow_parent: "\u89aa\u306b\u3064\u3044\u3066\u3044\u304f",
cursor_friend: "\u30ab\u30fc\u30bd\u30eb\u306b\u3064\u3044\u3066\u3044\u304f",
@ -171,7 +180,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: "\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\u8349\u5bdd\u5e8a", stone: "\u77f3", zunchi: "\u305a\u3093\u3061", trace: "\u8db3\u8de1", splat: "\u3057\u3076\u304d" }[target.type] || target.type;
if (Number.isFinite(target.x) && Number.isFinite(target.y)) return "\u30ab\u30fc\u30bd\u30eb";
return "\u4e0d\u660e";
}
@ -185,191 +194,6 @@ function relationFightRecord(t) {
return won || lost ? `\u52dd${won} / \u8ca0${lost}` : "\u306a\u3057";
}
function renderStats() {
const alive = world.tarinai;
const avg = (fn) => alive.length ? alive.reduce((a, t) => a + fn(t), 0) / alive.length : 0;
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);
@ -394,260 +218,78 @@ function escapeHtml(value) {
"'": "&#39;"
}[c]));
}
function buildArchiveRows(family) {
return liveFamilyComponents(family);
}
function liveFamilyComponents(family) {
const nodes = Object.values(family || {}).filter(Boolean).filter(n => n.hasPaired || (n.parents || []).length || (n.children || []).length);
const ids = new Set(nodes.map(n => n.id));
const graph = new Map();
const addEdge = (a, b) => {
if (!a || !b || !ids.has(a) || !ids.has(b)) return;
if (!graph.has(a)) graph.set(a, new Set());
if (!graph.has(b)) graph.set(b, new Set());
graph.get(a).add(b);
graph.get(b).add(a);
};
for (const n of nodes) {
if (!graph.has(n.id)) graph.set(n.id, new Set());
for (const p of n.parents || []) addEdge(n.id, p);
for (const c of n.children || []) addEdge(n.id, c);
}
const seen = new Set();
const comps = [];
for (const n of nodes) {
if (seen.has(n.id)) continue;
const stack = [n.id];
const comp = [];
seen.add(n.id);
while (stack.length) {
const id = stack.pop();
const item = family[id];
if (item) comp.push(item);
for (const next of graph.get(id) || []) {
if (seen.has(next)) continue;
seen.add(next);
stack.push(next);
}
}
if (comp.some(x => x.alive)) comps.push(comp);
}
comps.sort((a, b) => {
const ag = Math.min(...a.map(n => n.generation || 1));
const bg = Math.min(...b.map(n => n.generation || 1));
const at = Math.min(...a.map(n => n.birthTime || 0));
const bt = Math.min(...b.map(n => n.birthTime || 0));
return ag - bg || at - bt || b.length - a.length;
});
return comps;
}
function familyStatusText(n) {
if (!n) return "\u4e0d\u660e";
return n.alive ? "\u751f\u5b58" : "\u6b7b\u4ea1";
}
function 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 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>${gen ? `${escapeHtml(gen)} / ${status}` : `${status}`}</span>${reason}
</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 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 = "";
const components = liveFamilyComponents(family);
uiCache.archiveRows = components;
if (!components.length) {
ui.archiveContent.innerHTML = `<div class="selected-info empty">\u751f\u304d\u3066\u3044\u308b\u69cb\u6210\u54e1\u3092\u6301\u3064\u5bb6\u7cfb\u306f\u307e\u3060\u3042\u308a\u307e\u305b\u3093\u3002</div>`;
return;
}
const shown = components.reduce((a, c) => a + c.length, 0);
const note = `<div class="tree-note"><strong>${components.length}</strong> \u4ef6\u306e\u5bb6\u7cfb / <strong>${shown}</strong> \u5339\u3092\u8868\u793a\u3002\u5168\u6ec5\u3057\u305f\u5bb6\u7cfb\u306f\u975e\u8868\u793a\u3067\u3059\u3002</div>`;
ui.archiveContent.innerHTML = `${note}<div class="lineage-forest">${components.map((c, i) => lineageFamilyHtml(c, i, family)).join("")}</div>`;
}
function renderArchiveWindow() {
renderArchive();
}
function scheduleArchiveWindowRender() {
renderArchive();
}
function resizeCanvas() {
const rect = canvas.getBoundingClientRect();
const dpr = Math.max(1, Math.min(window.devicePixelRatio || 1, 2));
canvas.width = Math.floor(rect.width * dpr);
canvas.height = Math.floor(rect.height * dpr);
const perf = world?.performanceLevel ? world.performanceLevel() : 0;
const maxDpr = perf >= 2 ? 1 : (perf === 1 ? 1.5 : 2);
const dpr = Math.max(1, Math.min(window.devicePixelRatio || 1, maxDpr));
const nextW = Math.floor(rect.width * dpr);
const nextH = Math.floor(rect.height * dpr);
if (canvas.width === nextW && canvas.height === nextH && uiCache.canvasDpr === dpr) return;
uiCache.canvasDpr = dpr;
canvas.width = nextW;
canvas.height = nextH;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
world.w = rect.width;
world.h = rect.height;
world.setViewportSize(rect.width, rect.height);
uiCache.lastChartDraw = "";
}
function applyFieldLayout(fieldType = world.fieldType || "garden") {
const field = FIELD_TYPES?.[fieldType] || FIELD_TYPES.garden;
const root = document.documentElement;
root.style.setProperty("--field-height", field.height);
root.style.setProperty("--field-min-height", field.minHeight || field.height);
root.style.setProperty("--field-mobile-height", field.mobileHeight || field.height);
root.style.setProperty("--field-mobile-min-height", field.mobileMinHeight || field.mobileHeight || "460px");
world.fieldType = field.id;
const rect = canvas.getBoundingClientRect();
world.setViewportSize(rect.width, rect.height);
resizeCanvas();
}
function openFieldDialog() {
ui.fieldDialog?.classList.remove("hidden");
}
function closeFieldDialog() {
ui.fieldDialog?.classList.add("hidden");
}
function resetWithField(fieldType) {
const field = FIELD_TYPES?.[fieldType] || FIELD_TYPES.garden;
closeFieldDialog();
applyFieldLayout(field.id);
world.reset(null, field.id);
uiCache.archiveVersion = "";
renderLog(world.logs);
renderArchive();
renderStats();
render();
}
function screenToWorld(e) {
const rect = canvas.getBoundingClientRect();
const touch = e.touches ? e.touches[0] : e;
return { x: touch.clientX - rect.left, y: touch.clientY - rect.top };
const sx = touch.clientX - rect.left;
const sy = touch.clientY - rect.top;
return world.screenToWorld ? world.screenToWorld(sx, sy) : { x: sx, y: sy };
}
function resizeFieldFromWheel(e) {
if (!world.setFieldZoom) return;
e.preventDefault();
const rect = canvas.getBoundingClientRect();
const sx = e.clientX - rect.left;
const sy = e.clientY - rect.top;
const focus = world.screenToWorld ? world.screenToWorld(sx, sy) : { x: sx, y: sy };
const factor = e.deltaY > 0 ? 1.08 : 1 / 1.08;
if (!world.setFieldZoom((world.fieldZoom || 1) * factor)) return;
const off = world.fieldScreenOffset ? world.fieldScreenOffset() : { x: 0, y: 0 };
const scale = world.viewScale ? world.viewScale() : 1;
world.cameraX = focus.x - (sx - off.x) / scale;
world.cameraY = focus.y - (sy - off.y) / scale;
world.clampCamera?.();
render();
}
function selectTool(tool) {
@ -675,7 +317,19 @@ function loadGame() {
const raw = localStorage.getItem(CONFIG.saveKey);
if (!raw) { showToast("\u30bb\u30fc\u30d6\u30c7\u30fc\u30bf\u304c\u3042\u308a\u307e\u305b\u3093\u3002"); return; }
try {
const t0 = performance.now();
world.load(JSON.parse(raw));
applyFieldLayout(world.fieldType || "garden");
uiCache.archiveVersion = "";
uiCache.deferArchiveUntil = performance.now() + 80;
if (ui.archiveContent) ui.archiveContent.innerHTML = `<div class="selected-info empty">\u5bb6\u7cfb\u56f3\u3092\u8aad\u307f\u8fbc\u3093\u3067\u3044\u307e\u3059\u3002</div>`;
renderSelected();
renderStats();
requestAnimationFrame(() => {
uiCache.deferArchiveUntil = 0;
renderArchive();
});
if (typeof console !== "undefined") console.debug?.(`loaded save in ${Math.round(performance.now() - t0)}ms`);
showToast("\u8aad\u307f\u8fbc\u307f\u307e\u3057\u305f\u3002");
} catch (e) {
console.error(e);
@ -741,6 +395,7 @@ function bindUI() {
uiCache.chartMode = btn.dataset.chart || "population";
for (const b of ui.colonyChartTabs.querySelectorAll("button")) b.classList.toggle("active", b === btn);
uiCache.lastChartDraw = "";
uiCache.lastChartDrawAt = 0;
renderStats();
});
syncToolSizeBadges();
@ -775,7 +430,14 @@ function bindUI() {
ui.saveBtn.addEventListener("click", saveGame);
ui.loadBtn.addEventListener("click", loadGame);
ui.resetBtn.addEventListener("click", () => {
if (confirm("\u73fe\u5728\u306e\u89b3\u5bdf\u3092\u30ea\u30bb\u30c3\u30c8\u3057\u307e\u3059\u304b\uff1f \u30bb\u30fc\u30d6\u30c7\u30fc\u30bf\u306f\u6b8b\u308a\u307e\u3059\u3002")) world.reset();
openFieldDialog();
});
ui.fieldCancelBtn?.addEventListener("click", closeFieldDialog);
ui.fieldDialog?.addEventListener("click", (e) => {
if (e.target === ui.fieldDialog) { closeFieldDialog(); return; }
const btn = e.target.closest("[data-field-type]");
if (!btn) return;
resetWithField(btn.dataset.fieldType || "garden");
});
ui.toolPalette.addEventListener("click", (e) => {
@ -794,8 +456,59 @@ function bindUI() {
selectTool(tool);
});
const chainScroll = (el) => {
if (!el) return;
el.addEventListener("wheel", (e) => {
if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return;
const atTop = el.scrollTop <= 0;
const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 1;
if ((e.deltaY < 0 && atTop) || (e.deltaY > 0 && atBottom)) {
const page = document.scrollingElement || document.documentElement;
page.scrollTop += e.deltaY;
}
}, { passive: true });
};
chainScroll(document.querySelector(".panel"));
chainScroll(ui.archiveContent);
chainScroll(ui.log);
canvas.addEventListener("contextmenu", (e) => e.preventDefault());
canvas.addEventListener("mousedown", (e) => {
if (e.button !== 2) return;
e.preventDefault();
uiCache.panning = true;
uiCache.panMoved = false;
uiCache.panLastX = e.clientX;
uiCache.panLastY = e.clientY;
canvas.style.cursor = "grabbing";
});
window.addEventListener("mouseup", (e) => {
if (e.button !== 2 && !uiCache.panning) return;
if (uiCache.panning) {
uiCache.panning = false;
canvas.style.cursor = world.tool === "observe" ? "default" : (world.tool === "poke" || world.tool === "delete" ? "pointer" : "crosshair");
}
});
window.addEventListener("mousemove", (e) => {
if (!uiCache.panning) return;
const dx = e.clientX - uiCache.panLastX;
const dy = e.clientY - uiCache.panLastY;
uiCache.panLastX = e.clientX;
uiCache.panLastY = e.clientY;
if (Math.abs(dx) + Math.abs(dy) > 0.5) {
world.panCamera?.(-dx, -dy);
uiCache.panMoved = true;
render();
}
});
canvas.addEventListener("click", (e) => {
if (uiCache.panMoved) {
uiCache.panMoved = false;
return;
}
const p = screenToWorld(e);
if (!p.inside) return;
world.handleClick(p.x, p.y);
renderStats();
});
@ -804,13 +517,12 @@ function bindUI() {
const p = screenToWorld(e);
world.pointer.x = p.x;
world.pointer.y = p.y;
world.pointer.inside = true;
world.pointer.inside = !!p.inside;
});
canvas.addEventListener("wheel", resizeFieldFromWheel, { passive: false });
canvas.addEventListener("mouseleave", () => {
world.pointer.inside = false;
});
window.addEventListener("resize", resizeCanvas);
}
window.TarinaiUI = { canvas, ctx, ui, renderLog, renderSelected, stateLabel, renderStats, escapeHtml, renderArchive, resizeCanvas, screenToWorld, selectTool, showToast, saveGame, loadGame, syncTopButtons, bindUI };

193
js/ui_charts.js Normal file
View file

@ -0,0 +1,193 @@
"use strict";
function renderStats() {
const alive = world.tarinai;
const avg = (fn) => alive.length ? alive.reduce((a, t) => a + fn(t), 0) / alive.length : 0;
const values = {
pop: alive.length,
dead: world.deadCount,
hunger: avg(t => t.hunger),
lonely: avg(t => t.loneliness),
stress: avg(t => t.stress),
lack: avg(t => t.lack),
events: { ...(world.eventCounters || {}) },
objects: { ...(world.itemCounts || {}) },
};
setTextIfChanged(ui.statPop, "pop", values.pop);
setTextIfChanged(ui.statDead, "dead", values.dead);
updateColonyHistory(values);
drawColonyChart(values);
renderSelected();
renderArchive();
}
function updateColonyHistory(values) {
const t = Math.max(0, world.time || 0);
const history = uiCache.chartHistory;
const interval = Math.max(1, (CONFIG.dayLength || 120) / 12);
const maxPoints = 7 * 12;
if ((history.length && t < history[history.length - 1].t) || (Number.isFinite(uiCache.chartBucketStart) && t < uiCache.chartBucketStart)) {
history.length = 0;
uiCache.chartBucketStart = -Infinity;
uiCache.chartBucketEnd = -Infinity;
uiCache.chartBucketAccum = null;
uiCache.lastChartDraw = "";
}
const eventKeys = ["birth", "death", "fight", "accident", "food", "grass"];
const objectKeys = ["grass", "zunchi"];
const makeBucket = (start) => ({
start,
end: start + interval,
count: 0,
sums: { pop: 0, hunger: 0, lonely: 0, stress: 0, lack: 0 },
objects: Object.fromEntries(objectKeys.map(k => [k, 0])),
eventsStart: Object.fromEntries(eventKeys.map(k => [k, (values.events || {})[k] || 0])),
events: Object.fromEntries(eventKeys.map(k => [k, 0])),
});
if (!uiCache.chartBucketAccum) {
const start = Math.floor(t / interval) * interval;
uiCache.chartBucketStart = start;
uiCache.chartBucketEnd = start + interval;
uiCache.chartBucketAccum = makeBucket(start);
}
const pushBucket = (bucket) => {
if (!bucket || bucket.count <= 0) return;
const row = {
t: bucket.end,
pop: bucket.sums.pop / bucket.count,
hunger: bucket.sums.hunger / bucket.count,
lonely: bucket.sums.lonely / bucket.count,
stress: bucket.sums.stress / bucket.count,
lack: bucket.sums.lack / bucket.count,
...Object.fromEntries(objectKeys.map(k => [`obj_${k}`, bucket.objects[k] / bucket.count])),
...Object.fromEntries(eventKeys.map(k => [`ev_${k}`, bucket.events[k] || 0])),
};
history.push(row);
if (history.length > maxPoints) history.splice(0, history.length - maxPoints);
};
while (t >= uiCache.chartBucketEnd) {
const b = uiCache.chartBucketAccum;
for (const k of eventKeys) b.events[k] = Math.max(0, ((values.events || {})[k] || 0) - (b.eventsStart[k] || 0));
pushBucket(b);
uiCache.chartBucketStart = uiCache.chartBucketEnd;
uiCache.chartBucketEnd = uiCache.chartBucketStart + interval;
uiCache.chartBucketAccum = makeBucket(uiCache.chartBucketStart);
}
const bucket = uiCache.chartBucketAccum;
bucket.count += 1;
bucket.sums.pop += values.pop || 0;
bucket.sums.hunger += values.hunger || 0;
bucket.sums.lonely += values.lonely || 0;
bucket.sums.stress += values.stress || 0;
bucket.sums.lack += values.lack || 0;
for (const k of objectKeys) bucket.objects[k] += (values.objects || {})[k] || 0;
for (const k of eventKeys) bucket.events[k] = Math.max(0, ((values.events || {})[k] || 0) - (bucket.eventsStart[k] || 0));
}
function chartRows(values) {
const bucket = uiCache.chartBucketAccum;
const eventKeys = ["birth", "death", "fight", "accident", "food", "grass"];
const objectKeys = ["grass", "zunchi"];
const preview = bucket && bucket.count > 0 ? {
t: Math.max(world.time || 0, bucket.start || 0),
pop: bucket.sums.pop / bucket.count,
hunger: bucket.sums.hunger / bucket.count,
lonely: bucket.sums.lonely / bucket.count,
stress: bucket.sums.stress / bucket.count,
lack: bucket.sums.lack / bucket.count,
...Object.fromEntries(objectKeys.map(k => [`obj_${k}`, bucket.objects[k] / bucket.count])),
...Object.fromEntries(eventKeys.map(k => [`ev_${k}`, bucket.events[k] || 0])),
} : null;
return uiCache.chartHistory.length ? [...uiCache.chartHistory, ...(preview ? [preview] : [])] : [preview || { t: world.time || 0, ...values }];
}
function drawColonyChart(values) {
const chart = ui.colonyChart;
if (!chart) return;
const now = performance.now();
const perf = world.performanceLevel ? world.performanceLevel() : 0;
const minInterval = perf >= 3 ? 2600 : perf === 2 ? 1800 : perf === 1 ? 950 : 0;
if (minInterval && uiCache.lastChartDrawAt && now - uiCache.lastChartDrawAt < minInterval) return;
const rows = chartRows(values);
const mode = uiCache.chartMode || "population";
const last = rows[rows.length - 1] || values;
const series = chartSeries(mode);
const seriesSnapshot = series.map(s => `${s.key}:${Math.round(Number(last[s.key] || 0) * 10)}`).join(",");
const snapshot = `${mode}:${rows.length}:${seriesSnapshot}:${chart.clientWidth}x${chart.clientHeight}`;
if (uiCache.lastChartDraw === snapshot) return;
uiCache.lastChartDraw = snapshot;
uiCache.lastChartDrawAt = now;
const cssW = Math.max(280, Math.floor(chart.clientWidth || chart.offsetWidth || 320));
const cssH = Math.max(160, Math.floor(chart.clientHeight || chart.offsetHeight || 188));
if (mode === "events") return drawEventsChart(chart, rows, cssW, cssH);
const pad = { l: 30, r: 12, t: 12, b: 24 };
const w = cssW - pad.l - pad.r;
const h = cssH - pad.t - pad.b;
const maxVal = Math.max(10, ...series.flatMap(s => rows.map(r => Number(r[s.key] || 0))));
const xFor = (i) => rows.length <= 1 ? pad.l + w : pad.l + (i / (rows.length - 1)) * w;
const yFor = (row, s) => {
const raw = Number(row[s.key] || 0);
const scale = s.percent ? 100 : maxVal;
const v = clamp(raw / Math.max(scale, 1) * 100, 0, 100);
return pad.t + h - h * v / 100;
};
const grid = [0, 25, 50, 75, 100].map(yv => {
const y = pad.t + h - h * yv / 100;
return `<line x1="${pad.l}" y1="${y.toFixed(1)}" x2="${(pad.l + w).toFixed(1)}" y2="${y.toFixed(1)}" stroke="rgba(84,68,48,0.13)" stroke-width="1"/><text x="${pad.l - 6}" y="${(y + 3).toFixed(1)}" text-anchor="end" font-size="10" fill="rgba(117,106,94,0.78)">${yv}</text>`;
}).join("");
const lines = series.map(s => {
const pts = rows.map((row, i) => `${xFor(i).toFixed(1)},${yFor(row, s).toFixed(1)}`).join(" ");
const last = rows[rows.length - 1];
const cx = xFor(rows.length - 1).toFixed(1);
const cy = yFor(last, s).toFixed(1);
return `<polyline points="${pts}" fill="none" stroke="${s.color}" stroke-width="${s.key === "pop" ? 2.4 : 1.8}" stroke-linecap="round" stroke-linejoin="round"/><circle cx="${cx}" cy="${cy}" r="2.7" fill="${s.color}"/>`;
}).join("");
chart.innerHTML = `<svg viewBox="0 0 ${cssW} ${cssH}" preserveAspectRatio="none" aria-hidden="true"><rect x="0" y="0" width="${cssW}" height="${cssH}" rx="14" fill="rgba(255,255,255,0.18)"/>${grid}${lines}<text x="${pad.l}" y="${cssH - 8}" font-size="10" fill="rgba(117,106,94,0.76)">2\u6642\u9593\u3054\u3068 / 1\u9031\u9593</text></svg>`;
if (ui.colonyChartLegend) ui.colonyChartLegend.innerHTML = series.map(s => `<span><i style="background:${s.color}"></i>${s.label} ${formatChartValue(s.key, rows[rows.length - 1] || values)}</span>`).join("");
}
function drawEventsChart(chart, rows, cssW, cssH) {
const series = chartSeries("events");
const weekRows = rows.slice(-7 * 12);
const counts = series.map(s => ({ ...s, value: weekRows.reduce((sum, row) => sum + Math.max(0, Number(row[s.key] || 0)), 0) }));
const total = Math.max(1, counts.reduce((a, b) => a + b.value, 0));
const pad = { l: 68, r: 16, t: 12, b: 16 };
const barW = cssW - pad.l - pad.r;
const rowH = Math.max(18, (cssH - pad.t - pad.b) / counts.length);
const bars = counts.map((s, i) => {
const y = pad.t + i * rowH + 4;
const pct = s.value / total;
const bw = Math.max(2, barW * pct);
return `<text x="${pad.l - 8}" y="${(y + 11).toFixed(1)}" text-anchor="end" font-size="10" fill="rgba(84,68,48,0.76)">${s.label}</text><rect x="${pad.l}" y="${y.toFixed(1)}" width="${barW}" height="${Math.max(8, rowH - 8).toFixed(1)}" rx="6" fill="rgba(84,68,48,0.08)"/><rect x="${pad.l}" y="${y.toFixed(1)}" width="${bw.toFixed(1)}" height="${Math.max(8, rowH - 8).toFixed(1)}" rx="6" fill="${s.color}"/><text x="${(pad.l + Math.min(barW - 4, bw + 6)).toFixed(1)}" y="${(y + 11).toFixed(1)}" font-size="10" fill="rgba(42,36,29,0.82)">${s.value}\u4ef6 / ${Math.round(pct * 100)}%</text>`;
}).join("");
chart.innerHTML = `<svg viewBox="0 0 ${cssW} ${cssH}" preserveAspectRatio="none" aria-hidden="true"><rect x="0" y="0" width="${cssW}" height="${cssH}" rx="14" fill="rgba(255,255,255,0.18)"/>${bars}</svg>`;
if (ui.colonyChartLegend) ui.colonyChartLegend.innerHTML = `<span>1\u9031\u9593\u306e\u5272\u5408 / \u4ef6\u6570</span>`;
}
function chartSeries(mode = "population") {
if (mode === "life") return [
{ key: "hunger", label: "\u7a7a\u8179", color: "#e4a33f", percent: true },
{ key: "lonely", label: "\u5bc2\u3057\u3055", color: "#9b78d4", percent: true },
{ key: "stress", label: "\u30b9\u30c8\u30ec\u30b9", color: "#d96262", percent: true },
{ key: "lack", label: "\u305f\u308a\u306a\u3044", color: "#5b9d61", percent: true },
];
if (mode === "objects") return [
{ key: "obj_grass", label: "\u8349", color: "#5b9d61" },
{ key: "obj_zunchi", label: "\u305a\u3093\u3061", color: "#6f8a3f" },
];
if (mode === "events") return [
{ key: "ev_birth", label: "\u8a95\u751f", color: "#73b94b" },
{ key: "ev_death", label: "\u6b7b\u4ea1", color: "#5b534b" },
{ key: "ev_fight", label: "\u55a7\u5629", color: "#d65e46" },
{ key: "ev_accident", label: "\u4e8b\u6545", color: "#e28836" },
{ key: "ev_food", label: "\u98df\u4e8b", color: "#76b94d" },
{ key: "ev_grass", label: "\u8349", color: "#489443" },
];
return [{ key: "pop", label: "\u500b\u4f53", color: "#5d8ee6" }];
}
function formatChartValue(key, values) {
const v = values[key] ?? 0;
return String(Math.round(v));
}

3
js/ui_exports.js Normal file
View file

@ -0,0 +1,3 @@
"use strict";
window.TarinaiUI = { canvas, ctx, ui, renderLog, renderSelected, stateLabel, renderStats, escapeHtml, renderArchive, validateFamilyTree, resizeCanvas, applyFieldLayout, screenToWorld, selectTool, showToast, saveGame, loadGame, syncTopButtons, bindUI };

187
js/ui_family.js Normal file
View file

@ -0,0 +1,187 @@
"use strict";
function buildArchiveRows(family) {
return liveFamilyComponents(family);
}
function liveFamilyComponents(family) {
const nodes = Object.values(family || {}).filter(Boolean).filter(n => n.hasPaired || (n.parents || []).length || (n.children || []).length);
const ids = new Set(nodes.map(n => n.id));
const graph = new Map();
const addEdge = (a, b) => {
if (!a || !b || !ids.has(a) || !ids.has(b)) return;
if (!graph.has(a)) graph.set(a, new Set());
if (!graph.has(b)) graph.set(b, new Set());
graph.get(a).add(b);
graph.get(b).add(a);
};
for (const n of nodes) {
if (!graph.has(n.id)) graph.set(n.id, new Set());
for (const p of n.parents || []) addEdge(n.id, p);
for (const c of n.children || []) addEdge(n.id, c);
}
const seen = new Set();
const comps = [];
for (const n of nodes) {
if (seen.has(n.id)) continue;
const stack = [n.id];
const comp = [];
seen.add(n.id);
while (stack.length) {
const id = stack.pop();
const item = family[id];
if (item) comp.push(item);
for (const next of graph.get(id) || []) {
if (seen.has(next)) continue;
seen.add(next);
stack.push(next);
}
}
if (comp.some(x => x.alive)) comps.push(comp);
}
comps.sort((a, b) => {
const ag = Math.min(...a.map(n => n.generation || 1));
const bg = Math.min(...b.map(n => n.generation || 1));
const at = Math.min(...a.map(n => n.birthTime || 0));
const bt = Math.min(...b.map(n => n.birthTime || 0));
return ag - bg || at - bt || b.length - a.length;
});
return comps;
}
function familyStatusText(n) {
if (!n) return "\u4e0d\u660e";
return n.alive ? "\u751f\u5b58" : "\u6b7b\u4ea1";
}
function lineagePersonHtml(n) {
const live = n?.id ? world.tarinai.find(t => t.id === n.id && !t.dead) : null;
const img = n ? tarinaiSpritePathForId(n.id) : SPRITES[0].path;
const name = escapeHtml(n?.name || "\u4e0d\u660e");
const joined = Boolean(n?.hasPaired || (n?.parents || []).length || (n?.children || []).length);
const gen = joined && n?.generation ? `\u4e16\u4ee3 ${n.generation}` : "";
const status = escapeHtml(familyStatusText(n));
const reason = !n?.alive && n?.deathReason ? `<span class="death-reason">\u7406\u7531: ${escapeHtml(n.deathReason)}</span>` : "";
const cls = n ? ((live || n.alive) ? "alive" : "dead") : "missing";
const data = n?.id ? ` data-family-tarinai-id="${escapeHtml(n.id)}"` : "";
return `<div class="family-node lineage-person ${cls}"${data}>
<img src="${img}" alt="">
<div class="family-copy">
<strong title="${name}">${name}</strong>
<span>${gen ? `${escapeHtml(gen)} / ${status}` : `${status}`}</span>${reason}
</div>
</div>`;
}
function lineageMiniHtml(n, role = "") {
if (!n) return `<span class="lineage-mini missing">\u4e0d\u660e</span>`;
const img = tarinaiSpritePathForId(n.id);
const name = escapeHtml(n.name || n.id);
const gen = n.generation ? `G${n.generation}` : "";
const status = n.alive ? "\u751f" : "\u6b7b";
return `<span class="lineage-mini ${n.alive ? "alive" : "dead"} ${role}">
<img src="${img}" alt="">
<b title="${name}">${name}</b>
<small>${gen} / ${status}</small>
</span>`;
}
function lineageFamilyHtml(component, index, family) {
const nodeSort = (a, b) =>
(a.generation || 1) - (b.generation || 1) ||
(a.birthTime || 0) - (b.birthTime || 0) ||
String(a.name || "").localeCompare(String(b.name || "")) ||
String(a.id || "").localeCompare(String(b.id || ""));
const uniqueComponent = Array.from(new Map(component.filter(Boolean).map(n => [n.id, n])).values()).sort(nodeSort);
const byGeneration = new Map();
for (const n of uniqueComponent) {
const key = n.generation || 1;
if (!byGeneration.has(key)) byGeneration.set(key, []);
byGeneration.get(key).push(n);
}
const generationBlocks = Array.from(byGeneration.entries()).sort((a, b) => a[0] - b[0]).map(([generation, nodes]) => `
<div class="lineage-generation-block">
<b>\u4e16\u4ee3 ${generation}</b>
<div class="lineage-person-grid">${nodes.sort(nodeSort).map(lineagePersonHtml).join("")}</div>
</div>
`).join("");
const pairMap = new Map();
for (const child of uniqueComponent) {
const parents = (child.parents || []).filter(id => family[id]);
if (!parents.length) continue;
const key = parents.slice().sort().join("+");
if (!pairMap.has(key)) pairMap.set(key, { parents: parents.slice().sort(), children: [] });
pairMap.get(key).children.push(child.id);
}
const pairs = Array.from(pairMap.values()).sort((a, b) => {
const achild = family[a.children[0]];
const bchild = family[b.children[0]];
return nodeSort(achild, bchild);
});
const pairCards = pairs.length ? pairs.map(pair => {
const parents = pair.parents.map(id => family[id]).filter(Boolean).sort(nodeSort);
const children = pair.children.map(id => family[id]).filter(Boolean).sort(nodeSort);
return `<article class="lineage-pair-card">
<div class="lineage-pair-parents">${parents.map(n => lineageMiniHtml(n, "parent")).join('<span class="lineage-plus">+</span>')}</div>
<div class="lineage-birth-arrow">\u2193</div>
<div class="lineage-pair-children">${children.map(n => lineageMiniHtml(n, "child")).join("")}</div>
</article>`;
}).join("") : `<div class="lineage-empty-family">\u307e\u3060\u89aa\u5b50\u95a2\u4fc2\u306f\u8a18\u9332\u3055\u308c\u3066\u3044\u307e\u305b\u3093\u3002</div>`;
const aliveCount = uniqueComponent.filter(n => n.alive).length;
return `<section class="lineage-family lineage-family-readable">
<h3>\u5bb6\u7cfb ${index + 1} / ${uniqueComponent.length}\u5339 / \u751f\u5b58 ${aliveCount}</h3>
<div class="lineage-readable-grid">
<div class="lineage-member-column">${generationBlocks}</div>
<div class="lineage-reproduction-column">
<div class="lineage-column-title">\u7e41\u6b96\u30da\u30a2</div>
<div class="lineage-pair-list">${pairCards}</div>
</div>
</div>
</section>`;
}
function renderArchive() {
if (!ui.archiveContent) return;
if (uiCache.deferArchiveUntil && performance.now() < uiCache.deferArchiveUntil) return;
uiCache.deferArchiveUntil = 0;
if (world.normalizeFamily) world.normalizeFamily();
const family = world.family || {};
const ids = Object.keys(family);
const version = `${world.familyVersion || 0}:${ids.length}`;
if (uiCache.archiveVersion === version) return;
uiCache.archiveVersion = version;
uiCache.lastArchiveWindow = "";
const components = liveFamilyComponents(family);
uiCache.archiveRows = components;
if (!components.length) {
ui.archiveContent.innerHTML = `<div class="selected-info empty">\u751f\u304d\u3066\u3044\u308b\u69cb\u6210\u54e1\u3092\u6301\u3064\u5bb6\u7cfb\u306f\u307e\u3060\u3042\u308a\u307e\u305b\u3093\u3002</div>`;
return;
}
const shown = components.reduce((a, c) => a + c.length, 0);
const note = `<div class="tree-note"><strong>${components.length}</strong> \u4ef6\u306e\u5bb6\u7cfb / <strong>${shown}</strong> \u5339\u3092\u8868\u793a\u3002\u5168\u6ec5\u3057\u305f\u5bb6\u7cfb\u306f\u975e\u8868\u793a\u3067\u3059\u3002</div>`;
ui.archiveContent.innerHTML = `${note}<div class="lineage-forest">${components.map((c, i) => lineageFamilyHtml(c, i, family)).join("")}</div>`;
}
function validateFamilyTree() {
const issues = world.validateFamily ? [...world.validateFamily()] : [];
const rendered = new Map();
document.querySelectorAll("[data-family-tarinai-id]").forEach(node => {
const id = node.getAttribute("data-family-tarinai-id");
rendered.set(id, (rendered.get(id) || 0) + 1);
});
for (const [id, count] of rendered.entries()) {
if (count > 1) issues.push({ type: "duplicate-render-node", id, count });
}
return issues;
}
function renderArchiveWindow() {
renderArchive();
}
function scheduleArchiveWindowRender() {
renderArchive();
}
window.validateFamilyTree = validateFamilyTree;

1525
js/world.js Normal file

File diff suppressed because it is too large Load diff