1525 lines
61 KiB
JavaScript
1525 lines
61 KiB
JavaScript
|
|
"use strict";
|
||
|
|
|
||
|
|
class World {
|
||
|
|
constructor() {
|
||
|
|
this.w = 1000;
|
||
|
|
this.h = 720;
|
||
|
|
this.viewportW = 1000;
|
||
|
|
this.viewportH = 720;
|
||
|
|
this.time = 0;
|
||
|
|
this.day = 1;
|
||
|
|
this.paused = false;
|
||
|
|
this.speed = 1;
|
||
|
|
this.tool = "observe";
|
||
|
|
this.toolSize = "medium";
|
||
|
|
this.toolSizes = {};
|
||
|
|
this.tarinai = [];
|
||
|
|
this.items = [];
|
||
|
|
this.effects = [];
|
||
|
|
this.logs = [];
|
||
|
|
this.eventCounters = {};
|
||
|
|
this.countsTimer = 0;
|
||
|
|
this.compactTimer = 0;
|
||
|
|
this.drawSortTimer = 0;
|
||
|
|
this.drawListDirty = true;
|
||
|
|
this.selected = null;
|
||
|
|
this.deadCount = 0;
|
||
|
|
this.maxGeneration = 1;
|
||
|
|
this.family = {};
|
||
|
|
this.familyVersion = 0;
|
||
|
|
this.relationNotices = {};
|
||
|
|
this.resolvedFightIds = {};
|
||
|
|
this.pointer = { x: 0, y: 0, inside: false };
|
||
|
|
this.lastPhase = "";
|
||
|
|
this.weather = "sunny";
|
||
|
|
this.fieldType = "garden";
|
||
|
|
this.fieldZoom = 1;
|
||
|
|
this.cameraX = 0;
|
||
|
|
this.cameraY = 0;
|
||
|
|
this.nextWeatherChange = rand(CONFIG.weatherChangeMin, CONFIG.weatherChangeMax);
|
||
|
|
this.spatial = new SpatialGrid(96);
|
||
|
|
this.spatialScratch = [];
|
||
|
|
this.drawList = [];
|
||
|
|
this.itemCounts = {};
|
||
|
|
this.effectCounts = {};
|
||
|
|
this.countsTimer = 0;
|
||
|
|
this.compactTimer = 0;
|
||
|
|
this.drawSortTimer = 0;
|
||
|
|
this.drawListDirty = true;
|
||
|
|
this.updateItemCounts();
|
||
|
|
this.updateEffectCounts();
|
||
|
|
this.rebuildSpatial();
|
||
|
|
}
|
||
|
|
|
||
|
|
dayProgress() {
|
||
|
|
return (this.time % CONFIG.dayLength) / CONFIG.dayLength;
|
||
|
|
}
|
||
|
|
|
||
|
|
lightLevel() {
|
||
|
|
return (Math.sin(this.dayProgress() * Math.PI * 2 - Math.PI / 2) + 1) / 2;
|
||
|
|
}
|
||
|
|
|
||
|
|
phaseName() {
|
||
|
|
const p = this.dayProgress();
|
||
|
|
if (p < 0.20) return "\u671d";
|
||
|
|
if (p < 0.48) return "\u663c";
|
||
|
|
if (p < 0.68) return "\u5915\u65b9";
|
||
|
|
return "\u591c";
|
||
|
|
}
|
||
|
|
|
||
|
|
clockString() {
|
||
|
|
const totalMinutes = Math.floor(this.dayProgress() * 24 * 60);
|
||
|
|
const hh = String(Math.floor(totalMinutes / 60)).padStart(2, "0");
|
||
|
|
const mm = String(totalMinutes % 60).padStart(2, "0");
|
||
|
|
return `${hh}:${mm}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
fieldDefinition() {
|
||
|
|
return FIELD_TYPES?.[this.fieldType] || FIELD_TYPES.garden;
|
||
|
|
}
|
||
|
|
|
||
|
|
fieldWorldScale() {
|
||
|
|
const field = this.fieldDefinition();
|
||
|
|
// Field type defines the logical simulation area. Wheel zoom must not
|
||
|
|
// rewrite this size; it is a camera/display transform only.
|
||
|
|
return Math.max(0.22, field.worldScale || 1);
|
||
|
|
}
|
||
|
|
|
||
|
|
viewScale() {
|
||
|
|
return clamp(Number(this.fieldZoom) || 1, 0.42, 2.45);
|
||
|
|
}
|
||
|
|
|
||
|
|
visibleWorldW() {
|
||
|
|
return (this.viewportW || this.w) / this.viewScale();
|
||
|
|
}
|
||
|
|
|
||
|
|
visibleWorldH() {
|
||
|
|
return (this.viewportH || this.h) / this.viewScale();
|
||
|
|
}
|
||
|
|
|
||
|
|
screenSizeToWorld(size) {
|
||
|
|
// Kept as a compatibility shim for older hit-test call sites. The values
|
||
|
|
// currently passed here are already logical world units, not CSS pixels.
|
||
|
|
return size;
|
||
|
|
}
|
||
|
|
|
||
|
|
fieldScreenOffset() {
|
||
|
|
const scale = this.viewScale();
|
||
|
|
return {
|
||
|
|
x: Math.max(0, ((this.viewportW || this.w) - this.w * scale) / 2),
|
||
|
|
y: Math.max(0, ((this.viewportH || this.h) - this.h * scale) / 2),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
maxCameraX() {
|
||
|
|
return Math.max(0, this.w - this.visibleWorldW());
|
||
|
|
}
|
||
|
|
|
||
|
|
maxCameraY() {
|
||
|
|
return Math.max(0, this.h - this.visibleWorldH());
|
||
|
|
}
|
||
|
|
|
||
|
|
clampCamera() {
|
||
|
|
this.cameraX = clamp(this.cameraX || 0, 0, this.maxCameraX());
|
||
|
|
this.cameraY = clamp(this.cameraY || 0, 0, this.maxCameraY());
|
||
|
|
}
|
||
|
|
|
||
|
|
panCamera(dx, dy) {
|
||
|
|
const scale = this.viewScale();
|
||
|
|
this.cameraX = (this.cameraX || 0) + dx / scale;
|
||
|
|
this.cameraY = (this.cameraY || 0) + dy / scale;
|
||
|
|
this.clampCamera();
|
||
|
|
}
|
||
|
|
|
||
|
|
screenToWorld(x, y) {
|
||
|
|
const scale = this.viewScale();
|
||
|
|
const off = this.fieldScreenOffset();
|
||
|
|
const rawX = (x - off.x) / scale + (this.cameraX || 0);
|
||
|
|
const rawY = (y - off.y) / scale + (this.cameraY || 0);
|
||
|
|
return {
|
||
|
|
x: clamp(rawX, 0, this.w),
|
||
|
|
y: clamp(rawY, 0, this.h),
|
||
|
|
inside: rawX >= 0 && rawY >= 0 && rawX <= this.w && rawY <= this.h,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
worldToScreen(x, y) {
|
||
|
|
const scale = this.viewScale();
|
||
|
|
const off = this.fieldScreenOffset();
|
||
|
|
return {
|
||
|
|
x: (x - (this.cameraX || 0)) * scale + off.x,
|
||
|
|
y: (y - (this.cameraY || 0)) * scale + off.y,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
setViewportSize(width, height, opts = {}) {
|
||
|
|
const oldW = this.w || width;
|
||
|
|
const oldH = this.h || height;
|
||
|
|
this.viewportW = Math.max(1, width || this.viewportW || 1000);
|
||
|
|
this.viewportH = Math.max(1, height || this.viewportH || 720);
|
||
|
|
const requestedScale = this.fieldWorldScale();
|
||
|
|
const scale = Math.max(requestedScale, 220 / this.viewportW, 180 / this.viewportH);
|
||
|
|
this.w = this.viewportW * scale;
|
||
|
|
this.h = this.viewportH * scale;
|
||
|
|
this.clampCamera();
|
||
|
|
if (opts.scaleContents && oldW > 0 && oldH > 0) {
|
||
|
|
const sx = this.w / oldW;
|
||
|
|
const sy = this.h / oldH;
|
||
|
|
const scalePoint = (p) => {
|
||
|
|
if (!p) return;
|
||
|
|
if (Number.isFinite(p.x)) p.x *= sx;
|
||
|
|
if (Number.isFinite(p.y)) p.y *= sy;
|
||
|
|
};
|
||
|
|
for (const t of this.tarinai || []) scalePoint(t);
|
||
|
|
for (const it of this.items || []) scalePoint(it);
|
||
|
|
for (const ef of this.effects || []) scalePoint(ef);
|
||
|
|
scalePoint(this.pointer);
|
||
|
|
this.cameraX *= sx;
|
||
|
|
this.cameraY *= sy;
|
||
|
|
this.clampCamera();
|
||
|
|
this.rebuildSpatial();
|
||
|
|
this.drawListDirty = true;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
setFieldZoom(nextZoom) {
|
||
|
|
const next = clamp(Number(nextZoom) || 1, 0.42, 2.45);
|
||
|
|
if (Math.abs(next - (this.fieldZoom || 1)) < 0.001) return false;
|
||
|
|
this.fieldZoom = next;
|
||
|
|
this.clampCamera();
|
||
|
|
this.drawListDirty = true;
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
edgeSpawnPoint(targetX = null, targetY = null, margin = 34) {
|
||
|
|
const w = Math.max(this.w || 1000, margin * 2 + 1);
|
||
|
|
const h = Math.max(this.h || 720, margin * 2 + 1);
|
||
|
|
let side;
|
||
|
|
if (Number.isFinite(targetX) && Number.isFinite(targetY)) {
|
||
|
|
const distances = [targetY, w - targetX, h - targetY, targetX];
|
||
|
|
side = distances.indexOf(Math.min(...distances));
|
||
|
|
} else {
|
||
|
|
side = Math.floor(Math.random() * 4);
|
||
|
|
}
|
||
|
|
if (side === 0) return { x: clamp(targetX ?? rand(margin, w - margin), margin, w - margin), y: margin, vx: rand(-10, 10), vy: rand(18, 34), angle: Math.PI / 2 };
|
||
|
|
if (side === 1) return { x: w - margin, y: clamp(targetY ?? rand(margin, h - margin), margin, h - margin), vx: rand(-34, -18), vy: rand(-10, 10), angle: Math.PI };
|
||
|
|
if (side === 2) return { x: clamp(targetX ?? rand(margin, w - margin), margin, w - margin), y: h - margin, vx: rand(-10, 10), vy: rand(-34, -18), angle: -Math.PI / 2 };
|
||
|
|
return { x: margin, y: clamp(targetY ?? rand(margin, h - margin), margin, h - margin), vx: rand(18, 34), vy: rand(-10, 10), angle: 0 };
|
||
|
|
}
|
||
|
|
|
||
|
|
toolSizeFor(type = "") {
|
||
|
|
if (!["firecracker", "fence_v", "fence_h", "stone", "grass", "sweet", "zunchi"].includes(type)) return "medium";
|
||
|
|
return (this.toolSizes && this.toolSizes[type]) || this.toolSize || "medium";
|
||
|
|
}
|
||
|
|
|
||
|
|
toolSizeScale(type = "") {
|
||
|
|
if (!["firecracker", "fence_v", "fence_h", "stone", "grass", "sweet", "zunchi"].includes(type)) return 1;
|
||
|
|
return { small: 0.68, medium: 1.0, large: 1.48 }[this.toolSizeFor(type)] || 1;
|
||
|
|
}
|
||
|
|
|
||
|
|
applyToolSize(item) {
|
||
|
|
if (!item) return item;
|
||
|
|
const scale = this.toolSizeScale(item.type);
|
||
|
|
const size = this.toolSizeFor(item.type);
|
||
|
|
item.toolSize = size;
|
||
|
|
this.toolSize = size;
|
||
|
|
item.r *= scale;
|
||
|
|
if (["sweet", "grass", "zunchi"].includes(item.type)) item.amount *= scale * scale;
|
||
|
|
if (item.type === "firecracker") item.blastScale = scale;
|
||
|
|
return item;
|
||
|
|
}
|
||
|
|
|
||
|
|
reset(seedPopulation = null, fieldType = this.fieldType || "garden") {
|
||
|
|
const field = FIELD_TYPES?.[fieldType] || FIELD_TYPES.garden;
|
||
|
|
this.fieldType = field.id;
|
||
|
|
this.fieldZoom = 1;
|
||
|
|
this.setViewportSize(this.viewportW, this.viewportH);
|
||
|
|
this.time = 0;
|
||
|
|
this.day = 1;
|
||
|
|
this.paused = false;
|
||
|
|
this.speed = 1;
|
||
|
|
this.toolSize = this.toolSize || "medium";
|
||
|
|
this.toolSizes = this.toolSizes || {};
|
||
|
|
this.tarinai = [];
|
||
|
|
this.items = [];
|
||
|
|
this.effects = [];
|
||
|
|
this.logs = [];
|
||
|
|
this.eventCounters = {};
|
||
|
|
this.countsTimer = 0;
|
||
|
|
this.compactTimer = 0;
|
||
|
|
this.drawSortTimer = 0;
|
||
|
|
this.drawListDirty = true;
|
||
|
|
this.selected = null;
|
||
|
|
this.deadCount = 0;
|
||
|
|
this.maxGeneration = 1;
|
||
|
|
this.family = {};
|
||
|
|
this.familyVersion = 0;
|
||
|
|
this.relationNotices = {};
|
||
|
|
this.resolvedFightIds = {};
|
||
|
|
this.pointer = { x: this.w / 2, y: this.h / 2, inside: false };
|
||
|
|
this.cameraX = Math.max(0, (this.w - (this.viewportW || this.w)) / 2);
|
||
|
|
this.cameraY = Math.max(0, (this.h - (this.viewportH || this.h)) / 2);
|
||
|
|
this.clampCamera();
|
||
|
|
this.lastPhase = this.phaseName();
|
||
|
|
this.weather = "sunny";
|
||
|
|
this.nextWeatherChange = rand(CONFIG.weatherChangeMin, CONFIG.weatherChangeMax);
|
||
|
|
seedPopulation = seedPopulation ?? field.population ?? CONFIG.initialPopulation;
|
||
|
|
const grassCount = field.grass ?? 18;
|
||
|
|
const types = spawnableSprites().map(s => s.id);
|
||
|
|
for (let i = 0; i < seedPopulation; i++) {
|
||
|
|
this.addTarinai({
|
||
|
|
type: types[i % types.length],
|
||
|
|
x: rand(80, this.w - 80),
|
||
|
|
y: rand(80, this.h - 80),
|
||
|
|
generation: 1,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
for (let i = 0; i < grassCount; i++) this.items.push(new Item("grass", rand(70, this.w - 70), rand(70, this.h - 70)));
|
||
|
|
this.items.push(new Item("stone", this.w * 0.68, this.h * 0.32));
|
||
|
|
this.items.push(new Item("bed", this.w * 0.82, this.h * 0.22));
|
||
|
|
this.items.push(new Item("bed", this.w * 0.18, this.h * 0.78));
|
||
|
|
this.updateItemCounts();
|
||
|
|
this.updateEffectCounts();
|
||
|
|
this.rebuildSpatial();
|
||
|
|
this.log("\u305f\u308a\u306a\u3044\u3092\u89b3\u5bdf\u7bb1\u306b\u914d\u7f6e\u3057\u307e\u3057\u305f\u3002");
|
||
|
|
showToast("\u65b0\u3057\u3044\u89b3\u5bdf\u3092\u958b\u59cb\u3057\u307e\u3057\u305f\u3002");
|
||
|
|
syncTopButtons();
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
recordFamily(t) {
|
||
|
|
if (!this.family) this.family = {};
|
||
|
|
const uniqueIds = (list) => Array.from(new Set((list || []).filter(Boolean)));
|
||
|
|
const sameList = (a, b) => {
|
||
|
|
const aa = uniqueIds(a);
|
||
|
|
const bb = uniqueIds(b);
|
||
|
|
return aa.length === bb.length && aa.every((id, i) => id === bb[i]);
|
||
|
|
};
|
||
|
|
let changed = false;
|
||
|
|
const nextParents = uniqueIds(t.parents || []);
|
||
|
|
const nextChildren = uniqueIds(t.children || []);
|
||
|
|
if (!this.family[t.id]) {
|
||
|
|
this.family[t.id] = {
|
||
|
|
id: t.id,
|
||
|
|
name: t.name,
|
||
|
|
type: t.type,
|
||
|
|
generation: t.generation,
|
||
|
|
hasPaired: !!t.hasPaired,
|
||
|
|
parents: nextParents,
|
||
|
|
parentNames: [...(t.parentNames || [])],
|
||
|
|
children: nextChildren,
|
||
|
|
birthTime: t.birthTime,
|
||
|
|
alive: !t.dead,
|
||
|
|
deathReason: t.deathReason || "",
|
||
|
|
};
|
||
|
|
changed = true;
|
||
|
|
} else {
|
||
|
|
const entry = this.family[t.id];
|
||
|
|
const nextAlive = !t.dead;
|
||
|
|
if (entry.name !== t.name) changed = true;
|
||
|
|
if (entry.type !== t.type) changed = true;
|
||
|
|
if (entry.hasPaired !== !!t.hasPaired) changed = true;
|
||
|
|
if (entry.generation !== t.generation) changed = true;
|
||
|
|
if ((entry.deathReason || "") !== (t.deathReason || entry.deathReason || "")) changed = true;
|
||
|
|
if (entry.alive !== nextAlive) changed = true;
|
||
|
|
if (!sameList(entry.parents, nextParents)) changed = true;
|
||
|
|
if (!sameList(entry.children, nextChildren)) changed = true;
|
||
|
|
entry.name = t.name;
|
||
|
|
entry.type = t.type;
|
||
|
|
entry.alive = nextAlive;
|
||
|
|
entry.generation = t.generation;
|
||
|
|
entry.hasPaired = !!t.hasPaired;
|
||
|
|
entry.deathReason = t.deathReason || entry.deathReason || "";
|
||
|
|
entry.parents = nextParents;
|
||
|
|
entry.parentNames = [...(t.parentNames || entry.parentNames || [])];
|
||
|
|
entry.children = nextChildren;
|
||
|
|
}
|
||
|
|
if (changed) this.familyVersion = (this.familyVersion || 0) + 1;
|
||
|
|
}
|
||
|
|
|
||
|
|
normalizeFamily() {
|
||
|
|
if (!this.family) this.family = {};
|
||
|
|
if (this.familyCleanVersion === (this.familyVersion || 0)) return false;
|
||
|
|
const family = this.family;
|
||
|
|
const liveById = new Map((this.tarinai || []).map(t => [t.id, t]));
|
||
|
|
let changed = false;
|
||
|
|
const uniqueIds = (list, allow) => Array.from(new Set((list || []).filter(id => id && (!allow || allow.has(id)))));
|
||
|
|
const sameList = (a, b) => a.length === b.length && a.every((id, i) => id === b[i]);
|
||
|
|
for (const t of this.tarinai || []) {
|
||
|
|
const before = this.familyVersion || 0;
|
||
|
|
this.recordFamily(t);
|
||
|
|
changed = changed || before !== (this.familyVersion || 0);
|
||
|
|
}
|
||
|
|
const known = new Set(Object.keys(family));
|
||
|
|
for (const id of Object.keys(family)) {
|
||
|
|
const n = family[id] || {};
|
||
|
|
const live = liveById.get(id);
|
||
|
|
const parents = uniqueIds(n.parents, known);
|
||
|
|
const children = uniqueIds(n.children, known);
|
||
|
|
if (live) {
|
||
|
|
const liveParents = uniqueIds(live.parents, known);
|
||
|
|
const liveChildren = uniqueIds(live.children, known);
|
||
|
|
for (const p of liveParents) if (!parents.includes(p)) parents.push(p);
|
||
|
|
for (const c of liveChildren) if (!children.includes(c)) children.push(c);
|
||
|
|
}
|
||
|
|
const next = {
|
||
|
|
id,
|
||
|
|
name: live?.name || n.name || id,
|
||
|
|
type: live?.type || n.type || "smile",
|
||
|
|
generation: Math.max(1, live?.generation || n.generation || 1),
|
||
|
|
hasPaired: !!(live?.hasPaired || n.hasPaired),
|
||
|
|
parents,
|
||
|
|
parentNames: live?.parentNames ? [...live.parentNames] : [...(n.parentNames || [])],
|
||
|
|
children,
|
||
|
|
birthTime: live?.birthTime ?? n.birthTime ?? 0,
|
||
|
|
alive: live ? !live.dead : n.alive !== false,
|
||
|
|
deathReason: live?.deathReason || n.deathReason || "",
|
||
|
|
};
|
||
|
|
for (const key of Object.keys(next)) {
|
||
|
|
const a = family[id]?.[key];
|
||
|
|
const b = next[key];
|
||
|
|
const equal = Array.isArray(a) && Array.isArray(b) ? sameList(a, b) : a === b;
|
||
|
|
if (!equal) changed = true;
|
||
|
|
}
|
||
|
|
family[id] = next;
|
||
|
|
}
|
||
|
|
for (const child of Object.values(family)) {
|
||
|
|
for (const parentId of child.parents || []) {
|
||
|
|
const parent = family[parentId];
|
||
|
|
if (!parent) continue;
|
||
|
|
if (!parent.children.includes(child.id)) {
|
||
|
|
parent.children.push(child.id);
|
||
|
|
changed = true;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
for (const parent of Object.values(family)) {
|
||
|
|
const nextChildren = uniqueIds(parent.children, known).filter(childId => family[childId]?.parents?.includes(parent.id));
|
||
|
|
if (!sameList(parent.children || [], nextChildren)) {
|
||
|
|
parent.children = nextChildren;
|
||
|
|
changed = true;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
for (const [id, live] of liveById.entries()) {
|
||
|
|
const n = family[id];
|
||
|
|
if (!n) continue;
|
||
|
|
if (!sameList(live.parents || [], n.parents || [])) {
|
||
|
|
live.parents = [...(n.parents || [])];
|
||
|
|
changed = true;
|
||
|
|
}
|
||
|
|
if (!sameList(live.children || [], n.children || [])) {
|
||
|
|
live.children = [...(n.children || [])];
|
||
|
|
changed = true;
|
||
|
|
}
|
||
|
|
if (live.hasPaired !== !!n.hasPaired) {
|
||
|
|
live.hasPaired = !!n.hasPaired;
|
||
|
|
changed = true;
|
||
|
|
}
|
||
|
|
if (live.generation !== n.generation) {
|
||
|
|
live.generation = n.generation;
|
||
|
|
changed = true;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if (changed) this.familyVersion = (this.familyVersion || 0) + 1;
|
||
|
|
this.familyCleanVersion = this.familyVersion || 0;
|
||
|
|
return changed;
|
||
|
|
}
|
||
|
|
|
||
|
|
validateFamily() {
|
||
|
|
this.normalizeFamily();
|
||
|
|
const family = this.family || {};
|
||
|
|
const issues = [];
|
||
|
|
const seenRenderIds = new Set();
|
||
|
|
for (const n of Object.values(family)) {
|
||
|
|
if (!n?.id) continue;
|
||
|
|
const childSeen = new Set();
|
||
|
|
for (const p of n.parents || []) {
|
||
|
|
if (!family[p]) issues.push({ type: "missing-parent", id: n.id, parent: p });
|
||
|
|
else if (!family[p].children?.includes(n.id)) issues.push({ type: "missing-reciprocal-child", id: n.id, parent: p });
|
||
|
|
}
|
||
|
|
for (const c of n.children || []) {
|
||
|
|
if (childSeen.has(c)) issues.push({ type: "duplicate-child", id: n.id, child: c });
|
||
|
|
childSeen.add(c);
|
||
|
|
if (!family[c]) issues.push({ type: "missing-child", id: n.id, child: c });
|
||
|
|
else if (!family[c].parents?.includes(n.id)) issues.push({ type: "missing-reciprocal-parent", id: n.id, child: c });
|
||
|
|
}
|
||
|
|
if (seenRenderIds.has(n.id)) issues.push({ type: "duplicate-render-node", id: n.id });
|
||
|
|
seenRenderIds.add(n.id);
|
||
|
|
}
|
||
|
|
return issues;
|
||
|
|
}
|
||
|
|
|
||
|
|
markDead(t, reason) {
|
||
|
|
if (!this.family) this.family = {};
|
||
|
|
if (this.family[t.id]) {
|
||
|
|
if (this.family[t.id].alive !== false || this.family[t.id].deathReason !== reason) {
|
||
|
|
this.family[t.id].alive = false;
|
||
|
|
this.family[t.id].deathReason = reason;
|
||
|
|
this.familyVersion = (this.familyVersion || 0) + 1;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
this.normalizeFamily();
|
||
|
|
this.pruneExtinctFamilies();
|
||
|
|
}
|
||
|
|
|
||
|
|
pruneExtinctFamilies(opts = {}) {
|
||
|
|
if (opts.normalize !== false) this.normalizeFamily();
|
||
|
|
const family = this.family || {};
|
||
|
|
const nodes = Object.values(family).filter(Boolean).filter(n => n.hasPaired || (n.parents || []).length || (n.children || []).length);
|
||
|
|
if (!nodes.length) return;
|
||
|
|
const ids = new Set(nodes.map(n => n.id));
|
||
|
|
const graph = new Map();
|
||
|
|
const add = (id) => { if (!graph.has(id)) graph.set(id, new Set()); };
|
||
|
|
const link = (a, b) => {
|
||
|
|
if (!a || !b || !ids.has(a) || !ids.has(b)) return;
|
||
|
|
add(a); add(b);
|
||
|
|
graph.get(a).add(b);
|
||
|
|
graph.get(b).add(a);
|
||
|
|
};
|
||
|
|
for (const n of nodes) {
|
||
|
|
add(n.id);
|
||
|
|
for (const p of n.parents || []) link(n.id, p);
|
||
|
|
for (const c of n.children || []) link(n.id, c);
|
||
|
|
}
|
||
|
|
const seen = new Set();
|
||
|
|
const remove = [];
|
||
|
|
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();
|
||
|
|
comp.push(id);
|
||
|
|
for (const next of graph.get(id) || []) {
|
||
|
|
if (seen.has(next)) continue;
|
||
|
|
seen.add(next);
|
||
|
|
stack.push(next);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if (!comp.some(id => family[id]?.alive)) remove.push(...comp);
|
||
|
|
}
|
||
|
|
if (!remove.length) return;
|
||
|
|
for (const id of remove) delete family[id];
|
||
|
|
this.familyVersion = (this.familyVersion || 0) + 1;
|
||
|
|
this.familyCleanVersion = this.familyVersion || 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
addTarinai(opts = {}) {
|
||
|
|
const t = new Tarinai(this, opts);
|
||
|
|
this.tarinai.push(t);
|
||
|
|
this.drawListDirty = true;
|
||
|
|
this.recordFamily(t);
|
||
|
|
this.maxGeneration = Math.max(this.maxGeneration, t.generation);
|
||
|
|
return t;
|
||
|
|
}
|
||
|
|
|
||
|
|
startBirthRitual(a, b) {
|
||
|
|
if (!a || !b || a.dead || b.dead) return false;
|
||
|
|
if (a.birthRitualTimer > 0.04 || b.birthRitualTimer > 0.04) return false;
|
||
|
|
const aJoined = a.familyJoined ? a.familyJoined() : Boolean((a.parents || []).length || (a.children || []).length);
|
||
|
|
const bJoined = b.familyJoined ? b.familyJoined() : Boolean((b.parents || []).length || (b.children || []).length);
|
||
|
|
if (aJoined && !bJoined) b.generation = a.generation;
|
||
|
|
else if (bJoined && !aJoined) a.generation = b.generation;
|
||
|
|
else if (!aJoined && !bJoined) { a.generation = 1; b.generation = 1; }
|
||
|
|
a.hasPaired = true;
|
||
|
|
b.hasPaired = true;
|
||
|
|
this.recordFamily(a);
|
||
|
|
this.recordFamily(b);
|
||
|
|
const duration = 10.0;
|
||
|
|
a.birthRitualTimer = b.birthRitualTimer = duration;
|
||
|
|
a.birthRitualMax = b.birthRitualMax = duration;
|
||
|
|
a.birthPartnerId = b.id;
|
||
|
|
b.birthPartnerId = a.id;
|
||
|
|
a.birthRitualRole = -1;
|
||
|
|
b.birthRitualRole = 1;
|
||
|
|
a.birthRitualLeader = true;
|
||
|
|
b.birthRitualLeader = false;
|
||
|
|
a.state = b.state = "birth_ritual";
|
||
|
|
a.target = b;
|
||
|
|
b.target = a;
|
||
|
|
a.reproductionTimer = CONFIG.reproductionCooldown + rand(2, 10);
|
||
|
|
b.reproductionTimer = CONFIG.reproductionCooldown + rand(2, 10);
|
||
|
|
a.surpriseTimer = Math.max(a.surpriseTimer, 0.35);
|
||
|
|
b.surpriseTimer = Math.max(b.surpriseTimer, 0.35);
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
finishBirthRitual(a, b) {
|
||
|
|
if (!a || !b || a.dead || b.dead) return null;
|
||
|
|
a.birthRitualTimer = b.birthRitualTimer = 0;
|
||
|
|
a.birthPartnerId = b.birthPartnerId = null;
|
||
|
|
a.birthRitualLeader = b.birthRitualLeader = false;
|
||
|
|
a.state = b.state = "idle";
|
||
|
|
a.fightTimer = b.fightTimer = 0;
|
||
|
|
a.fightTargetIds = []; b.fightTargetIds = [];
|
||
|
|
a.fightTargetId = b.fightTargetId = null;
|
||
|
|
a.defeatedById = b.defeatedById = null;
|
||
|
|
a.postBirthPeaceTimer = b.postBirthPeaceTimer = 18;
|
||
|
|
a.fightCooldown = b.fightCooldown = Math.max(CONFIG.fightCooldown + 8, a.fightCooldown || 0, b.fightCooldown || 0);
|
||
|
|
const children = [];
|
||
|
|
const countRoll = Math.random();
|
||
|
|
const childCount = countRoll < 0.08 ? 3 : (countRoll < 0.36 ? 2 : 1);
|
||
|
|
for (let i = 0; i < childCount; i++) {
|
||
|
|
const child = this.spawnChild(a, b, i, childCount);
|
||
|
|
if (child) { child.postBirthPeaceTimer = 18; children.push(child); }
|
||
|
|
}
|
||
|
|
if (children.length > 1) this.log(`${a.name}\u3068${b.name}\u306e\u8fd1\u304f\u306b${children.length}\u5339\u306e\u5b50\u304c\u73fe\u308c\u305f\u3002`, "birth");
|
||
|
|
return children[0] || null;
|
||
|
|
}
|
||
|
|
|
||
|
|
spawnChild(a, b, index = 0, total = 1) {
|
||
|
|
const inherited = Math.random() < 0.56 ? a.type : b.type;
|
||
|
|
const mutation = Math.random() < 0.18 ? baseSpriteId() : inherited;
|
||
|
|
const child = this.addTarinai({
|
||
|
|
type: mutation,
|
||
|
|
name: makeName(),
|
||
|
|
age: 0,
|
||
|
|
x: (a.x + b.x) / 2 + rand(-14, 14) + (index - (total - 1) / 2) * 18,
|
||
|
|
y: (a.y + b.y) / 2 + rand(-14, 14) + (total > 1 ? rand(-10, 10) : 0),
|
||
|
|
scale: rand(0.17, 0.23),
|
||
|
|
hunger: rand(20, 40),
|
||
|
|
loneliness: rand(30, 56),
|
||
|
|
energy: rand(55, 95),
|
||
|
|
stress: rand(0, 14),
|
||
|
|
need: Math.random() < 0.6 ? pick([a.need, b.need]) : pick(NEEDS),
|
||
|
|
generation: Math.max(a.generation, b.generation) + 1,
|
||
|
|
lifeSpan: rand(1500, 2400),
|
||
|
|
parents: [a.id, b.id],
|
||
|
|
parentNames: [a.name, b.name],
|
||
|
|
birthTime: this.time,
|
||
|
|
});
|
||
|
|
this.maxGeneration = Math.max(this.maxGeneration, child.generation);
|
||
|
|
a.affection = 0; b.affection = 0;
|
||
|
|
a.loneliness *= 0.65; b.loneliness *= 0.65;
|
||
|
|
a.children.push(child.id);
|
||
|
|
b.children.push(child.id);
|
||
|
|
this.recordFamily(a);
|
||
|
|
this.recordFamily(b);
|
||
|
|
this.recordFamily(child);
|
||
|
|
if (this.family[a.id] && !this.family[a.id].children.includes(child.id)) this.family[a.id].children.push(child.id);
|
||
|
|
if (this.family[b.id] && !this.family[b.id].children.includes(child.id)) this.family[b.id].children.push(child.id);
|
||
|
|
this.normalizeFamily();
|
||
|
|
audio.birth();
|
||
|
|
if (total <= 1) this.log(`${child.name}\u304c${a.name}\u3068${b.name}\u306e\u8fd1\u304f\u306b\u73fe\u308c\u305f\u3002`, "birth");
|
||
|
|
return child;
|
||
|
|
}
|
||
|
|
|
||
|
|
startConflict(a, b) {
|
||
|
|
if (!a || !b || a.dead || b.dead) return;
|
||
|
|
if ((a.postBirthPeaceTimer || 0) > 0 || (b.postBirthPeaceTimer || 0) > 0) return;
|
||
|
|
if (a.fightTimer > 0.04 || b.fightTimer > 0.04 || a.intimidateTimer > 0.04 || b.intimidateTimer > 0.04) return;
|
||
|
|
const scoreA = a.energy * 0.38 + a.mood * 0.18 + a.stress * 0.12 + (a.type === "angry" ? 12 : 0) + (a.personality === "irritable" ? 8 : 0) + ((b.relationTo(a.id).fear || 0) * 0.65) + rand(-5, 5);
|
||
|
|
const scoreB = b.energy * 0.38 + b.mood * 0.18 + b.stress * 0.12 + (b.type === "angry" ? 12 : 0) + (b.personality === "irritable" ? 8 : 0) + ((a.relationTo(b.id).fear || 0) * 0.65) + rand(-5, 5);
|
||
|
|
const actor = scoreA >= scoreB ? a : b;
|
||
|
|
const target = actor === a ? b : a;
|
||
|
|
const actorScore = actor === a ? scoreA : scoreB;
|
||
|
|
const targetScore = actor === a ? scoreB : scoreA;
|
||
|
|
const canIntimidate = !actor.lowHealthSprite || !actor.lowHealthSprite();
|
||
|
|
const intimidationChance = 0.40;
|
||
|
|
if (canIntimidate && Math.random() < intimidationChance) {
|
||
|
|
this.startIntimidation(actor, target, actorScore, targetScore);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
this.startFight(a, b);
|
||
|
|
}
|
||
|
|
|
||
|
|
startIntimidation(actor, target, actorScore = 0, targetScore = 0) {
|
||
|
|
if (!actor || !target || actor.dead || target.dead) return false;
|
||
|
|
if (actor.lowHealthSprite && actor.lowHealthSprite()) return false;
|
||
|
|
const fearLoad = (target.relationTo(actor.id).fear || 0) * 0.72 + target.personalityProfile().fear * 8;
|
||
|
|
const resistance = target.energy * 0.30 + target.mood * 0.16 + (target.personality === "irritable" ? 5 : 0) + rand(-5, 5);
|
||
|
|
const successChance = clamp(0.62 + (actorScore - targetScore + fearLoad - resistance) / 76, 0.50, 0.96);
|
||
|
|
actor.surpriseTimer = Math.max(actor.surpriseTimer, 0.20);
|
||
|
|
target.surpriseTimer = Math.max(target.surpriseTimer, 0.32);
|
||
|
|
actor.fightCooldown = CONFIG.fightCooldown + rand(1, 4);
|
||
|
|
target.fightCooldown = CONFIG.fightCooldown + rand(1, 4);
|
||
|
|
actor.intimidateTimer = Math.max(actor.intimidateTimer, rand(2.45, 3.35));
|
||
|
|
actor.intimidateTargetId = target.id;
|
||
|
|
actor.state = "intimidate";
|
||
|
|
this.effects.push(new Effect("ring", actor.x, actor.y - actor.radius * 0.65, {
|
||
|
|
life: 0.42,
|
||
|
|
size: actor.radius * 0.55,
|
||
|
|
color: "rgba(145, 106, 55, 0.70)",
|
||
|
|
}));
|
||
|
|
this.spawnBubble(actor.x, actor.y - actor.radius * 1.30, "\u306f\u3046\u30fc\uff01", "rgba(92,62,34,0.82)");
|
||
|
|
target.intimidatedTimer = Math.max(target.intimidatedTimer || 0, rand(1.65, 2.45));
|
||
|
|
target.fearTimer = Math.max(target.fearTimer, 0.65 * target.personalityProfile().fear);
|
||
|
|
if (Math.random() >= successChance) {
|
||
|
|
target.adjustRelation(actor, -0.08, 0.22 * target.personalityProfile().fear, "intimidate");
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
target.defeatedById = actor.id;
|
||
|
|
target.fightWinnerId = actor.id;
|
||
|
|
target.defeatedTimer = Math.max(target.defeatedTimer, rand(2.8, 4.2));
|
||
|
|
target.fearTimer = Math.max(target.fearTimer, 1.35 * target.personalityProfile().fear);
|
||
|
|
target.intimidatedTimer = Math.max(target.intimidatedTimer || 0, rand(2.4, 3.4));
|
||
|
|
target.state = "panic";
|
||
|
|
target.target = actor;
|
||
|
|
this.spawnBubble(target.x, target.y - target.radius * 1.28, "\u306f\u3041\u3041\u3041\uff01", "rgba(84,66,86,0.80)");
|
||
|
|
target.vx += (target.x < actor.x ? -1 : 1) * rand(26, 54);
|
||
|
|
target.vy += rand(-18, 18);
|
||
|
|
actor.adjustRelation(target, -0.08, 0.06, "intimidate");
|
||
|
|
target.adjustRelation(actor, -0.22, 0.85 * target.personalityProfile().fear, "intimidate");
|
||
|
|
if (this.time - Math.max(actor.lastLog, target.lastLog) > 5.5) {
|
||
|
|
this.log(`${actor.name}\u306f${target.name}\u3092\u5a01\u5687\u3057\u3066\u8ffd\u3044\u6255\u3063\u305f\u3002`, "fight");
|
||
|
|
actor.lastLog = target.lastLog = this.time;
|
||
|
|
}
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
startFight(a, b) {
|
||
|
|
if (!a || !b || a.dead || b.dead) return;
|
||
|
|
if ((a.postBirthPeaceTimer || 0) > 0 || (b.postBirthPeaceTimer || 0) > 0) return;
|
||
|
|
a.state = "fight"; b.state = "fight";
|
||
|
|
a.fightTimer = Math.max(a.fightTimer, rand(4.5, 7.0));
|
||
|
|
b.fightTimer = Math.max(b.fightTimer, rand(4.5, 7.0));
|
||
|
|
a.nextHeadbutt = this.time + rand(0.08, 0.18);
|
||
|
|
b.nextHeadbutt = a.nextHeadbutt;
|
||
|
|
a.fightCooldown = CONFIG.fightCooldown + rand(1, 5);
|
||
|
|
b.fightCooldown = CONFIG.fightCooldown + rand(1, 5);
|
||
|
|
if (!a.fightTargetIds) a.fightTargetIds = [];
|
||
|
|
if (!b.fightTargetIds) b.fightTargetIds = [];
|
||
|
|
if (!a.fightTargetIds.includes(b.id)) a.fightTargetIds.push(b.id);
|
||
|
|
if (!b.fightTargetIds.includes(a.id)) b.fightTargetIds.push(a.id);
|
||
|
|
a.fightTargetIds = a.fightTargetIds.slice(-4);
|
||
|
|
b.fightTargetIds = b.fightTargetIds.slice(-4);
|
||
|
|
a.fightTargetId = a.fightTargetIds[0];
|
||
|
|
b.fightTargetId = b.fightTargetIds[0];
|
||
|
|
const aScore = a.energy * 0.52 + a.mood * 0.30 + (a.type === "angry" ? 10 : 0) + (a.personality === "irritable" ? 5 : 0) + rand(-7, 7);
|
||
|
|
const bScore = b.energy * 0.52 + b.mood * 0.30 + (b.type === "angry" ? 10 : 0) + (b.personality === "irritable" ? 5 : 0) + rand(-7, 7);
|
||
|
|
const loser = aScore <= bScore ? a : b;
|
||
|
|
const winner = loser === a ? b : a;
|
||
|
|
loser.defeatedById = winner.id;
|
||
|
|
loser.fightWinnerId = winner.id;
|
||
|
|
winner.fightWinnerId = winner.id;
|
||
|
|
winner.defeatedById = null;
|
||
|
|
winner.defeatedTimer = 0;
|
||
|
|
a.stress = clamp(a.stress + rand(8, 18), 0, 130);
|
||
|
|
b.stress = clamp(b.stress + rand(8, 18), 0, 130);
|
||
|
|
a.damage(rand(2, 7));
|
||
|
|
b.damage(rand(2, 7));
|
||
|
|
const dx = b.x - a.x, dy = b.y - a.y;
|
||
|
|
const d = Math.hypot(dx, dy) || 1;
|
||
|
|
a.vx -= dx / d * rand(32, 62); a.vy -= dy / d * rand(32, 62);
|
||
|
|
b.vx += dx / d * rand(32, 62); b.vy += dy / d * rand(32, 62);
|
||
|
|
a.surpriseTimer = 0.24; b.surpriseTimer = 0.24;
|
||
|
|
this.effects.push(new Effect("fight", (a.x + b.x) / 2, (a.y + b.y) / 2 - 8, {
|
||
|
|
size: rand(12, 20),
|
||
|
|
life: rand(0.32, 0.52),
|
||
|
|
color: "rgba(116, 73, 38, 0.82)",
|
||
|
|
}));
|
||
|
|
audio.fight();
|
||
|
|
if (this.time - Math.max(a.lastLog, b.lastLog) > 6) {
|
||
|
|
this.log(`${a.name}\u3068${b.name}\u304c\u5c0f\u3055\u306a\u55a7\u5629\u3092\u3057\u305f\u3002`, "fight");
|
||
|
|
a.lastLog = b.lastLog = this.time;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
explodeFirecracker(it) {
|
||
|
|
if (!it || it.dead || it.type !== "firecracker") return;
|
||
|
|
it.amount = 0;
|
||
|
|
const x = it.x, y = it.y;
|
||
|
|
const blastRadius = 240 * (it.blastScale || 1);
|
||
|
|
this.effects.push(new Effect("explosion", x, y, { size: 86, life: 1.05, color: "rgba(255,182,66,0.92)" }));
|
||
|
|
this.effects.push(new Effect("explosion", x, y, { size: 128, life: 0.82, color: "rgba(255,92,42,0.70)" }));
|
||
|
|
for (let r = 0; r < 4; r++) {
|
||
|
|
this.effects.push(new Effect("ring", x, y, { size: 30 + r * 24, life: 0.48 + r * 0.11, color: r % 2 ? "rgba(255,116,62,0.78)" : "rgba(255,240,124,0.88)" }));
|
||
|
|
}
|
||
|
|
for (let i = 0; i < 34; i++) {
|
||
|
|
const a = Math.PI * 2 * i / 34 + rand(-0.10, 0.10);
|
||
|
|
const speed = rand(90, 260);
|
||
|
|
this.effects.push(new Effect(i % 3 === 0 ? "explosion" : "fight", x + Math.cos(a) * rand(2, 22), y + Math.sin(a) * rand(2, 22), {
|
||
|
|
vx: Math.cos(a) * speed,
|
||
|
|
vy: Math.sin(a) * speed,
|
||
|
|
size: rand(8, i % 3 === 0 ? 24 : 20),
|
||
|
|
life: rand(0.26, 0.72),
|
||
|
|
color: i % 3 === 0 ? "rgba(255,218,70,0.78)" : "rgba(112,72,42,0.72)",
|
||
|
|
}));
|
||
|
|
}
|
||
|
|
for (const t of this.tarinai) {
|
||
|
|
if (t.dead) continue;
|
||
|
|
t.stress = clamp(t.stress + rand(8, 15), 0, 130);
|
||
|
|
t.fearTimer = Math.max(t.fearTimer, 0.58);
|
||
|
|
const dx = t.x - x;
|
||
|
|
const dy = t.y - y;
|
||
|
|
const d = Math.hypot(dx, dy) || 1;
|
||
|
|
if (d > blastRadius) continue;
|
||
|
|
const p = clamp(1 - d / blastRadius, 0, 1);
|
||
|
|
t.damage(5 + p * 18, "\u7206\u7af9\u306b\u9a5a\u3044\u305f");
|
||
|
|
t.stress = clamp(t.stress + 22 * p, 0, 130);
|
||
|
|
t.hurtTimer = Math.max(t.hurtTimer, 1.5 + p * 1.8);
|
||
|
|
t.surpriseTimer = Math.max(t.surpriseTimer, 0.85);
|
||
|
|
t.fearTimer = Math.max(t.fearTimer, 1.7 + p);
|
||
|
|
t.state = "panic";
|
||
|
|
t.target = { x, y };
|
||
|
|
const power = 170 + p * 360;
|
||
|
|
t.vx += dx / d * power + rand(-55, 55);
|
||
|
|
t.vy += dy / d * power + rand(-55, 55);
|
||
|
|
t.fallTimer = Math.max(t.fallTimer, 1.25 + p * 1.35);
|
||
|
|
t.fallMax = Math.max(t.fallMax || 0, t.fallTimer);
|
||
|
|
t.fallDir = (dx >= 0 ? 1 : -1) * (Math.random() < 0.5 ? 1 : -1);
|
||
|
|
t.blastSpinTimer = Math.max(t.blastSpinTimer || 0, 1.35 + p * 0.70);
|
||
|
|
t.blastSpinMax = Math.max(t.blastSpinMax || 0, t.blastSpinTimer);
|
||
|
|
this.spawnFallEffect(t.x, t.y + t.radius * 0.45, 1.1 + p);
|
||
|
|
if (!t.dead && Math.random() < 0.45) this.spawnBubble(t.x, t.y - t.radius * 1.35, "\u306f\u3041\u3041\u3041\uff01", "rgba(84,66,86,0.80)");
|
||
|
|
}
|
||
|
|
audio.explode();
|
||
|
|
this.log("\u7206\u7af9\u304c\u6d3e\u624b\u306b\u306f\u3058\u3051\u3001\u5ead\u304c\u3056\u308f\u3064\u3044\u305f\u3002", "accident");
|
||
|
|
}
|
||
|
|
|
||
|
|
spawnZunchi(x, y) {
|
||
|
|
const zunchiLimit = CONFIG.zunchiLimit ?? 56;
|
||
|
|
if ((this.itemCounts.zunchi || 0) >= zunchiLimit) {
|
||
|
|
let oldest = null;
|
||
|
|
for (const it of this.items) {
|
||
|
|
if (it.type !== "zunchi" || it.dead) continue;
|
||
|
|
if (!oldest || (it.age || 0) > (oldest.age || 0)) oldest = it;
|
||
|
|
}
|
||
|
|
if (oldest) oldest.amount = 0;
|
||
|
|
}
|
||
|
|
const it = new Item("zunchi", clamp(x, 40, this.w - 40), clamp(y, 40, this.h - 40));
|
||
|
|
it.amount = 260;
|
||
|
|
this.items.push(it);
|
||
|
|
this.itemCounts.zunchi = (this.itemCounts.zunchi || 0) + 1;
|
||
|
|
this.spawnBubble(it.x, it.y - 16, "\u3076\u308a\u3085\u3063", "rgba(62,84,45,0.78)");
|
||
|
|
}
|
||
|
|
|
||
|
|
spawnBubble(x, y, text, color = "rgba(42,36,29,0.78)") {
|
||
|
|
if ((this.effectCounts.bubble || 0) >= CONFIG.bubbleLimit) return;
|
||
|
|
if (text === "z") text = "Zzz...";
|
||
|
|
audio.bubble(text);
|
||
|
|
this.effects.push(new Effect("bubble", x, y, {
|
||
|
|
vx: rand(-2, 2),
|
||
|
|
vy: rand(-5, -2),
|
||
|
|
life: rand(2.2, 3.2),
|
||
|
|
size: 8,
|
||
|
|
text,
|
||
|
|
color,
|
||
|
|
}));
|
||
|
|
this.effectCounts.bubble = (this.effectCounts.bubble || 0) + 1;
|
||
|
|
}
|
||
|
|
|
||
|
|
spawnHeadbuttEffect(x, y) {
|
||
|
|
this.effects.push(new Effect("fight", x, y, {
|
||
|
|
size: rand(10, 16),
|
||
|
|
life: rand(0.20, 0.34),
|
||
|
|
color: "rgba(105, 62, 34, 0.86)",
|
||
|
|
}));
|
||
|
|
if (Math.random() < 0.5) audio.fight();
|
||
|
|
}
|
||
|
|
|
||
|
|
spawnFallEffect(x, y, scale = 1) {
|
||
|
|
if ((this.effectCounts.fall || 0) > 18) return;
|
||
|
|
this.effects.push(new Effect("fall", x, y, {
|
||
|
|
vx: rand(-12, 12),
|
||
|
|
vy: rand(-4, 3),
|
||
|
|
size: rand(20, 32) * scale,
|
||
|
|
life: rand(0.48, 0.72),
|
||
|
|
color: "rgba(154, 124, 80, 0.58)",
|
||
|
|
}));
|
||
|
|
}
|
||
|
|
|
||
|
|
spawnEatEffect(x, y, color = "#f1dfb7") {
|
||
|
|
if (Math.random() < 0.70) {
|
||
|
|
this.effects.push(new Effect("eat", x + rand(-4, 4), y + rand(-4, 4), {
|
||
|
|
vx: rand(-10, 10),
|
||
|
|
vy: rand(-18, -3),
|
||
|
|
size: rand(2.2, 4.2),
|
||
|
|
life: rand(0.18, 0.32),
|
||
|
|
color,
|
||
|
|
}));
|
||
|
|
}
|
||
|
|
if (Math.random() < 0.08) {
|
||
|
|
this.effects.push(new Effect("ring", x, y, { size: 5, life: 0.20, color }));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
spawnBleedEffect(x, y) {
|
||
|
|
this.effects.push(new Effect("bleed", x + rand(-3, 3), y + rand(-2, 3), {
|
||
|
|
vx: rand(-8, 8),
|
||
|
|
vy: rand(3, 14),
|
||
|
|
size: rand(2.0, 3.8),
|
||
|
|
life: rand(0.34, 0.62),
|
||
|
|
color: "rgba(80, 172, 55, 0.78)",
|
||
|
|
}));
|
||
|
|
}
|
||
|
|
|
||
|
|
bedOccupancy(bed) {
|
||
|
|
if (!bed || bed.type !== "bed") return 0;
|
||
|
|
let n = 0;
|
||
|
|
const radius = Math.max(86, bed.r * 2.45);
|
||
|
|
for (const t of this.nearbyTarinai(bed.x, bed.y, radius)) {
|
||
|
|
if (t.dead) continue;
|
||
|
|
if (t.state === "sleep" || t.state === "seek_bed" || t.target === bed) n += 1;
|
||
|
|
}
|
||
|
|
return n;
|
||
|
|
}
|
||
|
|
|
||
|
|
bedComfort(bed) {
|
||
|
|
if (!bed || bed.type !== "bed") return 0.7;
|
||
|
|
const occ = this.bedOccupancy(bed);
|
||
|
|
return clamp((bed.comfort ?? 1) - Math.max(0, occ - 3) * 0.075 - (bed.wear || 0) * 0.18, 0.42, 1.18);
|
||
|
|
}
|
||
|
|
|
||
|
|
bestBedFor(t, maxDist = 360) {
|
||
|
|
let best = null;
|
||
|
|
let bestScore = Infinity;
|
||
|
|
for (const bed of this.nearbyItems(t.x, t.y, maxDist)) {
|
||
|
|
if (bed.dead || bed.type !== "bed") continue;
|
||
|
|
if (t?.shouldAvoidTarget && t.shouldAvoidTarget(bed)) continue;
|
||
|
|
const d = dist(t, bed);
|
||
|
|
const occ = this.bedOccupancy(bed);
|
||
|
|
const comfort = this.bedComfort(bed);
|
||
|
|
const score = d - comfort * 46 + Math.max(0, occ - 3) * 34;
|
||
|
|
if (score < bestScore) { best = bed; bestScore = score; }
|
||
|
|
}
|
||
|
|
return best;
|
||
|
|
}
|
||
|
|
|
||
|
|
relationNotice(aId, bId, kind, cooldown = 30) {
|
||
|
|
const key = [aId, bId].sort().join(":") + `:${kind}`;
|
||
|
|
const last = this.relationNotices?.[key] || -Infinity;
|
||
|
|
if (this.time - last < cooldown) return false;
|
||
|
|
if (!this.relationNotices) this.relationNotices = {};
|
||
|
|
this.relationNotices[key] = this.time;
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
resolveFightOutcome(winner, loser) {
|
||
|
|
if (!winner || !loser || winner.dead || loser.dead) return;
|
||
|
|
const key = `${winner.id}:${loser.id}:${Math.floor(this.time / 2)}`;
|
||
|
|
if (!this.resolvedFightIds) this.resolvedFightIds = {};
|
||
|
|
if (this.resolvedFightIds[key]) return;
|
||
|
|
this.resolvedFightIds[key] = true;
|
||
|
|
winner.defeatedById = null;
|
||
|
|
winner.defeatedTimer = 0;
|
||
|
|
winner.fearTimer = Math.max(0, winner.fearTimer - 0.75);
|
||
|
|
winner.fightTargetIds = [];
|
||
|
|
winner.fightTargetId = null;
|
||
|
|
winner.adjustRelation(loser, -0.6, 0.02, "\u55a7\u5629\u306b\u52dd\u3063\u305f");
|
||
|
|
loser.adjustRelation(winner, -1.2, 7.2 * loser.personalityProfile().fear, "\u55a7\u5629\u306b\u8ca0\u3051\u305f");
|
||
|
|
winner.relationTo(loser.id).fightsWon = (winner.relationTo(loser.id).fightsWon || 0) + 1;
|
||
|
|
loser.relationTo(winner.id).fightsLost = (loser.relationTo(winner.id).fightsLost || 0) + 1;
|
||
|
|
loser.fearTimer = Math.max(loser.fearTimer, 1.2 * loser.personalityProfile().fear);
|
||
|
|
this.spawnBubble(loser.x, loser.y - loser.radius * 1.28, "\u306f\u3041\u3041\u3041\uff01", "rgba(84,66,86,0.80)");
|
||
|
|
winner.affection = clamp(winner.affection + 0.8, 0, 80);
|
||
|
|
winner.stress = clamp(winner.stress - rand(9, 18), 0, 130);
|
||
|
|
if (this.relationNotice(winner.id, loser.id, "fight-result", 8)) {
|
||
|
|
this.log(`${loser.name}\u306f${winner.name}\u306b\u8ca0\u3051\u3001\u305d\u306e\u3053\u3068\u3092\u899a\u3048\u305f\u3002`, "fight");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
itemDropImpact(it) {
|
||
|
|
if (!it || it.dead) return;
|
||
|
|
const isStone = it.type === "stone";
|
||
|
|
const radius = isStone ? 64 : Math.max(34, (it.r || 14) * 1.75);
|
||
|
|
const damage = isStone ? rand(20, 34) : rand(3, 7);
|
||
|
|
const push = isStone ? rand(145, 210) : rand(54, 92);
|
||
|
|
let hit = 0;
|
||
|
|
for (const t of this.nearbyTarinai(it.x, it.y, radius + 48)) {
|
||
|
|
if (!t || t.dead) continue;
|
||
|
|
const d = Math.max(1, distXY(t.x, t.y, it.x, it.y));
|
||
|
|
const p = clamp(1 - d / (radius + t.radius), 0, 1);
|
||
|
|
if (p <= 0) continue;
|
||
|
|
const nx = (t.x - it.x) / d;
|
||
|
|
const ny = (t.y - it.y) / d;
|
||
|
|
t.vx += nx * push * (0.35 + p);
|
||
|
|
t.vy += ny * push * (0.35 + p) - (isStone ? 42 : 18) * p;
|
||
|
|
t.damage(damage * (0.35 + p * 0.65), isStone ? "\u77f3\u304c\u843d\u3061\u305f" : "\u843d\u3061\u3066\u304d\u305f\u9053\u5177\u306b\u5f53\u305f\u3063\u305f");
|
||
|
|
t.hurtTimer = Math.max(t.hurtTimer, isStone ? 2.7 : 1.25);
|
||
|
|
t.fallTimer = Math.max(t.fallTimer, isStone ? 0.62 : 0.26);
|
||
|
|
t.fallMax = Math.max(t.fallMax || t.fallTimer, t.fallTimer);
|
||
|
|
t.fallDir = nx >= 0 ? 1 : -1;
|
||
|
|
hit += 1;
|
||
|
|
}
|
||
|
|
this.effects.push(new Effect("ring", it.x, it.y, { size: isStone ? 32 : 18, life: 0.34, color: isStone ? "rgba(128,96,58,0.76)" : "rgba(174,128,70,0.52)" }));
|
||
|
|
if (hit > 0) this.spawnFallEffect(it.x, it.y + (it.r || 12) * 0.6, isStone ? 0.9 : 0.45);
|
||
|
|
if (isStone && hit > 0) audio.poke();
|
||
|
|
if (hit > 0) this.log(`${isStone ? "\u77f3" : "\u9053\u5177"}\u304c\u843d\u3061\u3001${hit}\u5339\u306e\u305f\u308a\u306a\u3044\u304c\u5dfb\u304d\u8fbc\u307e\u308c\u305f\u3002`, "accident");
|
||
|
|
}
|
||
|
|
|
||
|
|
nearest(entity, types, maxDist = Infinity) {
|
||
|
|
let best = null, bestD = maxDist;
|
||
|
|
const candidates = Number.isFinite(maxDist) ? this.nearbyItems(entity.x, entity.y, maxDist) : this.items;
|
||
|
|
for (const it of candidates) {
|
||
|
|
if (it.dead || !types.includes(it.type)) continue;
|
||
|
|
if (entity?.shouldAvoidTarget && entity.shouldAvoidTarget(it)) continue;
|
||
|
|
const d = dist(entity, it);
|
||
|
|
if (d < bestD) { best = it; bestD = d; }
|
||
|
|
}
|
||
|
|
return best;
|
||
|
|
}
|
||
|
|
|
||
|
|
nearestOther(entity, maxDist = Infinity) {
|
||
|
|
let best = null, bestD = maxDist;
|
||
|
|
const candidates = Number.isFinite(maxDist) ? this.nearbyTarinai(entity.x, entity.y, maxDist) : this.tarinai;
|
||
|
|
for (const o of candidates) {
|
||
|
|
if (o === entity || o.dead) continue;
|
||
|
|
if (entity?.shouldAvoidTarget && entity.shouldAvoidTarget(o)) continue;
|
||
|
|
const d = dist(entity, o);
|
||
|
|
if (d < bestD) { best = o; bestD = d; }
|
||
|
|
}
|
||
|
|
return best;
|
||
|
|
}
|
||
|
|
|
||
|
|
temperatureAt(x, y) {
|
||
|
|
let temp = 0.54 + Math.sin(this.time / 55) * 0.05;
|
||
|
|
for (const it of this.nearbyItems(x, y, 190)) {
|
||
|
|
if (it.type === "stone") {
|
||
|
|
const d = distXY(x, y, it.x, it.y);
|
||
|
|
temp += clamp(1 - d / 190, 0, 1) * 0.35;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return clamp(temp, 0, 1);
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
fenceRect(it) {
|
||
|
|
const vertical = it?.type === "fence_v";
|
||
|
|
const horizontal = it?.type === "fence_h";
|
||
|
|
if (!vertical && !horizontal) return null;
|
||
|
|
const len = Math.max(82, (it.r || 42) * 1.95);
|
||
|
|
const thick = Math.max(20, (it.r || 42) * 0.43);
|
||
|
|
const halfW = vertical ? thick : len;
|
||
|
|
const halfH = vertical ? len : thick;
|
||
|
|
return { left: it.x - halfW, right: it.x + halfW, top: it.y - halfH, bottom: it.y + halfH, vertical, horizontal };
|
||
|
|
}
|
||
|
|
|
||
|
|
resolveFenceCollision(t) {
|
||
|
|
if (!t || t.dead) return;
|
||
|
|
const rr = Math.max(8, t.radius * 0.74);
|
||
|
|
for (const it of this.nearbyItems(t.x, t.y, rr + 92)) {
|
||
|
|
if (it.dead || (it.type !== "fence_v" && it.type !== "fence_h")) continue;
|
||
|
|
const r = this.fenceRect(it);
|
||
|
|
if (!r) continue;
|
||
|
|
const cx = clamp(t.x, r.left, r.right);
|
||
|
|
const cy = clamp(t.y, r.top, r.bottom);
|
||
|
|
let dx = t.x - cx;
|
||
|
|
let dy = t.y - cy;
|
||
|
|
let d = Math.hypot(dx, dy);
|
||
|
|
if (d >= rr) continue;
|
||
|
|
if (d < 0.001) {
|
||
|
|
const left = Math.abs(t.x - r.left);
|
||
|
|
const right = Math.abs(r.right - t.x);
|
||
|
|
const top = Math.abs(t.y - r.top);
|
||
|
|
const bottom = Math.abs(r.bottom - t.y);
|
||
|
|
const m = Math.min(left, right, top, bottom);
|
||
|
|
if (m === left) { dx = -1; dy = 0; d = 1; }
|
||
|
|
else if (m === right) { dx = 1; dy = 0; d = 1; }
|
||
|
|
else if (m === top) { dx = 0; dy = -1; d = 1; }
|
||
|
|
else { dx = 0; dy = 1; d = 1; }
|
||
|
|
}
|
||
|
|
const push = rr - d + 0.8;
|
||
|
|
t.x += dx / d * push;
|
||
|
|
t.y += dy / d * push;
|
||
|
|
if (Math.abs(dx) > Math.abs(dy)) t.vx *= -0.18;
|
||
|
|
else t.vy *= -0.18;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
|
||
|
|
segmentIntersectsRect(x1, y1, x2, y2, r) {
|
||
|
|
if (!r) return false;
|
||
|
|
if ((x1 >= r.left && x1 <= r.right && y1 >= r.top && y1 <= r.bottom) || (x2 >= r.left && x2 <= r.right && y2 >= r.top && y2 <= r.bottom)) return true;
|
||
|
|
const intersects = (ax, ay, bx, by, cx, cy, dx, dy) => {
|
||
|
|
const ccw = (px, py, qx, qy, rx, ry) => (ry - py) * (qx - px) > (qy - py) * (rx - px);
|
||
|
|
return ccw(ax, ay, cx, cy, dx, dy) !== ccw(bx, by, cx, cy, dx, dy) && ccw(ax, ay, bx, by, cx, cy) !== ccw(ax, ay, bx, by, dx, dy);
|
||
|
|
};
|
||
|
|
return intersects(x1, y1, x2, y2, r.left, r.top, r.right, r.top)
|
||
|
|
|| intersects(x1, y1, x2, y2, r.right, r.top, r.right, r.bottom)
|
||
|
|
|| intersects(x1, y1, x2, y2, r.right, r.bottom, r.left, r.bottom)
|
||
|
|
|| intersects(x1, y1, x2, y2, r.left, r.bottom, r.left, r.top);
|
||
|
|
}
|
||
|
|
|
||
|
|
pathBlockedByFence(x1, y1, x2, y2, padding = 16) {
|
||
|
|
const cx = (x1 + x2) / 2;
|
||
|
|
const cy = (y1 + y2) / 2;
|
||
|
|
const maxD = Math.hypot(x2 - x1, y2 - y1) / 2 + 120;
|
||
|
|
for (const it of this.items) {
|
||
|
|
if (it.dead || (it.type !== "fence_v" && it.type !== "fence_h")) continue;
|
||
|
|
if (distXY(cx, cy, it.x, it.y) > maxD + 110) continue;
|
||
|
|
const base = this.fenceRect(it);
|
||
|
|
if (!base) continue;
|
||
|
|
const r = { left: base.left - padding, right: base.right + padding, top: base.top - padding, bottom: base.bottom + padding };
|
||
|
|
if (this.segmentIntersectsRect(x1, y1, x2, y2, r)) return true;
|
||
|
|
}
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
grassBlockedAt(x, y, self = null) {
|
||
|
|
for (const it of this.nearbyItems(x, y, 52)) {
|
||
|
|
if (it === self || it.dead) continue;
|
||
|
|
if (it.type === "fence_v" || it.type === "fence_h") {
|
||
|
|
const r = this.fenceRect(it);
|
||
|
|
if (r && x >= r.left - 10 && x <= r.right + 10 && y >= r.top - 10 && y <= r.bottom + 10) return true;
|
||
|
|
}
|
||
|
|
if (it.type !== "zunchi") continue;
|
||
|
|
const dx = (x - it.x) / Math.max(22, it.r * 1.35);
|
||
|
|
const dy = (y - it.y) / Math.max(12, it.r * 0.82);
|
||
|
|
if (dx * dx + dy * dy < 1) return true;
|
||
|
|
}
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
grassCrowdedAt(x, y, minGap = 30, self = null) {
|
||
|
|
for (const it of this.nearbyItems(x, y, Math.max(42, minGap + 16))) {
|
||
|
|
if (it === self || it.dead || it.type !== "grass") continue;
|
||
|
|
if (distXY(x, y, it.x, it.y) < minGap) return true;
|
||
|
|
}
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
grassOnTarinaiAt(x, y, minGap = 24) {
|
||
|
|
for (const t of this.nearbyTarinai(x, y, minGap + 36)) {
|
||
|
|
if (t.dead) continue;
|
||
|
|
if (distXY(x, y, t.x, t.y) < Math.max(minGap, t.radius * 1.15)) return true;
|
||
|
|
}
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
grassSpotOpen(x, y, { minGrassGap = 30, avoidTarinai = true, self = null } = {}) {
|
||
|
|
if (x < 44 || y < 44 || x > this.w - 44 || y > this.h - 44) return false;
|
||
|
|
if (this.grassBlockedAt(x, y, self)) return false;
|
||
|
|
if (this.grassCrowdedAt(x, y, minGrassGap, self)) return false;
|
||
|
|
if (avoidTarinai && this.grassOnTarinaiAt(x, y)) return false;
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
findGrassPlantingSpot(x, y, { allowOriginal = true, minRadius = 18, maxRadius = 160, attempts = 36, avoidTarinai = true } = {}) {
|
||
|
|
const cx = clamp(x, 44, this.w - 44);
|
||
|
|
const cy = clamp(y, 44, this.h - 44);
|
||
|
|
if (allowOriginal && this.grassSpotOpen(cx, cy, { avoidTarinai })) return { x: cx, y: cy };
|
||
|
|
const blockedAtCenter = this.grassBlockedAt(cx, cy);
|
||
|
|
const inner = blockedAtCenter ? Math.max(minRadius, 34) : minRadius;
|
||
|
|
const outer = Math.max(maxRadius, inner + 42);
|
||
|
|
const golden = Math.PI * (3 - Math.sqrt(5));
|
||
|
|
for (let i = 0; i < attempts; i++) {
|
||
|
|
const t = attempts <= 1 ? 1 : i / (attempts - 1);
|
||
|
|
const radius = lerp(inner, outer, Math.sqrt(t));
|
||
|
|
const angle = i * golden + stableUnit(`${cx},${cy}`, "grass-plant") * Math.PI * 2;
|
||
|
|
const px = clamp(cx + Math.cos(angle) * radius, 44, this.w - 44);
|
||
|
|
const py = clamp(cy + Math.sin(angle) * radius * 0.72, 44, this.h - 44);
|
||
|
|
if (this.grassSpotOpen(px, py, { avoidTarinai })) return { x: px, y: py };
|
||
|
|
}
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
findGrassSproutSpot(source, fertile = false) {
|
||
|
|
const attempts = fertile ? 24 : 10;
|
||
|
|
const minR = fertile ? Math.max(30, source.r * 1.9) : 18;
|
||
|
|
const maxR = fertile ? 125 : 72;
|
||
|
|
for (let i = 0; i < attempts; i++) {
|
||
|
|
const angle = rand(0, Math.PI * 2);
|
||
|
|
const radius = rand(minR, maxR);
|
||
|
|
const x = clamp(source.x + Math.cos(angle) * radius, 44, this.w - 44);
|
||
|
|
const y = clamp(source.y + Math.sin(angle) * radius * 0.72, 44, this.h - 44);
|
||
|
|
if (this.grassSpotOpen(x, y, { minGrassGap: 30, avoidTarinai: true })) return { x, y };
|
||
|
|
}
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
performanceLevel() {
|
||
|
|
const fps = typeof window !== "undefined" ? Number(window.__tarinaiFps || 0) : 60;
|
||
|
|
if (!fps || !Number.isFinite(fps)) return 0;
|
||
|
|
if (fps < 22) return 3;
|
||
|
|
if (fps < 34) return 2;
|
||
|
|
if (fps < 48) return 1;
|
||
|
|
return 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
grassLoadLevel() {
|
||
|
|
const grass = this.itemCounts?.grass || 0;
|
||
|
|
const perf = this.performanceLevel();
|
||
|
|
if (perf >= 3 || grass >= 180) return 3;
|
||
|
|
if (perf >= 2 || grass >= 120) return 2;
|
||
|
|
if (perf >= 1 || grass >= 72) return 1;
|
||
|
|
return 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
grassUpdateFactor() {
|
||
|
|
return [1.0, 1.35, 1.85, 2.45][this.grassLoadLevel()] || 1.0;
|
||
|
|
}
|
||
|
|
|
||
|
|
sortedDrawList() {
|
||
|
|
if (!this.drawList) this.drawList = [];
|
||
|
|
if (this.drawListDirty || this.drawList.length !== this.tarinai.length) {
|
||
|
|
this.drawList.length = 0;
|
||
|
|
for (const t of this.tarinai) if (!t.dead) this.drawList.push(t);
|
||
|
|
this.drawList.sort((a, b) => a.y - b.y);
|
||
|
|
this.drawListDirty = false;
|
||
|
|
}
|
||
|
|
return this.drawList;
|
||
|
|
}
|
||
|
|
|
||
|
|
nearbyItems(x, y, radius) {
|
||
|
|
return this.spatial.nearby(this.spatial.itemCells, x, y, radius, this.spatialScratch);
|
||
|
|
}
|
||
|
|
|
||
|
|
nearbyTarinai(x, y, radius) {
|
||
|
|
return this.spatial.nearby(this.spatial.tarinaiCells, x, y, radius, this.spatialScratch);
|
||
|
|
}
|
||
|
|
|
||
|
|
rebuildSpatial() {
|
||
|
|
this.spatial.rebuild(this.items, this.tarinai);
|
||
|
|
}
|
||
|
|
|
||
|
|
updateItemCounts() {
|
||
|
|
const counts = this.itemCounts;
|
||
|
|
for (const key of Object.keys(counts)) counts[key] = 0;
|
||
|
|
for (const it of this.items) counts[it.type] = (counts[it.type] || 0) + 1;
|
||
|
|
}
|
||
|
|
|
||
|
|
updateEffectCounts() {
|
||
|
|
const counts = this.effectCounts;
|
||
|
|
for (const key of Object.keys(counts)) counts[key] = 0;
|
||
|
|
for (const ef of this.effects) counts[ef.type] = (counts[ef.type] || 0) + 1;
|
||
|
|
}
|
||
|
|
|
||
|
|
compactItems() {
|
||
|
|
this.enforceItemBudget();
|
||
|
|
const before = this.items.length;
|
||
|
|
let write = 0;
|
||
|
|
for (let read = 0; read < this.items.length; read++) {
|
||
|
|
const it = this.items[read];
|
||
|
|
if (it.type === "mirror") continue;
|
||
|
|
if (!it.dead) this.items[write++] = it;
|
||
|
|
}
|
||
|
|
this.items.length = write;
|
||
|
|
return before !== this.items.length;
|
||
|
|
}
|
||
|
|
|
||
|
|
enforceItemBudget() {
|
||
|
|
const pruneOldest = (type, limit) => {
|
||
|
|
if (!Number.isFinite(limit) || limit < 0) return;
|
||
|
|
let count = 0;
|
||
|
|
for (const it of this.items) if (it.type === type && !it.dead) count += 1;
|
||
|
|
if (count <= limit) return;
|
||
|
|
const victims = this.items
|
||
|
|
.filter(it => it.type === type && !it.dead)
|
||
|
|
.sort((a, b) => (b.age || 0) - (a.age || 0))
|
||
|
|
.slice(0, count - limit);
|
||
|
|
for (const it of victims) it.amount = 0;
|
||
|
|
};
|
||
|
|
pruneOldest("zunchi", CONFIG.zunchiLimit ?? 56);
|
||
|
|
pruneOldest("trace", CONFIG.traceLimit ?? 64);
|
||
|
|
pruneOldest("splat", CONFIG.splatLimit ?? 48);
|
||
|
|
pruneOldest("grass", CONFIG.grassLimit ?? 96);
|
||
|
|
|
||
|
|
const limit = CONFIG.itemLimit ?? Infinity;
|
||
|
|
if (!Number.isFinite(limit) || this.items.length <= limit) return;
|
||
|
|
const priority = { trace: 1, splat: 2, zunchi: 3, water: 4, grass: 5, sweet: 6, firecracker: 7 };
|
||
|
|
const removable = this.items
|
||
|
|
.filter(it => !it.dead && (priority[it.type] || 99) < 99)
|
||
|
|
.sort((a, b) => (priority[a.type] || 99) - (priority[b.type] || 99) || (b.age || 0) - (a.age || 0));
|
||
|
|
let extra = this.items.length - limit;
|
||
|
|
for (const it of removable) {
|
||
|
|
if (extra <= 0) break;
|
||
|
|
it.amount = 0;
|
||
|
|
extra -= 1;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
compactEffects() {
|
||
|
|
const limit = CONFIG.effectLimit ?? 96;
|
||
|
|
if (Number.isFinite(limit) && this.effects.length > limit) {
|
||
|
|
this.effects.sort((a, b) => (a.life / Math.max(a.maxLife || 1, 0.001)) - (b.life / Math.max(b.maxLife || 1, 0.001)));
|
||
|
|
const remove = this.effects.length - limit;
|
||
|
|
for (let i = 0; i < remove; i++) this.effects[i].life = 0;
|
||
|
|
}
|
||
|
|
let write = 0;
|
||
|
|
for (let read = 0; read < this.effects.length; read++) {
|
||
|
|
const ef = this.effects[read];
|
||
|
|
if (!ef.dead) this.effects[write++] = ef;
|
||
|
|
}
|
||
|
|
this.effects.length = write;
|
||
|
|
}
|
||
|
|
|
||
|
|
compactTarinai() {
|
||
|
|
let write = 0;
|
||
|
|
for (let read = 0; read < this.tarinai.length; read++) {
|
||
|
|
const t = this.tarinai[read];
|
||
|
|
if (!t.dead) this.tarinai[write++] = t;
|
||
|
|
}
|
||
|
|
if (this.tarinai.length !== write) this.drawListDirty = true;
|
||
|
|
this.tarinai.length = write;
|
||
|
|
}
|
||
|
|
|
||
|
|
update(dt) {
|
||
|
|
if (this.paused) return;
|
||
|
|
dt *= this.speed;
|
||
|
|
dt = Math.min(dt, 0.12 * this.speed);
|
||
|
|
|
||
|
|
this.time += dt;
|
||
|
|
this.day = Math.floor(this.time / CONFIG.dayLength) + 1;
|
||
|
|
this.updateWeather(dt);
|
||
|
|
this.rebuildSpatial();
|
||
|
|
|
||
|
|
const perf = this.performanceLevel();
|
||
|
|
const itemCountBefore = this.items.length;
|
||
|
|
for (const it of this.items) it.update(dt, this);
|
||
|
|
|
||
|
|
for (const ef of this.effects) ef.update(dt);
|
||
|
|
|
||
|
|
for (const t of this.tarinai) {
|
||
|
|
t.update(dt);
|
||
|
|
}
|
||
|
|
|
||
|
|
this.compactTimer += dt;
|
||
|
|
const compactInterval = perf >= 2 ? 0.95 : 0.58;
|
||
|
|
let itemsCompacted = false;
|
||
|
|
if (this.compactTimer >= compactInterval) {
|
||
|
|
itemsCompacted = this.compactItems();
|
||
|
|
this.compactEffects();
|
||
|
|
this.compactTarinai();
|
||
|
|
this.compactTimer = 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
this.countsTimer += dt;
|
||
|
|
const countsInterval = perf >= 2 ? 0.85 : 0.42;
|
||
|
|
if (this.countsTimer >= countsInterval || itemsCompacted || this.items.length !== itemCountBefore) {
|
||
|
|
this.updateItemCounts();
|
||
|
|
this.updateEffectCounts();
|
||
|
|
this.countsTimer = 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
this.drawSortTimer += dt;
|
||
|
|
const sortInterval = perf >= 2 ? 0.16 : perf === 1 ? 0.11 : 0.07;
|
||
|
|
if (this.drawSortTimer >= sortInterval) {
|
||
|
|
this.drawListDirty = true;
|
||
|
|
this.drawSortTimer = 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
const phase = this.phaseName();
|
||
|
|
if (phase !== this.lastPhase) {
|
||
|
|
this.lastPhase = phase;
|
||
|
|
audio.phase();
|
||
|
|
}
|
||
|
|
|
||
|
|
if (this.tarinai.length > 0 && this.tarinai.length < 5 && Math.random() < dt * 0.03) {
|
||
|
|
const spot = this.edgeSpawnPoint();
|
||
|
|
const t = this.addTarinai({ x: spot.x, y: spot.y, vx: spot.vx, vy: spot.vy, type: baseSpriteId() });
|
||
|
|
t.wanderAngle = spot.angle;
|
||
|
|
this.log("\u3069\u3053\u304b\u304b\u3089\u305f\u308a\u306a\u3044\u304c\u8ff7\u3044\u8fbc\u3093\u3067\u304d\u305f\u3002");
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
const grassCount = this.itemCounts.grass || 0;
|
||
|
|
if (grassCount < Math.min(24, CONFIG.grassLimit ?? 96) && Math.random() < dt * 0.036) {
|
||
|
|
const spot = this.findGrassPlantingSpot(rand(60, this.w - 60), rand(60, this.h - 60), {
|
||
|
|
allowOriginal: true,
|
||
|
|
minRadius: 28,
|
||
|
|
maxRadius: 140,
|
||
|
|
attempts: 24,
|
||
|
|
});
|
||
|
|
if (spot) {
|
||
|
|
const sprout = new Item("grass", spot.x, spot.y);
|
||
|
|
sprout.amount = rand(22, 48);
|
||
|
|
sprout.growth = rand(0.12, 0.30);
|
||
|
|
this.items.push(sprout);
|
||
|
|
this.itemCounts.grass = grassCount + 1;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (this.weather === "light_rain" && (this.itemCounts.water || 0) < 7 && Math.random() < dt * 0.035) {
|
||
|
|
const drop = new Item("water", rand(58, this.w - 58), rand(58, this.h - 58));
|
||
|
|
drop.amount = rand(18, 40);
|
||
|
|
this.items.push(drop);
|
||
|
|
this.itemCounts.water = (this.itemCounts.water || 0) + 1;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (Math.random() < dt * 0.004) {
|
||
|
|
const entries = [
|
||
|
|
"\u4f55\u5339\u304b\u304c\u3001\u5168\u54e1\u3067\u306f\u306a\u3044\u304c\u3001\u540c\u3058\u65b9\u5411\u3092\u898b\u305f\u3002",
|
||
|
|
"\u98df\u3079\u7269\u306f\u3042\u308b\u3002\u8db3\u308a\u3066\u3044\u308b\u304b\u306f\u5225\u554f\u984c\u3002",
|
||
|
|
"\u4e00\u5339\u304c\u7720\u308a\u3001\u8fd1\u304f\u306e\u5225\u306e\u4e00\u5339\u3082\u7720\u308b\u3053\u3068\u3092\u8003\u3048\u305f\u3002",
|
||
|
|
"\u8349\u306e\u8fd1\u304f\u306b\u3001\u77ed\u304f\u3066\u610f\u5473\u306e\u306a\u3044\u5217\u304c\u3067\u304d\u305f\u3002",
|
||
|
|
`${this.phaseName()}\u3002\u6c17\u5206\u306f\u3086\u3063\u304f\u308a\u5909\u308f\u3063\u3066\u3044\u308b\u3002`
|
||
|
|
];
|
||
|
|
this.log(pick(entries));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
updateWeather(dt) {
|
||
|
|
this.nextWeatherChange -= dt;
|
||
|
|
if (this.nextWeatherChange > 0) return;
|
||
|
|
const current = this.weather || "sunny";
|
||
|
|
const choices = current === "sunny"
|
||
|
|
? ["sunny", "cloudy", "light_rain"]
|
||
|
|
: current === "cloudy"
|
||
|
|
? ["sunny", "cloudy", "light_rain"]
|
||
|
|
: ["sunny", "cloudy"];
|
||
|
|
this.weather = pick(choices);
|
||
|
|
this.nextWeatherChange = rand(CONFIG.weatherChangeMin, CONFIG.weatherChangeMax);
|
||
|
|
this.log(`\u5929\u6c17: ${weatherLabel(this.weather)}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
placeItem(item, dropped = true) {
|
||
|
|
if (!item) return null;
|
||
|
|
if (dropped) {
|
||
|
|
item.dropMax = item.type === "stone" ? 0.72 : 0.58;
|
||
|
|
item.dropTimer = item.dropMax;
|
||
|
|
item.dropImpactDone = false;
|
||
|
|
}
|
||
|
|
this.items.push(item);
|
||
|
|
this.itemCounts[item.type] = (this.itemCounts[item.type] || 0) + 1;
|
||
|
|
this.rebuildSpatial();
|
||
|
|
return item;
|
||
|
|
}
|
||
|
|
|
||
|
|
handleClick(x, y) {
|
||
|
|
const tool = this.tool;
|
||
|
|
if (tool === "observe") {
|
||
|
|
let found = null;
|
||
|
|
for (let i = this.tarinai.length - 1; i >= 0; i--) {
|
||
|
|
if (this.tarinai[i].contains(x, y)) { found = this.tarinai[i]; break; }
|
||
|
|
}
|
||
|
|
this.selected = found;
|
||
|
|
if (found) this.log(`${found.name}\u3092\u9078\u629e\u3057\u305f\u3002`);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (tool === "poke") {
|
||
|
|
const target = [...this.tarinai].reverse().find(t => t.contains(x, y));
|
||
|
|
if (target) {
|
||
|
|
target.poke();
|
||
|
|
if (!target.dead) this.log(`${target.name}\u306f\u3064\u3064\u304b\u308c\u3001\u305f\u308a\u306a\u3044\u3053\u3068\u3092\u899a\u3048\u305f\u3002`);
|
||
|
|
} else {
|
||
|
|
this.log("\u7a7a\u6c17\u3092\u3064\u3064\u3044\u305f\u3002\u305d\u3053\u306b\u306f\u4f55\u3082\u305f\u308a\u306a\u304b\u3063\u305f\u3002");
|
||
|
|
}
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (tool === "new") {
|
||
|
|
const spot = this.edgeSpawnPoint(x, y);
|
||
|
|
const t = this.addTarinai({ x: spot.x, y: spot.y, vx: spot.vx, vy: spot.vy, type: baseSpriteId(), generation: 1 });
|
||
|
|
t.wanderAngle = spot.angle;
|
||
|
|
audio.birth();
|
||
|
|
this.log(`${t.name}\u304c\u753b\u9762\u5916\u304b\u3089\u8ff7\u3044\u8fbc\u3093\u3067\u304d\u305f\u3002`);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (tool === "delete") {
|
||
|
|
let best = null, bestD = Infinity;
|
||
|
|
for (let i = this.items.length - 1; i >= 0; i--) {
|
||
|
|
const it = this.items[i];
|
||
|
|
if (it.dead) continue;
|
||
|
|
const visualHit = Math.max(22, (it.r || 12) * 2.0);
|
||
|
|
const hit = this.screenSizeToWorld ? this.screenSizeToWorld(visualHit) : visualHit;
|
||
|
|
const d = distXY(x, y, it.x, it.y);
|
||
|
|
if (d < hit && d < bestD) { best = it; bestD = d; }
|
||
|
|
}
|
||
|
|
if (!best) { showToast("\u524a\u9664\u3067\u304d\u308b\u30aa\u30d6\u30b8\u30a7\u30af\u30c8\u304c\u3042\u308a\u307e\u305b\u3093\u3002"); return; }
|
||
|
|
best.amount = 0;
|
||
|
|
this.compactItems();
|
||
|
|
this.updateItemCounts();
|
||
|
|
this.rebuildSpatial();
|
||
|
|
const label = { zunchi: "\u305a\u3093\u3061", food: "\u98df\u3079\u7269", sweet: "\u305a\u3093\u3060\u9905", water: "\u6c34", grass: "\u8349", stone: "\u77f3", bed: "\u5e72\u8349\u5bdd\u5e8a", firecracker: "\u7206\u7af9", fence_v: "\u7e26\u306e\u67f5", fence_h: "\u6a2a\u306e\u67f5", trace: "\u8db3\u8de1", splat: "\u3057\u3076\u304d" }[best.type] || best.type;
|
||
|
|
this.log(`${label}\u3092\u524a\u9664\u3057\u305f\u3002`, "observe");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const itemType = {
|
||
|
|
zunchi: "zunchi", sweet: "sweet", water: "water", grass: "grass",
|
||
|
|
stone: "stone", bed: "bed", firecracker: "firecracker", fence_v: "fence_v", fence_h: "fence_h"
|
||
|
|
}[tool];
|
||
|
|
if (itemType) {
|
||
|
|
let px = x, py = y;
|
||
|
|
if (itemType === "grass") {
|
||
|
|
const spot = this.findGrassPlantingSpot(x, y, { allowOriginal: true, minRadius: 22, maxRadius: 128, attempts: 48 });
|
||
|
|
if (!spot) {
|
||
|
|
showToast("\u8349\u306b\u306f\u7a7a\u3044\u305f\u571f\u304c\u5fc5\u8981\u3067\u3059\u3002");
|
||
|
|
this.log("\u305d\u3053\u306b\u306f\u8349\u304c\u690d\u3048\u3089\u308c\u308b\u7a7a\u304d\u5730\u304c\u3042\u308a\u307e\u305b\u3093\u3002");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
px = spot.x;
|
||
|
|
py = spot.y;
|
||
|
|
}
|
||
|
|
this.placeItem(this.applyToolSize(new Item(itemType, px, py)), true);
|
||
|
|
const label = { zunchi: "\u305a\u3093\u3061", food: "\u98df\u3079\u7269", sweet: "\u305a\u3093\u3060\u9905", water: "\u6c34", grass: "\u8349", stone: "\u77f3", bed: "\u5e72\u8349\u5bdd\u5e8a", firecracker: "\u7206\u7af9", fence_v: "\u7e26\u306e\u67f5", fence_h: "\u6a2a\u306e\u67f5" }[itemType];
|
||
|
|
this.log(`${label}\u3092\u914d\u7f6e\u3057\u305f\u3002`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
inferLogKind(text) {
|
||
|
|
const s = String(text || "").toLowerCase();
|
||
|
|
if (s.includes("firecracker") || s.includes("accident") || s.includes("drop") || s.includes("\u7206\u7af9") || s.includes("\u843d\u3061") || s.includes("\u4e8b\u6545") || s.includes("\u5439\u304d\u98db")) return "accident";
|
||
|
|
if (s.includes("fight") || s.includes("lost to") || s.includes("poked") || s.includes("\u55a7\u5629") || s.includes("\u8ca0\u3051") || s.includes("\u3064\u3064\u304b")) return "fight";
|
||
|
|
if (s.includes("appeared") || s.includes("wandered in") || s.includes("birth") || s.includes("\u73fe\u308c") || s.includes("\u8ff7\u3044\u8fbc")) return "birth";
|
||
|
|
if (s.includes("dead") || s.includes("trace") || s.includes("reason:") || s.includes("\u6b7b\u4ea1") || s.includes("\u75d5\u8de1") || s.includes("\u7406\u7531:")) return "death";
|
||
|
|
if (s.includes("weather") || s.includes("day") || s.includes("night") || s.includes("morning") || s.includes("evening") || s.includes("\u5929\u6c17") || s.includes("\u65e5\u76ee") || s.includes("\u591c") || s.includes("\u671d") || s.includes("\u5915")) return "weather";
|
||
|
|
if (s.includes("zunda") || s.includes("food") || s.includes("eat") || s.includes("appreciated") || s.includes("\u305a\u3093\u3060") || s.includes("\u98df\u3079\u7269") || s.includes("\u5473\u308f")) return "food";
|
||
|
|
if (s.includes("grass") || s.includes("soil") || s.includes("\u8349") || s.includes("\u571f")) return "grass";
|
||
|
|
if (s.includes("selected") || s.includes("placed") || s.includes("\u9078\u629e") || s.includes("\u914d\u7f6e")) return "observe";
|
||
|
|
if (s.includes("near") || s.includes("\u8fd1\u304f") || s.includes("\u95a2\u4fc2")) return "relation";
|
||
|
|
return "note";
|
||
|
|
}
|
||
|
|
|
||
|
|
log(text, kind = null) {
|
||
|
|
const entry = { time: this.time, text, kind: kind || this.inferLogKind(text) };
|
||
|
|
if (!this.eventCounters) this.eventCounters = {};
|
||
|
|
this.eventCounters[entry.kind] = (this.eventCounters[entry.kind] || 0) + 1;
|
||
|
|
this.logs.unshift(entry);
|
||
|
|
this.logs = this.logs.slice(0, 100);
|
||
|
|
renderLog(this.logs);
|
||
|
|
}
|
||
|
|
|
||
|
|
serialize() {
|
||
|
|
return {
|
||
|
|
w: this.w, h: this.h, time: this.time, day: this.day, deadCount: this.deadCount,
|
||
|
|
maxGeneration: this.maxGeneration,
|
||
|
|
tarinai: this.tarinai.map(t => t.serialize()),
|
||
|
|
items: this.items.map(i => i.serialize()),
|
||
|
|
logs: this.logs.slice(0, 30),
|
||
|
|
family: this.family || {},
|
||
|
|
weather: this.weather || "sunny",
|
||
|
|
fieldType: this.fieldType || "garden",
|
||
|
|
fieldZoom: this.fieldZoom || 1,
|
||
|
|
cameraX: this.cameraX || 0,
|
||
|
|
cameraY: this.cameraY || 0,
|
||
|
|
eventCounters: this.eventCounters || {},
|
||
|
|
toolSize: this.toolSize || "medium",
|
||
|
|
toolSizes: this.toolSizes || {},
|
||
|
|
nextWeatherChange: this.nextWeatherChange || CONFIG.weatherChangeMin,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
load(data) {
|
||
|
|
this.time = data.time || 0;
|
||
|
|
this.day = data.day || 1;
|
||
|
|
this.deadCount = data.deadCount || 0;
|
||
|
|
this.eventCounters = data.eventCounters || {};
|
||
|
|
this.toolSize = data.toolSize || this.toolSize || "medium";
|
||
|
|
this.toolSizes = data.toolSizes || this.toolSizes || {};
|
||
|
|
this.maxGeneration = data.maxGeneration || 1;
|
||
|
|
this.fieldType = FIELD_TYPES?.[data.fieldType]?.id || "garden";
|
||
|
|
this.fieldZoom = clamp(Number(data.fieldZoom) || 1, 0.42, 2.45);
|
||
|
|
this.cameraX = Number(data.cameraX) || 0;
|
||
|
|
this.cameraY = Number(data.cameraY) || 0;
|
||
|
|
this.tarinai = (data.tarinai || []).map(o => Tarinai.from(this, o));
|
||
|
|
this.items = (data.items || []).filter(o => o?.type !== "mirror" && o?.type !== "food").map(o => Item.from(o));
|
||
|
|
this.effects = [];
|
||
|
|
this.compactItems();
|
||
|
|
this.compactEffects();
|
||
|
|
this.logs = (data.logs || []).map(e => typeof e === "string" ? { time: this.time, text: e, kind: this.inferLogKind(e) } : { time: e.time || this.time, text: e.text || "", kind: e.kind || this.inferLogKind(e.text || "") });
|
||
|
|
this.family = data.family || {};
|
||
|
|
this.familyCleanVersion = null;
|
||
|
|
this.familyVersion = (this.familyVersion || 0) + 1;
|
||
|
|
this.weather = data.weather || "sunny";
|
||
|
|
this.nextWeatherChange = data.nextWeatherChange || rand(CONFIG.weatherChangeMin, CONFIG.weatherChangeMax);
|
||
|
|
this.relationNotices = {};
|
||
|
|
this.resolvedFightIds = {};
|
||
|
|
for (const t of this.tarinai) this.recordFamily(t);
|
||
|
|
this.normalizeFamily();
|
||
|
|
this.pruneExtinctFamilies({ normalize: false });
|
||
|
|
this.lastPhase = this.phaseName();
|
||
|
|
this.selected = null;
|
||
|
|
this.updateItemCounts();
|
||
|
|
this.updateEffectCounts();
|
||
|
|
this.rebuildSpatial();
|
||
|
|
this.log("\u30bb\u30fc\u30d6\u30c7\u30fc\u30bf\u3092\u8aad\u307f\u8fbc\u307f\u307e\u3057\u305f\u3002");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const world = new World();
|