"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"]; 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 range = GENETIC_LIMITS[key] || [0.75, 1.25]; return clamp(Number(value) || 1, 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), }; } 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 base = ((ga[key] || 1) + (gb[key] || 1)) / 2; const mutation = (stableUnit(seed, `gene-mut-${key}`) - 0.5) * 0.10; 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 clampTarinaiEnergy(tarinai = null) { if (!tarinai) return 100; const max = tarinaiMaxEnergy(tarinai); tarinai.maxEnergy = max; tarinai.energy = clamp(Number(tarinai.energy) || 0, 0, max); return max; } 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 PERSONALITIES = Object.freeze({}); const PERSONALITY_IDS = []; 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 personalityFromParents(a, b) { const pa = ensurePersonality(a).currentPersonality; const pb = ensurePersonality(b).currentPersonality; const out = {}; for (const key of PERSONALITY_KEYS) out[key] = clampPersonalityValue(((pa[key] || 0) + (pb[key] || 0)) / 2 + rand(-0.2, 0.2)); 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 === "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") { const w = Math.max(30, 14 + this.text.length * 7); const h = 20; 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.font = "bold 12px Yomogi, ui-rounded, sans-serif"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(this.text, 0, -10); } ctx.restore(); } } 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(); } clearStaticItems(opts = {}) { this.staticItemCells.clear(); this.staticObstacleCells.clear(); this.staticFoodCells.clear(); this.staticHazardCells.clear(); if (opts.rebuildCombined) this.rebuildCombinedItemCells(); } clearDynamicItems(opts = {}) { this.dynamicItemCells.clear(); this.dynamicObstacleCells.clear(); this.dynamicFoodCells.clear(); this.dynamicHazardCells.clear(); if (opts.rebuildCombined) this.rebuildCombinedItemCells(); } 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); } isDynamicItem(it) { if (!it || it.dead) return false; const type = it.type || ""; if (type === "ball" || type === "genkotsu" || type === "firecracker" || type === "pushpin" || type === "oshibyo" || (type === "rotator" && Math.abs(Number(it.rotatorSpeed || 0)) > 0.001)) return true; 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; if (typeof itemHasTrait === "function" ? itemHasTrait(type, "obstacle") : (type === "stone" || type === "ball" || type === "nest_box" || type === "bed" || type === "fence_v" || type === "fence_h" || type === "bounce_fence" || type === "bounce_fence_v")) this.add(obstacleMap, it); if (typeof itemHasTrait === "function" ? itemHasTrait(type, "food_interest") : (type === "sweet" || type === "love_mochi" || type === "fight_mochi" || type === "grass" || type === "water" || type === "water_bowl" || type === "ant_corpse" || (typeof isParamEffectItemType === "function" && isParamEffectItemType(type)))) this.add(foodMap, it); if (typeof itemHasTrait === "function" && itemHasTrait(type, "hazard")) this.add(hazardMap, it); } 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); 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 (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; 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) 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(); }