tarinai/js/sim_core.js
2026-07-10 16:40:06 +09:00

994 lines
41 KiB
JavaScript

"use strict";
function makeName(worldRef = null) {
const existing = new Set((worldRef?.tarinai || []).filter(t => !t.dead).map(t => t.name));
const build = () => {
const len = randi(2, 7);
const hasStop = len >= 2 && Math.random() < 0.30;
const middleLen = Math.max(0, len - 1 - (hasStop ? 1 : 0));
let name = "\u306f";
for (let i = 0; i < middleLen; i++) name += Math.random() < 0.64 ? "\u3046" : "\u3045";
if (hasStop) name += "\u3063";
return name;
};
for (let i = 0; i < 80; i++) {
const candidate = build();
if (!existing.has(candidate)) return candidate;
}
return build();
}
function spawnableSprites() {
return SPRITES.filter(s => !s.actionOnly && !s.displayOnly);
}
function displayNormalSprites() {
return ["smile", "jito", "normal_smirk"].filter(id => SPRITES.some(s => s.id === id));
}
function lowStressSprites() {
return ["normal_tongue", "normal_happy"].filter(id => SPRITES.some(s => s.id === id));
}
function stableChoice(seed, salt, options) {
if (!options?.length) return null;
const i = Math.min(options.length - 1, Math.floor(stableUnit(seed, salt) * options.length));
return options[i];
}
function baseSpriteId() {
return pick(spawnableSprites()).id;
}
function makeTrait(type) {
const base = {
smile: { hunger: 0.92, social: 1.14, sleep: 1.00, stress: 0.82, speed: 1.02 },
angry: { hunger: 1.05, social: 0.84, sleep: 0.94, stress: 1.24, speed: 1.15 },
teary: { hunger: 0.96, social: 1.22, sleep: 1.04, stress: 1.12, speed: 0.96 },
jito: { hunger: 0.88, social: 0.80, sleep: 0.96, stress: 0.80, speed: 0.98 },
drool: { hunger: 1.30, social: 1.02, sleep: 1.04, stress: 0.98, speed: 1.00 },
cry: { hunger: 1.00, social: 1.30, sleep: 1.12, stress: 1.36, speed: 0.90 },
sleep: { hunger: 0.78, social: 0.88, sleep: 1.44, stress: 0.72, speed: 0.70 },
pokan: { hunger: 1.02, social: 1.02, sleep: 1.02, stress: 1.06, speed: 0.88 },
stretch: { hunger: 1.08, social: 0.90, sleep: 0.92, stress: 1.08, speed: 1.26 },
}[type] || { hunger: 1, social: 1, sleep: 1, stress: 1, speed: 1 };
return { ...base };
}
function stableUnit(seed, salt = "") {
const str = `${seed}:${salt}`;
let h = 2166136261;
for (let i = 0; i < str.length; i++) {
h ^= str.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return ((h >>> 0) % 100000) / 100000;
}
const GENETIC_KEYS = ["lifeSpanMul", "attackMul", "speedMul", "sizeMul", "temperatureOffset"];
const GENETIC_LIMITS = Object.freeze({
lifeSpanMul: [0.82, 1.24],
attackMul: [0.80, 1.24],
speedMul: [0.86, 1.18],
sizeMul: [0.84, 1.18],
});
function clampGeneticValue(key, value) {
const fallback = key === "temperatureOffset" ? 0 : 1;
const n = Number(value);
const finite = Number.isFinite(n) ? n : fallback;
if (key === "temperatureOffset") return finite;
const range = GENETIC_LIMITS[key] || [0.75, 1.25];
return clamp(finite, range[0], range[1]);
}
function randomGenetics(seed = "") {
return {
lifeSpanMul: clampGeneticValue("lifeSpanMul", 0.82 + stableUnit(seed, "gene-life") * 0.42),
attackMul: clampGeneticValue("attackMul", 0.80 + stableUnit(seed, "gene-attack") * 0.44),
speedMul: clampGeneticValue("speedMul", 0.86 + stableUnit(seed, "gene-speed") * 0.32),
sizeMul: clampGeneticValue("sizeMul", 0.84 + stableUnit(seed, "gene-size") * 0.34),
temperatureOffset: clampGeneticValue("temperatureOffset", -5 + stableUnit(seed, "gene-temperature-offset") * 10),
};
}
function normalizeGenetics(source = null, seed = "") {
const fallback = randomGenetics(seed);
const out = {};
for (const key of GENETIC_KEYS) {
out[key] = clampGeneticValue(key, source?.[key] ?? fallback[key]);
}
return out;
}
function inheritedGenetics(a, b, seed = "") {
const ga = normalizeGenetics(a?.genetics, a?.familyKey || a?.id || "a");
const gb = normalizeGenetics(b?.genetics, b?.familyKey || b?.id || "b");
const out = {};
for (const key of GENETIC_KEYS) {
const fallback = key === "temperatureOffset" ? 0 : 1;
const av = Number.isFinite(Number(ga[key])) ? Number(ga[key]) : fallback;
const bv = Number.isFinite(Number(gb[key])) ? Number(gb[key]) : fallback;
const base = (av + bv) / 2;
const mutationScale = key === "temperatureOffset" ? 2.5 : 0.10;
const mutation = (stableUnit(seed, `gene-mut-${key}`) - 0.5) * mutationScale;
out[key] = clampGeneticValue(key, base + mutation);
}
return out;
}
function adultScaleFromGenetics(seed = "", genetics = null) {
const g = normalizeGenetics(genetics, seed);
return clamp((0.265 + stableUnit(seed, "adult-scale") * 0.060) * g.sizeMul, 0.205, 0.390);
}
function childGrowthScaleFactor(growth = 1) {
const x = clamp(Number(growth) || 0, 0, 1);
const smooth = x * x * (3 - 2 * x);
return 0.46 + smooth * 0.54;
}
function scaleForGrowth(adultScale = 0.28, growth = 1) {
return clamp((Number(adultScale) || 0.28) * childGrowthScaleFactor(growth), 0.10, 0.42);
}
function tarinaiBodySizeRatio(tarinai = null) {
const baseScale = 0.28;
let effectiveScale = Number(tarinai?.scale);
if (tarinai && typeof tarinai.effectiveScale === "function") effectiveScale = Number(tarinai.effectiveScale());
if (!Number.isFinite(effectiveScale) || effectiveScale <= 0) effectiveScale = baseScale;
return clamp(effectiveScale / baseScale, 0.28, 3.25);
}
function tarinaiMaxEnergy(tarinai = null) {
return Math.round(clamp(100 * tarinaiBodySizeRatio(tarinai), 28, 240));
}
function tarinaiEnergyRatio(tarinai = null) {
const max = Math.max(1, typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(tarinai) : (Number(tarinai?.maxEnergy) || 100));
return clamp((Number(tarinai?.energy) || 0) / max, 0, 1);
}
function tarinaiMetabolicHungerScale(tarinai = null) {
if (!tarinai) return 1;
const attack = Math.max(0.15, typeof tarinai.outgoingDamage === "function" ? tarinai.outgoingDamage(1) : (tarinai.geneticStatRatio ? tarinai.geneticStatRatio("attack") : (Number(tarinai.genetics?.attackMul) || 1)));
const speed = Math.max(0.15, (tarinai.geneticStatRatio ? tarinai.geneticStatRatio("speed") : (Number(tarinai.genetics?.speedMul) || 1)) * (tarinai.itemSpeedMultiplier ? tarinai.itemSpeedMultiplier() : 1));
const size = tarinaiBodySizeRatio(tarinai);
return clamp(attack * speed * size, 0.18, 8.0);
}
const PERSONALITY_KEYS = ["aggression", "openness", "sociability", "neuroticism"];
const PERSONALITY_DAILY_LIMIT = { perAxis: 0.08, total: 0.20 };
const FRIEND_AFFINITY_THRESHOLD = 14;
const PERSONALITY_LABELS = {
aggression: { label: "\u653b\u6483\u6027", low: "\u304a\u3060\u3084\u304b", high: "\u6012\u308a\u3063\u307d\u3044" },
openness: { label: "\u958b\u653e\u6027", low: "\u4fdd\u5b88\u7684", high: "\u904a\u3073\u597d\u304d" },
sociability: { label: "\u793e\u4ea4\u6027", low: "\u4e00\u4eba\u597d\u304d", high: "\u4ef2\u9593\u597d\u304d" },
neuroticism: { label: "\u795e\u7d4c\u8cea\u6027", low: "\u7121\u9813\u7740", high: "\u7e4a\u7d30" },
};
function clampPersonalityValue(value) {
return clamp(Number(value) || 0, -1, 1);
}
function normalizePersonality(source = null) {
const out = {};
for (const key of PERSONALITY_KEYS) out[key] = clampPersonalityValue(source?.[key]);
return out;
}
function zunchiSlavePersonalityValue() {
const out = {};
for (const key of PERSONALITY_KEYS) out[key] = -1;
return out;
}
function applyZunchiSlavePersonality(tarinai, opts = {}) {
if (!tarinai) return false;
const next = zunchiSlavePersonalityValue();
let changed = false;
tarinai.birthPersonality = normalizePersonality(tarinai.birthPersonality || next);
tarinai.currentPersonality = normalizePersonality(tarinai.currentPersonality || next);
for (const key of PERSONALITY_KEYS) {
if ((tarinai.currentPersonality[key] || 0) !== -1) changed = true;
tarinai.currentPersonality[key] = -1;
if (opts.birth) tarinai.birthPersonality[key] = -1;
}
tarinai.personalityDaily = { day: tarinai.world?.day || 1, total: 0, byKey: {}, byCause: {} };
if (changed && opts.record !== false) tarinai.recordChangeCause?.("\u305a\u3093\u3061\u3069\u308c\u3044\u5316", "\u6027\u683c", { value: -1 });
return changed;
}
function randomBirthPersonality(seed = "") {
const out = {};
for (const key of PERSONALITY_KEYS) out[key] = clampPersonalityValue(stableUnit(seed, `personality-${key}`) * 1.2 - 0.6);
return out;
}
function getTraitStrength(value) {
const v = clampPersonalityValue(value);
if (v >= 0.75) return { direction: 1, strength: "strong" };
if (v >= 0.5) return { direction: 1, strength: "slight" };
if (v <= -0.75) return { direction: -1, strength: "strong" };
if (v <= -0.5) return { direction: -1, strength: "slight" };
return { direction: 0, strength: "neutral" };
}
function personalityBand(value) {
const t = getTraitStrength(value);
return t.direction === 0 ? "neutral" : `${t.direction > 0 ? "high" : "low"}-${t.strength}`;
}
function personalityEffectValue(value) {
const trait = getTraitStrength(value);
if (!trait.direction) return 0;
return trait.direction * (trait.strength === "strong" ? 1 : 0.5);
}
function effectivePersonalityValue(tarinai, key) {
const p = ensurePersonality(tarinai).currentPersonality;
const base = clampPersonalityValue(p[key]);
const moodDelta = clampPersonalityValue(tarinai?.world?.colonyMood?.effects?.personality?.[key] || 0);
return clampPersonalityValue(base + moodDelta);
}
function personalityReasonText(reason = "") {
const key = String(reason || "").replace(/[.\u3002]+$/g, "").trim();
const map = {
"after winning fights": "\u55a7\u5629\u306b\u52dd\u3063\u305f\u5f71\u97ff\u3067",
"after losing fights": "\u55a7\u5629\u306b\u8ca0\u3051\u305f\u5f71\u97ff\u3067",
"after being petted": "\u64ab\u3067\u3089\u308c\u305f\u5f71\u97ff\u3067",
"after staying near family": "\u89aa\u5b50\u306e\u8fd1\u304f\u3067\u904e\u3054\u3057\u305f\u5f71\u97ff\u3067",
"after staying near friends": "\u53cb\u9054\u306e\u8fd1\u304f\u3067\u904e\u3054\u3057\u305f\u5f71\u97ff\u3067",
"after having no friends": "\u53cb\u9054\u304c\u3044\u306a\u3044\u6642\u9593\u304c\u7d9a\u3044\u305f\u5f71\u97ff\u3067",
"after repeated contact with poop": "\u305a\u3093\u3061\u306b\u6163\u308c\u305f\u5f71\u97ff\u3067",
"after repeated contact with corpses": "\u6b7b\u9ab8\u306b\u6163\u308c\u305f\u5f71\u97ff\u3067",
"after being hurt": "\u75db\u307f\u3092\u899a\u3048\u305f\u5f71\u97ff\u3067",
"after ammo knockback": "\u5f3e\u85ac\u3067\u5f3e\u304d\u98db\u3070\u3057\u305f\u5f71\u97ff\u3067",
"after successful intimidation": "\u5a01\u5687\u306b\u6210\u529f\u3057\u305f\u5f71\u97ff\u3067",
"after failed intimidation": "\u5a01\u5687\u306b\u5931\u6557\u3057\u305f\u5f71\u97ff\u3067",
"after being struck by genkotsu": "\u3052\u3093\u3053\u3064\u3092\u53d7\u3051\u305f\u5f71\u97ff\u3067",
};
return map[key] || key;
}
function personalityBecomeText(label = "") {
const text = String(label || "");
if (text === "\u6012\u308a\u3063\u307d\u3044") return "\u6012\u308a\u3063\u307d\u304f";
return text;
}
function personalityAppearanceText(label = "") {
const text = String(label || "");
if (text === "\u6012\u308a\u3063\u307d\u3044") return "\u6012\u308a\u3063\u307d\u3044\u69d8\u5b50";
return `${text}\u306a\u69d8\u5b50`;
}
function getPersonalityTraitTags(tarinai) {
const p = ensurePersonality(tarinai).currentPersonality;
const tags = [];
for (const key of PERSONALITY_KEYS) {
const trait = getTraitStrength(effectivePersonalityValue(tarinai, key));
if (!trait.direction) continue;
const label = PERSONALITY_LABELS[key]?.[trait.direction > 0 ? "high" : "low"] || key;
tags.push(trait.strength === "slight" ? `\u5c11\u3057${label}` : label);
}
const cowardly = effectivePersonalityValue(tarinai, "neuroticism") >= 0.75 && effectivePersonalityValue(tarinai, "aggression") <= -0.5;
if (cowardly && !tags.includes("\u81c6\u75c5")) tags.push("\u81c6\u75c5");
return tags;
}
function personalityDailyState(tarinai) {
const day = tarinai?.world?.day || 1;
const state = tarinai.personalityDaily || {};
if (state.day !== day) {
tarinai.personalityDaily = { day, total: 0, byKey: {}, byCause: {} };
return tarinai.personalityDaily;
}
state.byKey = state.byKey || {};
for (const key of Object.keys(state.byKey)) {
const v = state.byKey[key];
if (typeof v === "number") state.byKey[key] = { up: Math.max(0, v), down: Math.max(0, -v), causes: [] };
else {
const causes = Array.isArray(v?.causes) ? [...new Set(v.causes.map(c => String(c || "").trim()).filter(Boolean))].slice(0, 4) : [];
state.byKey[key] = { up: Math.max(0, Number(v?.up) || 0), down: Math.max(0, Number(v?.down) || 0), causes };
}
}
state.byCause = state.byCause && typeof state.byCause === "object" ? state.byCause : {};
for (const cause of Object.keys(state.byCause)) {
const entry = state.byCause[cause] || {};
const byKey = entry.byKey && typeof entry.byKey === "object" ? entry.byKey : {};
const nextByKey = {};
for (const key of PERSONALITY_KEYS) {
const v = byKey[key] || {};
const up = Math.max(0, Number(v.up) || 0);
const down = Math.max(0, Number(v.down) || 0);
if (up > 0.0001 || down > 0.0001) nextByKey[key] = { up, down };
}
if (Object.keys(nextByKey).length) state.byCause[cause] = { byKey: nextByKey };
else delete state.byCause[cause];
}
state.total = Math.max(0, Number(state.total) || 0);
tarinai.personalityDaily = state;
return state;
}
function ensurePersonality(tarinai) {
if (!tarinai) return { birthPersonality: normalizePersonality(), currentPersonality: normalizePersonality() };
if (!tarinai.birthPersonality) tarinai.birthPersonality = randomBirthPersonality(tarinai.id || Math.random());
tarinai.birthPersonality = normalizePersonality(tarinai.birthPersonality);
tarinai.currentPersonality = normalizePersonality(tarinai.currentPersonality || tarinai.birthPersonality);
personalityDailyState(tarinai);
return { birthPersonality: tarinai.birthPersonality, currentPersonality: tarinai.currentPersonality };
}
function personalityThresholdLogText(tarinai, key, value, reason = "", previousBand = "") {
const trait = getTraitStrength(value);
const name = tarinai?.name || "\u305f\u308a\u306a\u3044";
if (!trait.direction) {
const oldSide = String(previousBand).startsWith("low") ? "low" : "high";
const oldLabel = PERSONALITY_LABELS[key]?.[oldSide] || key;
return `\u300c${name}\u300d\u306f${personalityAppearanceText(oldLabel)}\u304c\u76ee\u7acb\u305f\u306a\u304f\u306a\u3063\u305f\u3002`;
}
const label = PERSONALITY_LABELS[key]?.[trait.direction > 0 ? "high" : "low"] || key;
const visible = trait.strength === "slight" ? `\u5c11\u3057${personalityBecomeText(label)}` : personalityBecomeText(label);
const prefix = personalityReasonText(reason);
return `\u300c${name}\u300d\u306f${prefix ? `${prefix}\u3001` : ""}${visible}\u306a\u3063\u305f\u3002`;
}
function adjustPersonality(tarinai, key, delta, reason = "") {
if (!tarinai || !PERSONALITY_KEYS.includes(key)) return 0;
ensurePersonality(tarinai);
const requested = Number(delta) || 0;
if (!requested) return 0;
const state = personalityDailyState(tarinai);
const dir = Math.sign(requested);
const used = state.byKey[key] || { up: 0, down: 0 };
const dirKey = dir > 0 ? "up" : "down";
const keyRoom = Math.max(0, PERSONALITY_DAILY_LIMIT.perAxis - (used[dirKey] || 0));
const totalRoom = Math.max(0, PERSONALITY_DAILY_LIMIT.total - (state.total || 0));
const allowed = Math.min(Math.abs(requested), keyRoom, totalRoom);
if (allowed <= 0) return 0;
const before = tarinai.currentPersonality[key] || 0;
const beforeBand = personalityBand(before);
const applied = dir * allowed;
const after = clampPersonalityValue(before + applied);
const actual = after - before;
if (!actual) return 0;
tarinai.currentPersonality[key] = after;
state.byKey[key] = used;
if (!Array.isArray(used.causes)) used.causes = [];
used[actual > 0 ? "up" : "down"] = (used[actual > 0 ? "up" : "down"] || 0) + Math.abs(actual);
state.total = (state.total || 0) + Math.abs(actual);
const label = PERSONALITY_LABELS[key]?.label || key;
const reasonText = personalityReasonText(reason) || "\u6027\u683c\u5909\u5316";
if (reasonText && !used.causes.includes(reasonText)) used.causes.push(reasonText);
if (used.causes.length > 4) used.causes.length = 4;
if (reasonText) {
state.byCause = state.byCause && typeof state.byCause === "object" ? state.byCause : {};
const causeEntry = state.byCause[reasonText] || { byKey: {} };
causeEntry.byKey = causeEntry.byKey && typeof causeEntry.byKey === "object" ? causeEntry.byKey : {};
const causeKeyEntry = causeEntry.byKey[key] || { up: 0, down: 0 };
causeKeyEntry[actual > 0 ? "up" : "down"] = (causeKeyEntry[actual > 0 ? "up" : "down"] || 0) + Math.abs(actual);
causeEntry.byKey[key] = causeKeyEntry;
state.byCause[reasonText] = causeEntry;
}
tarinai.recordChangeCause?.(reasonText, label, { value: actual });
const afterBand = personalityBand(after);
if (beforeBand !== afterBand) {
const text = personalityThresholdLogText(tarinai, key, after, reason, beforeBand);
tarinai.world?.log?.(text, "observe");
tarinai.addRecord?.(text, "observe");
}
return actual;
}
function shouldApplyPersonalityBehavior(tarinai, key, direction = 1) {
const trait = getTraitStrength(effectivePersonalityValue(tarinai, key));
if (trait.direction !== Math.sign(direction || 1)) return false;
return trait.strength === "strong" || Math.random() < 0.5;
}
function relationDefaults() {
return { affinity: 0, fear: 0, fightsWon: 0, fightsLost: 0, lastEvent: "", lastTime: 0 };
}
function relationDisplayName(worldRef, id) {
if (!id) return "\u306a\u3057";
const live = worldRef?.tarinai?.find(t => t.id === id || t.familyKey === id);
if (live) return live.name;
const fam = worldRef?.family?.[id];
return fam?.name || "\u4e0d\u660e";
}
class Effect {
constructor(type, x, y, options = {}) {
this.type = type;
this.x = x;
this.y = y;
this.vx = options.vx ?? rand(-10, 10);
this.vy = options.vy ?? rand(-10, 10);
this.size = options.size ?? rand(3, 8);
this.life = options.life ?? 0.7;
this.maxLife = this.life;
this.color = options.color || "#fff4a8";
this.seed = Math.random() * 1000;
this.text = options.text || "";
}
update(dt) {
this.life -= dt;
this.x += this.vx * dt;
this.y += this.vy * dt;
this.vx *= Math.pow(0.92, dt * 60);
this.vy *= Math.pow(0.92, dt * 60);
}
get dead() { return this.life <= 0; }
draw(ctx) {
const alpha = clamp(this.life / this.maxLife, 0, 1);
ctx.save();
ctx.globalAlpha = alpha;
ctx.translate(this.x, this.y);
if (this.type === "eat") {
ctx.fillStyle = this.color;
ctx.strokeStyle = "rgba(143, 111, 43, 0.7)";
ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.arc(0, 0, this.size, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
} else if (this.type === "bleed") {
ctx.fillStyle = this.color;
ctx.beginPath();
ctx.ellipse(0, 0, this.size * 1.25, this.size * 0.72, this.seed, 0, Math.PI * 2);
ctx.fill();
} else if (this.type === "heart") {
const s = this.size * (0.80 + (1 - alpha) * 0.36);
ctx.rotate(Math.sin(this.seed + (1 - alpha) * 3.2) * 0.18);
drawHeartShape(ctx, 0, 0, s, { fill: this.color || "rgba(240,91,135,0.92)", stroke: "rgba(255,252,246,0.88)", alpha: 1 });
} else if (this.type === "ring") {
ctx.strokeStyle = this.color;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(0, 0, this.size + (1 - alpha) * 18, 0, Math.PI * 2);
ctx.stroke();
} else if (this.type === "flame") {
const fn = globalThis.drawSimpleFireShape;
if (typeof fn === "function") fn(ctx, 0, 0, this.size * (0.74 + (1 - alpha) * 0.20), { phase: this.seed + (1 - alpha) * 4.8, alpha: alpha * 0.92, shadow: false });
else {
ctx.fillStyle = this.color || "rgba(236,46,24,0.82)";
ctx.beginPath();
ctx.ellipse(0, -this.size * 0.2, this.size * 0.34, this.size * 0.72, 0, 0, Math.PI * 2);
ctx.fill();
}
} else if (this.type === "shoot_impact") {
const s = this.size * (0.65 + (1 - alpha) * 0.55);
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.strokeStyle = this.color || "rgba(255,226,150,0.92)";
ctx.lineWidth = Math.max(1.2, this.size * 0.16);
for (let i = 0; i < 7; i++) {
const a = this.seed + i * 0.897;
const inner = s * randSeed(this.seed + i + 2, 0.16, 0.34);
const outer = s * randSeed(this.seed + i + 9, 0.72, 1.18) + (1 - alpha) * this.size * 1.1;
ctx.globalAlpha = alpha * randSeed(this.seed + i + 31, 0.42, 0.95);
ctx.beginPath();
ctx.moveTo(Math.cos(a) * inner, Math.sin(a) * inner);
ctx.lineTo(Math.cos(a) * outer, Math.sin(a) * outer);
ctx.stroke();
}
ctx.globalAlpha = alpha * 0.75;
ctx.strokeStyle = "rgba(255,255,255,0.70)";
ctx.lineWidth = Math.max(1, this.size * 0.08);
ctx.beginPath();
ctx.arc(0, 0, s * 0.68 + (1 - alpha) * this.size * 0.65, 0, Math.PI * 2);
ctx.stroke();
} else if (this.type === "electric_shock") {
const phase = 1 - alpha;
const reach = this.size * (0.78 + phase * 0.22);
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.shadowColor = this.color || "rgba(118,224,255,0.96)";
ctx.shadowBlur = Math.max(5, this.size * 0.42);
for (let bolt = 0; bolt < 4; bolt++) {
const baseAngle = this.seed + bolt * Math.PI * 0.5 + phase * (bolt % 2 ? -0.8 : 0.8);
const points = 6;
ctx.beginPath();
ctx.moveTo(0, 0);
for (let i = 1; i <= points; i++) {
const t = i / points;
const jitter = (i === points ? 0 : randSeed(this.seed + bolt * 31 + i * 7, -0.28, 0.28)) * this.size;
const px = Math.cos(baseAngle) * reach * t + Math.cos(baseAngle + Math.PI * 0.5) * jitter * (1 - t * 0.45);
const py = Math.sin(baseAngle) * reach * t + Math.sin(baseAngle + Math.PI * 0.5) * jitter * (1 - t * 0.45);
ctx.lineTo(px, py);
}
ctx.globalAlpha = alpha * 0.42;
ctx.strokeStyle = this.color || "rgba(118,224,255,0.96)";
ctx.lineWidth = Math.max(3.0, this.size * 0.16);
ctx.stroke();
ctx.globalAlpha = alpha * 0.96;
ctx.strokeStyle = "rgba(255,255,238,0.98)";
ctx.lineWidth = Math.max(1.1, this.size * 0.055);
ctx.stroke();
}
ctx.shadowBlur = 0;
ctx.globalAlpha = alpha * 0.72;
ctx.strokeStyle = "rgba(255,238,96,0.90)";
ctx.lineWidth = Math.max(1.2, this.size * 0.07);
ctx.beginPath();
ctx.arc(0, 0, this.size * (0.28 + phase * 0.24), 0, Math.PI * 2);
ctx.stroke();
} else if (this.type === "fight") {
ctx.lineCap = "round";
ctx.lineJoin = "round";
const puffColor = this.color || "rgba(105, 62, 34, 0.86)";
ctx.fillStyle = puffColor;
ctx.strokeStyle = puffColor;
for (let i = 0; i < 4; i++) {
const a = this.seed + i * 1.55;
const px = Math.cos(a) * this.size * randSeed(this.seed + i, 0.10, 0.72);
const py = Math.sin(a) * this.size * randSeed(this.seed + i + 8, 0.08, 0.48);
ctx.globalAlpha = alpha * (0.26 + i * 0.08);
ctx.beginPath();
ctx.ellipse(px, py, this.size * randSeed(this.seed + i + 20, 0.18, 0.34), this.size * randSeed(this.seed + i + 40, 0.10, 0.22), a, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = alpha * 0.78;
ctx.lineWidth = 2.0;
for (let i = 0; i < 2; i++) {
const dir = i === 0 ? -1 : 1;
const y = dir * this.size * 0.14;
ctx.beginPath();
ctx.moveTo(-this.size * 0.62, y);
ctx.quadraticCurveTo(-this.size * 0.12, y - dir * this.size * 0.42, this.size * 0.54, y + dir * this.size * 0.06);
ctx.stroke();
}
} else if (this.type === "fall") {
ctx.fillStyle = this.color || "rgba(154, 124, 80, 0.62)";
for (let i = 0; i < 5; i++) {
const a = this.seed + i * 1.37;
const rr = this.size * randSeed(this.seed + i, 0.18, 0.82);
const px = Math.cos(a) * rr;
const py = Math.sin(a) * rr * 0.42;
ctx.globalAlpha = alpha * randSeed(this.seed + i + 9, 0.18, 0.42);
ctx.beginPath();
ctx.ellipse(px, py, this.size * randSeed(this.seed + i + 20, 0.16, 0.34), this.size * randSeed(this.seed + i + 30, 0.08, 0.18), a, 0, Math.PI * 2);
ctx.fill();
}
} else if (this.type === "explosion") {
const burst = 1 - alpha;
ctx.globalAlpha = alpha * 0.95;
for (let i = 0; i < 3; i++) {
ctx.strokeStyle = i === 0 ? "rgba(255,238,145,0.92)" : (i === 1 ? "rgba(255,128,66,0.68)" : "rgba(96,62,42,0.42)");
ctx.lineWidth = Math.max(2, this.size * (0.09 - i * 0.018));
ctx.beginPath();
ctx.arc(0, 0, this.size * (0.28 + burst * (0.78 + i * 0.22)), 0, Math.PI * 2);
ctx.stroke();
}
ctx.fillStyle = "rgba(255,214,86,0.88)";
for (let i = 0; i < 14; i++) {
const a = this.seed + i * 0.73;
const r = this.size * burst * randSeed(this.seed + i, 0.28, 1.00);
ctx.globalAlpha = alpha * randSeed(this.seed + i + 4, 0.36, 0.88);
ctx.beginPath();
ctx.arc(Math.cos(a) * r, Math.sin(a) * r, randSeed(this.seed + i + 12, 2.2, 5.2), 0, Math.PI * 2);
ctx.fill();
}
} else if (this.type === "wind") {
const drift = 1 - alpha;
ctx.strokeStyle = this.color || "rgba(160,210,245,0.30)";
ctx.lineWidth = Math.max(1.2, this.size * 0.12);
ctx.lineCap = "round";
for (let i = 0; i < 3; i++) {
const y = (i - 1) * this.size * 0.28 + Math.sin(this.seed + i) * 2;
const x0 = -this.size * (0.55 + i * 0.08) + drift * this.size * 0.55;
const x1 = this.size * (0.72 + i * 0.12) + drift * this.size * 0.85;
ctx.globalAlpha = alpha * (0.22 + i * 0.08);
ctx.beginPath();
ctx.moveTo(x0, y);
ctx.quadraticCurveTo(0, y - this.size * 0.22, x1, y);
ctx.stroke();
}
} else if (this.type === "zunchi_miasma") {
const drift = 1 - alpha;
ctx.globalCompositeOperation = "source-over";
ctx.fillStyle = this.color || "rgba(16,78,28,0.62)";
for (let i = 0; i < 5; i++) {
const a = this.seed + i * 1.41 + drift * 1.8;
const px = Math.cos(a) * this.size * randSeed(this.seed + i, 0.08, 0.62);
const py = Math.sin(a * 0.8) * this.size * randSeed(this.seed + i + 7, 0.05, 0.42) - drift * this.size * 0.42;
ctx.globalAlpha = alpha * randSeed(this.seed + i + 14, 0.10, 0.28);
ctx.beginPath();
ctx.ellipse(px, py, this.size * randSeed(this.seed + i + 21, 0.22, 0.46), this.size * randSeed(this.seed + i + 35, 0.16, 0.38), a, 0, Math.PI * 2);
ctx.fill();
}
} else if (this.type === "bubble") {
ctx.font = "bold 12px Yomogi, ui-rounded, sans-serif";
const maxTextW = 118;
const lineH = 14;
const ellipsize = (text, maxW) => globalThis.canvasEllipsizeText(ctx, text, maxW);
const wrap = (text, maxW) => {
const src = String(text || "").replace(/\s+/g, " ").trim() || " ";
const units = src.includes(" ") ? src.split(/(\s+)/).filter(Boolean) : Array.from(src);
const out = [];
let line = "";
for (const unit of units) {
const candidate = line ? `${line}${unit}` : unit;
if (ctx.measureText(candidate).width <= maxW) line = candidate;
else {
if (line) out.push(line.trim());
line = unit.trim();
if (ctx.measureText(line).width > maxW) { out.push(ellipsize(line, maxW)); line = ""; }
}
if (out.length >= 3) break;
}
if (line && out.length < 3) out.push(line.trim());
if (out.length > 2) out[1] = ellipsize([out[1], ...out.slice(2)].join(""), maxW);
return out.slice(0, 2).map(line => ellipsize(line, maxW));
};
const lines = wrap(this.text, maxTextW);
const textW = lines.reduce((m, line) => Math.max(m, ctx.measureText(line).width), 0);
const w = Math.max(30, Math.min(142, textW + 18));
const h = Math.max(20, 10 + lines.length * lineH);
ctx.globalAlpha = Math.min(1, alpha * 1.25);
ctx.fillStyle = "rgba(255, 252, 240, 0.92)";
ctx.strokeStyle = "rgba(78, 64, 48, 0.22)";
ctx.lineWidth = 1.2;
roundedRect(ctx, -w / 2, -h, w, h, 9);
ctx.fill();
ctx.stroke();
ctx.fillStyle = "rgba(255, 252, 240, 0.92)";
ctx.beginPath();
ctx.moveTo(-4, -1);
ctx.lineTo(2, 5);
ctx.lineTo(6, -2);
ctx.closePath();
ctx.fill();
ctx.stroke();
ctx.fillStyle = this.color || "rgba(42,36,29,0.78)";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
for (let i = 0; i < lines.length; i++) {
ctx.fillText(lines[i], 0, -h + 8 + lineH * i + lineH / 2);
}
}
ctx.restore();
}
}
// Explicit export for systems implemented inside module-style IIFEs.
globalThis.TarinaiEffect = Effect;
class SpatialGrid {
constructor(cellSize = 96) {
this.cellSize = cellSize;
this.itemCells = new Map();
this.staticItemCells = new Map();
this.dynamicItemCells = new Map();
this.tarinaiCells = new Map();
this.antCells = new Map();
this.obstacleCells = new Map();
this.foodCells = new Map();
this.hazardCells = new Map();
this.staticObstacleCells = new Map();
this.dynamicObstacleCells = new Map();
this.staticFoodCells = new Map();
this.dynamicFoodCells = new Map();
this.staticHazardCells = new Map();
this.dynamicHazardCells = new Map();
}
clear() {
this.clearItems();
this.clearTarinai();
this.clearAnts();
}
clearItems() {
this.itemCells.clear();
this.staticItemCells.clear();
this.dynamicItemCells.clear();
this.obstacleCells.clear();
this.foodCells.clear();
this.hazardCells.clear();
this.staticObstacleCells.clear();
this.dynamicObstacleCells.clear();
this.staticFoodCells.clear();
this.dynamicFoodCells.clear();
this.staticHazardCells.clear();
this.dynamicHazardCells.clear();
}
clearTarinai() {
this.tarinaiCells.clear();
}
clearAnts() {
this.antCells.clear();
}
keyFor(x, y) {
const cx = Math.floor(x / this.cellSize);
const cy = Math.floor(y / this.cellSize);
return cx + cy * 100000;
}
add(map, entity) {
if (!entity) return;
const key = this.keyFor(entity.x || 0, entity.y || 0);
let bucket = map.get(key);
if (!bucket) {
bucket = [];
map.set(key, bucket);
}
bucket.push(entity);
}
addAabb(map, entity, bounds) {
if (!entity || !bounds) return this.add(map, entity);
const left = Number(bounds.left);
const right = Number(bounds.right);
const top = Number(bounds.top);
const bottom = Number(bounds.bottom);
if (!Number.isFinite(left) || !Number.isFinite(right) || !Number.isFinite(top) || !Number.isFinite(bottom)) return this.add(map, entity);
const minX = Math.floor(Math.min(left, right) / this.cellSize);
const maxX = Math.floor(Math.max(left, right) / this.cellSize);
const minY = Math.floor(Math.min(top, bottom) / this.cellSize);
const maxY = Math.floor(Math.max(top, bottom) / this.cellSize);
const cellCount = Math.max(1, (maxX - minX + 1) * (maxY - minY + 1));
// Very large bodies would pollute most buckets; keep them in their origin
// cell and let callers use their own radius/AABB checks after retrieval.
if (cellCount > 144) return this.add(map, entity);
for (let cy = minY; cy <= maxY; cy += 1) {
for (let cx = minX; cx <= maxX; cx += 1) {
const key = cx + cy * 100000;
let bucket = map.get(key);
if (!bucket) {
bucket = [];
map.set(key, bucket);
}
bucket.push(entity);
}
}
}
boundsForTrait(it, trait) {
const mech = (typeof window !== "undefined" ? window : globalThis).TarinaiMechanicalSystem;
if (mech?.isMechanicalType?.(it?.type)) return mech.boundsAabb?.(it);
const ropePath = it?.type === "rope" ? ((typeof window !== "undefined" ? window : globalThis).TarinaiPhysicsBodySystem.particles(it) || null) : null;
if (ropePath && ropePath.length) {
let left = Infinity, right = -Infinity, top = Infinity, bottom = -Infinity;
for (const p of ropePath) {
left = Math.min(left, (Number(p.x) || 0) - 8);
right = Math.max(right, (Number(p.x) || 0) + 8);
top = Math.min(top, (Number(p.y) || 0) - 8);
bottom = Math.max(bottom, (Number(p.y) || 0) + 8);
}
if (Number.isFinite(left)) return { left, right, top, bottom, item: it, type: it.type };
}
const r = Math.max(Number(it?.r || it?.radius || 0) || 0, 12);
if (r > this.cellSize * 0.72) return { left: (it.x || 0) - r, right: (it.x || 0) + r, top: (it.y || 0) - r, bottom: (it.y || 0) + r, item: it, type: it?.type };
return null;
}
addTrait(map, it, trait) {
const bounds = this.boundsForTrait(it, trait);
if (bounds) this.addAabb(map, it, bounds);
else this.add(map, it);
}
isDynamicItem(it) {
if (!it || it.dead) return false;
const type = it.type || "";
if (type === "ball" || type === "balloon" || type === "fan" || type === "genkotsu" || type === "firecracker" || type === "pushpin" || type === "oshibyo") return true;
if (["rope", "rod", "spring", "wire", "insulated_wire"].includes(type)) return true;
const globalRef = (typeof window !== "undefined" ? window : globalThis);
const physics = globalRef.TarinaiPhysicsBodySystem;
const body = physics?.isBodyType?.(it) ? physics.ensureBody?.(it, null, { syncFromLegacy: false }) : null;
if (type === "rotator") {
const motor = body?.motor || {};
const velocity = body?.velocity || {};
return Math.abs(Number(motor.speed || 0)) > 0.001 || Math.abs(Number(velocity.angular || 0)) > 0.001 || motor.powered !== false;
}
if (type === "reciprocator") {
const motor = body?.motor || {};
const velocity = body?.velocity || {};
return motor.powered !== false || Math.abs(Number(velocity.linear || 0)) > 0.05;
}
if (type === "poison_block") return Boolean(globalRef.TarinaiMechanicalSystem.passiveItemAwake(it));
if ((it.dropTimer || 0) > 0) return true;
if (Math.hypot(it.vx || 0, it.vy || 0) > 0.05) return true;
if (it.isStructure && it.type === "plushie" && it.carriedById) return true;
return false;
}
addItemToTraitCells(it, dynamic = this.isDynamicItem(it)) {
const type = it.type || "";
const obstacleMap = dynamic ? this.dynamicObstacleCells : this.staticObstacleCells;
const foodMap = dynamic ? this.dynamicFoodCells : this.staticFoodCells;
const hazardMap = dynamic ? this.dynamicHazardCells : this.staticHazardCells;
const hasObstacle = itemHasTrait(type, "obstacle");
const hasFood = itemHasTrait(type, "food_interest");
const hasHazard = itemHasTrait(type, "hazard");
if (type === "poison_block") {
const body = (typeof window !== "undefined" ? window : globalThis).TarinaiPhysicsBodySystem.ensureBody(it, null, { syncFromLegacy: false });
const solid = body?.collision ? body.collision.solid !== false : true;
if (solid && hasObstacle) this.addTrait(obstacleMap, it, "obstacle");
if (hasHazard) this.addTrait(hazardMap, it, "hazard");
return;
}
if (hasObstacle) this.addTrait(obstacleMap, it, "obstacle");
if (hasFood) this.addTrait(foodMap, it, "food_interest");
if (hasHazard) this.addTrait(hazardMap, it, "hazard");
}
classifyItem(it) {
if (!it || it.dead) return;
const dynamic = this.isDynamicItem(it);
const target = dynamic ? this.dynamicItemCells : this.staticItemCells;
this.add(target, it);
this.add(this.itemCells, it);
this.addItemToTraitCells(it, dynamic);
}
rebuildItems(items) {
this.clearItems();
for (const it of items || []) this.classifyItem(it);
this.rebuildCombinedItemCells();
}
rebuildStaticItems(items, opts = {}) {
this.staticItemCells.clear();
this.staticObstacleCells.clear();
this.staticFoodCells.clear();
this.staticHazardCells.clear();
for (const it of items || []) {
if (!it || it.dead || this.isDynamicItem(it)) continue;
this.add(this.staticItemCells, it);
this.addItemToTraitCells(it, false);
}
if (opts.rebuildCombined) this.rebuildCombinedItemCells();
}
rebuildDynamicItems(items, opts = {}) {
this.dynamicItemCells.clear();
this.dynamicObstacleCells.clear();
this.dynamicFoodCells.clear();
this.dynamicHazardCells.clear();
for (const it of items || []) {
if (!it || it.dead || !this.isDynamicItem(it)) continue;
this.add(this.dynamicItemCells, it);
this.addItemToTraitCells(it, true);
}
if (opts.rebuildCombined) this.rebuildCombinedItemCells();
}
copyCells(src, dst) {
for (const [key, bucket] of src.entries()) {
let out = dst.get(key);
if (!out) {
out = [];
dst.set(key, out);
}
for (const entity of bucket) out.push(entity);
}
}
rebuildCombinedItemCells() {
this.itemCells.clear();
this.obstacleCells.clear();
this.foodCells.clear();
this.hazardCells.clear();
this.copyCells(this.staticItemCells, this.itemCells);
this.copyCells(this.dynamicItemCells, this.itemCells);
this.copyCells(this.staticObstacleCells, this.obstacleCells);
this.copyCells(this.dynamicObstacleCells, this.obstacleCells);
this.copyCells(this.staticFoodCells, this.foodCells);
this.copyCells(this.dynamicFoodCells, this.foodCells);
this.copyCells(this.staticHazardCells, this.hazardCells);
this.copyCells(this.dynamicHazardCells, this.hazardCells);
}
rebuildTarinai(tarinai) {
this.tarinaiCells.clear();
for (const t of tarinai || []) if (!t.dead) this.add(this.tarinaiCells, t);
}
rebuildAnts(ants = []) {
this.antCells.clear();
for (const a of ants || []) if (!a.dead) this.add(this.antCells, a);
}
rebuild(items, tarinai, ants = []) {
this.rebuildItems(items);
this.rebuildTarinai(tarinai);
this.rebuildAnts(ants);
}
nearby(map, x, y, radius, out = [], filterDistance = false) {
out.length = 0;
return this.nearbyInto(map, x, y, radius, out, filterDistance);
}
nearbyInto(map, x, y, radius, out = [], filterDistance = false) {
const r = Number.isFinite(radius) ? radius : Math.max(1200, this.cellSize * 12);
// AABB-indexed entities can appear in several buckets. The per-query stamp
// dedupes without allocating a Set on every hot spatial lookup.
const stamp = (this._spatialQueryStamp = (this._spatialQueryStamp || 0) + 1);
const minX = Math.floor((x - r) / this.cellSize);
const maxX = Math.floor((x + r) / this.cellSize);
const minY = Math.floor((y - r) / this.cellSize);
const maxY = Math.floor((y + r) / this.cellSize);
for (let cy = minY; cy <= maxY; cy++) {
for (let cx = minX; cx <= maxX; cx++) {
const bucket = map.get(cx + cy * 100000);
if (!bucket) continue;
for (const entity of bucket) {
if (!entity || entity._spatialQueryStamp === stamp) continue;
entity._spatialQueryStamp = stamp;
if (filterDistance) {
const dx = (entity.x || 0) - x;
const dy = (entity.y || 0) - y;
const er = Math.max(entity.r || entity.radius || 0, 0);
if (dx * dx + dy * dy > (r + er) * (r + er)) continue;
}
out.push(entity);
}
}
}
return out;
}
nearbySplitInto(staticMap, dynamicMap, x, y, radius, out = [], filterDistance = false) {
out.length = 0;
if (staticMap) this.nearbyInto(staticMap, x, y, radius, out, filterDistance);
if (dynamicMap) this.nearbyInto(dynamicMap, x, y, radius, out, filterDistance);
return out;
}
nearbyRectInto(map, rect, out = []) {
if (!rect) return out;
// See nearbyInto(): multi-cell insertions require per-query dedupe.
const stamp = (this._spatialQueryStamp = (this._spatialQueryStamp || 0) + 1);
const minX = Math.floor((rect.left || 0) / this.cellSize);
const maxX = Math.floor((rect.right || 0) / this.cellSize);
const minY = Math.floor((rect.top || 0) / this.cellSize);
const maxY = Math.floor((rect.bottom || 0) / this.cellSize);
for (let cy = minY; cy <= maxY; cy++) {
for (let cx = minX; cx <= maxX; cx++) {
const bucket = map.get(cx + cy * 100000);
if (!bucket) continue;
for (const entity of bucket) {
if (!entity || entity._spatialQueryStamp === stamp) continue;
entity._spatialQueryStamp = stamp;
out.push(entity);
}
}
}
return out;
}
}
function drawHeartShape(ctx, x, y, size, opts = {}) {
const fill = opts.fill || "rgba(240,91,135,0.90)";
const stroke = opts.stroke || "rgba(255,252,246,0.92)";
const alpha = opts.alpha == null ? 1 : opts.alpha;
const rotation = opts.rotation || 0;
ctx.save();
ctx.translate(x, y);
if (rotation) ctx.rotate(rotation);
ctx.globalAlpha *= alpha;
ctx.fillStyle = fill;
ctx.strokeStyle = stroke;
ctx.lineWidth = Math.max(1.2, size * 0.14);
ctx.beginPath();
ctx.moveTo(0, size * 0.72);
ctx.bezierCurveTo(-size * 1.55, -size * 0.22, -size * 1.16, -size * 1.36, 0, -size * 0.52);
ctx.bezierCurveTo(size * 1.16, -size * 1.36, size * 1.55, -size * 0.22, 0, size * 0.72);
ctx.fill();
ctx.stroke();
ctx.restore();
}