tarinai/js/world_combat_effects.js
2026-06-25 14:51:58 +09:00

890 lines
48 KiB
JavaScript

"use strict";
(function (global) {
const World = global.World;
if (!World) throw new Error("World is not available for mixin: world_combat_effects.js");
Object.defineProperties(World.prototype, Object.getOwnPropertyDescriptors({
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) {
if (t.enterPanic) t.enterPanic({ target: { x, y, dead: false }, reason: "\u7206\u7af9\u3067\u305f\u305f\u304d\u8d77\u3053\u3055\u308c\u305f", fear: 1.2, surpriseTimer: 0.95, wake: true, cause: "firecracker_wakeup" });
else { t.setActionState?.("panic", { target: { x, y, dead: false }, reason: "\u7206\u7af9\u3067\u305f\u305f\u304d\u8d77\u3053\u3055\u308c\u305f", wake: true }); t.surpriseTimer = Math.max(t.surpriseTimer, 0.95); t.fearTimer = Math.max(t.fearTimer, 1.2); }
}
if (t.addStress) t.addStress(rand(8, 15), { threshold: 8 });
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");
if (t.addStress) t.addStress(22 * p, { threshold: 8 });
t.hurtTimer = Math.max(t.hurtTimer, 1.5 + p * 1.8);
t.surpriseTimer = Math.max(t.surpriseTimer, 0.85);
if (t.enterPanic) t.enterPanic({ target: { x, y }, reason: "\u7206\u7af9\u306e\u7206\u98a8\u3067\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", fear: 1.7 + p, wake: true, cause: "firecracker_blast" });
else { t.fearTimer = Math.max(t.fearTimer, 1.7 + p); t.setActionState?.("panic", { target: { x, y }, reason: "\u7206\u7af9\u306e\u7206\u98a8\u3067\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", wake: true }); }
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,
thought: "\u7206\u7af9\u306e\u7206\u98a8\u3067\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b",
});
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);
const explosionChance = window.TarinaiDiseaseRegistry?.infectionChance?.("disease_explosion", 0.15) ?? 0.15;
if (!t.dead && p > 0.22 && Math.random() < (t.diseaseChance ? t.diseaseChance(explosionChance) : explosionChance)) 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)");
}
if (typeof damageNearbyStructures === "function") damageNearbyStructures(this, x, y, blastRadius, 34 * (it.blastScale || 1), it);
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.itemsOfType ? this.itemsOfType("ball") : 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\u8fba\u308a\u304c\u3056\u308f\u3064\u3044\u305f\u3002", "accident");
},
explodeDiseaseTarinai(t) {
if (!t || t.dead) return;
const x = t.x, y = t.y;
const blastScale = 0.68;
const blastRadius = 240 * blastScale;
t.explosionDisease = false;
t.explosionDiseaseTimer = 0;
this.effects.push(new Effect("explosion", x, y, { size: 86 * blastScale, life: 1.05, color: "rgba(255,182,66,0.92)" }));
this.effects.push(new Effect("explosion", x, y, { size: 128 * blastScale, 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) * blastScale, 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, blastScale);
for (let i = 0; i < 34; i++) {
const a = Math.PI * 2 * i / 34 + rand(-0.10, 0.10);
const speed = rand(90, 260) * blastScale;
this.effects.push(new Effect(i % 3 === 0 ? "explosion" : "fight", x + Math.cos(a) * rand(2, 22) * blastScale, y + Math.sin(a) * rand(2, 22) * blastScale, {
vx: Math.cos(a) * speed,
vy: Math.sin(a) * speed,
size: rand(8, i % 3 === 0 ? 24 : 20) * blastScale,
life: rand(0.26, 0.72),
color: i % 3 === 0 ? "rgba(255,218,70,0.78)" : "rgba(112,72,42,0.72)",
}));
}
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(5 + q * 18, "\u7206\u767a\u75c5");
if (o.addStress) o.addStress(22 * q, { threshold: 8 });
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 = q * q * 0.55 + q * 0.45;
const power = (260 + distanceBoost * 920) * rand(0.78, 1.24) * blastScale;
this.applyImpulse(o, nx * power + rand(-95, 95) * (0.6 + q), ny * power + rand(-95, 95) * (0.6 + q), {
panic: true,
target: { x, y },
fearTimer: 1.7 + q,
thought: "\u7206\u767a\u75c5\u306e\u7206\u767a\u306b\u5dfb\u304d\u8fbc\u307e\u308c\u3066\u3044\u308b",
});
o.hurtTimer = Math.max(o.hurtTimer || 0, 1.5 + q * 1.8);
o.surpriseTimer = Math.max(o.surpriseTimer || 0, 0.85);
if (o.enterPanic) o.enterPanic({ target: { x, y }, reason: "\u7206\u767a\u75c5\u306e\u7206\u767a\u306b\u5dfb\u304d\u8fbc\u307e\u308c\u3066\u3044\u308b", fear: 1.7 + q, wake: true, cause: "explosion_disease_blast" });
else { o.fearTimer = Math.max(o.fearTimer || 0, 1.7 + q); o.setActionState?.("panic", { target: { x, y }, reason: "\u7206\u767a\u75c5\u306e\u7206\u767a\u306b\u5dfb\u304d\u8fbc\u307e\u308c\u3066\u3044\u308b", wake: true }); }
o.fallTimer = Math.max(o.fallTimer || 0, 1.45 + q * 1.85);
o.fallMax = Math.max(o.fallMax || 0, o.fallTimer);
o.fallDir = (dx >= 0 ? 1 : -1) * (Math.random() < 0.5 ? 1 : -1);
o.blastSpinTimer = Math.max(o.blastSpinTimer || 0, 1.35 + q * 0.70);
o.blastSpinMax = Math.max(o.blastSpinMax || 0, o.blastSpinTimer);
this.spawnFallEffect(o.x, o.y + o.radius * 0.45, 1.1 + q);
const explosionChance = window.TarinaiDiseaseRegistry?.infectionChance?.("disease_explosion", 0.15) ?? 0.15;
if (!o.dead && q > 0.22 && Math.random() < (o.diseaseChance ? o.diseaseChance(explosionChance) : explosionChance)) o.infectExplosionDisease?.(t);
if (!o.dead && Math.random() < 0.45) this.spawnBubble(o.x, o.y - o.radius * 1.35, "\u306f\u3041\u3041\u3041\uff01", "rgba(84,66,86,0.80)");
}
this.damageAntsInRadius(x, y, blastRadius, p => 5 + p * 18, "\u7206\u767a\u75c5", {
push: p => (260 + (p * p * 0.55 + p * 0.45) * 920) * 0.42 * blastScale,
});
for (const ball of (this.itemsOfType ? this.itemsOfType("ball") : 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 q = 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 + q * 960;
this.applyImpulse(ball, dx / d * blast * blastScale + rand(-80, 80), dy / d * blast * blastScale + rand(-80, 80));
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)" }));
}
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();
}
},
spawnBurstZunchi(source, count = 1, pin = null) {
const n = Math.max(1, Math.min(28, Math.floor(count || 1)));
const sx = Number.isFinite(source?.x) ? source.x : (pin?.x || this.w * 0.5);
const sy = Number.isFinite(source?.y) ? source.y : (pin?.y || this.h * 0.5);
for (let i = 0; i < n; i++) {
const a = -Math.PI * 0.15 + rand(-0.92, 0.92) + (i / Math.max(1, n - 1) - 0.5) * 1.2;
const it = new Item("zunchi", clamp(sx + rand(-8, 8), 40, this.w - 40), clamp(sy + rand(-8, 8), 40, this.h - 40));
it.amount = 240;
it.vx = Math.cos(a) * rand(260, 560) + rand(-40, 40);
it.vy = Math.sin(a) * rand(200, 480) - rand(30, 160);
it.stage = "fresh";
it.burstFromOshibyo = true;
this.addItem?.(it, "zunchi-burst") || this.items.push(it);
}
this.effects?.push(new Effect("zunchi_miasma", sx, sy, { size: 28, life: 0.65, color: "rgba(74,91,50,0.48)" }));
this.log?.(`${source?.name || "\u305f\u308a\u306a\u3044"}\u304b\u3089\u6e9c\u307e\u3063\u3066\u3044\u305f\u305a\u3093\u3061\u304c\u5f3e\u3051\u98db\u3093\u3060\u3002`, "accident", { participants: source?.name ? [source] : [] });
audio.place?.("zunchi");
this.ensureSpatial?.("zunchi-burst");
},
spawnZunchi(x, y, source = null) {
const it = new Item("zunchi", clamp(x, 40, this.w - 40), clamp(y, 40, this.h - 40));
it.amount = 260;
it.stage = "fresh";
it.producedById = source?.id || "";
it.producedAt = this.time || 0;
it.spawnGrace = Math.max(it.spawnGrace || 0, 10.0);
it.eatProtectedUntil = (this.time || 0) + 90;
this.addItem?.(it, "zunchi-spawn") || this.items.push(it);
audio.place?.("zunchi");
},
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.roles?.sleepPlace || (typeof isSleepFurnitureType === "function" ? isSleepFurnitureType(it.type) : (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);
},
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) {
if (entity.enterPanic) entity.enterPanic({ target: options.target || null, reason: options.thought || "\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", fear: options.fearTimer ?? 0.55, wake: true, cause: options.cause || "impulse" });
else { entity.setActionState?.("panic", { target: options.target || null, reason: options.thought || "\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", wake: true }); entity.fearTimer = Math.max(entity.fearTimer || 0, options.fearTimer ?? 0.55); }
}
return true;
},
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";
this.emit?.(String(normalized).includes("\u55a7\u5629") ? "fight:hit" : "impact:hit", { target, amount, cause: normalized, source: options.source || null });
if (target.damage) target.damage(amount, normalized);
else if (Number.isFinite(target.hp)) target.hp -= amount;
return true;
},
maybeDefeatFromFightDamage(victim, attacker, damage = 0) {
if (!victim || !attacker || victim.dead || attacker.dead || victim.id === attacker.id) return false;
if ((victim.fightTimer || 0) <= 0.04 || (attacker.fightTimer || 0) <= 0.04) return false;
if (victim.defeatedById || (victim.defeatedTimer || 0) > 0.04) return false;
const profile = victim.personalityProfile ? victim.personalityProfile() : { fight: 1 };
const aggressionHold = clamp((profile.fight || 1) - 1, -0.8, 1.3);
const healthRisk = clamp((42 - (victim.energy || 0)) / 42, 0, 1.25);
const damageRisk = clamp((damage || 0) / Math.max(2.5, 9 + (victim.energy || 0) * 0.045), 0, 1.4);
const stressRisk = clamp(((victim.stress || 0) - 70) / 70, 0, 0.8);
const chance = clamp(0.025 + damageRisk * 0.23 + healthRisk * 0.34 + stressRisk * 0.10 - aggressionHold * 0.12, 0.015, 0.78);
if (Math.random() >= chance) return false;
victim.defeatedById = attacker.id;
victim.fightWinnerId = attacker.id;
attacker.fightWinnerId = attacker.id;
attacker.defeatedById = null;
victim.fightTimer = Math.min(victim.fightTimer || 0, rand(0.20, 0.42));
attacker.fightTimer = Math.min(attacker.fightTimer || 0, rand(0.20, 0.42));
this.emit?.("fight:lost", { winner: attacker, loser: victim, damage });
if (victim.enterPanic) victim.enterPanic({ target: attacker, threat: attacker, reason: "\u55a7\u5629\u306b\u8ca0\u3051\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", fear: 1.1, wake: true, cause: "fight_lost" });
else { victim.setActionState?.("panic", { target: attacker, reason: "\u55a7\u5629\u306b\u8ca0\u3051\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", wake: true }); victim.fearTimer = Math.max(victim.fearTimer || 0, 1.1 * (profile.fear || 1)); }
const dx = victim.x - attacker.x;
const dy = victim.y - attacker.y;
const d = Math.hypot(dx, dy) || 1;
victim.vx += dx / d * rand(38, 76);
victim.vy += dy / d * rand(22, 56);
this.spawnBubble?.(victim.x, victim.y - victim.radius * 1.25, "\u306f\u3041\u3041\u3041\uff01", "rgba(84,66,86,0.80)");
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;
this.emit?.("fight:ended", { winner, loser });
winner.adjustPersonality?.("aggression", 0.018, "after winning fights.");
loser.adjustPersonality?.("aggression", -0.018, "after losing fights.");
this.maybeApplyZunchiSlaveByFightRecord?.(winner, loser);
this.maybeApplyZunchiSlaveByFightRecord?.(loser, winner);
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);
if (typeof applyNeedRelief === "function") applyNeedRelief(winner, { safety: -rand(12, 22), fulfill: -rand(8, 16) });
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] });
}
},
maybeApplyZunchiSlaveByFightRecord(tarinai, other = null) {
if (!tarinai || tarinai.dead || tarinai.isZunchiSlave) return false;
const wins = Number(tarinai.totalFightWins || 0);
const losses = Number(tarinai.totalFightLosses || 0);
const total = wins + losses;
if (total < 5) return false;
const winRate = total > 0 ? wins / total : 0;
if (winRate > 0.30) return false;
const oldName = tarinai.formerName || tarinai.name;
if (!tarinai.becomeZunchiSlave?.({ fightRecord: true, locked: true })) return false;
this.spawnBubble?.(tarinai.x, tarinai.y - tarinai.radius * 1.20, "\u3076\u308a\u3085\u2026", "rgba(74,91,50,0.78)");
this.log?.(`${oldName}\u306f\u55a7\u5629\u30925\u56de\u7d4c\u9a13\u3057\u3001\u52dd\u738750%\u4ee5\u4e0b\u3067\u305a\u3093\u3061\u3069\u308c\u3044\u306b\u306a\u3063\u305f\u3002`, "fight", { participants: other ? [tarinai, other] : [tarinai] });
return true;
},
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(164, (it.r || 88) * 2.05) : (isStone ? 64 : Math.max(34, (it.r || 14) * 1.75));
const damage = isGenkotsu ? rand(38, 58) : (isStone ? rand(20, 34) : rand(3, 7));
const push = isGenkotsu ? rand(760, 1040) : (isStone ? rand(145, 210) : rand(54, 92));
const reason = isGenkotsu ? "\u3052\u3093\u3053\u3064" : (isStone ? "\u77f3\u304c\u843d\u3061\u305f" : "\u843d\u3061\u3066\u304d\u305f\u9053\u5177\u306b\u5f53\u305f\u3063\u305f");
if (isGenkotsu) { it.impactFlash = 1; it.lingerTimer = Math.max(it.lingerDuration || 2, 2); this.triggerGroundShake?.(2.0, 10); }
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 ? 190 : (isStone ? 42 : 18)) * p;
const dealtDamage = damage * (0.35 + p * 0.65);
t.damage(dealtDamage, reason);
if (isGenkotsu) {
t.fearTimer = Math.max(t.fearTimer || 0, 4.0 + p * 2.2);
if (typeof applyNeedShock === "function") applyNeedShock(t, { safety: 18 + p * 30, health: 8 + p * 15 });
t.adjustPersonality?.("openness", -(0.020 + p * 0.030), "after being struck by genkotsu.");
t.thought = "\u3052\u3093\u3053\u3064\u304c\u3053\u308f\u3044";
} else {
t.thought = isStone ? "\u77f3\u304c\u843d\u3061\u3066\u304d\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b" : "\u843d\u3061\u3066\u304d\u305f\u9053\u5177\u306b\u5f53\u305f\u3063\u3066\u6df7\u4e71\u3057\u3066\u3044\u308b";
}
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;
}
if (typeof damageNearbyStructures === "function") damageNearbyStructures(this, it.x, it.y, radius, damage, it);
const antHit = this.damageAntsInRadius(it.x, it.y, radius + 54, p => damage * (0.35 + p * 0.65), reason, {
push: p => push * (0.62 + p * 1.25),
});
if (isGenkotsu) {
this.blastZunchiFrom?.(it.x, it.y, radius + 92, 2.75);
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 || isGenkotsu) this.spawnFallEffect(it.x, it.y + (it.r || 12) * 0.6, isGenkotsu ? 1.32 : (isStone ? 0.9 : 0.45));
if (isGenkotsu) audio.genkotsuImpact?.();
else if (isStone) audio.stoneImpact?.();
else if (hit > 0 || antHit > 0) audio.poke?.();
else audio.drop?.();
if (hit > 0 || antHit > 0) {
const label = isGenkotsu ? "\u3052\u3093\u3053\u3064" : (isStone ? "\u77f3" : "\u9053\u5177");
const victims = [];
if (hit > 0) victims.push(`${hit}\u5339\u306e\u305f\u308a\u306a\u3044`);
if (antHit > 0) victims.push(`${antHit}\u5339\u306e\u30a2\u30ea`);
this.log(`${label}\u304c\u843d\u3061\u3001${victims.join("\u3068")}\u304c\u5dfb\u304d\u8fbc\u307e\u308c\u305f\u3002`, "accident");
}
},
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.goIdle("\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.ballHit?.();
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.itemsOfType?.("ball") || (this.items || []).filter(it => it && !it.dead && it.type === "ball");
for (const a of balls) {
if (!a || a.dead || a.type !== "ball") continue;
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.itemsOfType ? this.itemsOfType("ball") : 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,
thought: "\u9ad8\u901f\u306e\u30dc\u30fc\u30eb\u306b\u3076\u3064\u304b\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b",
});
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);
if (t.addStress) t.addStress(stressGain, { threshold: 8 });
if (t.enterPanic) t.enterPanic({ target: { x: ball.x, y: ball.y }, reason: "\u9ad8\u901f\u306e\u30dc\u30fc\u30eb\u306b\u3076\u3064\u304b\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", fear: 0.95, stress: 0, wake: true, cause: "fast_ball_hit" });
else t.setActionState?.("panic", { target: { x: ball.x, y: ball.y }, reason: "\u9ad8\u901f\u306e\u30dc\u30fc\u30eb\u306b\u3076\u3064\u304b\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", wake: true });
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;
if (typeof applyNeedShock === "function" && sensitiveBump > playRelief) applyNeedShock(t, { safety: (sensitiveBump - playRelief) * 4 }); else if (typeof applyNeedRelief === "function") applyNeedRelief(t, { fulfill: -playRelief * 6, social: -playRelief * 2 });
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,
thought: "\u9ad8\u901f\u3067\u3076\u3064\u304b\u3063\u3066\u6df7\u4e71\u3057\u3066\u3044\u308b",
});
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;
if (t.enterPanic) t.enterPanic({ target: { x: impactor.x, y: impactor.y }, reason: "\u9ad8\u901f\u3067\u3076\u3064\u304b\u3063\u3066\u6df7\u4e71\u3057\u3066\u3044\u308b", fear: 0.75, wake: true, cause: "tarinai_highspeed_collision" });
else t.setActionState?.("panic", { target: { x: impactor.x, y: impactor.y }, reason: "\u9ad8\u901f\u3067\u3076\u3064\u304b\u3063\u3066\u6df7\u4e71\u3057\u3066\u3044\u308b", wake: true });
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] });
}
}
}
}));
})(typeof window !== "undefined" ? window : globalThis);