tarinai/js/world.js
2026-06-20 23:27:39 +09:00

3073 lines
131 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.ants = [];
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.liveIdNext = 1;
this.liveIdFree = [];
this.liveIdSerial = 0;
this.liveTarinai = new Map();
this.lastBirthAt = -999;
this.maxGeneration = 1;
this.family = {};
this.familyVersion = 0;
this.familyTreeDirty = true;
this.familyTreeDirtyReason = "init";
this.relationNotices = {};
this.resolvedFightIds = {};
this.pointer = { x: 0, y: 0, inside: false, motion: 0, movedAt: 0 };
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.spatialItemScratch = [];
this.spatialTarinaiScratch = [];
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);
}
grassLimit() {
const field = this.fieldDefinition();
if (Number.isFinite(field?.grassLimit)) return Math.max(0, Math.round(field.grassLimit));
const base = CONFIG.grassLimit ?? 96;
const mediumScale = Math.max(0.22, FIELD_TYPES?.garden?.worldScale || 1);
const ratio = this.fieldWorldScale() / mediumScale;
// \u4e2d\u30b5\u30a4\u30ba\u3092 CONFIG.grassLimit \u306e\u57fa\u6e96\u306b\u3057\u3001\u30d5\u30a3\u30fc\u30eb\u30c9\u306e\u8ad6\u7406\u30b5\u30a4\u30ba\u306b\u5fdc\u3058\u3066\u5897\u6e1b\u3059\u308b\u3002
return Math.max(12, Math.round(base * ratio));
}
fenceLimit() {
const base = CONFIG.fenceLimit ?? 72;
const mediumScale = Math.max(0.22, FIELD_TYPES?.garden?.worldScale || 1);
const ratio = Math.max(0.24, this.fieldWorldScale() / mediumScale);
return clamp(Math.round(base * Math.sqrt(ratio)), 28, 140);
}
isFenceType(type = "") {
return type === "fence_v" || type === "fence_h";
}
liveFenceCount() {
let count = 0;
for (const it of this.items || []) if (!it.dead && this.isFenceType(it.type)) count += 1;
return count;
}
baseZoomMin() {
return CONFIG.fieldZoomMin || 0.42;
}
baseZoomMax() {
return CONFIG.fieldZoomMax || 2.45;
}
fitFieldZoom() {
const vw = Math.max(1, this.viewportW || this.w || 1000);
const vh = Math.max(1, this.viewportH || this.h || 720);
const ww = Math.max(1, this.w || vw);
const wh = Math.max(1, this.h || vh);
// The minimum visible zoom is field-size dependent. Small fields must not
// be allowed to shrink below the viewport, otherwise empty margins appear.
return Math.max(vw / ww, vh / wh);
}
zoomLimits() {
const min = Math.max(this.baseZoomMin(), this.fitFieldZoom());
// Keep the old upper bound for normal/large fields, but give small fields
// proportional zoom-in headroom because their no-margin minimum is higher.
const max = Math.max(this.baseZoomMax(), min * this.baseZoomMax());
return { min, max };
}
normalizedFieldZoom(value = this.fieldZoom) {
const limits = this.zoomLimits();
return clamp(Number(value) || limits.min, limits.min, limits.max);
}
viewScale() {
return this.normalizedFieldZoom(this.fieldZoom);
}
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.fieldZoom = this.normalizedFieldZoom(this.fieldZoom);
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 current = this.normalizedFieldZoom(this.fieldZoom);
const next = this.normalizedFieldZoom(nextZoom);
if (Math.abs(next - current) < 0.001) {
this.fieldZoom = current;
this.clampCamera();
return false;
}
this.fieldZoom = next;
this.clampCamera();
this.drawListDirty = true;
return true;
}
edgeSpawnPoint(targetX = null, targetY = null, margin = 34, outside = false) {
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);
}
const p = outside ? -(margin + 24) : margin;
if (side === 0) return { x: clamp(targetX ?? rand(margin, w - margin), margin, w - margin), y: p, vx: rand(-10, 10), vy: rand(28, 48), angle: Math.PI / 2 };
if (side === 1) return { x: outside ? w - p : w - margin, y: clamp(targetY ?? rand(margin, h - margin), margin, h - margin), vx: rand(-48, -28), vy: rand(-10, 10), angle: Math.PI };
if (side === 2) return { x: clamp(targetX ?? rand(margin, w - margin), margin, w - margin), y: outside ? h - p : h - margin, vx: rand(-10, 10), vy: rand(-48, -28), angle: -Math.PI / 2 };
return { x: p, y: clamp(targetY ?? rand(margin, h - margin), margin, h - margin), vx: rand(28, 48), vy: rand(-10, 10), angle: 0 };
}
toolSizeFor(type = "") {
if (!["firecracker", "fence_v", "fence_h", "stone", "grass", "food", "sweet", "love_mochi", "fight_mochi", "sleep_drug", "zunchi"].includes(type)) return "medium";
return (this.toolSizes && this.toolSizes[type]) || this.toolSize || "medium";
}
toolSizeScale(type = "") {
if (!["firecracker", "fence_v", "fence_h", "stone", "grass", "food", "sweet", "love_mochi", "fight_mochi", "sleep_drug", "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 (typeof isServingFoodType === "function" && isServingFoodType(item.type)) {
const servings = typeof foodServingsForSize === "function" ? foodServingsForSize(size) : 5;
item.foodServingScale = scale;
item.foodServingsMax = servings;
item.foodServingsRemaining = servings;
item.amount = servings;
} else if (["grass", "zunchi"].includes(item.type)) {
item.amount *= scale * scale;
item.foodServingScale = 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.ants = [];
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.liveIdNext = 1;
this.liveIdFree = [];
this.liveIdSerial = 0;
this.liveTarinai = new Map();
this.lastBirthAt = -999;
this.maxGeneration = 1;
this.family = {};
this.familyVersion = 0;
this.relationNotices = {};
this.resolvedFightIds = {};
this.pointer = { x: this.w / 2, y: this.h / 2, inside: false, motion: 0, movedAt: 0 };
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();
}
tarinaiFamilyKey(t) {
return t?.familyKey || t?.archiveKey || t?.id || "";
}
assignTarinaiLiveId(t) {
if (!t) return "";
if (!this.liveTarinai) this.liveTarinai = new Map();
// Live IDs are never recycled. Relationships and transient targets are keyed by
// live ID, so reuse can make dead friends/enemies leak onto new individuals.
this.liveIdFree = [];
if (!Number.isFinite(this.liveIdNext)) this.liveIdNext = 1;
if (!Number.isFinite(this.liveIdSerial)) this.liveIdSerial = 0;
const id = `t${this.liveIdNext++}`;
t.id = id;
t.liveToken = ++this.liveIdSerial;
this.liveTarinai.set(id, { token: t.liveToken, target: t });
return id;
}
releaseTarinaiLiveId(t) {
if (!t?.id || !this.liveTarinai) return false;
const current = this.liveTarinai.get(t.id);
if (!current || current.target !== t || current.token !== t.liveToken) return false;
const id = t.id;
this.liveTarinai.delete(id);
// Do not recycle live IDs. Relationships are keyed by live ID; recycling can
// make a dead friend's/enemy's relationship appear on a newly spawned individual.
t.releasedLiveId = id;
t.id = "";
t.liveToken = 0;
return true;
}
liveTarinaiById(id, token = null) {
if (!id || !this.liveTarinai) return null;
const entry = this.liveTarinai.get(id);
if (!entry || (token !== null && entry.token !== token)) return null;
const t = entry.target;
return t && !t.dead ? t : null;
}
recordFamily(t) {
if (!this.family) this.family = {};
const familyKey = this.tarinaiFamilyKey(t);
if (!familyKey) return;
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 || []);
const nextPersonalityTags = getPersonalityTraitTags(t);
if (!this.family[familyKey]) {
this.family[familyKey] = {
id: familyKey,
name: t.name,
type: t.type,
personalityTags: nextPersonalityTags,
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[familyKey];
const nextAlive = !t.dead;
if (entry.name !== t.name) changed = true;
if (entry.type !== t.type) changed = true;
if (!sameList(entry.personalityTags, nextPersonalityTags)) 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;
delete entry.personality;
entry.personalityTags = nextPersonalityTags;
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;
this.markFamilyTreeDirty("family-record");
}
}
markFamilyTreeDirty(reason = "family") {
this.familyTreeDirty = true;
this.familyTreeDirtyReason = reason;
this.familyTreeDirtyAt = this.time || 0;
}
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 => [this.tarinaiFamilyKey(t), t]).filter(([id]) => id));
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 familyKey = this.tarinaiFamilyKey(t);
if (!familyKey || (!this.family[familyKey] && !(t.hasPaired || (t.parents || []).length || (t.children || []).length))) continue;
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",
personalityTags: live ? getPersonalityTraitTags(live) : [...(n.personalityTags || [])],
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 of Object.keys(family)) {
const n = family[id];
if (!n?.hasPaired && !(n?.parents || []).length && !(n?.children || []).length) {
delete family[id];
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.markFamilyTreeDirty("family-normalize");
}
this.familyCleanVersion = this.familyVersion || 0;
return changed;
}
resetFamilyTree() {
this.family = {};
this.familyCleanVersion = 0;
this.familyVersion = (this.familyVersion || 0) + 1;
this.markFamilyTreeDirty("family-reset");
this.maxGeneration = 1;
for (const t of this.tarinai || []) {
t.parents = [];
t.parentNames = [];
t.children = [];
t.generation = 1;
t.hasPaired = false;
}
}
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;
}
clearLiveReferencesTo(dead) {
const deadId = dead?.id || dead?.releasedLiveId || "";
if (!deadId) return false;
let changed = false;
for (const other of this.tarinai || []) {
if (!other || other === dead || other.dead) continue;
if (other.target === dead || other.target?.id === deadId) {
other.target = null;
if (["seek_friend", "panic", "fight", "intimidate"].includes(other.state)) other.state = "idle";
changed = true;
}
if (other.panicTarget === dead || other.panicTarget?.id === deadId) { other.panicTarget = null; changed = true; }
if (other.fightTargetId === deadId) { other.fightTargetId = null; changed = true; }
if (Array.isArray(other.fightTargetIds) && other.fightTargetIds.includes(deadId)) {
other.fightTargetIds = other.fightTargetIds.filter(id => id !== deadId);
changed = true;
}
for (const key of ["defeatedById", "fightWinnerId", "intimidateTargetId", "birthPartnerId"]) {
if (other[key] === deadId) { other[key] = null; changed = true; }
}
}
return changed;
}
markDead(t, reason) {
this.clearLiveReferencesTo(t);
if (!this.family) this.family = {};
const finalReason = t?.normalizeDeathReason ? t.normalizeDeathReason(reason) : reason;
const familyKey = this.tarinaiFamilyKey(t);
if (familyKey && this.family[familyKey]) {
if (this.family[familyKey].alive !== false || this.family[familyKey].deathReason !== finalReason) {
this.family[familyKey].alive = false;
this.family[familyKey].deathReason = finalReason;
delete this.family[familyKey].personality;
this.familyVersion = (this.familyVersion || 0) + 1;
this.markFamilyTreeDirty("death");
}
}
this.familyPrunePending = true;
}
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.markFamilyTreeDirty("family-prune");
this.familyCleanVersion = this.familyVersion || 0;
}
addTarinai(opts = {}) {
const t = new Tarinai(this, opts);
this.assignTarinaiLiveId(t);
this.tarinai.push(t);
this.drawListDirty = true;
if (t.hasPaired || (t.parents || []).length || (t.children || []).length) 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.isZunchiSlave || b.isZunchiSlave) return false;
if (this.areParentChild(a, b)) return false;
if (a.sleepDisease || b.sleepDisease || a.fightDisease || b.fightDisease) 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; }
// Do not mark either parent as part of a family during the ritual.
// The archive should only gain nodes after a child has actually been created;
// otherwise childless ritual participants appear as isolated family trees.
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;
const reproductionBlockedByDisease = a.sleepDisease || b.sleepDisease || a.fightDisease || b.fightDisease;
a.birthRitualTimer = b.birthRitualTimer = 0;
a.birthPartnerId = b.birthPartnerId = null;
a.birthRitualLeader = b.birthRitualLeader = false;
a.state = b.state = "idle";
if (reproductionBlockedByDisease) {
a.birthRitualRole = b.birthRitualRole = 0;
return null;
}
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 > 0) this.lastBirthAt = this.time;
if (children.length > 1) this.log(`${a.name}\u3068${b.name}\u306e\u5b50\u304c${children.length}\u5339\u751f\u307e\u308c\u305f\u3002`, "birth", { participants: [a, b, ...children] });
return children[0] || null;
}
areParentChild(a, b) {
if (!a || !b) return false;
const aId = this.tarinaiFamilyKey(a) || a;
const bId = this.tarinaiFamilyKey(b) || b;
if (!aId || !bId) return false;
return Boolean((a.parents || []).includes(bId) || (a.children || []).includes(bId) || (b.parents || []).includes(aId) || (b.children || []).includes(aId));
}
areCoParents(a, b) {
if (!a || !b) return false;
const aLiveId = a.id || a;
const bLiveId = b.id || b;
if (!aLiveId || !bLiveId || aLiveId === bLiveId) return false;
if (a.birthPartnerId && a.birthPartnerId === bLiveId) return true;
if (b.birthPartnerId && b.birthPartnerId === aLiveId) return true;
const aId = this.tarinaiFamilyKey(a) || aLiveId;
const bId = this.tarinaiFamilyKey(b) || bLiveId;
if (!aId || !bId || aId === bId) return false;
const aChildren = new Set((a.children || []).filter(Boolean));
if (!aChildren.size) return false;
for (const id of (b.children || [])) {
if (aChildren.has(id)) return true;
}
return false;
}
canStartFight(t) {
if (!t || t.dead) return false;
if (t.sleepDisease) return false;
// Disease fight rule: explosion, zunchi, and fight disease can lash out;
// sleep disease cannot fight or be challenged, and other diseases cannot start fights.
if (t.explosionDisease || t.zunchiDisease || t.fightDisease) return true;
const flag = (value) => typeof value === "function" ? Boolean(value.call(t)) : Boolean(value);
const hasOtherDisease = flag(t.disease) || flag(t.sick) || flag(t.infection) || flag(t.hasDisease);
if (hasOtherDisease) return false;
return true;
}
canBeFightTarget(t) {
return Boolean(t && !t.dead && !t.sleepDisease);
}
canFightPair(actor, target) {
return this.canStartFight(actor) && this.canBeFightTarget(target);
}
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 childBirthPersonality = personalityFromParents(a, b);
const child = this.addTarinai({
type: mutation,
birthPersonality: childBirthPersonality,
currentPersonality: childBirthPersonality,
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: [this.tarinaiFamilyKey(a), this.tarinaiFamilyKey(b)],
parentNames: [a.name, b.name],
birthTime: this.time,
});
this.maxGeneration = Math.max(this.maxGeneration, child.generation);
a.hasPaired = true;
b.hasPaired = true;
a.affection = 0; b.affection = 0;
a.loneliness *= 0.65; b.loneliness *= 0.65;
a.adjustRelation?.(b, 0.65, -0.20, "co_parent");
b.adjustRelation?.(a, 0.65, -0.20, "co_parent");
a.postBirthPeaceTimer = Math.max(a.postBirthPeaceTimer || 0, 28);
b.postBirthPeaceTimer = Math.max(b.postBirthPeaceTimer || 0, 28);
const childKey = this.tarinaiFamilyKey(child);
const aKey = this.tarinaiFamilyKey(a);
const bKey = this.tarinaiFamilyKey(b);
a.children.push(childKey);
b.children.push(childKey);
this.recordFamily(a);
this.recordFamily(b);
this.recordFamily(child);
if (this.family[aKey] && !this.family[aKey].children.includes(childKey)) this.family[aKey].children.push(childKey);
if (this.family[bKey] && !this.family[bKey].children.includes(childKey)) this.family[bKey].children.push(childKey);
this.familyCleanVersion = null;
audio.birth();
this.lastBirthAt = this.time;
if (total <= 1) this.log(`${child.name}\u304c${a.name}\u3068${b.name}\u306e\u5b50\u3068\u3057\u3066\u751f\u307e\u308c\u305f\u3002`, "birth", { participants: [child, a, b] });
return child;
}
startForcedFight(a, b) {
if (!a || !b || a === b || a.dead || b.dead) return false;
if (!this.canFightPair(a, b)) return false;
const alreadyFighting = a.fightTimer > 0.04 && b.fightTimer > 0.04 && ((a.fightTargetIds || []).includes(b.id) || a.fightTargetId === b.id);
if (alreadyFighting) return true;
for (const t of [a, b]) {
t.birthRitualTimer = 0;
t.birthRitualMax = 0;
t.birthPartnerId = null;
t.birthRitualLeader = false;
t.birthRitualRole = 0;
t.postBirthPeaceTimer = 0;
t.fightCooldown = 0;
t.intimidateTimer = 0;
t.intimidateTargetId = null;
t.intimidatedTimer = 0;
t.defeatedTimer = 0;
t.defeatedById = null;
t.fightWinnerId = null;
t.target = null;
t.state = "idle";
}
this.startFightMochiIntimidation(a, b);
this.startFight(a, b);
return true;
}
startFightMochiIntimidation(a, b) {
if (!a || !b || a.dead || b.dead) return false;
const aDrug = (a.fightMochiTimer || 0) > 0.04;
const bDrug = (b.fightMochiTimer || 0) > 0.04;
const actor = aDrug && !bDrug ? a : (!aDrug && bDrug ? b : (a.energy + a.stress * 0.36 >= b.energy + b.stress * 0.36 ? a : b));
const target = actor === a ? b : a;
if (!this.canFightPair(actor, target)) return false;
actor.intimidateTimer = Math.max(actor.intimidateTimer || 0, rand(0.85, 1.35));
actor.intimidateTargetId = target.id;
actor.surpriseTimer = Math.max(actor.surpriseTimer || 0, 0.28);
target.intimidatedTimer = Math.max(target.intimidatedTimer || 0, rand(0.75, 1.25));
target.fearTimer = Math.max(target.fearTimer || 0, 0.72 * target.personalityProfile().fear);
actor.adjustRelation(target, -0.05, 0.06, "intimidate");
target.adjustRelation(actor, -0.12, 0.30 * target.personalityProfile().fear, "intimidate");
this.effects.push(new Effect("ring", actor.x, actor.y - actor.radius * 0.65, {
life: 0.34,
size: actor.radius * 0.62,
color: "rgba(154, 88, 42, 0.74)",
}));
this.spawnBubble(actor.x, actor.y - actor.radius * 1.30, "!", "rgba(120,62,38,0.82)");
return true;
}
startConflict(a, b) {
if (!a || !b || a.dead || b.dead) return;
if ((a.fightMochiTimer || 0) > 0.04 || (b.fightMochiTimer || 0) > 0.04) { this.startForcedFight(a, b); return; }
if (this.areParentChild(a, b) || this.areCoParents?.(a, b)) 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 aAggressiveIntent = a.shouldApplyPersonalityBehavior?.("aggression", 1) ? 1 : 0;
const bAggressiveIntent = b.shouldApplyPersonalityBehavior?.("aggression", 1) ? 1 : 0;
const scoreA = a.energy * 0.38 + a.mood * 0.18 + a.stress * 0.12 + (a.type === "angry" ? 12 : 0) + aAggressiveIntent * 8 + ((b.relationTo(a.id).fear || 0) * 0.65) + (a.isZunchiSlave ? -10 : 0) + ((a.fightMochiTimer || 0) > 0.04 ? 9 : 0) + rand(-5, 5);
const scoreB = b.energy * 0.38 + b.mood * 0.18 + b.stress * 0.12 + (b.type === "angry" ? 12 : 0) + bAggressiveIntent * 8 + ((a.relationTo(b.id).fear || 0) * 0.65) + (b.isZunchiSlave ? -10 : 0) + ((b.fightMochiTimer || 0) > 0.04 ? 9 : 0) + rand(-5, 5);
const actor = scoreA >= scoreB ? a : b;
const target = actor === a ? b : a;
if (!this.canFightPair(actor, target)) return;
const actorScore = actor === a ? scoreA : scoreB;
const targetScore = actor === a ? scoreB : scoreA;
const canIntimidate = !actor.lowHealthSprite || !actor.lowHealthSprite();
const battleDrug = (actor.fightMochiTimer || 0) > 0.04 || (target.fightMochiTimer || 0) > 0.04;
const intimidationChance = clamp(0.40 + (battleDrug ? 0.24 : 0) + (target.isZunchiSlave ? 0.18 : 0), 0.24, 0.90);
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.shouldApplyPersonalityBehavior?.("aggression", 1) ? 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", { participants: [actor, target] });
actor.lastLog = target.lastLog = this.time;
}
return true;
}
startFight(a, b) {
if (!a || !b || a.dead || b.dead) return;
if (!this.canFightPair(a, b) && !this.canFightPair(b, a)) 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 aAggressiveIntent = a.shouldApplyPersonalityBehavior?.("aggression", 1) ? 1 : 0;
const bAggressiveIntent = b.shouldApplyPersonalityBehavior?.("aggression", 1) ? 1 : 0;
const aScore = a.energy * 0.52 + a.mood * 0.30 + (a.type === "angry" ? 10 : 0) + aAggressiveIntent * 5 + ((a.fightMochiTimer || 0) > 0.04 ? 12 : 0) + (a.isZunchiSlave ? -14 : 0) + rand(-7, 7);
const bScore = b.energy * 0.52 + b.mood * 0.30 + (b.type === "angry" ? 10 : 0) + bAggressiveIntent * 5 + ((b.fightMochiTimer || 0) > 0.04 ? 12 : 0) + (b.isZunchiSlave ? -14 : 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.addStress ? a.addStress(rand(8, 18), { threshold: 8 }) : (a.stress = clamp(a.stress + rand(8, 18), 0, 130));
b.addStress ? b.addStress(rand(8, 18), { threshold: 8 }) : (b.stress = clamp(b.stress + rand(8, 18), 0, 130));
a.damage(rand(2, 7), "\u55a7\u5629");
b.damage(rand(2, 7), "\u55a7\u5629");
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", { participants: [a, b] });
a.lastLog = b.lastLog = this.time;
}
}
damageAntsInRadius(x, y, radius, damageFn, reason = "", opts = {}) {
let hit = 0;
for (const ant of this.ants || []) {
if (!ant || ant.dead) continue;
const d = Math.max(1, distXY(ant.x, ant.y, x, y));
if (d > radius + (ant.r || 4)) continue;
const p = clamp(1 - d / Math.max(1, radius), 0, 1);
if (p <= 0 && d > radius) continue;
const damage = typeof damageFn === "function" ? damageFn(p, ant, d) : Number(damageFn || 0);
if (damage > 0) {
ant.hp = Math.max(0, (ant.hp ?? ant.maxHp ?? ANT_WORKER_HP ?? 32) - damage);
ant.hpBarTimer = Math.max(ant.hpBarTimer || 0, 1.25);
}
if (opts.push) {
let nx = (ant.x - x) / d;
let ny = (ant.y - y) / d;
if (!Number.isFinite(nx) || !Number.isFinite(ny) || Math.abs(nx) + Math.abs(ny) < 0.001) {
const a = rand(0, Math.PI * 2);
nx = Math.cos(a);
ny = Math.sin(a);
}
const push = typeof opts.push === "function" ? opts.push(p, ant, d) : Number(opts.push || 0);
ant.vx = (ant.vx || 0) + nx * push;
ant.vy = (ant.vy || 0) + ny * push;
ant.x = clamp(ant.x + nx * Math.min(18, push * 0.018), CONFIG.worldPadding, this.w - CONFIG.worldPadding);
ant.y = clamp(ant.y + ny * Math.min(18, push * 0.018), CONFIG.worldPadding, this.h - CONFIG.worldPadding);
}
hit += 1;
if (ant.hp <= 0 && ant.dieAsCorpse) ant.dieAsCorpse(ant.homeNest?.());
}
if (hit > 0) this.drawListDirty = true;
return hit;
}
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)" }));
}
this.blastZunchiFrom(x, y, blastRadius * 0.92, it.blastScale || 1);
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;
const wasSleeping = t.state === "sleep" || t.sleeping || t.state === "seek_bed";
t.sleeping = false;
if (wasSleeping) {
t.state = "panic";
t.target = { x, y, dead: false };
t.surpriseTimer = Math.max(t.surpriseTimer, 0.95);
t.fearTimer = Math.max(t.fearTimer, 1.2);
t.thought = "\u7206\u7af9\u3067\u305f\u305f\u304d\u8d77\u3053\u3055\u308c\u305f";
}
t.addStress ? t.addStress(rand(8, 15), { threshold: 8 }) : (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");
t.addStress ? t.addStress(22 * p, { threshold: 8 }) : (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 };
let nx = dx / d;
let ny = dy / d;
if (!Number.isFinite(nx) || !Number.isFinite(ny) || Math.abs(nx) + Math.abs(ny) < 0.001) {
const a = rand(0, Math.PI * 2);
nx = Math.cos(a);
ny = Math.sin(a);
}
const distanceBoost = p * p * 0.55 + p * 0.45;
const power = (260 + distanceBoost * 920) * rand(0.78, 1.24) * (it.blastScale || 1);
this.applyImpulse(t, nx * power + rand(-95, 95) * (0.6 + p), ny * power + rand(-95, 95) * (0.6 + p), {
panic: true,
target: { x, y },
fearTimer: 1.7 + p,
});
t.fallTimer = Math.max(t.fallTimer, 1.45 + p * 1.85);
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 && p > 0.22 && Math.random() < 0.035 * p) t.infectExplosionDisease?.(it);
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)");
}
this.damageAntsInRadius(x, y, blastRadius, p => 5 + p * 18, "\u7206\u7af9", {
push: p => (260 + (p * p * 0.55 + p * 0.45) * 920) * 0.42 * (it.blastScale || 1),
});
let blastedBalls = 0;
for (const ball of this.items) {
if (!ball || ball.dead || ball.type !== "ball") continue;
let dx = ball.x - x;
let dy = ball.y - y;
let d = Math.hypot(dx, dy) || 1;
if (d > blastRadius * 1.18) continue;
const p = clamp(1 - d / (blastRadius * 1.18), 0, 1);
if (d < 0.001) {
const a = rand(0, Math.PI * 2);
dx = Math.cos(a); dy = Math.sin(a); d = 1;
}
const blast = 430 + p * 960;
this.applyImpulse(ball, dx / d * blast + rand(-80, 80), dy / d * blast + rand(-80, 80));
const speed = Math.hypot(ball.vx || 0, ball.vy || 0);
ball.spinVelocity = clamp((ball.spinVelocity || 0) + rand(-24, 24), -38, 38);
ball.lastPokedAt = this.time || 0;
ball.pokeCombo = Math.max(ball.pokeCombo || 0, 5);
this.effects.push(new Effect("ring", ball.x, ball.y, { size: Math.max(18, ball.r * 1.2), life: 0.24, color: "rgba(255,230,116,0.58)" }));
blastedBalls += 1;
}
audio.explode();
this.log(blastedBalls ? "\u7206\u7af9\u304c\u6d3e\u624b\u306b\u306f\u3058\u3051\u3001\u30dc\u30fc\u30eb\u304c\u6025\u52a0\u901f\u3057\u305f\u3002" : "\u7206\u7af9\u304c\u6d3e\u624b\u306b\u306f\u3058\u3051\u3001\u5ead\u304c\u3056\u308f\u3064\u3044\u305f\u3002", "accident");
}
explodeDiseaseTarinai(t) {
if (!t || t.dead) return;
const x = t.x, y = t.y;
const blastRadius = 190;
t.explosionDisease = false;
t.explosionDiseaseTimer = 0;
this.effects.push(new Effect("explosion", x, y, { size: 72, life: 0.82, color: "rgba(255,176,68,0.88)" }));
this.effects.push(new Effect("ring", x, y, { size: 58, life: 0.52, color: "rgba(255,238,132,0.82)" }));
this.blastZunchiFrom(x, y, blastRadius * 0.92, 0.82);
for (const o of this.tarinai) {
if (!o || o.dead || o === t) continue;
const dx = o.x - x;
const dy = o.y - y;
const d = Math.hypot(dx, dy) || 1;
if (d > blastRadius) continue;
const q = clamp(1 - d / blastRadius, 0, 1);
o.damage(4 + q * 15, "\u7206\u767a\u75c5");
o.addStress ? o.addStress(16 * q, { threshold: 8 }) : (o.stress = clamp(o.stress + 16 * q, 0, 130));
let nx = dx / d;
let ny = dy / d;
if (!Number.isFinite(nx) || !Number.isFinite(ny) || Math.abs(nx) + Math.abs(ny) < 0.001) {
const a = rand(0, Math.PI * 2);
nx = Math.cos(a);
ny = Math.sin(a);
}
const diseaseBlast = (190 + q * 620) * rand(0.80, 1.22);
this.applyImpulse(o, nx * diseaseBlast + rand(-64, 64) * (0.5 + q), ny * diseaseBlast + rand(-64, 64) * (0.5 + q), {
panic: true,
target: { x, y },
fearTimer: 1.0 + q,
});
o.hurtTimer = Math.max(o.hurtTimer || 0, 1.2 + q);
o.fearTimer = Math.max(o.fearTimer || 0, 1.0 + q);
if (!o.dead && Math.random() < 0.020 * q) o.infectExplosionDisease?.(t);
}
t.die("\u7206\u767a\u75c5");
audio.explode();
this.log(`${t.name}\u306f\u7206\u767a\u75c5\u3067\u7206\u767a\u3057\u305f\u3002`, "accident", { participants: [t] });
}
applyWaterHose(x, y, dx = 0, dy = 0, dt = 0.08) {
const radius = 96;
const speed = Math.hypot(dx, dy) || 1;
const nx = speed > 1 ? dx / speed : Math.cos(this.time * 9.1);
const ny = speed > 1 ? dy / speed : Math.sin(this.time * 7.7);
let cleaned = 0;
for (const it of this.nearbyItems(x, y, radius + 80)) {
if (!it || it.dead) continue;
const d = distXY(it.x, it.y, x, y);
if (d > radius + (it.r || 12)) continue;
const p = clamp(1 - d / (radius + (it.r || 12)), 0, 1);
if (it.type === "zunchi" || it.type === "splat" || it.type === "trace") {
it.amount -= dt * (it.type === "zunchi" ? 230 : 180) * (0.35 + p);
cleaned += p;
}
}
for (const ef of this.effects || []) {
if (!ef || ef.dead) continue;
if (ef.type !== "bleed" && ef.type !== "splat") continue;
const d = distXY(ef.x || 0, ef.y || 0, x, y);
if (d > radius + 22) continue;
const p = clamp(1 - d / (radius + 22), 0, 1);
ef.life = Math.min(ef.life || 0.1, Math.max(0.01, (ef.life || 0.1) - dt * (1.6 + p * 5.0)));
cleaned += p * 0.55;
}
for (const t of this.nearbyTarinai(x, y, radius + 70)) {
if (!t || t.dead || this.isTarinaiHiddenInNestBox(t)) continue;
const d = distXY(t.x, t.y, x, y);
if (d > radius + t.radius) continue;
const p = clamp(1 - d / (radius + t.radius), 0, 1);
t.vx += nx * (95 + 165 * p) + rand(-10, 10);
t.vy += ny * (95 + 165 * p) + rand(-10, 10);
t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.24);
t.applyWaterEffect?.(dt * (1.0 + p * 1.8), "hose");
}
if (Math.random() < 0.72) {
this.effects.push(new Effect("ring", x + rand(-18, 18), y + rand(-12, 12), { size: rand(8, 22), life: rand(0.18, 0.32), color: "rgba(128,204,238,0.60)" }));
}
if (cleaned > 0.25) {
this.drawListDirty = true;
this.updateItemCounts();
}
}
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)",
}));
}
isSleepFurniture(it) {
return Boolean(it && (it.type === "bed" || it.type === "nest_box"));
}
isTarinaiHiddenInNestBox(t) {
return Boolean(t && !t.dead && t.insideNestBoxId);
}
bedOccupancy(bed) {
if (!this.isSleepFurniture(bed)) return 0;
if (bed.type === "nest_box") {
return this.nestBoxOccupants ? this.nestBoxOccupants(bed, Infinity).length : 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 || this.isTarinaiHiddenInNestBox(t)) continue;
if (t.state === "sleep" || t.state === "seek_bed" || t.target === bed) n += 1;
}
return n;
}
bedComfort(bed) {
if (!this.isSleepFurniture(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) {
const choose = (kind) => {
let best = null;
let bestScore = Infinity;
for (const bed of this.nearbyItems(t.x, t.y, maxDist)) {
if (bed.dead || !this.isSleepFurniture(bed) || bed.type !== kind) continue;
if (bed.type === "nest_box" && t?.insideNestBoxId !== bed.id) {
const occupants = this.nestBoxOccupants ? this.nestBoxOccupants(bed, Infinity) : [];
if (occupants.length >= this.nestBoxCapacity(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 crowdPenalty = bed.type === "nest_box" ? Math.max(0, occ - this.nestBoxCapacity(bed) + 1) * 120 : Math.max(0, occ - 3) * 34;
const score = d - comfort * (bed.type === "nest_box" ? 72 : 46) + crowdPenalty;
if (score < bestScore) { best = bed; bestScore = score; }
}
return best;
};
// Prefer an available nest box, then fall back to a hay bed.
return choose("nest_box") || choose("bed");
}
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;
}
applyImpulse(entity, vx = 0, vy = 0, options = {}) {
if (!entity || entity.dead) return false;
if (!Number.isFinite(vx) || !Number.isFinite(vy)) return false;
if (entity instanceof Tarinai || "impulseVx" in entity || "impulseVy" in entity) {
entity.impulseVx = (Number.isFinite(entity.impulseVx) ? entity.impulseVx : 0) + vx;
entity.impulseVy = (Number.isFinite(entity.impulseVy) ? entity.impulseVy : 0) + vy;
} else {
entity.vx = (Number.isFinite(entity.vx) ? entity.vx : 0) + vx;
entity.vy = (Number.isFinite(entity.vy) ? entity.vy : 0) + vy;
}
if (options.panic && entity instanceof Tarinai) {
entity.state = "panic";
entity.target = options.target || null;
entity.fearTimer = Math.max(entity.fearTimer || 0, options.fearTimer ?? 0.55);
}
return true;
}
applyKnockback(entity, sourceX, sourceY, strength = 0, options = {}) {
if (!entity || !Number.isFinite(strength)) return false;
let dx = (entity.x || 0) - sourceX;
let dy = (entity.y || 0) - sourceY;
let d = Math.hypot(dx, dy);
if (!Number.isFinite(d) || d < 0.001) {
const a = Number.isFinite(options.angle) ? options.angle : rand(0, Math.PI * 2);
dx = Math.cos(a);
dy = Math.sin(a);
d = 1;
}
const random = options.random || 0;
return this.applyImpulse(
entity,
dx / d * strength + rand(-random, random),
dy / d * strength + rand(-random, random),
options,
);
}
applyImpactDamage(target, amount, cause = "\u885d\u7a81", options = {}) {
if (!target || target.dead || !Number.isFinite(amount) || amount <= 0) return false;
const normalized = options.cause || cause || "\u885d\u7a81";
if (target.damage) target.damage(amount, normalized);
else if (Number.isFinite(target.hp)) target.hp -= amount;
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;
winner.totalFightWins = (winner.totalFightWins || 0) + 1;
loser.totalFightLosses = (loser.totalFightLosses || 0) + 1;
winner.adjustPersonality?.("aggression", 0.018, "after winning fights.");
loser.adjustPersonality?.("aggression", -0.018, "after losing fights.");
if ((loser.totalFightLosses || 0) >= 5 && loser.becomeZunchiSlave?.(winner)) {
const oldName = loser.formerName || loser.name;
this.spawnBubble(loser.x, loser.y - loser.radius * 1.20, "\u3076\u308a\u3085\u2026", "rgba(74,91,50,0.78)");
this.log(`${oldName}\u306f5\u56de\u55a7\u5629\u306b\u8ca0\u3051\u3001\u305a\u3093\u3061\u3069\u308c\u3044\u306b\u306a\u3063\u305f\u3002`, "fight", { participants: [loser] });
}
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", { participants: [loser, winner] });
}
}
itemDropImpact(it) {
if (!it || it.dead || it.type === "water") return;
const isStone = it.type === "stone";
const isGenkotsu = it.type === "genkotsu";
const radius = isGenkotsu ? Math.max(118, (it.r || 72) * 1.72) : (isStone ? 64 : Math.max(34, (it.r || 14) * 1.75));
const damage = isGenkotsu ? rand(34, 54) : (isStone ? rand(20, 34) : rand(3, 7));
const push = isGenkotsu ? rand(330, 470) : (isStone ? rand(145, 210) : rand(54, 92));
const reason = isGenkotsu ? "\u3052\u3093\u3053\u3064\u304c\u843d\u3061\u305f" : (isStone ? "\u77f3\u304c\u843d\u3061\u305f" : "\u843d\u3061\u3066\u304d\u305f\u9053\u5177\u306b\u5f53\u305f\u3063\u305f");
if (isGenkotsu) it.impactFlash = 1;
let hit = 0;
for (const t of this.nearbyTarinai(it.x, it.y, radius + 54)) {
if (!t || t.dead || this.isTarinaiHiddenInNestBox(t)) 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;
let nx = (t.x - it.x) / d;
let ny = (t.y - it.y) / d;
if (!Number.isFinite(nx) || !Number.isFinite(ny) || Math.abs(nx) + Math.abs(ny) < 0.001) {
const a = rand(0, Math.PI * 2);
nx = Math.cos(a);
ny = Math.sin(a);
}
t.vx += nx * push * (0.35 + p);
t.vy += ny * push * (0.35 + p) - (isGenkotsu ? 92 : (isStone ? 42 : 18)) * p;
t.damage(damage * (0.35 + p * 0.65), reason);
t.hurtTimer = Math.max(t.hurtTimer, isGenkotsu ? 3.4 : (isStone ? 2.7 : 1.25));
t.fallTimer = Math.max(t.fallTimer, isGenkotsu ? 0.86 : (isStone ? 0.62 : 0.26));
t.fallMax = Math.max(t.fallMax || t.fallTimer, t.fallTimer);
t.fallDir = nx >= 0 ? 1 : -1;
hit += 1;
}
const antHit = this.damageAntsInRadius(it.x, it.y, radius + 54, p => damage * (0.35 + p * 0.65), reason, {
push: p => push * (0.35 + p),
});
if (isGenkotsu) {
for (let r = 0; r < 4; r++) this.effects.push(new Effect("ring", it.x, it.y, { size: 32 + r * 34, life: 0.34 + r * 0.07, color: r % 2 ? "rgba(255,203,54,0.72)" : "rgba(74,57,42,0.42)" }));
for (let i = 0; i < 12; i++) this.effects.push(new Effect("fight", it.x + rand(-38, 38), it.y + rand(-22, 28), { size: rand(12, 22), life: rand(0.25, 0.48), color: "rgba(255,205,46,0.82)" }));
} else {
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 || antHit > 0) this.spawnFallEffect(it.x, it.y + (it.r || 12) * 0.6, isGenkotsu ? 1.22 : (isStone ? 0.9 : 0.45));
if ((isStone || isGenkotsu) && (hit > 0 || antHit > 0)) audio.poke();
if (hit > 0 || antHit > 0) {
const label = isGenkotsu ? "\u3052\u3093\u3053\u3064" : (isStone ? "\u77f3" : "\u9053\u5177");
this.log(`${label}\u304c\u843d\u3061\u3001${hit}\u5339\u306e\u305f\u308a\u306a\u3044\u3068${antHit}\u5339\u306e\u30a2\u30ea\u304c\u5dfb\u304d\u8fbc\u307e\u308c\u305f\u3002`, "accident");
}
}
countBallChasers(ball, except = null) {
if (!ball) return 0;
let count = 0;
for (const t of this.tarinai || []) {
if (!t || t === except || t.dead) continue;
if (t.state === "play_ball" && t.target === ball) count += 1;
}
return count;
}
limitBallChasers() {
if (!this.itemCounts?.ball) return;
const groups = new Map();
for (const t of this.tarinai || []) {
if (!t || t.dead || t.state !== "play_ball" || !t.target || t.target.dead || t.target.type !== "ball") continue;
const arr = groups.get(t.target) || [];
arr.push(t);
groups.set(t.target, arr);
}
for (const [ball, arr] of groups) {
if (arr.length <= 5) continue;
arr.sort((a, b) => {
const ap = ((a.currentPersonality?.openness || 0) >= 0.5) ? -120 : 0;
const bp = ((b.currentPersonality?.openness || 0) >= 0.5) ? -120 : 0;
return dist(a, ball) + ap - (dist(b, ball) + bp);
});
for (const t of arr.slice(5)) {
t.state = "idle";
t.target = null;
t.thought = "\u30dc\u30fc\u30eb\u304c\u6df7\u307f\u5408\u3063\u3066\u3044\u308b\u306e\u3067\u773a\u3081\u3066\u3044\u308b";
t.wanderAngle = Math.atan2(t.y - ball.y, t.x - ball.x) + rand(-0.45, 0.45);
}
}
}
pokeBall(ball, x, y) {
if (!ball || ball.dead || ball.type !== "ball") return false;
const now = (typeof performance !== "undefined" && performance.now) ? performance.now() / 1000 : (this.time || 0);
let dx = ball.x - x;
let dy = ball.y - y;
let d = Math.hypot(dx, dy);
if (d < 0.001) {
const a = (ball.lastPokeAngle ?? rand(0, Math.PI * 2)) + rand(-0.55, 0.55);
dx = Math.cos(a);
dy = Math.sin(a);
d = 1;
}
const nx = dx / d;
const ny = dy / d;
const recent = now - (ball.lastPokedAt || -999) < 0.82;
ball.pokeCombo = recent ? Math.min(10, (ball.pokeCombo || 0) + 1) : 1;
ball.lastPokedAt = now;
ball.lastPokeAngle = Math.atan2(ny, nx);
const currentSpeed = Math.hypot(ball.vx || 0, ball.vy || 0);
const impulse = 145 + ball.pokeCombo * 64 + Math.min(260, currentSpeed * 0.24);
ball.vx = (ball.vx || 0) + nx * impulse;
ball.vy = (ball.vy || 0) + ny * impulse;
const speed = Math.hypot(ball.vx || 0, ball.vy || 0);
ball.spinVelocity = clamp((ball.spinVelocity || 0) + (nx >= 0 ? 1 : -1) * (5.6 + ball.pokeCombo * 1.15), -38, 38);
ball.amount = Math.max(ball.amount || 999, 999);
audio.poke();
this.effects.push(new Effect("ring", ball.x, ball.y, { size: Math.max(16, ball.r * (0.95 + ball.pokeCombo * 0.05)), life: 0.22, color: "rgba(255,255,255,0.58)" }));
this.log("\u30dc\u30fc\u30eb\u3092\u3064\u3064\u3044\u3066\u8ee2\u304c\u3057\u305f\u3002", "observe", { eventType: "ball_poke", hiddenFromObservation: true });
return true;
}
resolveBallBallCollisions(dt) {
if ((this.itemCounts?.ball || 0) < 2) return;
const now = this.time || 0;
if (!this.ballCollisionMemo) this.ballCollisionMemo = new Map();
const balls = (this.items || []).filter(it => it && !it.dead && it.type === "ball");
for (const a of balls) {
const ar = a.r || 18;
const avx = a.vx || 0;
const avy = a.vy || 0;
const aSpeed = Math.hypot(avx, avy);
const range = ar * 2 + aSpeed * Math.max(0.016, dt || 0.016) + 52;
for (const b of this.nearbyItems(a.x, a.y, range) || []) {
if (!b || b === a || b.dead || b.type !== "ball") continue;
const key = a.id < b.id ? `${a.id}:${b.id}` : `${b.id}:${a.id}`;
if (now - (this.ballCollisionMemo.get(key) || -999) < 0.055) continue;
const br = b.r || 18;
const hitRadius = ar + br;
let dx = b.x - a.x;
let dy = b.y - a.y;
let d = Math.hypot(dx, dy);
let hitX = a.x;
let hitY = a.y;
if (d > hitRadius) {
const ax0 = Number.isFinite(a.prevX) ? a.prevX : a.x;
const ay0 = Number.isFinite(a.prevY) ? a.prevY : a.y;
const bx0 = Number.isFinite(b.prevX) ? b.prevX : b.x;
const by0 = Number.isFinite(b.prevY) ? b.prevY : b.y;
const rvx = (a.x - ax0) - (b.x - bx0);
const rvy = (a.y - ay0) - (b.y - by0);
const rx = ax0 - bx0;
const ry = ay0 - by0;
const aa = rvx * rvx + rvy * rvy;
if (aa <= 0.0001) continue;
const bb = 2 * (rx * rvx + ry * rvy);
const cc = rx * rx + ry * ry - hitRadius * hitRadius;
const disc = bb * bb - 4 * aa * cc;
if (disc < 0) continue;
const u = clamp((-bb - Math.sqrt(disc)) / (2 * aa), 0, 1);
const axHit = ax0 + (a.x - ax0) * u;
const ayHit = ay0 + (a.y - ay0) * u;
const bxHit = bx0 + (b.x - bx0) * u;
const byHit = by0 + (b.y - by0) * u;
dx = bxHit - axHit;
dy = byHit - ayHit;
d = Math.hypot(dx, dy);
hitX = (axHit + bxHit) * 0.5;
hitY = (ayHit + byHit) * 0.5;
if (d > hitRadius + 0.5) continue;
} else {
hitX = (a.x + b.x) * 0.5;
hitY = (a.y + b.y) * 0.5;
}
if (d < 0.001) {
const rvx = (b.vx || 0) - (a.vx || 0);
const rvy = (b.vy || 0) - (a.vy || 0);
const rs = Math.hypot(rvx, rvy);
if (rs > 0.001) { dx = rvx / rs; dy = rvy / rs; d = 1; }
else { const ang = rand(0, Math.PI * 2); dx = Math.cos(ang); dy = Math.sin(ang); d = 1; }
}
const nx = dx / d;
const ny = dy / d;
const rvx = (b.vx || 0) - (a.vx || 0);
const rvy = (b.vy || 0) - (a.vy || 0);
const relNormal = rvx * nx + rvy * ny;
if (relNormal > 0 && d >= hitRadius - 0.5) continue;
this.ballCollisionMemo.set(key, now);
const restitution = 0.86;
const impulse = -(1 + restitution) * relNormal / 2;
if (Number.isFinite(impulse)) {
a.vx = (a.vx || 0) - impulse * nx;
a.vy = (a.vy || 0) - impulse * ny;
b.vx = (b.vx || 0) + impulse * nx;
b.vy = (b.vy || 0) + impulse * ny;
}
const overlap = Math.max(0, hitRadius - d + 0.6);
if (overlap > 0) {
a.x -= nx * overlap * 0.5;
a.y -= ny * overlap * 0.5;
b.x += nx * overlap * 0.5;
b.y += ny * overlap * 0.5;
}
const tangent = nx * ((a.vy || 0) - (b.vy || 0)) - ny * ((a.vx || 0) - (b.vx || 0));
a.spinVelocity = clamp((a.spinVelocity || 0) - tangent / Math.max(14, ar) * 1.8, -38, 38);
b.spinVelocity = clamp((b.spinVelocity || 0) + tangent / Math.max(14, br) * 1.8, -38, 38);
this.effects.push(new Effect("ring", hitX, hitY, { size: Math.max(13, hitRadius * 0.38), life: 0.16, color: "rgba(255,255,255,0.44)" }));
}
}
}
resolveBallInteractions(dt) {
if (!this.itemCounts?.ball) return;
for (const ball of this.items) {
if (!ball || ball.dead || ball.type !== "ball") continue;
const ballSpeed = Math.hypot(ball.vx || 0, ball.vy || 0);
const sweep = ballSpeed * Math.max(0.016, dt || 0.016) + 84;
const range = (ball.r || 18) + 72 + sweep;
const px = Number.isFinite(ball.prevX) ? ball.prevX : ball.x;
const py = Number.isFinite(ball.prevY) ? ball.prevY : ball.y;
const sx = ball.x - px;
const sy = ball.y - py;
const segLenSq = sx * sx + sy * sy;
for (const t of this.nearbyTarinai(ball.x, ball.y, range)) {
if (!t || t.dead || t.state === "sleep" || this.isTarinaiHiddenInNestBox(t)) continue;
let hitX = ball.x;
let hitY = ball.y;
if (segLenSq > 1) {
const u = clamp(((t.x - px) * sx + (t.y - py) * sy) / segLenSq, 0, 1);
hitX = px + sx * u;
hitY = py + sy * u;
}
let d = Math.max(0.001, distXY(hitX, hitY, t.x, t.y));
const hitDistance = (ball.r || 18) + t.radius * 0.74;
if (d > hitDistance) continue;
const now = this.time || 0;
const repeat = ball.lastKickerId === t.id && now - (ball.lastKickedAt || -999) < 0.18;
if (repeat && ballSpeed < 300) continue;
let nx = (hitX - t.x) / d;
let ny = (hitY - t.y) / d;
if (!Number.isFinite(nx) || !Number.isFinite(ny) || Math.abs(nx) + Math.abs(ny) < 0.001) {
if (ballSpeed > 0.01) {
nx = (ball.vx || 0) / ballSpeed;
ny = (ball.vy || 0) / ballSpeed;
} else {
nx = t.facingDir ? t.facingDir() : 1;
ny = rand(-0.22, 0.22);
}
}
const isDangerous = ballSpeed >= 320;
if (isDangerous && now - (t.lastBallDamageAt || -999) > 0.46) {
const bvx = ballSpeed > 0.01 ? (ball.vx || 0) / ballSpeed : nx;
const bvy = ballSpeed > 0.01 ? (ball.vy || 0) / ballSpeed : ny;
const damage = clamp((ballSpeed - 285) / 18, 3.5, 38);
t.lastBallDamageAt = now;
this.applyImpulse(t, (ball.vx || 0) * 0.20, (ball.vy || 0) * 0.20, {
panic: true,
target: { x: ball.x, y: ball.y },
fearTimer: 0.95,
});
this.applyImpactDamage(t, damage, "\u885d\u7a81");
const sensitiveHit = t.shouldApplyPersonalityBehavior?.("neuroticism", 1) ? 1.35 : (t.shouldApplyPersonalityBehavior?.("neuroticism", -1) ? 0.82 : 1.0);
const stressGain = clamp(damage * 0.55 * sensitiveHit, 3, sensitiveHit > 1 ? 24 : 18);
t.addStress ? t.addStress(stressGain, { threshold: 8 }) : (t.stress = clamp(t.stress + stressGain, 0, 130));
t.state = "panic";
t.target = { x: ball.x, y: ball.y };
t.hurtTimer = Math.max(t.hurtTimer, damage > 16 ? 2.4 : 1.35);
t.fallTimer = Math.max(t.fallTimer, damage > 14 ? 0.98 : 0.46);
t.fallMax = Math.max(t.fallMax || 0.98, t.fallTimer);
t.fallDir = bvx >= 0 ? 1 : -1;
t.fearTimer = Math.max(t.fearTimer, 0.95);
t.blastSpinTimer = Math.max(t.blastSpinTimer || 0, damage > 14 ? 1.0 : 0.44);
t.blastSpinMax = Math.max(t.blastSpinMax || 0, t.blastSpinTimer);
this.spawnFallEffect(t.x, t.y + t.radius * 0.55, clamp(damage / 18, 0.55, 1.8));
this.effects.push(new Effect("ring", hitX, hitY, { size: Math.max(20, ball.r * 1.25), life: 0.22, color: "rgba(210,75,65,0.50)" }));
if (this.relationNotice(t.id, ball.id || "ball", "fast-ball-hit", 2.8)) this.log(`${t.name}\u306f\u9ad8\u901f\u306e\u30dc\u30fc\u30eb\u306b\u885d\u7a81\u3057\u3066\u5f3e\u304d\u98db\u3070\u3055\u308c\u305f\u3002`, "accident", { participants: [t] });
ball.vx = (ball.vx || 0) * 0.58 - nx * Math.min(110, ballSpeed * 0.10);
ball.vy = (ball.vy || 0) * 0.58 - ny * Math.min(110, ballSpeed * 0.10);
ball.spinVelocity = clamp((ball.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * 8.5, -38, 38);
continue;
}
const relVx = (t.vx || 0) - (ball.vx || 0);
const relVy = (t.vy || 0) - (ball.vy || 0);
const relativeSpeed = Math.hypot(relVx, relVy);
const playIntent = t.state === "play_ball" || t.target === ball;
const playful = t.shouldApplyPersonalityBehavior?.("openness", 1);
const impulse = clamp(48 + relativeSpeed * 0.72 + (playIntent ? 52 : 0) + (playful ? 24 : 0), 48, isDangerous ? 360 : 260);
ball.vx = (ball.vx || 0) * (isDangerous ? 0.78 : 0.92) + nx * impulse + (t.vx || 0) * 0.52;
ball.vy = (ball.vy || 0) * (isDangerous ? 0.78 : 0.92) + ny * impulse + (t.vy || 0) * 0.52;
const kickedSpeed = Math.hypot(ball.vx || 0, ball.vy || 0);
ball.spinVelocity = clamp((ball.spinVelocity || 0) + (nx * (t.vy || 0) - ny * (t.vx || 0)) / 16 + impulse / Math.max(12, ball.r || 18) * (nx >= 0 ? 1 : -1), -38, 38);
ball.x = clamp(t.x + nx * (hitDistance + 2), CONFIG.worldPadding, this.w - CONFIG.worldPadding);
ball.y = clamp(t.y + ny * (hitDistance + 2), CONFIG.worldPadding, this.h - CONFIG.worldPadding);
ball.lastKickedAt = now;
ball.lastKickerId = t.id;
t.vx -= nx * Math.min(22, impulse * 0.10);
t.vy -= ny * Math.min(22, impulse * 0.10);
t.energy = clamp(t.energy - (playIntent ? 0.20 : 0.08), 0, 100);
const playRelief = playful && playIntent ? 2.8 : playful ? 1.5 : playIntent ? 0.75 : 0.20;
const sensitiveBump = t.shouldApplyPersonalityBehavior?.("neuroticism", 1) && !playIntent ? clamp(ballSpeed / 230, 0.6, 2.6) : 0;
t.stress = clamp(t.stress - playRelief + sensitiveBump, 0, 130);
if (playful && playIntent) {
t.loneliness = clamp(t.loneliness - 0.9, 0, 100);
t.affection = clamp(t.affection + 0.18, 0, 100);
}
t.goodMode = playIntent || playful ? "smile" : t.goodMode;
if (playIntent && now > (t.nextPlayBubbleAt || 0)) {
t.nextPlayBubbleAt = now + rand(2.0, 3.8);
this.spawnBubble(t.x, t.y - t.radius * 1.15, pick(["!", "\u306f\u3046", "?" ]), "rgba(70,96,50,0.76)");
}
if (playful && this.relationNotice(t.id, ball.id || "ball", "ball-kick", 16)) this.log(`${t.name}\u306f\u30dc\u30fc\u30eb\u3092\u8ffd\u3044\u304b\u3051\u3066\u5f3e\u3044\u305f\u3002`, "observe", { participants: [t] });
this.effects.push(new Effect("ring", ball.x, ball.y, { size: Math.max(12, ball.r * 0.80), life: 0.20, color: "rgba(170,210,95,0.50)" }));
}
}
}
resolveTarinaiHighSpeedCollisions(dt) {
const threshold = 150;
const now = this.time || 0;
if (!this.tarinaiCollisionMemo) this.tarinaiCollisionMemo = new Map();
for (const a of this.tarinai || []) {
if (!a || a.dead || this.isTarinaiHiddenInNestBox(a)) continue;
const avx = a.vx || 0;
const avy = a.vy || 0;
const aSpeed = Math.hypot(avx, avy);
if (aSpeed < threshold) continue;
const px = Number.isFinite(a.prevX) ? a.prevX : a.x;
const py = Number.isFinite(a.prevY) ? a.prevY : a.y;
const sx = a.x - px;
const sy = a.y - py;
const segLenSq = sx * sx + sy * sy;
const range = (a.radius || 22) * 2.5 + aSpeed * Math.max(0.016, dt || 0.016) + 64;
for (const b of this.nearbyTarinai(a.x, a.y, range)) {
if (!b || b === a || b.dead || this.isTarinaiHiddenInNestBox(b)) continue;
const key = a.id < b.id ? `${a.id}:${b.id}` : `${b.id}:${a.id}`;
if (now - (this.tarinaiCollisionMemo.get(key) || -999) < 0.42) continue;
const bvx = b.vx || 0;
const bvy = b.vy || 0;
const relVx = avx - bvx;
const relVy = avy - bvy;
const relSpeed = Math.hypot(relVx, relVy);
if (relSpeed < threshold) continue;
let hitX = a.x;
let hitY = a.y;
if (segLenSq > 1) {
const u = clamp(((b.x - px) * sx + (b.y - py) * sy) / segLenSq, 0, 1);
hitX = px + sx * u;
hitY = py + sy * u;
}
const d = Math.max(0.001, distXY(hitX, hitY, b.x, b.y));
const hitDistance = (a.radius || 22) * 0.74 + (b.radius || 22) * 0.74;
if (d > hitDistance) continue;
this.tarinaiCollisionMemo.set(key, now);
const impactor = aSpeed >= Math.hypot(bvx, bvy) ? a : b;
const target = impactor === a ? b : a;
const ivx = impactor.vx || 0;
const ivy = impactor.vy || 0;
const impactSpeed = Math.hypot(ivx, ivy) || relSpeed || 1;
let nx = impactSpeed > 0.001 ? ivx / impactSpeed : (target.x - impactor.x) / Math.max(1, distXY(target.x, target.y, impactor.x, impactor.y));
let ny = impactSpeed > 0.001 ? ivy / impactSpeed : (target.y - impactor.y) / Math.max(1, distXY(target.x, target.y, impactor.x, impactor.y));
if (!Number.isFinite(nx) || !Number.isFinite(ny) || Math.abs(nx) + Math.abs(ny) < 0.001) {
const aa = rand(0, Math.PI * 2);
nx = Math.cos(aa);
ny = Math.sin(aa);
}
const damage = clamp((relSpeed - 118) / 26, 1.2, 22);
this.applyImpulse(target, ivx * 0.20 + nx * 28, ivy * 0.20 + ny * 28, {
panic: true,
target: { x: impactor.x, y: impactor.y },
fearTimer: 0.55,
});
impactor.vx = ivx * 0.72 - nx * Math.min(56, relSpeed * 0.10);
impactor.vy = ivy * 0.72 - ny * Math.min(56, relSpeed * 0.10);
this.applyImpactDamage(target, damage, "\u885d\u7a81");
if (!impactor.dead) this.applyImpactDamage(impactor, damage * 0.35, "\u885d\u7a81");
for (const t of [target, impactor]) {
if (!t || t.dead) continue;
t.state = "panic";
t.target = { x: impactor.x, y: impactor.y };
t.hurtTimer = Math.max(t.hurtTimer || 0, 0.9 + damage * 0.035);
t.fallTimer = Math.max(t.fallTimer || 0, 0.36 + damage * 0.025);
t.fallMax = Math.max(t.fallMax || 0, t.fallTimer);
t.fallDir = nx >= 0 ? 1 : -1;
t.fearTimer = Math.max(t.fearTimer || 0, 0.55);
}
this.effects.push(new Effect("ring", (a.x + b.x) * 0.5, (a.y + b.y) * 0.5, { size: Math.max(16, hitDistance * 0.45), life: 0.20, color: "rgba(210,75,65,0.40)" }));
if (this.relationNotice(target.id, impactor.id, "tarinai-highspeed-collision", 2.6)) this.log(`${target.name}\u306f\u9ad8\u901f\u306e${impactor.name}\u306b\u885d\u7a81\u3057\u3066\u5f3e\u304d\u98db\u3070\u3055\u308c\u305f\u3002`, "accident", { participants: [target, impactor] });
}
}
}
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, predicate = null) {
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 || this.isTarinaiHiddenInNestBox(o)) continue;
if (predicate && !predicate(o)) 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(112, (it.r || 42) * 3.55);
const thick = Math.max(10, (it.r || 42) * 0.31);
const halfW = vertical ? thick / 2 : len / 2;
const halfH = vertical ? len / 2 : thick / 2;
return { left: it.x - halfW, right: it.x + halfW, top: it.y - halfH, bottom: it.y + halfH, vertical, horizontal };
}
nestBoxCapacity() {
return 5;
}
nestBoxBaseRect(it) {
if (!it || it.dead || it.type !== "nest_box") return null;
const r = it.r || 42;
return {
left: it.x - r * 1.28,
right: it.x + r * 1.28,
top: it.y - r * 0.80,
bottom: it.y + r * 0.72,
type: "nest_box",
item: it,
};
}
nestBoxSolidRects(it) {
const base = this.nestBoxBaseRect(it);
if (!base) return [];
const w = base.right - base.left;
const h = base.bottom - base.top;
const colW = w / 3;
const rowH = h / 3;
const mk = (left, right, top, bottom, cell) => ({ left, right, top, bottom, type: "nest_box", cell, item: it });
return [
mk(base.left, base.right, base.top, base.top + rowH, "top"),
mk(base.left, base.left + colW, base.top + rowH, base.top + rowH * 2, "middle-left"),
mk(base.right - colW, base.right, base.top + rowH, base.top + rowH * 2, "middle-right"),
];
}
nestBoxTopRect(it) {
return this.nestBoxSolidRects(it)[0] || null;
}
nestBoxEntryPoint(box) {
const base = this.nestBoxBaseRect(box);
if (!base) return { x: box?.x || 0, y: box?.y || 0 };
const r = box.r || 42;
// Move the approach point to the center of the open middle cell. The
// collision resolver also ignores this nest box for active sleepers near
// the door, so tarinai can cross the threshold instead of sliding off posts.
return {
x: clamp(box.x, CONFIG.worldPadding, this.w - CONFIG.worldPadding),
y: clamp(box.y + r * 0.02, base.top + r * 0.46, base.bottom - r * 0.18),
};
}
nestBoxExitPoint(box, occupant = null) {
const base = this.nestBoxBaseRect(box);
if (!base) return this.nestBoxEntryPoint(box);
const r = box.r || 42;
const side = occupant ? (stableUnit(occupant.id || "nest", `nest-exit-${box.id || "box"}`) - 0.5) * r * 0.36 : 0;
return {
x: clamp(box.x + side, CONFIG.worldPadding, this.w - CONFIG.worldPadding),
y: clamp(base.bottom + r * 0.22, CONFIG.worldPadding, this.h - CONFIG.worldPadding),
};
}
nestBoxInnerPoint(box, occupant = null) {
const base = this.nestBoxBaseRect(box);
if (!base) return { x: box?.x || 0, y: box?.y || 0 };
const occupants = this.nestBoxOccupants ? this.nestBoxOccupants(box, Infinity) : [];
let index = occupants.indexOf(occupant);
if (index < 0) index = Math.min(occupants.length, this.nestBoxCapacity(box) - 1);
const r = box.r || 42;
const slots = [
[0.00, -0.02], [-0.22, 0.04], [0.22, 0.04], [-0.11, 0.16], [0.11, 0.16],
];
const slot = slots[index % slots.length];
return {
x: clamp(box.x + slot[0] * r, base.left + r * 0.46, base.right - r * 0.46),
y: clamp(box.y + slot[1] * r, base.top + r * 0.34, base.bottom - r * 0.18),
};
}
solidObstacleRects(it) {
if (!it || it.dead) return [];
if (this.isFenceType(it.type)) {
const rect = this.fenceRect(it);
return rect ? [{ ...rect, type: it.type, item: it }] : [];
}
if (it.type === "nest_box") return this.nestBoxSolidRects(it);
return [];
}
shouldIgnoreNestBoxCollisionFor(t, box) {
if (!t || !box || box.dead || box.type !== "nest_box") return false;
if (t.insideNestBoxId) return true;
if (t.target !== box || (t.state !== "seek_bed" && t.state !== "sleep")) return false;
const entry = this.nestBoxEntryPoint(box);
const base = this.nestBoxBaseRect(box);
const r = box.r || 42;
const entryD = distXY(t.x, t.y, entry.x, entry.y);
const centerD = distXY(t.x, t.y, box.x, box.y);
const aroundDoor = entryD <= Math.max(128, r * 2.25) || centerD <= Math.max(116, r * 2.0);
const inDoorColumn = base && t.x >= base.left + r * 0.18 && t.x <= base.right - r * 0.18 && t.y >= base.top + r * 0.02 && t.y <= base.bottom + r * 0.58;
return Boolean(aroundDoor || inDoorColumn);
}
nearbySolidObstacleRects(x, y, radius, { include = null, exclude = null, maxChecks = CONFIG.maxFenceCollisionChecks ?? 24 } = {}) {
const rects = [];
let checked = 0;
for (const it of this.nearbyItems(x, y, radius)) {
if (!it || it === exclude || it.dead) continue;
if (include && !include(it)) continue;
const partRects = this.solidObstacleRects(it);
if (!partRects.length) continue;
checked += 1;
for (const rect of partRects) rects.push(rect);
if (checked >= maxChecks) break;
}
return rects;
}
pointInRect(x, y, r, padding = 0) {
return Boolean(r && x >= r.left - padding && x <= r.right + padding && y >= r.top - padding && y <= r.bottom + padding);
}
pushTarinaiOutOfRect(t, r, rr) {
if (!t || !r) return false;
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) return false;
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 = Math.min(rr - d + 0.8, Math.max(10, rr * 0.92));
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;
return true;
}
resolveSolidObstacleCollision(t) {
if (!t || t.dead || this.isTarinaiHiddenInNestBox(t)) return;
const rr = Math.max(8, t.radius * 0.74);
let pushed = false;
for (const rect of this.nearbySolidObstacleRects(t.x, t.y, rr + 150)) {
if (rect?.type === "nest_box" && this.shouldIgnoreNestBoxCollisionFor(t, rect.item)) continue;
if (this.pushTarinaiOutOfRect(t, rect, rr)) pushed = true;
}
if (pushed) {
const pad = CONFIG.worldPadding + Math.max(2, t.radius * 0.18);
t.x = clamp(t.x, pad, this.w - pad);
t.y = clamp(t.y, pad, this.h - pad);
t.vx = clamp(t.vx || 0, -130, 130);
t.vy = clamp(t.vy || 0, -130, 130);
}
}
resolveFenceCollision(t) {
this.resolveSolidObstacleCollision(t);
}
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 rect of this.nearbySolidObstacleRects(cx, cy, maxD + padding + 170)) {
const r = { left: rect.left - padding, right: rect.right + padding, top: rect.top - padding, bottom: rect.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, 96)) {
if (it === self || it.dead) continue;
for (const r of this.solidObstacleRects(it)) {
if (this.pointInRect(x, y, r, 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 || this.isTarinaiHiddenInNestBox(t)) 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 = [];
const antCount = (this.ants || []).filter(a => a && !a.dead).length;
const tarinaiCount = (this.tarinai || []).filter(t => t && !t.dead).length;
if (this.drawListDirty || this.drawList.length !== tarinaiCount + antCount) {
this.drawList.length = 0;
for (const t of this.tarinai || []) if (t && !t.dead) this.drawList.push(t);
for (const a of this.ants || []) if (a && !a.dead) this.drawList.push(a);
this.drawList.sort((a, b) => (a.y || 0) - (b.y || 0));
this.drawListDirty = false;
}
return this.drawList;
}
nearbyItems(x, y, radius) {
return this.spatial.nearby(this.spatial.itemCells, x, y, radius, this.spatialItemScratch);
}
nearbyTarinai(x, y, radius) {
return this.spatial.nearby(this.spatial.tarinaiCells, x, y, radius, this.spatialTarinaiScratch);
}
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", this.grassLimit ? this.grassLimit() : (CONFIG.grassLimit ?? 96));
const fenceLimit = this.fenceLimit ? this.fenceLimit() : (CONFIG.fenceLimit ?? 72);
const liveFences = this.liveFenceCount ? this.liveFenceCount() : 0;
if (liveFences > fenceLimit) {
const victims = this.items
.filter(it => this.isFenceType?.(it.type) && !it.dead)
.sort((a, b) => (b.age || 0) - (a.age || 0))
.slice(0, liveFences - fenceLimit);
for (const it of victims) it.amount = 0;
}
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, fence_v: 8, fence_h: 8 };
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;
}
compactAnts() {
if (!this.ants) this.ants = [];
let write = 0;
for (let read = 0; read < this.ants.length; read++) {
const a = this.ants[read];
if (a && !a.dead) this.ants[write++] = a;
}
if (this.ants.length !== write) this.drawListDirty = true;
this.ants.length = write;
}
updateAnts(dt) {
if (!this.ants) this.ants = [];
const nests = (this.items || []).filter(it => it && !it.dead && it.type === "ant_nest");
for (const nest of nests) {
if (typeof ensureAntNestWorkerPool === "function") ensureAntNestWorkerPool(nest);
if (!Number.isFinite(nest.antCount)) nest.antCount = ANT_NEST_START_COUNT || 6;
if (typeof syncAntNestCount === "function") syncAntNestCount(this, nest);
nest.antSpawnTimer = Math.max(0, (nest.antSpawnTimer || 0) - dt);
const outside = this.ants.filter(a => a && !a.dead && a.kind === "worker" && a.homeId === nest.id).length;
const available = Array.isArray(nest.antWorkers) ? nest.antWorkers.length : Math.max(0, (nest.antCount || 0) - outside);
const maxOutside = Math.min(ANT_NEST_MAX_OUTSIDE || 3, nest.antCount || 0);
if (available > 0 && outside < maxOutside && (nest.antSpawnTimer || 0) <= 0 && (this.tarinai || []).some(t => t && !t.dead && !t.insideNestBoxId)) {
const hp = Array.isArray(nest.antWorkers) && nest.antWorkers.length ? clamp(nest.antWorkers.pop(), 0, ANT_WORKER_HP || 32) : (ANT_WORKER_HP || 32);
const a = new AntActor(this, {
kind: "worker",
homeId: nest.id,
x: nest.x + rand(-nest.r * 0.18, nest.r * 0.18),
y: nest.y - nest.r * 1.00 + rand(-3, 3),
hp,
maxHp: ANT_WORKER_HP || 32,
});
this.ants.push(a);
if (typeof syncAntNestCount === "function") syncAntNestCount(this, nest);
nest.antSpawnTimer = rand(1.6, 3.8) + outside * 0.45;
this.drawListDirty = true;
}
}
for (const a of this.ants) a.update(dt);
}
completeAntHaul(home, target) {
if (!home || home.dead || home.type !== "ant_nest" || !target || target.dead) return;
if (typeof ensureAntNestWorkerPool === "function") ensureAntNestWorkerPool(home);
const before = typeof syncAntNestCount === "function"
? syncAntNestCount(this, home)
: clamp(Math.round(home.antCount ?? ANT_NEST_START_COUNT ?? 6), 0, ANT_NEST_MAX_COUNT ?? 10);
if (before >= (ANT_NEST_MAX_COUNT || 10)) {
this.spawnQueenAnt(home);
} else {
if (Array.isArray(home.antWorkers)) home.antWorkers.push(ANT_WORKER_HP || 32);
home.antCount = clamp(before + 1, 0, ANT_NEST_MAX_COUNT || 10);
if (typeof syncAntNestCount === "function") syncAntNestCount(this, home);
}
target.x = home.x;
target.y = home.y;
target.die("\u30a2\u30ea\u306b\u98df\u3079\u3089\u308c\u305f");
this.drawListDirty = true;
}
spawnQueenAnt(home) {
if (!home || home.dead || home.type !== "ant_nest") return null;
if ((home.queenSpawnAt || -999) + (CONFIG.dayLength || 120) > (this.time || 0)) return null;
if ((this.ants || []).some(a => a && !a.dead && a.kind === "queen" && a.homeId === home.id)) return null;
const spot = this.findAntNestFoundingSpot(home) || {
x: clamp((home.x || this.w * 0.5) + rand(-220, 220), 56, this.w - 56),
y: clamp((home.y || this.h * 0.5) + rand(-160, 160), 56, this.h - 56),
};
home.queenSpawnAt = this.time || 0;
const queen = new AntActor(this, {
kind: "queen",
homeId: home.id,
x: home.x,
y: home.y - (home.r || 34) * 0.9,
foundingX: spot.x,
foundingY: spot.y,
foundingDelayUntil: (this.time || 0) + 0.35,
});
this.ants.push(queen);
this.log("\u5973\u738b\u30a2\u30ea\u304c\u65b0\u3057\u3044\u5de3\u5834\u6240\u3092\u63a2\u3057\u59cb\u3081\u305f\u3002", "event");
this.drawListDirty = true;
return queen;
}
findAntNestFoundingSpot(source) {
const baseX = Number.isFinite(source?.x) ? source.x : this.w * 0.5;
const baseY = Number.isFinite(source?.y) ? source.y : this.h * 0.5;
let best = null;
let bestD = -Infinity;
for (let i = 0; i < 70; i++) {
const a = rand(0, Math.PI * 2);
const r = rand(150, Math.min(430, Math.max(this.w, this.h) * 0.52));
const x = clamp(baseX + Math.cos(a) * r, 56, this.w - 56);
const y = clamp(baseY + Math.sin(a) * r * 0.74, 56, this.h - 56);
const nest = new Item("ant_nest", x, y);
const spot = this.findPlacementSpot ? this.findPlacementSpot(nest, { allowOriginal: true, maxRadius: 80, attempts: 10 }) : { x, y };
if (!spot) continue;
let nearest = Infinity;
for (const it of this.items || []) {
if (!it || it.dead || it.type !== "ant_nest") continue;
nearest = Math.min(nearest, distXY(spot.x, spot.y, it.x, it.y));
}
if (nearest > bestD) { best = spot; bestD = nearest; }
if (nearest > 280) break;
}
return best;
}
environmentScores() {
const alive = this.tarinai || [];
const avgStress = alive.length ? alive.reduce((sum, t) => sum + (t.stress || 0), 0) / alive.length : 0;
let fightPressure = 0;
let diseaseCount = 0;
for (const t of alive) {
if (!t || t.dead) continue;
if (t.state === "fight") fightPressure += 16;
if (t.state === "intimidate" || (t.intimidateTimer || 0) > 0.04 || (t.intimidatedTimer || 0) > 0.04) fightPressure += 7;
if ((t.hurtTimer || 0) > 0.04 || (t.lastBleedAt || -999) + 30 > (this.time || 0)) fightPressure += 5;
if (t.zunchiDisease || t.sleepDisease || t.explosionDisease || t.fightDisease) diseaseCount += 1;
}
let blood = 0;
for (const ef of this.effects || []) if (ef && !ef.dead && (ef.type === "bleed" || ef.type === "splat")) blood += 1;
const zunchi = this.itemCounts?.zunchi || 0;
const splat = this.itemCounts?.splat || 0;
const trace = this.itemCounts?.trace || 0;
const antCorpse = this.itemCounts?.ant_corpse || 0;
const security = clamp(100 - fightPressure - blood * 1.6, 0, 100);
const hygiene = clamp(100 - zunchi * 2.0 - splat * 1.2 - trace * 0.30 - antCorpse * 0.65 - blood * 1.8 - diseaseCount * 8.0, 0, 100);
const happiness = clamp(100 - Math.max(0, 100 - security) * 0.32 - Math.max(0, 100 - hygiene) * 0.24 - avgStress * 0.42, 0, 100);
return { security, hygiene, happiness };
}
observeHighlightKind(t) {
const s = this.selected;
if (!s || !t || t === s || t.dead || this.tool !== "observe") return "";
const tFamilyKey = this.tarinaiFamilyKey(t);
if ((s.parents || []).includes(tFamilyKey)) return "parent";
if ((s.children || []).includes(tFamilyKey)) return "child";
const rel = s.relationTo ? s.relationTo(t.id) : null;
if (rel && (rel.affinity || 0) >= FRIEND_AFFINITY_THRESHOLD) return "friend";
if (rel && (rel.fear || 0) >= 8) return "enemy";
return "";
}
nestBoxOccupants(box, limit = 5) {
if (!box || box.dead || box.type !== "nest_box") return [];
const occupants = this.tarinai.filter(t => t && !t.dead && t.insideNestBoxId === box.id);
return Number.isFinite(limit) ? occupants.slice(0, Math.max(0, limit)) : occupants;
}
pointerNestBoxInfo() {
const p = this.pointer;
if (!p?.inside) return null;
let best = null, bestD = Infinity;
for (const it of this.nearbyItems(p.x, p.y, 140)) {
if (!it || it.dead || it.type !== "nest_box") continue;
const d = distXY(p.x, p.y, it.x, it.y);
if (d < bestD && d <= Math.max(80, it.r * 2.1)) { best = it; bestD = d; }
}
if (!best) return null;
const occupants = this.nestBoxOccupants(best, Infinity);
const capacity = this.nestBoxCapacity(best);
return { box: best, occupants, capacity };
}
nestBoxToolTipCount() {
const pointed = this.pointerNestBoxInfo?.();
if (pointed?.box) return { current: pointed.occupants.length, max: pointed.capacity || this.nestBoxCapacity(pointed.box) };
const boxes = (this.items || []).filter(it => it && !it.dead && it.type === "nest_box");
if (!boxes.length) return { current: 0, max: this.nestBoxCapacity() };
let current = 0, max = 0;
for (const box of boxes) {
current += this.nestBoxOccupants(box, Infinity).length;
max += this.nestBoxCapacity(box);
}
return { current, max };
}
blastZunchiFrom(x, y, radius, strength = 1) {
let moved = 0;
for (const it of this.nearbyItems(x, y, radius + 60)) {
if (!it || it.dead || it.type !== "zunchi") continue;
let dx = it.x - x;
let dy = it.y - y;
let d = Math.hypot(dx, dy) || 1;
if (d > radius) continue;
if (d < 0.001) {
const a = rand(0, Math.PI * 2);
dx = Math.cos(a); dy = Math.sin(a); d = 1;
}
const p = clamp(1 - d / radius, 0, 1);
it.vx = (it.vx || 0) + dx / d * (130 + p * 430) * strength + rand(-45, 45);
it.vy = (it.vy || 0) + dy / d * (130 + p * 430) * strength + rand(-45, 45);
it.amount = Math.max(10, (it.amount || 80) - p * 8);
it.stage = "fresh";
moved += 1;
if (moved < 12) this.effects.push(new Effect("zunchi_miasma", it.x, it.y - 4, { vx: (it.vx || 0) * 0.15, vy: (it.vy || 0) * 0.15 - 18, size: rand(9, 18), life: rand(0.36, 0.62), color: "rgba(58,102,38,0.48)" }));
}
if (moved) this.drawListDirty = true;
return moved;
}
update(dt) {
if (this.paused) return;
dt *= this.speed;
dt = Math.min(dt, 0.12 * this.speed);
this.time += dt;
const previousDay = this.day || 1;
this.day = Math.floor(this.time / CONFIG.dayLength) + 1;
if (this.day !== previousDay) {
for (const t of this.tarinai || []) if (t) t.personalityDaily = { day: this.day, total: 0, byKey: {} };
}
if (this.pointer) this.pointer.motion = Math.max(0, (this.pointer.motion || 0) - dt * 2.4);
this.updateWeather(dt);
this.rebuildSpatial();
const perf = this.performanceLevel();
const itemCountBefore = this.items.length;
for (const it of this.items) it.update(dt, this);
if (this.itemCounts?.ball) {
this.rebuildSpatial();
this.resolveBallBallCollisions(dt);
this.rebuildSpatial();
}
this.updateAnts?.(dt);
for (const ef of this.effects) ef.update(dt);
for (const t of this.tarinai) {
t.update(dt);
}
this.rebuildSpatial();
this.limitBallChasers();
this.resolveBallInteractions(dt);
this.resolveTarinaiHighSpeedCollisions(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.compactAnts?.();
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;
}
if ((this.outOfBoundsCheckAt || -999) + 1.0 <= this.time) {
this.outOfBoundsCheckAt = this.time;
this.sanitizeOutOfBounds();
}
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 <= 10 && this.time - (this.lastBirthAt || -999) > 12 && Math.random() < dt * 0.022) {
const spot = this.edgeSpawnPoint(null, null, 42, true);
const t = this.addTarinai({ x: spot.x, y: spot.y, vx: spot.vx, vy: spot.vy, type: baseSpriteId(), entryTimer: 2.0 });
t.wanderAngle = spot.angle;
t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.35);
this.log(`${t.name}\u304c\u753b\u9762\u5916\u304b\u3089\u8ff7\u3044\u8fbc\u3093\u3067\u304d\u305f\u3002`);
}
const grassCount = this.itemCounts.grass || 0;
if (grassCount < Math.min(24, this.grassLimit ? this.grassLimit() : (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) < 24 && Math.random() < dt * 0.12) {
const drop = new Item("water", rand(58, this.w - 58), rand(58, this.h - 58));
drop.amount = rand(24, 58);
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));
}
}
sanitizeOutOfBounds() {
const pad = Math.max(28, CONFIG.worldPadding || 30);
const far = Math.max(360, Math.max(this.w || 1000, this.h || 720) * 0.75);
let changedItems = false;
const repairPoint = (obj, radius = 12) => {
if (!obj) return "delete";
if (!Number.isFinite(obj.x) || !Number.isFinite(obj.y)) return "delete";
const minX = pad - radius;
const maxX = (this.w || 1000) - pad + radius;
const minY = pad - radius;
const maxY = (this.h || 720) - pad + radius;
if (obj.x >= minX && obj.x <= maxX && obj.y >= minY && obj.y <= maxY) return "ok";
if (obj.x < -far || obj.x > (this.w || 1000) + far || obj.y < -far || obj.y > (this.h || 720) + far) return "delete";
obj.x = clamp(obj.x, pad, (this.w || 1000) - pad);
obj.y = clamp(obj.y, pad, (this.h || 720) - pad);
if (Number.isFinite(obj.vx)) obj.vx *= -0.18;
if (Number.isFinite(obj.vy)) obj.vy *= -0.18;
return "moved";
};
for (const it of this.items || []) {
const result = repairPoint(it, it.r || 12);
if (result === "delete") { it.amount = 0; changedItems = true; }
else if (result === "moved") changedItems = true;
}
for (const ef of this.effects || []) {
const result = repairPoint(ef, ef.size || 12);
if (result === "delete") ef.life = 0;
}
for (const t of this.tarinai || []) {
if (!t || t.dead) continue;
const result = repairPoint(t, t.radius || 22);
if (result === "delete") {
t.x = clamp(Number.isFinite(t.x) ? t.x : this.w * 0.5, pad, this.w - pad);
t.y = clamp(Number.isFinite(t.y) ? t.y : this.h * 0.5, pad, this.h - pad);
t.vx = 0;
t.vy = 0;
t.entryTimer = 0;
t.die(HEALTH?.CAUSES?.outOfBounds || "\u4ed5\u69d8\u306b\u3088\u308a\u753b\u9762\u5916\u3067\u524a\u9664");
} else if (result === "moved") {
t.x = clamp(Number.isFinite(t.x) ? t.x : this.w * 0.5, pad, this.w - pad);
t.y = clamp(Number.isFinite(t.y) ? t.y : this.h * 0.5, pad, this.h - pad);
t.vx = 0;
t.vy = 0;
t.entryTimer = 0;
}
}
let changedAnts = false;
for (const ant of this.ants || []) {
if (!ant || ant.dead) continue;
const result = repairPoint(ant, ant.kind === "queen" ? 18 : 10);
if (result === "delete") {
ant.dead = true;
changedAnts = true;
continue;
}
if (result === "moved") {
ant.vx = 0;
ant.vy = 0;
if (ant.state === "drag") {
ant.state = "return";
ant.targetId = "";
ant.targetToken = 0;
ant.targetRef = null;
}
changedAnts = true;
}
}
if (changedAnts) this.compactAnts?.();
if (changedItems) {
this.compactItems();
this.updateItemCounts();
this.rebuildSpatial();
}
}
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"];
const next = pick(choices);
this.weather = next;
this.nextWeatherChange = rand(CONFIG.weatherChangeMin, CONFIG.weatherChangeMax);
if (next !== current) this.log(`\u5929\u6c17: ${weatherLabel(this.weather)}`);
}
fencePlacementBlocked(item) {
if (!item || !this.isFenceType(item.type)) return false;
const rect = this.fenceRect(item);
if (!rect) return false;
const margin = Math.max(6, (item.r || 42) * 0.24);
if (rect.left < CONFIG.worldPadding || rect.top < CONFIG.worldPadding || rect.right > this.w - CONFIG.worldPadding || rect.bottom > this.h - CONFIG.worldPadding) return true;
const searchRadius = Math.max(rect.right - rect.left, rect.bottom - rect.top) * 0.5 + 72;
for (const it of this.nearbyItems(item.x, item.y, searchRadius)) {
if (!it || it.dead) continue;
for (const r of this.solidObstacleRects(it)) {
const separated = rect.right + margin < r.left || rect.left - margin > r.right || rect.bottom + margin < r.top || rect.top - margin > r.bottom;
if (!separated) return true;
}
}
return false;
}
importantPlacementOverlapBlocked(item) {
if (!item || item.dead) return true;
const important = new Set(["grass", "fence_v", "fence_h", "nest_box", "water_bowl"]);
if (!important.has(item.type)) return false;
const placingGrass = item.type === "grass";
const itemRects = this.solidObstacleRects(item);
const radiusFor = typeof itemRadiusFor === "function" ? itemRadiusFor : ((type, fallback = 12) => fallback);
const itemRadius = Math.max(14, item.r || radiusFor(item.type, 12) || 12);
const rectCircleOverlap = (rect, cx, cy, radius, margin = 0) => {
if (!rect) return false;
const px = clamp(cx, rect.left - margin, rect.right + margin);
const py = clamp(cy, rect.top - margin, rect.bottom + margin);
return distXY(cx, cy, px, py) < radius + margin;
};
const rectsOverlap = (a, b, margin = 0) => Boolean(a && b && !(a.right + margin < b.left || a.left - margin > b.right || a.bottom + margin < b.top || a.top - margin > b.bottom));
for (const other of this.nearbyItems(item.x, item.y, Math.max(132, itemRadius * 4))) {
if (!other || other === item || other.dead || !important.has(other.type)) continue;
// Grass is a ground layer: objects may be placed on top of existing grass.
// Grass placement itself still avoids objects and other grass via grassSpotOpen().
if (!placingGrass && other.type === "grass") continue;
const otherRects = this.solidObstacleRects(other);
const otherRadius = Math.max(14, other.r || radiusFor(other.type, 12) || 12);
const margin = Math.max(4, Math.min(itemRadius, otherRadius) * 0.18);
if (itemRects.length && otherRects.length) {
if (itemRects.some(a => otherRects.some(b => rectsOverlap(a, b, margin)))) return true;
continue;
}
if (itemRects.length) {
if (itemRects.some(r => rectCircleOverlap(r, other.x, other.y, otherRadius, margin))) return true;
continue;
}
if (otherRects.length) {
if (otherRects.some(r => rectCircleOverlap(r, item.x, item.y, itemRadius, margin))) return true;
continue;
}
if (distXY(item.x, item.y, other.x, other.y) < itemRadius + otherRadius + margin) return true;
}
return false;
}
placementBlocked(item) {
if (!item || item.dead) return true;
if (item.type === "genkotsu") return false;
const pad = CONFIG.worldPadding || 30;
const rects = this.solidObstacleRects(item);
if (rects.length) {
for (const rect of rects) {
if (rect.left < pad || rect.top < pad || rect.right > this.w - pad || rect.bottom > this.h - pad) return true;
}
} else {
const radius = Math.max(14, (item.r || 12) * (item.type === "bed" ? 1.55 : 1.25));
if (item.x - radius < pad || item.y - radius < pad || item.x + radius > this.w - pad || item.y + radius > this.h - pad) return true;
}
if (item.type === "grass" && !this.grassSpotOpen(item.x, item.y, { avoidTarinai: true })) return true;
if (this.isFenceType(item.type) && this.fencePlacementBlocked(item)) return true;
if (this.importantPlacementOverlapBlocked(item)) return true;
if (item.type === "nest_box") {
const margin = Math.max(8, (item.r || 42) * 0.18);
for (const it of this.nearbyItems(item.x, item.y, Math.max(120, (item.r || 42) * 3))) {
if (!it || it === item || it.dead) continue;
for (const rect of this.solidObstacleRects(it)) {
for (const own of rects) {
const separated = own.right + margin < rect.left || own.left - margin > rect.right || own.bottom + margin < rect.top || own.top - margin > rect.bottom;
if (!separated) return true;
}
}
}
}
return false;
}
placementClampPointFor(item, x = item?.x || 0, y = item?.y || 0) {
const pad = CONFIG.worldPadding || 30;
const rects = this.solidObstacleRects(item);
if (rects.length) {
let left = 0, right = 0, top = 0, bottom = 0;
for (const rect of rects) {
left = Math.max(left, (item.x || 0) - rect.left);
right = Math.max(right, rect.right - (item.x || 0));
top = Math.max(top, (item.y || 0) - rect.top);
bottom = Math.max(bottom, rect.bottom - (item.y || 0));
}
return {
x: clamp(x, pad + left, this.w - pad - right),
y: clamp(y, pad + top, this.h - pad - bottom),
};
}
const radius = Math.max(14, (item.r || 12) * (item.type === "bed" ? 1.55 : 1.25));
return {
x: clamp(x, pad + radius, this.w - pad - radius),
y: clamp(y, pad + radius, this.h - pad - radius),
};
}
placementProbeItem(item, x, y) {
return { ...item, x, y, dead: false };
}
findPlacementSpot(item, { allowOriginal = true, maxRadius = null, attempts = null } = {}) {
if (!item || item.dead) return null;
if (item.type === "grass") {
const spot = this.findGrassPlantingSpot
? this.findGrassPlantingSpot(item.x, item.y, { allowOriginal, attempts: attempts || 42, maxRadius: maxRadius || 170, avoidTarinai: true })
: null;
if (!spot) return null;
const probe = this.placementProbeItem(item, spot.x, spot.y);
return this.placementBlocked(probe) ? null : spot;
}
const start = this.placementClampPointFor(item, item.x, item.y);
const original = this.placementProbeItem(item, start.x, start.y);
if (allowOriginal && !this.placementBlocked(original)) return start;
const solid = this.isFenceType(item.type) || item.type === "nest_box";
const searchRadius = maxRadius || (solid ? Math.max(150, (item.r || 42) * 4.2) : Math.max(90, (item.r || 16) * 3.2));
const count = attempts || (solid ? 72 : 36);
const golden = Math.PI * (3 - Math.sqrt(5));
let best = null;
let bestD = Infinity;
for (let i = 0; i < count; i++) {
const t = count <= 1 ? 1 : i / (count - 1);
const radius = Math.max(8, searchRadius * Math.sqrt(t));
const angle = i * golden + stableUnit(`${item.type}:${item.x.toFixed(1)},${item.y.toFixed(1)}`, "place") * Math.PI * 2;
const rawX = item.x + Math.cos(angle) * radius;
const rawY = item.y + Math.sin(angle) * radius * 0.74;
const p = this.placementClampPointFor(item, rawX, rawY);
const probe = this.placementProbeItem(item, p.x, p.y);
if (this.placementBlocked(probe)) continue;
const d = distXY(item.x, item.y, p.x, p.y);
if (d < bestD) { best = p; bestD = d; }
if (d < Math.max(18, (item.r || 12) * 0.75)) break;
}
return best;
}
findGrabTargetAt(x, y) {
let foundT = null;
for (let i = this.tarinai.length - 1; i >= 0; i--) {
const t = this.tarinai[i];
if (!t || t.dead || this.isTarinaiHiddenInNestBox(t)) continue;
if (t.contains ? t.contains(x, y) : distXY(x, y, t.x, t.y) <= (t.radius || 22) * 1.4) { foundT = t; break; }
}
let foundItem = null, foundItemD = Infinity;
for (let i = this.items.length - 1; i >= 0; i--) {
const it = this.items[i];
if (!it || it.dead || it.type === "trace" || it.type === "splat") continue;
const hit = Math.max(24, (it.r || 12) * (it.type === "ball" ? 4.0 : 2.2));
const d = distXY(x, y, it.x, it.y);
if (d <= hit && d < foundItemD) { foundItem = it; foundItemD = d; }
}
if (foundT && foundItem) {
const td = distXY(x, y, foundT.x, foundT.y);
return td <= foundItemD + 8 ? foundT : foundItem;
}
return foundT || foundItem;
}
placeItem(item, dropped = true) {
if (!item) return null;
if (item.type === "grass" && (this.itemCounts?.grass || 0) >= (this.grassLimit ? this.grassLimit() : (CONFIG.grassLimit ?? 96))) {
showToast("\u3053\u306e\u30d5\u30a3\u30fc\u30eb\u30c9\u306e\u8349\u306e\u4e0a\u9650\u306b\u9054\u3057\u3066\u3044\u307e\u3059\u3002");
return null;
}
if (this.isFenceType(item.type)) {
this.compactItems();
this.updateItemCounts();
if (this.liveFenceCount() >= this.fenceLimit()) {
showToast("\u67f5\u304c\u591a\u3059\u304e\u307e\u3059\u3002\u5148\u306b\u4e00\u90e8\u3092\u524a\u9664\u3057\u3066\u304f\u3060\u3055\u3044\u3002");
return null;
}
}
if (this.placementBlocked(item)) {
showToast(this.isFenceType(item.type) ? "\u305d\u3053\u306b\u306f\u67f5\u3092\u8ffd\u52a0\u3067\u304d\u307e\u305b\u3093\u3002" : "\u305d\u3053\u306b\u306f\u914d\u7f6e\u3067\u304d\u307e\u305b\u3093\u3002");
return null;
}
if (dropped) {
item.dropMax = item.type === "genkotsu" ? 0.78 : (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.isTarinaiHiddenInNestBox(this.tarinai[i]) && this.tarinai[i].contains(x, y)) { found = this.tarinai[i]; break; }
}
this.selected = found;
return;
}
if (tool === "poke") {
let ballTarget = null;
let ballTargetD = Infinity;
for (let i = this.items.length - 1; i >= 0; i--) {
const it = this.items[i];
if (!it || it.dead || it.type !== "ball") continue;
const hit = Math.max(72, (it.r || 18) * 4.0);
const d = distXY(x, y, it.x, it.y);
if (d <= hit && d < ballTargetD) { ballTarget = it; ballTargetD = d; }
}
if (ballTarget && this.pokeBall(ballTarget, x, y)) return;
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`, "observe", { participants: [target] });
} else {
this.log("\u7a7a\u6c17\u3092\u3064\u3064\u3044\u305f\u3002\u305d\u3053\u306b\u306f\u4f55\u3082\u305f\u308a\u306a\u304b\u3063\u305f\u3002", "observe", { hiddenFromObservation: true, countInStats: false });
}
return;
}
if (tool === "new") {
const spot = this.edgeSpawnPoint(x, y, 42, true);
const t = this.addTarinai({ x: spot.x, y: spot.y, vx: spot.vx, vy: spot.vy, type: baseSpriteId(), generation: 1, entryTimer: 2.0 });
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 = toolLabel(best.type);
this.log(`${label}\u3092\u524a\u9664\u3057\u305f\u3002`, "observe");
return;
}
const itemType = toolItemType(tool);
if (itemType) {
let px = x, py = y;
const rawItem = this.applyToolSize(new Item(itemType, px, py));
// Placement uses the same footprint that the preview validates.
const placed = this.placeItem(rawItem, true);
if (!placed) return;
const label = toolLabel(itemType);
this.log(itemType === "genkotsu" ? `${label}\u3092\u843d\u3068\u3057\u305f\u3002` : `${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("\u55a7\u5629") || s.includes("\u8ca0\u3051")) 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:") || s.includes("\u6b7b\u56e0:")) 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";
}
shouldHideFromObservationRecords(entry = {}) {
const type = typeof entry === "string" ? "" : (entry.eventType || "");
return type === "ball_poke" || type === "avoid_relationship";
}
log(text, kind = null, options = {}) {
const entry = {
time: this.time,
text,
kind: kind || this.inferLogKind(text),
eventType: options.eventType || "",
hiddenFromObservation: Boolean(options.hiddenFromObservation),
participants: (options.participants || []).filter(Boolean).map(t => ({
id: t.id || t.releasedLiveId || "",
liveId: t.id || t.releasedLiveId || "",
liveToken: t.liveToken || t.releasedLiveToken || 0,
familyKey: this.tarinaiFamilyKey(t),
name: t.name || "",
type: t.type || "",
generation: t.generation || 1,
})),
participantLiveIds: (options.participants || []).filter(t => t?.id).map(t => t.id),
participantSnapshots: (options.participants || []).filter(Boolean).map(t => ({
liveId: t.id || t.releasedLiveId || "",
liveToken: t.liveToken || t.releasedLiveToken || 0,
familyKey: this.tarinaiFamilyKey(t),
name: t.name || "",
type: t.type || "",
generation: t.generation || 1,
})),
};
if (this.shouldHideFromObservationRecords(entry)) entry.hiddenFromObservation = true;
const countInStats = options.countInStats ?? !entry.hiddenFromObservation;
entry.countInStats = Boolean(countInStats);
if (!this.eventCounters) this.eventCounters = {};
if (countInStats) this.eventCounters[entry.kind] = (this.eventCounters[entry.kind] || 0) + 1;
for (const t of options.participants || []) {
if (!t) continue;
if (!entry.hiddenFromObservation && t.addRecord) t.addRecord(text, entry.kind);
}
this.logs.unshift(entry);
this.logs = this.logs.slice(0, 100);
renderLog(this.logs);
}
}
let world;
try {
world = new World();
} catch (error) {
if (typeof window !== "undefined") window.__worldInitError = error?.stack || String(error);
throw error;
}