3073 lines
131 KiB
JavaScript
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;
|
|
}
|