tarinai/js/world.js

2191 lines
93 KiB
JavaScript
Raw Normal View History

2026-06-19 14:00:48 +09:00
"use strict";
class World {
constructor() {
this.w = 1000;
this.h = 720;
this.viewportW = 1000;
this.viewportH = 720;
this.time = 0;
this.day = 1;
this.paused = false;
this.speed = 1;
this.tool = "observe";
this.toolSize = "medium";
this.toolSizes = {};
this.tarinai = [];
this.items = [];
this.effects = [];
this.logs = [];
this.eventCounters = {};
this.countsTimer = 0;
this.compactTimer = 0;
this.drawSortTimer = 0;
this.drawListDirty = true;
this.selected = null;
this.deadCount = 0;
2026-06-19 23:22:15 +09:00
this.lastBirthAt = -999;
2026-06-19 14:00:48 +09:00
this.maxGeneration = 1;
this.family = {};
this.familyVersion = 0;
this.relationNotices = {};
this.resolvedFightIds = {};
2026-06-19 20:52:40 +09:00
this.pointer = { x: 0, y: 0, inside: false, motion: 0, movedAt: 0 };
2026-06-19 14:00:48 +09:00
this.lastPhase = "";
this.weather = "sunny";
this.fieldType = "garden";
this.fieldZoom = 1;
this.cameraX = 0;
this.cameraY = 0;
this.nextWeatherChange = rand(CONFIG.weatherChangeMin, CONFIG.weatherChangeMax);
this.spatial = new SpatialGrid(96);
this.spatialScratch = [];
this.drawList = [];
this.itemCounts = {};
this.effectCounts = {};
this.countsTimer = 0;
this.compactTimer = 0;
this.drawSortTimer = 0;
this.drawListDirty = true;
this.updateItemCounts();
this.updateEffectCounts();
this.rebuildSpatial();
}
dayProgress() {
return (this.time % CONFIG.dayLength) / CONFIG.dayLength;
}
lightLevel() {
return (Math.sin(this.dayProgress() * Math.PI * 2 - Math.PI / 2) + 1) / 2;
}
phaseName() {
const p = this.dayProgress();
if (p < 0.20) return "\u671d";
if (p < 0.48) return "\u663c";
if (p < 0.68) return "\u5915\u65b9";
return "\u591c";
}
clockString() {
const totalMinutes = Math.floor(this.dayProgress() * 24 * 60);
const hh = String(Math.floor(totalMinutes / 60)).padStart(2, "0");
const mm = String(totalMinutes % 60).padStart(2, "0");
return `${hh}:${mm}`;
}
fieldDefinition() {
return FIELD_TYPES?.[this.fieldType] || FIELD_TYPES.garden;
}
fieldWorldScale() {
const field = this.fieldDefinition();
// Field type defines the logical simulation area. Wheel zoom must not
// rewrite this size; it is a camera/display transform only.
return Math.max(0.22, field.worldScale || 1);
}
2026-06-19 20:52:40 +09:00
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;
2026-06-19 23:22:15 +09:00
// \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
2026-06-19 20:52:40 +09:00
return Math.max(12, Math.round(base * ratio));
}
2026-06-19 23:22:15 +09:00
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;
}
2026-06-19 20:52:40 +09:00
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);
}
2026-06-19 14:00:48 +09:00
viewScale() {
2026-06-19 20:52:40 +09:00
return this.normalizedFieldZoom(this.fieldZoom);
2026-06-19 14:00:48 +09:00
}
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;
2026-06-19 20:52:40 +09:00
this.fieldZoom = this.normalizedFieldZoom(this.fieldZoom);
2026-06-19 14:00:48 +09:00
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) {
2026-06-19 20:52:40 +09:00
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;
}
2026-06-19 14:00:48 +09:00
this.fieldZoom = next;
this.clampCamera();
this.drawListDirty = true;
return true;
}
2026-06-19 20:52:40 +09:00
edgeSpawnPoint(targetX = null, targetY = null, margin = 34, outside = false) {
2026-06-19 14:00:48 +09:00
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);
}
2026-06-19 20:52:40 +09:00
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 };
2026-06-19 14:00:48 +09:00
}
toolSizeFor(type = "") {
2026-06-20 02:07:01 +09:00
if (!["firecracker", "fence_v", "fence_h", "stone", "grass", "sweet", "love_mochi", "fight_mochi", "sleep_drug", "zunchi"].includes(type)) return "medium";
2026-06-19 14:00:48 +09:00
return (this.toolSizes && this.toolSizes[type]) || this.toolSize || "medium";
}
toolSizeScale(type = "") {
2026-06-20 02:07:01 +09:00
if (!["firecracker", "fence_v", "fence_h", "stone", "grass", "sweet", "love_mochi", "fight_mochi", "sleep_drug", "zunchi"].includes(type)) return 1;
2026-06-19 14:00:48 +09:00
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;
2026-06-20 02:07:01 +09:00
if (["sweet", "love_mochi", "fight_mochi", "sleep_drug", "grass", "zunchi"].includes(item.type)) item.amount *= scale * scale;
2026-06-19 14:00:48 +09:00
if (item.type === "firecracker") item.blastScale = scale;
return item;
}
reset(seedPopulation = null, fieldType = this.fieldType || "garden") {
const field = FIELD_TYPES?.[fieldType] || FIELD_TYPES.garden;
this.fieldType = field.id;
this.fieldZoom = 1;
this.setViewportSize(this.viewportW, this.viewportH);
this.time = 0;
this.day = 1;
this.paused = false;
this.speed = 1;
this.toolSize = this.toolSize || "medium";
this.toolSizes = this.toolSizes || {};
this.tarinai = [];
this.items = [];
this.effects = [];
this.logs = [];
this.eventCounters = {};
this.countsTimer = 0;
this.compactTimer = 0;
this.drawSortTimer = 0;
this.drawListDirty = true;
this.selected = null;
this.deadCount = 0;
2026-06-19 23:22:15 +09:00
this.lastBirthAt = -999;
2026-06-19 14:00:48 +09:00
this.maxGeneration = 1;
this.family = {};
this.familyVersion = 0;
this.relationNotices = {};
this.resolvedFightIds = {};
2026-06-19 20:52:40 +09:00
this.pointer = { x: this.w / 2, y: this.h / 2, inside: false, motion: 0, movedAt: 0 };
2026-06-19 14:00:48 +09:00
this.cameraX = Math.max(0, (this.w - (this.viewportW || this.w)) / 2);
this.cameraY = Math.max(0, (this.h - (this.viewportH || this.h)) / 2);
this.clampCamera();
this.lastPhase = this.phaseName();
this.weather = "sunny";
this.nextWeatherChange = rand(CONFIG.weatherChangeMin, CONFIG.weatherChangeMax);
seedPopulation = seedPopulation ?? field.population ?? CONFIG.initialPopulation;
const grassCount = field.grass ?? 18;
const types = spawnableSprites().map(s => s.id);
for (let i = 0; i < seedPopulation; i++) {
this.addTarinai({
type: types[i % types.length],
x: rand(80, this.w - 80),
y: rand(80, this.h - 80),
generation: 1,
});
}
for (let i = 0; i < grassCount; i++) this.items.push(new Item("grass", rand(70, this.w - 70), rand(70, this.h - 70)));
this.items.push(new Item("stone", this.w * 0.68, this.h * 0.32));
this.items.push(new Item("bed", this.w * 0.82, this.h * 0.22));
this.items.push(new Item("bed", this.w * 0.18, this.h * 0.78));
this.updateItemCounts();
this.updateEffectCounts();
this.rebuildSpatial();
this.log("\u305f\u308a\u306a\u3044\u3092\u89b3\u5bdf\u7bb1\u306b\u914d\u7f6e\u3057\u307e\u3057\u305f\u3002");
showToast("\u65b0\u3057\u3044\u89b3\u5bdf\u3092\u958b\u59cb\u3057\u307e\u3057\u305f\u3002");
syncTopButtons();
}
recordFamily(t) {
if (!this.family) this.family = {};
2026-06-19 23:22:15 +09:00
if (!t?.id) return;
2026-06-19 14:00:48 +09:00
const uniqueIds = (list) => Array.from(new Set((list || []).filter(Boolean)));
const sameList = (a, b) => {
const aa = uniqueIds(a);
const bb = uniqueIds(b);
return aa.length === bb.length && aa.every((id, i) => id === bb[i]);
};
let changed = false;
const nextParents = uniqueIds(t.parents || []);
const nextChildren = uniqueIds(t.children || []);
if (!this.family[t.id]) {
this.family[t.id] = {
id: t.id,
name: t.name,
type: t.type,
2026-06-19 23:22:15 +09:00
personality: t.personality || "",
2026-06-19 14:00:48 +09:00
generation: t.generation,
hasPaired: !!t.hasPaired,
parents: nextParents,
parentNames: [...(t.parentNames || [])],
children: nextChildren,
birthTime: t.birthTime,
alive: !t.dead,
deathReason: t.deathReason || "",
};
changed = true;
} else {
const entry = this.family[t.id];
const nextAlive = !t.dead;
if (entry.name !== t.name) changed = true;
if (entry.type !== t.type) changed = true;
2026-06-19 23:22:15 +09:00
if ((entry.personality || "") !== (t.personality || "")) changed = true;
2026-06-19 14:00:48 +09:00
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;
2026-06-19 23:22:15 +09:00
entry.personality = t.personality || entry.personality || "";
2026-06-19 14:00:48 +09:00
entry.alive = nextAlive;
entry.generation = t.generation;
entry.hasPaired = !!t.hasPaired;
entry.deathReason = t.deathReason || entry.deathReason || "";
entry.parents = nextParents;
entry.parentNames = [...(t.parentNames || entry.parentNames || [])];
entry.children = nextChildren;
}
if (changed) this.familyVersion = (this.familyVersion || 0) + 1;
}
normalizeFamily() {
if (!this.family) this.family = {};
if (this.familyCleanVersion === (this.familyVersion || 0)) return false;
const family = this.family;
const liveById = new Map((this.tarinai || []).map(t => [t.id, t]));
let changed = false;
const uniqueIds = (list, allow) => Array.from(new Set((list || []).filter(id => id && (!allow || allow.has(id)))));
const sameList = (a, b) => a.length === b.length && a.every((id, i) => id === b[i]);
for (const t of this.tarinai || []) {
2026-06-19 23:22:15 +09:00
if (!this.family[t.id] && !(t.hasPaired || (t.parents || []).length || (t.children || []).length)) continue;
2026-06-19 14:00:48 +09:00
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",
2026-06-19 23:22:15 +09:00
personality: live?.personality || n.personality || "",
2026-06-19 14:00:48 +09:00
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;
}
}
2026-06-19 23:22:15 +09:00
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;
}
}
2026-06-19 14:00:48 +09:00
for (const [id, live] of liveById.entries()) {
const n = family[id];
if (!n) continue;
if (!sameList(live.parents || [], n.parents || [])) {
live.parents = [...(n.parents || [])];
changed = true;
}
if (!sameList(live.children || [], n.children || [])) {
live.children = [...(n.children || [])];
changed = true;
}
if (live.hasPaired !== !!n.hasPaired) {
live.hasPaired = !!n.hasPaired;
changed = true;
}
if (live.generation !== n.generation) {
live.generation = n.generation;
changed = true;
}
}
if (changed) this.familyVersion = (this.familyVersion || 0) + 1;
this.familyCleanVersion = this.familyVersion || 0;
return changed;
}
2026-06-19 20:52:40 +09:00
resetFamilyTree() {
this.family = {};
this.familyCleanVersion = 0;
this.familyVersion = (this.familyVersion || 0) + 1;
this.maxGeneration = 1;
for (const t of this.tarinai || []) {
t.parents = [];
t.parentNames = [];
t.children = [];
t.generation = 1;
t.hasPaired = false;
}
}
2026-06-19 14:00:48 +09:00
validateFamily() {
this.normalizeFamily();
const family = this.family || {};
const issues = [];
const seenRenderIds = new Set();
for (const n of Object.values(family)) {
if (!n?.id) continue;
const childSeen = new Set();
for (const p of n.parents || []) {
if (!family[p]) issues.push({ type: "missing-parent", id: n.id, parent: p });
else if (!family[p].children?.includes(n.id)) issues.push({ type: "missing-reciprocal-child", id: n.id, parent: p });
}
for (const c of n.children || []) {
if (childSeen.has(c)) issues.push({ type: "duplicate-child", id: n.id, child: c });
childSeen.add(c);
if (!family[c]) issues.push({ type: "missing-child", id: n.id, child: c });
else if (!family[c].parents?.includes(n.id)) issues.push({ type: "missing-reciprocal-parent", id: n.id, child: c });
}
if (seenRenderIds.has(n.id)) issues.push({ type: "duplicate-render-node", id: n.id });
seenRenderIds.add(n.id);
}
return issues;
}
markDead(t, reason) {
if (!this.family) this.family = {};
2026-06-19 23:22:15 +09:00
const finalReason = t?.normalizeDeathReason ? t.normalizeDeathReason(reason) : reason;
2026-06-19 14:00:48 +09:00
if (this.family[t.id]) {
2026-06-19 23:22:15 +09:00
if (this.family[t.id].alive !== false || this.family[t.id].deathReason !== finalReason) {
2026-06-19 14:00:48 +09:00
this.family[t.id].alive = false;
2026-06-19 23:22:15 +09:00
this.family[t.id].deathReason = finalReason;
this.family[t.id].personality = t.personality || this.family[t.id].personality || "";
2026-06-19 14:00:48 +09:00
this.familyVersion = (this.familyVersion || 0) + 1;
}
}
2026-06-19 23:22:15 +09:00
this.familyPrunePending = true;
2026-06-19 14:00:48 +09:00
}
pruneExtinctFamilies(opts = {}) {
if (opts.normalize !== false) this.normalizeFamily();
const family = this.family || {};
const nodes = Object.values(family).filter(Boolean).filter(n => n.hasPaired || (n.parents || []).length || (n.children || []).length);
if (!nodes.length) return;
const ids = new Set(nodes.map(n => n.id));
const graph = new Map();
const add = (id) => { if (!graph.has(id)) graph.set(id, new Set()); };
const link = (a, b) => {
if (!a || !b || !ids.has(a) || !ids.has(b)) return;
add(a); add(b);
graph.get(a).add(b);
graph.get(b).add(a);
};
for (const n of nodes) {
add(n.id);
for (const p of n.parents || []) link(n.id, p);
for (const c of n.children || []) link(n.id, c);
}
const seen = new Set();
const remove = [];
for (const n of nodes) {
if (seen.has(n.id)) continue;
const stack = [n.id];
const comp = [];
seen.add(n.id);
while (stack.length) {
const id = stack.pop();
comp.push(id);
for (const next of graph.get(id) || []) {
if (seen.has(next)) continue;
seen.add(next);
stack.push(next);
}
}
if (!comp.some(id => family[id]?.alive)) remove.push(...comp);
}
if (!remove.length) return;
for (const id of remove) delete family[id];
this.familyVersion = (this.familyVersion || 0) + 1;
this.familyCleanVersion = this.familyVersion || 0;
}
addTarinai(opts = {}) {
const t = new Tarinai(this, opts);
this.tarinai.push(t);
this.drawListDirty = true;
2026-06-19 23:22:15 +09:00
if (t.hasPaired || (t.parents || []).length || (t.children || []).length) this.recordFamily(t);
2026-06-19 14:00:48 +09:00
this.maxGeneration = Math.max(this.maxGeneration, t.generation);
return t;
}
startBirthRitual(a, b) {
if (!a || !b || a.dead || b.dead) return false;
2026-06-19 20:52:40 +09:00
if (a.isZunchiSlave || b.isZunchiSlave) return false;
2026-06-19 14:00:48 +09:00
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; }
2026-06-19 20:52:40 +09:00
// 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.
2026-06-19 14:00:48 +09:00
const duration = 10.0;
a.birthRitualTimer = b.birthRitualTimer = duration;
a.birthRitualMax = b.birthRitualMax = duration;
a.birthPartnerId = b.id;
b.birthPartnerId = a.id;
a.birthRitualRole = -1;
b.birthRitualRole = 1;
a.birthRitualLeader = true;
b.birthRitualLeader = false;
a.state = b.state = "birth_ritual";
a.target = b;
b.target = a;
a.reproductionTimer = CONFIG.reproductionCooldown + rand(2, 10);
b.reproductionTimer = CONFIG.reproductionCooldown + rand(2, 10);
a.surpriseTimer = Math.max(a.surpriseTimer, 0.35);
b.surpriseTimer = Math.max(b.surpriseTimer, 0.35);
return true;
}
finishBirthRitual(a, b) {
if (!a || !b || a.dead || b.dead) return null;
a.birthRitualTimer = b.birthRitualTimer = 0;
a.birthPartnerId = b.birthPartnerId = null;
a.birthRitualLeader = b.birthRitualLeader = false;
a.state = b.state = "idle";
a.fightTimer = b.fightTimer = 0;
a.fightTargetIds = []; b.fightTargetIds = [];
a.fightTargetId = b.fightTargetId = null;
a.defeatedById = b.defeatedById = null;
a.postBirthPeaceTimer = b.postBirthPeaceTimer = 18;
a.fightCooldown = b.fightCooldown = Math.max(CONFIG.fightCooldown + 8, a.fightCooldown || 0, b.fightCooldown || 0);
const children = [];
const countRoll = Math.random();
const childCount = countRoll < 0.08 ? 3 : (countRoll < 0.36 ? 2 : 1);
for (let i = 0; i < childCount; i++) {
const child = this.spawnChild(a, b, i, childCount);
if (child) { child.postBirthPeaceTimer = 18; children.push(child); }
}
2026-06-19 23:22:15 +09:00
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");
2026-06-19 14:00:48 +09:00
return children[0] || null;
}
2026-06-19 20:52:40 +09:00
areParentChild(a, b) {
if (!a || !b) return false;
const aId = a.id || a;
const bId = b.id || 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 aId = a.id || a;
const bId = b.id || b;
if (!aId || !bId || aId === bId) return false;
if (a.birthPartnerId && a.birthPartnerId === bId) return true;
if (b.birthPartnerId && b.birthPartnerId === aId) return true;
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;
}
2026-06-19 14:00:48 +09:00
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;
2026-06-19 23:22:15 +09:00
const inheritedPersonality = a.personality === b.personality ? a.personality : (Math.random() < 0.5 ? a.personality : b.personality);
2026-06-19 14:00:48 +09:00
const child = this.addTarinai({
type: mutation,
2026-06-19 23:22:15 +09:00
personality: inheritedPersonality || personalityForSeed(`${a.id}:${b.id}:${this.time}:${index}`),
2026-06-19 14:00:48 +09:00
name: makeName(),
age: 0,
x: (a.x + b.x) / 2 + rand(-14, 14) + (index - (total - 1) / 2) * 18,
y: (a.y + b.y) / 2 + rand(-14, 14) + (total > 1 ? rand(-10, 10) : 0),
scale: rand(0.17, 0.23),
hunger: rand(20, 40),
loneliness: rand(30, 56),
energy: rand(55, 95),
stress: rand(0, 14),
need: Math.random() < 0.6 ? pick([a.need, b.need]) : pick(NEEDS),
generation: Math.max(a.generation, b.generation) + 1,
lifeSpan: rand(1500, 2400),
parents: [a.id, b.id],
parentNames: [a.name, b.name],
birthTime: this.time,
});
this.maxGeneration = Math.max(this.maxGeneration, child.generation);
2026-06-19 20:52:40 +09:00
a.hasPaired = true;
b.hasPaired = true;
2026-06-19 14:00:48 +09:00
a.affection = 0; b.affection = 0;
a.loneliness *= 0.65; b.loneliness *= 0.65;
2026-06-19 20:52:40 +09:00
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);
2026-06-19 14:00:48 +09:00
a.children.push(child.id);
b.children.push(child.id);
this.recordFamily(a);
this.recordFamily(b);
this.recordFamily(child);
if (this.family[a.id] && !this.family[a.id].children.includes(child.id)) this.family[a.id].children.push(child.id);
if (this.family[b.id] && !this.family[b.id].children.includes(child.id)) this.family[b.id].children.push(child.id);
2026-06-19 23:22:15 +09:00
this.familyCleanVersion = null;
2026-06-19 14:00:48 +09:00
audio.birth();
2026-06-19 23:22:15 +09:00
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");
2026-06-19 14:00:48 +09:00
return child;
}
2026-06-19 23:22:15 +09:00
startForcedFight(a, b) {
if (!a || !b || a === b || a.dead || b.dead) 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;
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;
}
2026-06-19 14:00:48 +09:00
startConflict(a, b) {
if (!a || !b || a.dead || b.dead) return;
2026-06-19 23:22:15 +09:00
if ((a.fightMochiTimer || 0) > 0.04 || (b.fightMochiTimer || 0) > 0.04) { this.startForcedFight(a, b); return; }
2026-06-19 20:52:40 +09:00
if (this.areParentChild(a, b) || this.areCoParents?.(a, b)) return;
2026-06-19 14:00:48 +09:00
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;
2026-06-19 20:52:40 +09:00
const scoreA = a.energy * 0.38 + a.mood * 0.18 + a.stress * 0.12 + (a.type === "angry" ? 12 : 0) + (a.personality === "irritable" ? 8 : 0) + ((b.relationTo(a.id).fear || 0) * 0.65) + (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) + (b.personality === "irritable" ? 8 : 0) + ((a.relationTo(b.id).fear || 0) * 0.65) + (b.isZunchiSlave ? -10 : 0) + ((b.fightMochiTimer || 0) > 0.04 ? 9 : 0) + rand(-5, 5);
2026-06-19 14:00:48 +09:00
const actor = scoreA >= scoreB ? a : b;
const target = actor === a ? b : a;
const actorScore = actor === a ? scoreA : scoreB;
const targetScore = actor === a ? scoreB : scoreA;
const canIntimidate = !actor.lowHealthSprite || !actor.lowHealthSprite();
2026-06-19 20:52:40 +09:00
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);
2026-06-19 14:00:48 +09:00
if (canIntimidate && Math.random() < intimidationChance) {
this.startIntimidation(actor, target, actorScore, targetScore);
return;
}
this.startFight(a, b);
}
startIntimidation(actor, target, actorScore = 0, targetScore = 0) {
if (!actor || !target || actor.dead || target.dead) return false;
if (actor.lowHealthSprite && actor.lowHealthSprite()) return false;
const fearLoad = (target.relationTo(actor.id).fear || 0) * 0.72 + target.personalityProfile().fear * 8;
const resistance = target.energy * 0.30 + target.mood * 0.16 + (target.personality === "irritable" ? 5 : 0) + rand(-5, 5);
const successChance = clamp(0.62 + (actorScore - targetScore + fearLoad - resistance) / 76, 0.50, 0.96);
actor.surpriseTimer = Math.max(actor.surpriseTimer, 0.20);
target.surpriseTimer = Math.max(target.surpriseTimer, 0.32);
actor.fightCooldown = CONFIG.fightCooldown + rand(1, 4);
target.fightCooldown = CONFIG.fightCooldown + rand(1, 4);
actor.intimidateTimer = Math.max(actor.intimidateTimer, rand(2.45, 3.35));
actor.intimidateTargetId = target.id;
actor.state = "intimidate";
this.effects.push(new Effect("ring", actor.x, actor.y - actor.radius * 0.65, {
life: 0.42,
size: actor.radius * 0.55,
color: "rgba(145, 106, 55, 0.70)",
}));
this.spawnBubble(actor.x, actor.y - actor.radius * 1.30, "\u306f\u3046\u30fc\uff01", "rgba(92,62,34,0.82)");
target.intimidatedTimer = Math.max(target.intimidatedTimer || 0, rand(1.65, 2.45));
target.fearTimer = Math.max(target.fearTimer, 0.65 * target.personalityProfile().fear);
if (Math.random() >= successChance) {
target.adjustRelation(actor, -0.08, 0.22 * target.personalityProfile().fear, "intimidate");
return true;
}
target.defeatedById = actor.id;
target.fightWinnerId = actor.id;
target.defeatedTimer = Math.max(target.defeatedTimer, rand(2.8, 4.2));
target.fearTimer = Math.max(target.fearTimer, 1.35 * target.personalityProfile().fear);
target.intimidatedTimer = Math.max(target.intimidatedTimer || 0, rand(2.4, 3.4));
target.state = "panic";
target.target = actor;
this.spawnBubble(target.x, target.y - target.radius * 1.28, "\u306f\u3041\u3041\u3041\uff01", "rgba(84,66,86,0.80)");
target.vx += (target.x < actor.x ? -1 : 1) * rand(26, 54);
target.vy += rand(-18, 18);
actor.adjustRelation(target, -0.08, 0.06, "intimidate");
target.adjustRelation(actor, -0.22, 0.85 * target.personalityProfile().fear, "intimidate");
if (this.time - Math.max(actor.lastLog, target.lastLog) > 5.5) {
this.log(`${actor.name}\u306f${target.name}\u3092\u5a01\u5687\u3057\u3066\u8ffd\u3044\u6255\u3063\u305f\u3002`, "fight");
actor.lastLog = target.lastLog = this.time;
}
return true;
}
startFight(a, b) {
if (!a || !b || a.dead || b.dead) return;
if ((a.postBirthPeaceTimer || 0) > 0 || (b.postBirthPeaceTimer || 0) > 0) return;
a.state = "fight"; b.state = "fight";
a.fightTimer = Math.max(a.fightTimer, rand(4.5, 7.0));
b.fightTimer = Math.max(b.fightTimer, rand(4.5, 7.0));
a.nextHeadbutt = this.time + rand(0.08, 0.18);
b.nextHeadbutt = a.nextHeadbutt;
a.fightCooldown = CONFIG.fightCooldown + rand(1, 5);
b.fightCooldown = CONFIG.fightCooldown + rand(1, 5);
if (!a.fightTargetIds) a.fightTargetIds = [];
if (!b.fightTargetIds) b.fightTargetIds = [];
if (!a.fightTargetIds.includes(b.id)) a.fightTargetIds.push(b.id);
if (!b.fightTargetIds.includes(a.id)) b.fightTargetIds.push(a.id);
a.fightTargetIds = a.fightTargetIds.slice(-4);
b.fightTargetIds = b.fightTargetIds.slice(-4);
a.fightTargetId = a.fightTargetIds[0];
b.fightTargetId = b.fightTargetIds[0];
2026-06-19 20:52:40 +09:00
const aScore = a.energy * 0.52 + a.mood * 0.30 + (a.type === "angry" ? 10 : 0) + (a.personality === "irritable" ? 5 : 0) + ((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) + (b.personality === "irritable" ? 5 : 0) + ((b.fightMochiTimer || 0) > 0.04 ? 12 : 0) + (b.isZunchiSlave ? -14 : 0) + rand(-7, 7);
2026-06-19 14:00:48 +09:00
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;
2026-06-19 23:22:15 +09:00
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");
2026-06-19 14:00:48 +09:00
const dx = b.x - a.x, dy = b.y - a.y;
const d = Math.hypot(dx, dy) || 1;
a.vx -= dx / d * rand(32, 62); a.vy -= dy / d * rand(32, 62);
b.vx += dx / d * rand(32, 62); b.vy += dy / d * rand(32, 62);
a.surpriseTimer = 0.24; b.surpriseTimer = 0.24;
this.effects.push(new Effect("fight", (a.x + b.x) / 2, (a.y + b.y) / 2 - 8, {
size: rand(12, 20),
life: rand(0.32, 0.52),
color: "rgba(116, 73, 38, 0.82)",
}));
audio.fight();
if (this.time - Math.max(a.lastLog, b.lastLog) > 6) {
this.log(`${a.name}\u3068${b.name}\u304c\u5c0f\u3055\u306a\u55a7\u5629\u3092\u3057\u305f\u3002`, "fight");
a.lastLog = b.lastLog = this.time;
}
}
explodeFirecracker(it) {
if (!it || it.dead || it.type !== "firecracker") return;
it.amount = 0;
const x = it.x, y = it.y;
const blastRadius = 240 * (it.blastScale || 1);
this.effects.push(new Effect("explosion", x, y, { size: 86, life: 1.05, color: "rgba(255,182,66,0.92)" }));
this.effects.push(new Effect("explosion", x, y, { size: 128, life: 0.82, color: "rgba(255,92,42,0.70)" }));
for (let r = 0; r < 4; r++) {
this.effects.push(new Effect("ring", x, y, { size: 30 + r * 24, life: 0.48 + r * 0.11, color: r % 2 ? "rgba(255,116,62,0.78)" : "rgba(255,240,124,0.88)" }));
}
2026-06-20 02:07:01 +09:00
this.blastZunchiFrom(x, y, blastRadius * 0.92, it.blastScale || 1);
2026-06-19 14:00:48 +09:00
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;
2026-06-19 20:52:40 +09:00
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);
2026-06-19 23:22:15 +09:00
t.thought = "\u7206\u7af9\u3067\u305f\u305f\u304d\u8d77\u3053\u3055\u308c\u305f";
2026-06-19 20:52:40 +09:00
}
2026-06-19 23:22:15 +09:00
t.addStress ? t.addStress(rand(8, 15), { threshold: 8 }) : (t.stress = clamp(t.stress + rand(8, 15), 0, 130));
2026-06-19 14:00:48 +09:00
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);
2026-06-19 23:22:15 +09:00
t.damage(5 + p * 18, "\u7206\u7af9");
t.addStress ? t.addStress(22 * p, { threshold: 8 }) : (t.stress = clamp(t.stress + 22 * p, 0, 130));
2026-06-19 14:00:48 +09:00
t.hurtTimer = Math.max(t.hurtTimer, 1.5 + p * 1.8);
t.surpriseTimer = Math.max(t.surpriseTimer, 0.85);
t.fearTimer = Math.max(t.fearTimer, 1.7 + p);
t.state = "panic";
t.target = { x, y };
const power = 170 + p * 360;
t.vx += dx / d * power + rand(-55, 55);
t.vy += dy / d * power + rand(-55, 55);
t.fallTimer = Math.max(t.fallTimer, 1.25 + p * 1.35);
t.fallMax = Math.max(t.fallMax || 0, t.fallTimer);
t.fallDir = (dx >= 0 ? 1 : -1) * (Math.random() < 0.5 ? 1 : -1);
t.blastSpinTimer = Math.max(t.blastSpinTimer || 0, 1.35 + p * 0.70);
t.blastSpinMax = Math.max(t.blastSpinMax || 0, t.blastSpinTimer);
this.spawnFallEffect(t.x, t.y + t.radius * 0.45, 1.1 + p);
2026-06-20 02:07:01 +09:00
if (!t.dead && p > 0.22 && Math.random() < 0.035 * p) t.infectExplosionDisease?.(it);
2026-06-19 14:00:48 +09:00
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)");
}
2026-06-19 20:52:40 +09:00
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;
ball.vx = (ball.vx || 0) + dx / d * blast + rand(-80, 80);
ball.vy = (ball.vy || 0) + dy / d * blast + rand(-80, 80);
const speed = Math.hypot(ball.vx || 0, ball.vy || 0);
const cap = 1250;
if (speed > cap) {
ball.vx = ball.vx / speed * cap;
ball.vy = ball.vy / speed * cap;
}
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;
}
2026-06-19 14:00:48 +09:00
audio.explode();
2026-06-19 23:22:15 +09:00
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");
2026-06-19 14:00:48 +09:00
}
2026-06-20 02:07:01 +09:00
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));
o.vx += dx / d * (120 + q * 260) + rand(-34, 34);
o.vy += dy / d * (120 + q * 260) + rand(-34, 34);
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");
}
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) 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();
}
}
2026-06-19 14:00:48 +09:00
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)",
}));
}
2026-06-19 20:52:40 +09:00
isSleepFurniture(it) {
2026-06-20 02:07:01 +09:00
return Boolean(it && (it.type === "bed" || it.type === "nest_box"));
2026-06-19 20:52:40 +09:00
}
2026-06-19 14:00:48 +09:00
bedOccupancy(bed) {
2026-06-19 20:52:40 +09:00
if (!this.isSleepFurniture(bed)) return 0;
2026-06-19 14:00:48 +09:00
let n = 0;
const radius = Math.max(86, bed.r * 2.45);
for (const t of this.nearbyTarinai(bed.x, bed.y, radius)) {
if (t.dead) continue;
if (t.state === "sleep" || t.state === "seek_bed" || t.target === bed) n += 1;
}
return n;
}
bedComfort(bed) {
2026-06-19 20:52:40 +09:00
if (!this.isSleepFurniture(bed)) return 0.7;
2026-06-19 14:00:48 +09:00
const occ = this.bedOccupancy(bed);
return clamp((bed.comfort ?? 1) - Math.max(0, occ - 3) * 0.075 - (bed.wear || 0) * 0.18, 0.42, 1.18);
}
bestBedFor(t, maxDist = 360) {
let best = null;
let bestScore = Infinity;
for (const bed of this.nearbyItems(t.x, t.y, maxDist)) {
2026-06-19 20:52:40 +09:00
if (bed.dead || !this.isSleepFurniture(bed)) continue;
2026-06-19 14:00:48 +09:00
if (t?.shouldAvoidTarget && t.shouldAvoidTarget(bed)) continue;
const d = dist(t, bed);
const occ = this.bedOccupancy(bed);
const comfort = this.bedComfort(bed);
2026-06-19 20:52:40 +09:00
const crowdPenalty = Math.max(0, occ - 3) * 34;
const score = d - comfort * 46 + crowdPenalty;
2026-06-19 14:00:48 +09:00
if (score < bestScore) { best = bed; bestScore = score; }
}
return best;
}
relationNotice(aId, bId, kind, cooldown = 30) {
const key = [aId, bId].sort().join(":") + `:${kind}`;
const last = this.relationNotices?.[key] || -Infinity;
if (this.time - last < cooldown) return false;
if (!this.relationNotices) this.relationNotices = {};
this.relationNotices[key] = this.time;
return true;
}
resolveFightOutcome(winner, loser) {
if (!winner || !loser || winner.dead || loser.dead) return;
const key = `${winner.id}:${loser.id}:${Math.floor(this.time / 2)}`;
if (!this.resolvedFightIds) this.resolvedFightIds = {};
if (this.resolvedFightIds[key]) return;
this.resolvedFightIds[key] = true;
winner.defeatedById = null;
winner.defeatedTimer = 0;
winner.fearTimer = Math.max(0, winner.fearTimer - 0.75);
winner.fightTargetIds = [];
winner.fightTargetId = null;
winner.adjustRelation(loser, -0.6, 0.02, "\u55a7\u5629\u306b\u52dd\u3063\u305f");
loser.adjustRelation(winner, -1.2, 7.2 * loser.personalityProfile().fear, "\u55a7\u5629\u306b\u8ca0\u3051\u305f");
winner.relationTo(loser.id).fightsWon = (winner.relationTo(loser.id).fightsWon || 0) + 1;
loser.relationTo(winner.id).fightsLost = (loser.relationTo(winner.id).fightsLost || 0) + 1;
2026-06-19 20:52:40 +09:00
loser.totalFightLosses = (loser.totalFightLosses || 0) + 1;
if ((loser.totalFightLosses || 0) >= 5 && loser.becomeZunchiSlave?.(winner)) {
const oldName = loser.formerName || loser.name;
2026-06-19 23:22:15 +09:00
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");
2026-06-19 20:52:40 +09:00
}
2026-06-19 14:00:48 +09:00
loser.fearTimer = Math.max(loser.fearTimer, 1.2 * loser.personalityProfile().fear);
this.spawnBubble(loser.x, loser.y - loser.radius * 1.28, "\u306f\u3041\u3041\u3041\uff01", "rgba(84,66,86,0.80)");
winner.affection = clamp(winner.affection + 0.8, 0, 80);
winner.stress = clamp(winner.stress - rand(9, 18), 0, 130);
if (this.relationNotice(winner.id, loser.id, "fight-result", 8)) {
this.log(`${loser.name}\u306f${winner.name}\u306b\u8ca0\u3051\u3001\u305d\u306e\u3053\u3068\u3092\u899a\u3048\u305f\u3002`, "fight");
}
}
itemDropImpact(it) {
2026-06-19 23:22:15 +09:00
if (!it || it.dead || it.type === "water") return;
2026-06-19 14:00:48 +09:00
const isStone = it.type === "stone";
const radius = isStone ? 64 : Math.max(34, (it.r || 14) * 1.75);
const damage = isStone ? rand(20, 34) : rand(3, 7);
const push = isStone ? rand(145, 210) : rand(54, 92);
let hit = 0;
for (const t of this.nearbyTarinai(it.x, it.y, radius + 48)) {
if (!t || t.dead) continue;
const d = Math.max(1, distXY(t.x, t.y, it.x, it.y));
const p = clamp(1 - d / (radius + t.radius), 0, 1);
if (p <= 0) continue;
const nx = (t.x - it.x) / d;
const ny = (t.y - it.y) / d;
t.vx += nx * push * (0.35 + p);
t.vy += ny * push * (0.35 + p) - (isStone ? 42 : 18) * p;
t.damage(damage * (0.35 + p * 0.65), isStone ? "\u77f3\u304c\u843d\u3061\u305f" : "\u843d\u3061\u3066\u304d\u305f\u9053\u5177\u306b\u5f53\u305f\u3063\u305f");
t.hurtTimer = Math.max(t.hurtTimer, isStone ? 2.7 : 1.25);
t.fallTimer = Math.max(t.fallTimer, isStone ? 0.62 : 0.26);
t.fallMax = Math.max(t.fallMax || t.fallTimer, t.fallTimer);
t.fallDir = nx >= 0 ? 1 : -1;
hit += 1;
}
this.effects.push(new Effect("ring", it.x, it.y, { size: isStone ? 32 : 18, life: 0.34, color: isStone ? "rgba(128,96,58,0.76)" : "rgba(174,128,70,0.52)" }));
if (hit > 0) this.spawnFallEffect(it.x, it.y + (it.r || 12) * 0.6, isStone ? 0.9 : 0.45);
if (isStone && hit > 0) audio.poke();
if (hit > 0) this.log(`${isStone ? "\u77f3" : "\u9053\u5177"}\u304c\u843d\u3061\u3001${hit}\u5339\u306e\u305f\u308a\u306a\u3044\u304c\u5dfb\u304d\u8fbc\u307e\u308c\u305f\u3002`, "accident");
}
2026-06-19 20:52:40 +09:00
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.personality === "playful" ? -120 : 0;
const bp = b.personality === "playful" ? -120 : 0;
return dist(a, ball) + ap - (dist(b, ball) + bp);
});
for (const t of arr.slice(5)) {
t.state = "idle";
t.target = null;
2026-06-19 23:22:15 +09:00
t.thought = "\u30dc\u30fc\u30eb\u304c\u6df7\u307f\u5408\u3063\u3066\u3044\u308b\u306e\u3067\u773a\u3081\u3066\u3044\u308b";
2026-06-19 20:52:40 +09:00
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);
const cap = 1250;
if (speed > cap) {
ball.vx = ball.vx / speed * cap;
ball.vy = ball.vy / speed * cap;
}
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)" }));
2026-06-19 23:22:15 +09:00
if (ball.pokeCombo >= 4 || speed > 430) this.log(`\u30dc\u30fc\u30eb\u3092\u9023\u7d9a\u3067\u3064\u3064\u304d\u3001\u52e2\u3044\u304c\u5897\u3057\u305f\u3002`, "observe");
else this.log(`\u30dc\u30fc\u30eb\u3092\u3064\u3064\u3044\u3066\u5f3e\u3044\u305f\u3002`, "observe");
2026-06-19 20:52:40 +09:00
return true;
}
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 = Math.min(420, 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") 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);
const push = clamp(ballSpeed * 0.34, 95, 310);
t.lastBallDamageAt = now;
t.vx += bvx * push;
t.vy += bvy * push;
2026-06-19 23:22:15 +09:00
t.damage(damage, "\u8d85\u9ad8\u901f\u306e\u30dc\u30fc\u30eb\u306b\u5f53\u305f\u3063\u305f");
2026-06-19 20:52:40 +09:00
const timidStress = t.personality === "timid" ? 1.55 : 1.0;
2026-06-19 23:22:15 +09:00
const stressGain = clamp(damage * 0.55 * timidStress, 3, t.personality === "timid" ? 26 : 18);
t.addStress ? t.addStress(stressGain, { threshold: 8 }) : (t.stress = clamp(t.stress + stressGain, 0, 130));
2026-06-19 20:52:40 +09:00
t.hurtTimer = Math.max(t.hurtTimer, damage > 16 ? 2.4 : 1.35);
t.fallTimer = Math.max(t.fallTimer, damage > 14 ? 0.82 : 0.38);
t.fallMax = Math.max(t.fallMax || 0.82, t.fallTimer);
t.fallDir = bvx >= 0 ? 1 : -1;
t.fearTimer = Math.max(t.fearTimer, 0.8);
this.spawnFallEffect(t.x, t.y + t.radius * 0.55, clamp(damage / 18, 0.55, 1.6));
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)" }));
2026-06-19 23:22:15 +09:00
if (this.relationNotice(t.id, ball.id || "ball", "fast-ball-hit", 2.8)) this.log(`${t.name}\u306f\u8d85\u9ad8\u901f\u306e\u30dc\u30fc\u30eb\u306b\u5f53\u305f\u3063\u3066\u30c0\u30e1\u30fc\u30b8\u3092\u53d7\u3051\u305f\u3002`, "accident");
2026-06-19 20:52:40 +09:00
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);
if (t.dead) 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.personality === "playful";
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);
const kickCap = 1250;
if (kickedSpeed > kickCap) {
ball.vx = ball.vx / kickedSpeed * kickCap;
ball.vy = ball.vy / kickedSpeed * kickCap;
}
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 timidBump = t.personality === "timid" && !playIntent ? clamp(ballSpeed / 190, 0.9, 3.8) : 0;
t.stress = clamp(t.stress - playRelief + timidBump, 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);
2026-06-19 23:22:15 +09:00
this.spawnBubble(t.x, t.y - t.radius * 1.15, pick(["!", "\u306f\u3046", "?" ]), "rgba(70,96,50,0.76)");
2026-06-19 20:52:40 +09:00
}
2026-06-19 23:22:15 +09:00
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");
2026-06-19 20:52:40 +09:00
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)" }));
}
}
}
2026-06-19 14:00:48 +09:00
nearest(entity, types, maxDist = Infinity) {
let best = null, bestD = maxDist;
const candidates = Number.isFinite(maxDist) ? this.nearbyItems(entity.x, entity.y, maxDist) : this.items;
for (const it of candidates) {
if (it.dead || !types.includes(it.type)) continue;
if (entity?.shouldAvoidTarget && entity.shouldAvoidTarget(it)) continue;
const d = dist(entity, it);
if (d < bestD) { best = it; bestD = d; }
}
return best;
}
nearestOther(entity, maxDist = Infinity) {
let best = null, bestD = maxDist;
const candidates = Number.isFinite(maxDist) ? this.nearbyTarinai(entity.x, entity.y, maxDist) : this.tarinai;
for (const o of candidates) {
if (o === entity || o.dead) continue;
if (entity?.shouldAvoidTarget && entity.shouldAvoidTarget(o)) continue;
const d = dist(entity, o);
if (d < bestD) { best = o; bestD = d; }
}
return best;
}
temperatureAt(x, y) {
let temp = 0.54 + Math.sin(this.time / 55) * 0.05;
for (const it of this.nearbyItems(x, y, 190)) {
if (it.type === "stone") {
const d = distXY(x, y, it.x, it.y);
temp += clamp(1 - d / 190, 0, 1) * 0.35;
}
}
return clamp(temp, 0, 1);
}
fenceRect(it) {
const vertical = it?.type === "fence_v";
const horizontal = it?.type === "fence_h";
if (!vertical && !horizontal) return null;
const len = Math.max(82, (it.r || 42) * 1.95);
const thick = Math.max(20, (it.r || 42) * 0.43);
const halfW = vertical ? thick : len;
const halfH = vertical ? len : thick;
return { left: it.x - halfW, right: it.x + halfW, top: it.y - halfH, bottom: it.y + halfH, vertical, horizontal };
}
resolveFenceCollision(t) {
if (!t || t.dead) return;
const rr = Math.max(8, t.radius * 0.74);
2026-06-19 23:22:15 +09:00
const maxChecks = CONFIG.maxFenceCollisionChecks ?? 18;
let checked = 0;
let pushed = false;
for (const it of this.nearbyItems(t.x, t.y, rr + 132)) {
if (checked >= maxChecks) break;
if (it.dead || !this.isFenceType(it.type)) continue;
checked += 1;
2026-06-19 14:00:48 +09:00
const r = this.fenceRect(it);
if (!r) continue;
const cx = clamp(t.x, r.left, r.right);
const cy = clamp(t.y, r.top, r.bottom);
let dx = t.x - cx;
let dy = t.y - cy;
let d = Math.hypot(dx, dy);
if (d >= rr) continue;
if (d < 0.001) {
const left = Math.abs(t.x - r.left);
const right = Math.abs(r.right - t.x);
const top = Math.abs(t.y - r.top);
const bottom = Math.abs(r.bottom - t.y);
const m = Math.min(left, right, top, bottom);
if (m === left) { dx = -1; dy = 0; d = 1; }
else if (m === right) { dx = 1; dy = 0; d = 1; }
else if (m === top) { dx = 0; dy = -1; d = 1; }
else { dx = 0; dy = 1; d = 1; }
}
2026-06-19 23:22:15 +09:00
const push = Math.min(rr - d + 0.8, Math.max(10, rr * 0.92));
2026-06-19 14:00:48 +09:00
t.x += dx / d * push;
t.y += dy / d * push;
2026-06-19 23:22:15 +09:00
pushed = true;
2026-06-19 14:00:48 +09:00
if (Math.abs(dx) > Math.abs(dy)) t.vx *= -0.18;
else t.vy *= -0.18;
}
2026-06-19 23:22:15 +09:00
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);
}
2026-06-19 14:00:48 +09:00
}
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;
2026-06-19 23:22:15 +09:00
for (const it of this.nearbyItems(cx, cy, maxD + padding + 160)) {
if (it.dead || !this.isFenceType(it.type)) continue;
if (distXY(cx, cy, it.x, it.y) > maxD + padding + 130) continue;
2026-06-19 14:00:48 +09:00
const base = this.fenceRect(it);
if (!base) continue;
const r = { left: base.left - padding, right: base.right + padding, top: base.top - padding, bottom: base.bottom + padding };
if (this.segmentIntersectsRect(x1, y1, x2, y2, r)) return true;
}
return false;
}
grassBlockedAt(x, y, self = null) {
for (const it of this.nearbyItems(x, y, 52)) {
if (it === self || it.dead) continue;
if (it.type === "fence_v" || it.type === "fence_h") {
const r = this.fenceRect(it);
if (r && x >= r.left - 10 && x <= r.right + 10 && y >= r.top - 10 && y <= r.bottom + 10) return true;
}
if (it.type !== "zunchi") continue;
const dx = (x - it.x) / Math.max(22, it.r * 1.35);
const dy = (y - it.y) / Math.max(12, it.r * 0.82);
if (dx * dx + dy * dy < 1) return true;
}
return false;
}
grassCrowdedAt(x, y, minGap = 30, self = null) {
for (const it of this.nearbyItems(x, y, Math.max(42, minGap + 16))) {
if (it === self || it.dead || it.type !== "grass") continue;
if (distXY(x, y, it.x, it.y) < minGap) return true;
}
return false;
}
grassOnTarinaiAt(x, y, minGap = 24) {
for (const t of this.nearbyTarinai(x, y, minGap + 36)) {
if (t.dead) continue;
if (distXY(x, y, t.x, t.y) < Math.max(minGap, t.radius * 1.15)) return true;
}
return false;
}
grassSpotOpen(x, y, { minGrassGap = 30, avoidTarinai = true, self = null } = {}) {
if (x < 44 || y < 44 || x > this.w - 44 || y > this.h - 44) return false;
if (this.grassBlockedAt(x, y, self)) return false;
if (this.grassCrowdedAt(x, y, minGrassGap, self)) return false;
if (avoidTarinai && this.grassOnTarinaiAt(x, y)) return false;
return true;
}
findGrassPlantingSpot(x, y, { allowOriginal = true, minRadius = 18, maxRadius = 160, attempts = 36, avoidTarinai = true } = {}) {
const cx = clamp(x, 44, this.w - 44);
const cy = clamp(y, 44, this.h - 44);
if (allowOriginal && this.grassSpotOpen(cx, cy, { avoidTarinai })) return { x: cx, y: cy };
const blockedAtCenter = this.grassBlockedAt(cx, cy);
const inner = blockedAtCenter ? Math.max(minRadius, 34) : minRadius;
const outer = Math.max(maxRadius, inner + 42);
const golden = Math.PI * (3 - Math.sqrt(5));
for (let i = 0; i < attempts; i++) {
const t = attempts <= 1 ? 1 : i / (attempts - 1);
const radius = lerp(inner, outer, Math.sqrt(t));
const angle = i * golden + stableUnit(`${cx},${cy}`, "grass-plant") * Math.PI * 2;
const px = clamp(cx + Math.cos(angle) * radius, 44, this.w - 44);
const py = clamp(cy + Math.sin(angle) * radius * 0.72, 44, this.h - 44);
if (this.grassSpotOpen(px, py, { avoidTarinai })) return { x: px, y: py };
}
return null;
}
findGrassSproutSpot(source, fertile = false) {
const attempts = fertile ? 24 : 10;
const minR = fertile ? Math.max(30, source.r * 1.9) : 18;
const maxR = fertile ? 125 : 72;
for (let i = 0; i < attempts; i++) {
const angle = rand(0, Math.PI * 2);
const radius = rand(minR, maxR);
const x = clamp(source.x + Math.cos(angle) * radius, 44, this.w - 44);
const y = clamp(source.y + Math.sin(angle) * radius * 0.72, 44, this.h - 44);
if (this.grassSpotOpen(x, y, { minGrassGap: 30, avoidTarinai: true })) return { x, y };
}
return null;
}
performanceLevel() {
const fps = typeof window !== "undefined" ? Number(window.__tarinaiFps || 0) : 60;
if (!fps || !Number.isFinite(fps)) return 0;
if (fps < 22) return 3;
if (fps < 34) return 2;
if (fps < 48) return 1;
return 0;
}
grassLoadLevel() {
const grass = this.itemCounts?.grass || 0;
const perf = this.performanceLevel();
if (perf >= 3 || grass >= 180) return 3;
if (perf >= 2 || grass >= 120) return 2;
if (perf >= 1 || grass >= 72) return 1;
return 0;
}
grassUpdateFactor() {
return [1.0, 1.35, 1.85, 2.45][this.grassLoadLevel()] || 1.0;
}
sortedDrawList() {
if (!this.drawList) this.drawList = [];
if (this.drawListDirty || this.drawList.length !== this.tarinai.length) {
this.drawList.length = 0;
for (const t of this.tarinai) if (!t.dead) this.drawList.push(t);
this.drawList.sort((a, b) => a.y - b.y);
this.drawListDirty = false;
}
return this.drawList;
}
nearbyItems(x, y, radius) {
return this.spatial.nearby(this.spatial.itemCells, x, y, radius, this.spatialScratch);
}
nearbyTarinai(x, y, radius) {
return this.spatial.nearby(this.spatial.tarinaiCells, x, y, radius, this.spatialScratch);
}
rebuildSpatial() {
this.spatial.rebuild(this.items, this.tarinai);
}
updateItemCounts() {
const counts = this.itemCounts;
for (const key of Object.keys(counts)) counts[key] = 0;
for (const it of this.items) counts[it.type] = (counts[it.type] || 0) + 1;
}
updateEffectCounts() {
const counts = this.effectCounts;
for (const key of Object.keys(counts)) counts[key] = 0;
for (const ef of this.effects) counts[ef.type] = (counts[ef.type] || 0) + 1;
}
compactItems() {
this.enforceItemBudget();
const before = this.items.length;
let write = 0;
for (let read = 0; read < this.items.length; read++) {
const it = this.items[read];
if (it.type === "mirror") continue;
if (!it.dead) this.items[write++] = it;
}
this.items.length = write;
return before !== this.items.length;
}
enforceItemBudget() {
const pruneOldest = (type, limit) => {
if (!Number.isFinite(limit) || limit < 0) return;
let count = 0;
for (const it of this.items) if (it.type === type && !it.dead) count += 1;
if (count <= limit) return;
const victims = this.items
.filter(it => it.type === type && !it.dead)
.sort((a, b) => (b.age || 0) - (a.age || 0))
.slice(0, count - limit);
for (const it of victims) it.amount = 0;
};
pruneOldest("zunchi", CONFIG.zunchiLimit ?? 56);
pruneOldest("trace", CONFIG.traceLimit ?? 64);
pruneOldest("splat", CONFIG.splatLimit ?? 48);
2026-06-19 20:52:40 +09:00
pruneOldest("grass", this.grassLimit ? this.grassLimit() : (CONFIG.grassLimit ?? 96));
2026-06-19 23:22:15 +09:00
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;
}
2026-06-19 14:00:48 +09:00
const limit = CONFIG.itemLimit ?? Infinity;
if (!Number.isFinite(limit) || this.items.length <= limit) return;
2026-06-19 23:22:15 +09:00
const priority = { trace: 1, splat: 2, zunchi: 3, water: 4, grass: 5, sweet: 6, firecracker: 7, fence_v: 8, fence_h: 8 };
2026-06-19 14:00:48 +09:00
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;
}
2026-06-20 02:07:01 +09:00
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 security = clamp(100 - fightPressure - blood * 1.6, 0, 100);
const hygiene = clamp(100 - zunchi * 2.0 - splat * 1.2 - trace * 0.30 - 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) return "";
if ((s.parents || []).includes(t.id)) return "parent";
if ((s.children || []).includes(t.id)) return "child";
const rel = s.relationTo ? s.relationTo(t.id) : null;
if (rel && (rel.fear || 0) >= 8) return "enemy";
if (rel && (rel.affinity || 0) >= 10) return "friend";
return "";
}
nestBoxOccupants(box) {
if (!box || box.dead || box.type !== "nest_box") return [];
return this.tarinai.filter(t => t && !t.dead && t.insideNestBoxId === box.id).slice(0, 12);
}
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);
return { box: best, occupants };
}
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;
}
2026-06-19 14:00:48 +09:00
update(dt) {
if (this.paused) return;
dt *= this.speed;
dt = Math.min(dt, 0.12 * this.speed);
this.time += dt;
this.day = Math.floor(this.time / CONFIG.dayLength) + 1;
2026-06-19 20:52:40 +09:00
if (this.pointer) this.pointer.motion = Math.max(0, (this.pointer.motion || 0) - dt * 2.4);
2026-06-19 14:00:48 +09:00
this.updateWeather(dt);
this.rebuildSpatial();
const perf = this.performanceLevel();
const itemCountBefore = this.items.length;
for (const it of this.items) it.update(dt, this);
2026-06-19 20:52:40 +09:00
if (this.itemCounts?.ball) this.rebuildSpatial();
2026-06-19 14:00:48 +09:00
for (const ef of this.effects) ef.update(dt);
for (const t of this.tarinai) {
t.update(dt);
}
2026-06-19 20:52:40 +09:00
this.limitBallChasers();
this.resolveBallInteractions(dt);
2026-06-19 14:00:48 +09:00
this.compactTimer += dt;
const compactInterval = perf >= 2 ? 0.95 : 0.58;
let itemsCompacted = false;
if (this.compactTimer >= compactInterval) {
itemsCompacted = this.compactItems();
this.compactEffects();
this.compactTarinai();
this.compactTimer = 0;
}
this.countsTimer += dt;
const countsInterval = perf >= 2 ? 0.85 : 0.42;
if (this.countsTimer >= countsInterval || itemsCompacted || this.items.length !== itemCountBefore) {
this.updateItemCounts();
this.updateEffectCounts();
this.countsTimer = 0;
}
this.drawSortTimer += dt;
const sortInterval = perf >= 2 ? 0.16 : perf === 1 ? 0.11 : 0.07;
if (this.drawSortTimer >= sortInterval) {
this.drawListDirty = true;
this.drawSortTimer = 0;
}
const phase = this.phaseName();
if (phase !== this.lastPhase) {
this.lastPhase = phase;
audio.phase();
}
2026-06-19 23:22:15 +09:00
if (this.tarinai.length > 0 && this.tarinai.length <= 10 && this.time - (this.lastBirthAt || -999) > 12 && Math.random() < dt * 0.022) {
2026-06-19 20:52:40 +09:00
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 });
2026-06-19 14:00:48 +09:00
t.wanderAngle = spot.angle;
2026-06-19 20:52:40 +09:00
t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.35);
2026-06-19 23:22:15 +09:00
this.log(`${t.name}\u304c\u753b\u9762\u5916\u304b\u3089\u8ff7\u3044\u8fbc\u3093\u3067\u304d\u305f\u3002`);
2026-06-19 14:00:48 +09:00
}
const grassCount = this.itemCounts.grass || 0;
2026-06-19 20:52:40 +09:00
if (grassCount < Math.min(24, this.grassLimit ? this.grassLimit() : (CONFIG.grassLimit ?? 96)) && Math.random() < dt * 0.036) {
2026-06-19 14:00:48 +09:00
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;
}
}
2026-06-20 02:07:01 +09:00
if (this.weather === "light_rain" && (this.itemCounts.water || 0) < 24 && Math.random() < dt * 0.12) {
2026-06-19 14:00:48 +09:00
const drop = new Item("water", rand(58, this.w - 58), rand(58, this.h - 58));
2026-06-20 02:07:01 +09:00
drop.amount = rand(24, 58);
2026-06-19 14:00:48 +09:00
this.items.push(drop);
this.itemCounts.water = (this.itemCounts.water || 0) + 1;
}
if (Math.random() < dt * 0.004) {
const entries = [
"\u4f55\u5339\u304b\u304c\u3001\u5168\u54e1\u3067\u306f\u306a\u3044\u304c\u3001\u540c\u3058\u65b9\u5411\u3092\u898b\u305f\u3002",
"\u98df\u3079\u7269\u306f\u3042\u308b\u3002\u8db3\u308a\u3066\u3044\u308b\u304b\u306f\u5225\u554f\u984c\u3002",
"\u4e00\u5339\u304c\u7720\u308a\u3001\u8fd1\u304f\u306e\u5225\u306e\u4e00\u5339\u3082\u7720\u308b\u3053\u3068\u3092\u8003\u3048\u305f\u3002",
"\u8349\u306e\u8fd1\u304f\u306b\u3001\u77ed\u304f\u3066\u610f\u5473\u306e\u306a\u3044\u5217\u304c\u3067\u304d\u305f\u3002",
`${this.phaseName()}\u3002\u6c17\u5206\u306f\u3086\u3063\u304f\u308a\u5909\u308f\u3063\u3066\u3044\u308b\u3002`
];
this.log(pick(entries));
}
}
updateWeather(dt) {
this.nextWeatherChange -= dt;
if (this.nextWeatherChange > 0) return;
const current = this.weather || "sunny";
const choices = current === "sunny"
? ["sunny", "cloudy", "light_rain"]
: current === "cloudy"
? ["sunny", "cloudy", "light_rain"]
: ["sunny", "cloudy"];
this.weather = pick(choices);
this.nextWeatherChange = rand(CONFIG.weatherChangeMin, CONFIG.weatherChangeMax);
this.log(`\u5929\u6c17: ${weatherLabel(this.weather)}`);
}
2026-06-19 23:22:15 +09:00
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 || !this.isFenceType(it.type)) continue;
const r = this.fenceRect(it);
if (!r) continue;
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;
}
2026-06-19 20:52:40 +09:00
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) 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;
}
2026-06-19 14:00:48 +09:00
placeItem(item, dropped = true) {
if (!item) return null;
2026-06-19 20:52:40 +09:00
if (item.type === "grass" && (this.itemCounts?.grass || 0) >= (this.grassLimit ? this.grassLimit() : (CONFIG.grassLimit ?? 96))) {
2026-06-19 23:22:15 +09:00
showToast("\u3053\u306e\u30d5\u30a3\u30fc\u30eb\u30c9\u306e\u8349\u306e\u4e0a\u9650\u306b\u9054\u3057\u3066\u3044\u307e\u3059\u3002");
2026-06-19 20:52:40 +09:00
return null;
}
2026-06-19 23:22:15 +09:00
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.fencePlacementBlocked(item)) {
showToast("\u305d\u3053\u306b\u306f\u67f5\u3092\u8ffd\u52a0\u3067\u304d\u307e\u305b\u3093\u3002");
return null;
}
}
2026-06-19 14:00:48 +09:00
if (dropped) {
item.dropMax = item.type === "stone" ? 0.72 : 0.58;
item.dropTimer = item.dropMax;
item.dropImpactDone = false;
}
this.items.push(item);
this.itemCounts[item.type] = (this.itemCounts[item.type] || 0) + 1;
this.rebuildSpatial();
return item;
}
handleClick(x, y) {
const tool = this.tool;
if (tool === "observe") {
let found = null;
for (let i = this.tarinai.length - 1; i >= 0; i--) {
if (this.tarinai[i].contains(x, y)) { found = this.tarinai[i]; break; }
}
this.selected = found;
if (found) this.log(`${found.name}\u3092\u9078\u629e\u3057\u305f\u3002`);
return;
}
if (tool === "poke") {
2026-06-19 20:52:40 +09:00
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;
2026-06-19 14:00:48 +09:00
const target = [...this.tarinai].reverse().find(t => t.contains(x, y));
if (target) {
target.poke();
2026-06-19 23:22:15 +09:00
if (!target.dead) this.log(`${target.name}\u306f\u3064\u3064\u304b\u308c\u3001\u305f\u308a\u306a\u3044\u3053\u3068\u3092\u899a\u3048\u305f\u3002`);
2026-06-19 14:00:48 +09:00
} else {
2026-06-19 23:22:15 +09:00
this.log("\u7a7a\u6c17\u3092\u3064\u3064\u3044\u305f\u3002\u305d\u3053\u306b\u306f\u4f55\u3082\u305f\u308a\u306a\u304b\u3063\u305f\u3002");
2026-06-19 14:00:48 +09:00
}
return;
}
if (tool === "new") {
2026-06-19 20:52:40 +09:00
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 });
2026-06-19 14:00:48 +09:00
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();
2026-06-20 02:07:01 +09:00
const label = { zunchi: "\u305a\u3093\u3061", food: "\u98df\u3079\u7269", sweet: "\u305a\u3093\u3060\u9905", love_mochi: "\u3078\u3053\u9905", fight_mochi: "\u3051\u3093\u304b\u9905", water: "\u6c34", water_bowl: "\u6c34\u306e\u76bf", sleep_drug: "\u306d\u3080\u308a\u85ac", grass: "\u8349", stone: "\u77f3", bed: "\u5e72\u8349\u5bdd\u5e8a", nest_box: "\u5de3\u7bb1", ball: "\u30dc\u30fc\u30eb", firecracker: "\u7206\u7af9", fence_v: "\u7e26\u306e\u67f5", fence_h: "\u6a2a\u306e\u67f5", trace: "\u8db3\u8de1", splat: "\u3057\u3076\u304d" }[best.type] || best.type;
2026-06-19 14:00:48 +09:00
this.log(`${label}\u3092\u524a\u9664\u3057\u305f\u3002`, "observe");
return;
}
const itemType = {
2026-06-20 02:07:01 +09:00
zunchi: "zunchi", sweet: "sweet", love_mochi: "love_mochi", fight_mochi: "fight_mochi", sleep_drug: "sleep_drug", water: "water", water_bowl: "water_bowl", grass: "grass",
stone: "stone", bed: "bed", nest_box: "nest_box", ball: "ball", firecracker: "firecracker", fence_v: "fence_v", fence_h: "fence_h"
2026-06-19 14:00:48 +09:00
}[tool];
if (itemType) {
let px = x, py = y;
if (itemType === "grass") {
const spot = this.findGrassPlantingSpot(x, y, { allowOriginal: true, minRadius: 22, maxRadius: 128, attempts: 48 });
if (!spot) {
showToast("\u8349\u306b\u306f\u7a7a\u3044\u305f\u571f\u304c\u5fc5\u8981\u3067\u3059\u3002");
this.log("\u305d\u3053\u306b\u306f\u8349\u304c\u690d\u3048\u3089\u308c\u308b\u7a7a\u304d\u5730\u304c\u3042\u308a\u307e\u305b\u3093\u3002");
return;
}
px = spot.x;
py = spot.y;
}
2026-06-19 23:22:15 +09:00
const rawItem = this.applyToolSize(new Item(itemType, px, py));
if (this.isFenceType(rawItem.type)) {
const rect = this.fenceRect(rawItem);
if (rect) {
const dx = Math.max(CONFIG.worldPadding - rect.left, 0) - Math.max(rect.right - (this.w - CONFIG.worldPadding), 0);
const dy = Math.max(CONFIG.worldPadding - rect.top, 0) - Math.max(rect.bottom - (this.h - CONFIG.worldPadding), 0);
rawItem.x += dx;
rawItem.y += dy;
}
}
const placed = this.placeItem(rawItem, true);
2026-06-19 20:52:40 +09:00
if (!placed) return;
2026-06-20 02:07:01 +09:00
const label = { zunchi: "\u305a\u3093\u3061", food: "\u98df\u3079\u7269", sweet: "\u305a\u3093\u3060\u9905", love_mochi: "\u3078\u3053\u9905", fight_mochi: "\u3051\u3093\u304b\u9905", water: "\u6c34", water_bowl: "\u6c34\u306e\u76bf", sleep_drug: "\u306d\u3080\u308a\u85ac", grass: "\u8349", stone: "\u77f3", bed: "\u5e72\u8349\u5bdd\u5e8a", nest_box: "\u5de3\u7bb1", ball: "\u30dc\u30fc\u30eb", firecracker: "\u7206\u7af9", fence_v: "\u7e26\u306e\u67f5", fence_h: "\u6a2a\u306e\u67f5" }[itemType];
2026-06-19 23:22:15 +09:00
this.log(`${label}\u3092\u914d\u7f6e\u3057\u305f\u3002`);
2026-06-19 14:00:48 +09:00
}
}
inferLogKind(text) {
const s = String(text || "").toLowerCase();
if (s.includes("firecracker") || s.includes("accident") || s.includes("drop") || s.includes("\u7206\u7af9") || s.includes("\u843d\u3061") || s.includes("\u4e8b\u6545") || s.includes("\u5439\u304d\u98db")) return "accident";
if (s.includes("fight") || s.includes("lost to") || s.includes("poked") || s.includes("\u55a7\u5629") || s.includes("\u8ca0\u3051") || s.includes("\u3064\u3064\u304b")) return "fight";
if (s.includes("appeared") || s.includes("wandered in") || s.includes("birth") || s.includes("\u73fe\u308c") || s.includes("\u8ff7\u3044\u8fbc")) return "birth";
2026-06-19 23:22:15 +09:00
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";
2026-06-19 14:00:48 +09:00
if (s.includes("weather") || s.includes("day") || s.includes("night") || s.includes("morning") || s.includes("evening") || s.includes("\u5929\u6c17") || s.includes("\u65e5\u76ee") || s.includes("\u591c") || s.includes("\u671d") || s.includes("\u5915")) return "weather";
if (s.includes("zunda") || s.includes("food") || s.includes("eat") || s.includes("appreciated") || s.includes("\u305a\u3093\u3060") || s.includes("\u98df\u3079\u7269") || s.includes("\u5473\u308f")) return "food";
if (s.includes("grass") || s.includes("soil") || s.includes("\u8349") || s.includes("\u571f")) return "grass";
if (s.includes("selected") || s.includes("placed") || s.includes("\u9078\u629e") || s.includes("\u914d\u7f6e")) return "observe";
if (s.includes("near") || s.includes("\u8fd1\u304f") || s.includes("\u95a2\u4fc2")) return "relation";
return "note";
}
log(text, kind = null) {
const entry = { time: this.time, text, kind: kind || this.inferLogKind(text) };
if (!this.eventCounters) this.eventCounters = {};
this.eventCounters[entry.kind] = (this.eventCounters[entry.kind] || 0) + 1;
2026-06-20 02:07:01 +09:00
for (const t of this.tarinai || []) {
if (!t || !t.name || !text || !String(text).includes(t.name)) continue;
if (t.addRecord) t.addRecord(text, entry.kind);
}
2026-06-19 14:00:48 +09:00
this.logs.unshift(entry);
this.logs = this.logs.slice(0, 100);
renderLog(this.logs);
}
serialize() {
return {
w: this.w, h: this.h, time: this.time, day: this.day, deadCount: this.deadCount,
2026-06-19 23:22:15 +09:00
maxGeneration: this.maxGeneration, lastBirthAt: this.lastBirthAt || -999,
2026-06-19 14:00:48 +09:00
tarinai: this.tarinai.map(t => t.serialize()),
items: this.items.map(i => i.serialize()),
logs: this.logs.slice(0, 30),
family: this.family || {},
weather: this.weather || "sunny",
fieldType: this.fieldType || "garden",
fieldZoom: this.fieldZoom || 1,
cameraX: this.cameraX || 0,
cameraY: this.cameraY || 0,
eventCounters: this.eventCounters || {},
toolSize: this.toolSize || "medium",
toolSizes: this.toolSizes || {},
nextWeatherChange: this.nextWeatherChange || CONFIG.weatherChangeMin,
};
}
load(data) {
this.time = data.time || 0;
this.day = data.day || 1;
this.deadCount = data.deadCount || 0;
this.eventCounters = data.eventCounters || {};
this.toolSize = data.toolSize || this.toolSize || "medium";
this.toolSizes = data.toolSizes || this.toolSizes || {};
this.maxGeneration = data.maxGeneration || 1;
2026-06-19 23:22:15 +09:00
this.lastBirthAt = Number.isFinite(data.lastBirthAt) ? data.lastBirthAt : -999;
2026-06-19 14:00:48 +09:00
this.fieldType = FIELD_TYPES?.[data.fieldType]?.id || "garden";
2026-06-19 20:52:40 +09:00
this.fieldZoom = Number(data.fieldZoom) || 1;
2026-06-19 14:00:48 +09:00
this.cameraX = Number(data.cameraX) || 0;
this.cameraY = Number(data.cameraY) || 0;
this.tarinai = (data.tarinai || []).map(o => Tarinai.from(this, o));
2026-06-19 20:52:40 +09:00
this.items = (data.items || []).filter(o => o?.type !== "mirror" && o?.type !== "food" && o?.type !== "cardboard").map(o => Item.from(o));
2026-06-19 14:00:48 +09:00
this.effects = [];
this.compactItems();
this.compactEffects();
this.logs = (data.logs || []).map(e => typeof e === "string" ? { time: this.time, text: e, kind: this.inferLogKind(e) } : { time: e.time || this.time, text: e.text || "", kind: e.kind || this.inferLogKind(e.text || "") });
this.family = data.family || {};
this.familyCleanVersion = null;
this.familyVersion = (this.familyVersion || 0) + 1;
this.weather = data.weather || "sunny";
this.nextWeatherChange = data.nextWeatherChange || rand(CONFIG.weatherChangeMin, CONFIG.weatherChangeMax);
this.relationNotices = {};
this.resolvedFightIds = {};
for (const t of this.tarinai) this.recordFamily(t);
this.normalizeFamily();
this.pruneExtinctFamilies({ normalize: false });
this.lastPhase = this.phaseName();
this.selected = null;
this.updateItemCounts();
this.updateEffectCounts();
this.rebuildSpatial();
this.log("\u30bb\u30fc\u30d6\u30c7\u30fc\u30bf\u3092\u8aad\u307f\u8fbc\u307f\u307e\u3057\u305f\u3002");
}
}
const world = new World();