?
This commit is contained in:
parent
0e8ece2dc0
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 |
194
css/styles.css
194
css/styles.css
|
|
@ -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
29
docs/code_map.md
Normal 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.
|
||||
|
|
@ -101,7 +101,7 @@
|
|||
繁殖で生まれた子は `childGrowTime` の間、親のどちらか近い方を追従する。成長すると通常AIへ移行する。
|
||||
|
||||
### 系譜
|
||||
系譜は `family` レジストリで保持し、親子関係を樹状図で表示する。二親制のため、同じ子が複数枝に表示される場合がある。
|
||||
系譜は `family` レジストリで保持し、親子関係を樹状図で表示する。表示時は同じ個体を重複させず、共有された親子関係は複数の接続線で表す。
|
||||
|
||||
### 食事エフェクト
|
||||
食事パーティクルは表示頻度を抑え、対象アイテムの色を反映する。
|
||||
|
|
|
|||
23
index.html
23
index.html
|
|
@ -56,7 +56,7 @@
|
|||
<button class="tool-category-toggle" type="button" aria-expanded="true">オブジェクト</button>
|
||||
<div class="tool-grid tool-category-body">
|
||||
<button class="tool" data-tool="stone"><span>R</span>石</button>
|
||||
<button class="tool" data-tool="bed"><span>B</span>干し草ベッド</button>
|
||||
<button class="tool" data-tool="bed"><span>B</span>干草寝床</button>
|
||||
<button class="tool" data-tool="firecracker"><span>F</span>爆竹</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">フィールドを選択</h2>
|
||||
<div class="field-choice-grid">
|
||||
<button class="field-choice" data-field-type="park" type="button"><strong>公園</strong><span>大</span></button>
|
||||
<button class="field-choice" data-field-type="garden" type="button"><strong>庭</strong><span>中</span></button>
|
||||
<button class="field-choice" data-field-type="cage" type="button"><strong>ケージ</strong><span>小</span></button>
|
||||
</div>
|
||||
<div class="field-dialog-actions">
|
||||
<button id="fieldCancelBtn" class="btn" type="button">キャンセル</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>
|
||||
|
|
|
|||
15
js/data.js
15
js/data.js
|
|
@ -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
491
js/items.js
Normal 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();
|
||||
}
|
||||
}
|
||||
|
||||
14
js/main.js
14
js/main.js
|
|
@ -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();
|
||||
|
|
|
|||
131
js/render.js
131
js/render.js
|
|
@ -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;
|
||||
|
|
|
|||
306
js/sim_core.js
Normal file
306
js/sim_core.js
Normal 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
1448
js/tarinai.js
Normal file
File diff suppressed because it is too large
Load diff
584
js/ui.js
584
js/ui.js
|
|
@ -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) {
|
|||
"'": "'"
|
||||
}[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
193
js/ui_charts.js
Normal 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
3
js/ui_exports.js
Normal 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
187
js/ui_family.js
Normal 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
1525
js/world.js
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue