tarinai/js/world_combat_effects.js
2026-07-09 16:08:11 +09:00

713 lines
40 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;
const candidates = this.nearbyAnts?.(x, y, radius + 24, true) || this.ants || [];
for (const ant of candidates) {
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 = deterministicAngle(this, "ant-push-overlap", ant, x, y);
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;
},
blastLoosePinsFrom(x, y, blastRadius, source = null, scale = 1, salt = "blast") {
let blastedPins = 0;
const pinSystem = globalThis.TarinaiItemDynamicPinSystem;
const pins = (this.items || []).filter(it => it && !it.dead && isPinType(it.type));
for (const pin of pins) {
if (pin.pinState === "lodged") continue;
let dx = pin.x - x;
let dy = pin.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 = deterministicAngle(this, `${salt}-pin-overlap`, source || pin, pin);
dx = Math.cos(a);
dy = Math.sin(a);
d = 1;
}
if (pin.pinState === "ball_lodged") pinSystem?.detach?.(pin, this, "blast");
const blast = (360 + p * 820) * (Number(scale) || 1);
this.applyImpulse(pin,
dx / d * blast + deterministicRange(this, `${salt}-pin-jitter-x`, -70, 70, source || pin, pin),
dy / d * blast + deterministicRange(this, `${salt}-pin-jitter-y`, -70, 70, source || pin, pin)
);
pin.spinVelocity = clamp((pin.spinVelocity || 0) + deterministicRange(this, `${salt}-pin-spin`, -18, 18, source || pin, pin), -38, 38);
pin.noStickUntil = (this.time || 0) + 0.22;
pin.lastPokedAt = this.time || 0;
this.effects.push(new Effect("ring", pin.x, pin.y, { size: Math.max(12, (pin.r || 8) * 1.8), life: 0.18, color: "rgba(255,230,116,0.46)" }));
blastedPins += 1;
}
return blastedPins;
},
spawnFireAt(x, y, opts = {}) {
return globalThis.TarinaiItemDynamicToolSystem?.spawnFireAt?.(this, x, y, opts) || null;
},
scatterFireFrom(it) {
return globalThis.TarinaiItemDynamicToolSystem?.scatterFireFrom?.(it, this) || false;
},
explodeFirecracker(it) {
if (!it || it.dead || (it.type !== "firecracker" && it.type !== "sticky_bomb")) return false;
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 + deterministicRange(this, "firecracker-spark-angle", -0.10, 0.10, it, i);
const speed = deterministicRange(this, "firecracker-spark-speed", 90, 260, it, i);
this.effects.push(new Effect(i % 3 === 0 ? "explosion" : "fight", x + Math.cos(a) * deterministicRange(this, "firecracker-spark-x", 2, 22, it, i), y + Math.sin(a) * deterministicRange(this, "firecracker-spark-y", 2, 22, it, i), {
vx: Math.cos(a) * speed,
vy: Math.sin(a) * speed,
size: deterministicRange(this, "firecracker-spark-size", 8, i % 3 === 0 ? 24 : 20, it, i),
life: deterministicRange(this, "firecracker-spark-life", 0.26, 0.72, it, i),
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(deterministicRange(this, "firecracker-startle-stress", 8, 15, it, t), { 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 = deterministicAngle(this, "drop-impact-overlap-normal", it, t);
nx = Math.cos(a);
ny = Math.sin(a);
}
const distanceBoost = p * p * 0.55 + p * 0.45;
const power = (260 + distanceBoost * 920) * deterministicRange(this, "firecracker-tarinai-power", 0.78, 1.24, it, t) * (it.blastScale || 1);
this.applyImpulse(t, nx * power + deterministicRange(this, "firecracker-tarinai-jitter-x", -95, 95, it, t) * (0.6 + p), ny * power + deterministicRange(this, "firecracker-tarinai-jitter-y", -95, 95, it, t) * (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) * deterministicSigned(this, "firecracker-fall-dir", it, t);
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 && deterministicChance(this, "firecracker-explosion-disease", t.diseaseChance ? t.diseaseChance(explosionChance) : explosionChance, it, t)) t.infectExplosionDisease?.(it);
if (!t.dead && deterministicChance(this, "firecracker-panic-bubble", 0.45, it, t)) 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") || []).concat(this.itemsOfType("balloon") || []) : this.items)) {
if (!ball || ball.dead || (ball.type !== "ball" && ball.type !== "balloon")) 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 = deterministicAngle(this, "firecracker-ball-overlap", it, ball);
dx = Math.cos(a); dy = Math.sin(a); d = 1;
}
if (ball.type === "balloon") {
globalThis.TarinaiItemDynamicBallSystem?.popBalloon?.(ball, this, it);
blastedBalls += 1;
continue;
}
const blast = 430 + p * 960;
this.applyImpulse(ball, dx / d * blast + deterministicRange(this, "firecracker-ball-jitter-x", -80, 80, it, ball), dy / d * blast + deterministicRange(this, "firecracker-ball-jitter-y", -80, 80, it, ball));
ball.spinVelocity = clamp((ball.spinVelocity || 0) + deterministicRange(this, "firecracker-ball-spin", -24, 24, it, ball), -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;
}
const blastedPins = this.blastLoosePinsFrom(x, y, blastRadius, it, it.blastScale || 1, "firecracker");
audio.explode();
this.log((blastedBalls || blastedPins) ? "\u7206\u7af9\u304c\u6d3e\u624b\u306b\u306f\u3058\u3051\u3001\u8efd\u3044\u7269\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");
return true;
},
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 + deterministicRange(this, "disease-explosion-spark-angle", -0.10, 0.10, t, i);
const speed = deterministicRange(this, "disease-explosion-spark-speed", 90, 260, t, i) * blastScale;
this.effects.push(new Effect(i % 3 === 0 ? "explosion" : "fight", x + Math.cos(a) * deterministicRange(this, "disease-explosion-spark-x", 2, 22, t, i) * blastScale, y + Math.sin(a) * deterministicRange(this, "disease-explosion-spark-y", 2, 22, t, i) * blastScale, {
vx: Math.cos(a) * speed,
vy: Math.sin(a) * speed,
size: deterministicRange(this, "disease-explosion-spark-size", 8, i % 3 === 0 ? 24 : 20, t, i) * blastScale,
life: deterministicRange(this, "disease-explosion-spark-life", 0.26, 0.72, t, i),
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 = deterministicAngle(this, "disease-explosion-overlap-normal", t, o);
nx = Math.cos(a);
ny = Math.sin(a);
}
const distanceBoost = q * q * 0.55 + q * 0.45;
const power = (260 + distanceBoost * 920) * deterministicRange(this, "disease-explosion-tarinai-power", 0.78, 1.24, t, o) * blastScale;
this.applyImpulse(o, nx * power + deterministicRange(this, "disease-explosion-tarinai-jitter-x", -95, 95, t, o) * (0.6 + q), ny * power + deterministicRange(this, "disease-explosion-tarinai-jitter-y", -95, 95, t, o) * (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) * deterministicSigned(this, "disease-explosion-fall-dir", t, o);
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 && deterministicChance(this, "disease-explosion-spread", o.diseaseChance ? o.diseaseChance(explosionChance) : explosionChance, t, o)) o.infectExplosionDisease?.(t);
if (!o.dead && deterministicChance(this, "disease-explosion-panic-bubble", 0.45, t, o)) 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") || []).concat(this.itemsOfType("balloon") || []) : this.items)) {
if (!ball || ball.dead || (ball.type !== "ball" && ball.type !== "balloon")) 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 = deterministicAngle(this, "disease-explosion-ball-overlap", t, ball);
dx = Math.cos(a); dy = Math.sin(a); d = 1;
}
if (ball.type === "balloon") {
globalThis.TarinaiItemDynamicBallSystem?.popBalloon?.(ball, this, t);
continue;
}
const blast = 430 + q * 960;
this.applyImpulse(ball, dx / d * blast * blastScale + deterministicRange(this, "disease-explosion-ball-jitter-x", -80, 80, t, ball), dy / d * blast * blastScale + deterministicRange(this, "disease-explosion-ball-jitter-y", -80, 80, t, ball));
ball.spinVelocity = clamp((ball.spinVelocity || 0) + deterministicRange(this, "disease-explosion-ball-spin", -24, 24, t, ball), -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)" }));
}
this.blastLoosePinsFrom(x, y, blastRadius, t, blastScale, "disease-explosion");
t.die("\u7206\u767a\u75c5");
audio.explode();
this.log(`${t.name}\u306f\u7206\u767a\u75c5\u3067\u7206\u767a\u3057\u305f\u3002`, "accident", { participants: [t] });
},
waterHoseRadius() {
return 138;
},
applyWaterHose(x, y, dx = 0, dy = 0, dt = 0.08) {
const radius = this.waterHoseRadius?.() || 138;
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;
} else if (it.type === "grass") {
it.amount = Math.max(0, (Number(it.amount) || 0) - dt * 135 * (0.30 + p));
it.growth = Math.max(0, (Number(it.growth) || 0) - dt * 0.50 * (0.20 + p));
if (it.amount <= 0.5) {
it.amount = 0;
it.hp = 0;
it.growth = 0;
it.grassStage = -1;
}
cleaned += p * 0.72;
} else if (it.type === "fire") {
it.amount = Math.max(0, (Number(it.amount) || 0) - dt * 540 * (0.35 + p));
it.flameLife = Math.max(0, (Number(it.flameLife) || 0) - dt * 5.2 * (0.35 + p));
it.fireShrinkPulse = Math.max(Number(it.fireShrinkPulse || 0), 0.18);
if (it.amount <= 4 || it.flameLife <= 0.05) {
it.amount = 0;
it.flameLife = 0;
}
cleaned += p * 1.15;
} else if (it.type === "grass_bed") {
it.amount = 0;
it.hp = 0;
global.TarinaiStructureLifecycle.deleteItem(this, it, { reason: "water-hose-grass-bed", userReason: "\u6d17\u6d44\u3067\u6d41\u3055\u308c\u305f", wake: true });
cleaned += p * 0.95;
}
}
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) + deterministicRange(this, "water-hose-jitter-x", -10, 10, t, x, y);
t.vy += ny * (95 + 165 * p) + deterministicRange(this, "water-hose-jitter-y", -10, 10, t, x, y);
t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.24);
t.applyWaterEffect?.(dt * (1.0 + p * 1.8), "hose");
if (t.burning || (t.burnTimer || 0) > 0) {
t.extinguishFire?.("\u6d17\u6d44\u3067\u6d88\u706b\u3055\u308c\u305f");
cleaned += p * 1.10;
}
}
for (const ant of this.nearbyAnts?.(x, y, radius + 45) || []) {
if (!ant || ant.dead) continue;
const d = distXY(ant.x, ant.y, x, y);
if (d > radius + (ant.r || 4)) continue;
const p = clamp(1 - d / (radius + (ant.r || 4)), 0, 1);
if (ant.burning || (ant.burnTimer || 0) > 0) {
ant.extinguishFire?.("water-hose");
cleaned += p * 0.75;
}
}
if (deterministicChance(this, "water-hose-ring", 0.72, x, y)) {
this.effects.push(new Effect("ring", x + deterministicRange(this, "water-hose-ring-x", -18, 18, x, y), y + deterministicRange(this, "water-hose-ring-y", -12, 12, x, y), { size: deterministicRange(this, "water-hose-ring-size", 8, 22, x, y), life: deterministicRange(this, "water-hose-ring-life", 0.18, 0.32, x, y), color: "rgba(128,204,238,0.60)" }));
}
if (cleaned > 0.25) {
this.drawListDirty = true;
this.markItemBucketsDirty?.("water-hose-clean");
this.markTerrainDirtyAt?.(x, y, radius + 84, "water-hose-clean");
this.markSpatialDirty?.("water-hose-clean");
this.compactItems?.();
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);
let spawned = 0;
for (let i = 0; i < n; i++) {
const a = -Math.PI * 0.15 + deterministicRange(this, "zunchi-burst-angle", -0.92, 0.92, source, pin, i) + (i / Math.max(1, n - 1) - 0.5) * 1.2;
const it = new Item("zunchi", clamp(sx + deterministicRange(this, "zunchi-burst-x", -8, 8, source, pin, i), 40, this.w - 40), clamp(sy + deterministicRange(this, "zunchi-burst-y", -8, 8, source, pin, i), 40, this.h - 40));
it.amount = 240;
it.vx = Math.cos(a) * deterministicRange(this, "zunchi-burst-speed-x", 260, 560, source, pin, i) + deterministicRange(this, "zunchi-burst-jitter-x", -40, 40, source, pin, i);
it.vy = Math.sin(a) * deterministicRange(this, "zunchi-burst-speed-y", 200, 480, source, pin, i) - deterministicRange(this, "zunchi-burst-up", 30, 160, source, pin, i);
it.stage = "fresh";
if (this.addItem?.(it, "zunchi-burst")) spawned += 1;
}
if (!spawned) return 0;
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");
return spawned;
},
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.spawnGrace = Math.max(it.spawnGrace || 0, 10.0);
it.eatProtectedUntil = (this.time || 0) + 90;
const added = this.addItem?.(it, "zunchi-spawn");
if (!added) return null;
audio.place?.("zunchi");
return added;
},
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: deterministicRange(this, "bubble-vx", -2, 2, x, y, text),
vy: deterministicRange(this, "bubble-vy", -5, -2, x, y, text),
life: deterministicRange(this, "bubble-life", 2.2, 3.2, x, y, text),
size: 8,
text,
color,
}));
this.effectCounts.bubble = (this.effectCounts.bubble || 0) + 1;
},
spawnHeadbuttEffect(x, y) {
this.effects.push(new Effect("fight", x, y, {
size: deterministicRange(this, "headbutt-effect-size", 10, 16, x, y),
life: deterministicRange(this, "headbutt-effect-life", 0.20, 0.34, x, y),
color: "rgba(105, 62, 34, 0.86)",
}));
if (deterministicChance(this, "headbutt-audio", 0.5, x, y)) audio.fight();
},
spawnFallEffect(x, y, scale = 1) {
if ((this.effectCounts.fall || 0) > 18) return;
this.effects.push(new Effect("fall", x, y, {
vx: deterministicRange(this, "fall-effect-vx", -12, 12, x, y),
vy: deterministicRange(this, "fall-effect-vy", -4, 3, x, y),
size: deterministicRange(this, "fall-effect-size", 20, 32, x, y) * scale,
life: deterministicRange(this, "fall-effect-life", 0.48, 0.72, x, y),
color: "rgba(154, 124, 80, 0.58)",
}));
},
spawnEatEffect(x, y, color = "#f1dfb7") {
if (deterministicChance(this, "eat-effect", 0.70, x, y, color)) {
this.effects.push(new Effect("eat", x + deterministicRange(this, "eat-effect-x", -4, 4, x, y, color), y + deterministicRange(this, "eat-effect-y", -4, 4, x, y, color), {
vx: deterministicRange(this, "eat-effect-vx", -10, 10, x, y, color),
vy: deterministicRange(this, "eat-effect-vy", -18, -3, x, y, color),
size: deterministicRange(this, "eat-effect-size", 2.2, 4.2, x, y, color),
life: deterministicRange(this, "eat-effect-life", 0.18, 0.32, x, y, color),
color,
}));
}
if (deterministicChance(this, "eat-ring", 0.08, x, y, color)) {
this.effects.push(new Effect("ring", x, y, { size: 5, life: 0.20, color }));
}
},
spawnBleedEffect(x, y) {
this.effects.push(new Effect("bleed", x + deterministicRange(this, "bleed-effect-x", -3, 3, x, y), y + deterministicRange(this, "bleed-effect-y", -2, 3, x, y), {
vx: deterministicRange(this, "bleed-effect-vx", -8, 8, x, y),
vy: deterministicRange(this, "bleed-effect-vy", 3, 14, x, y),
size: deterministicRange(this, "bleed-effect-size", 2.0, 3.8, x, y),
life: deterministicRange(this, "bleed-effect-life", 0.34, 0.62, x, y),
color: "rgba(80, 172, 55, 0.78)",
}));
},
isSleepFurniture(it) {
return Boolean(globalThis.TarinaiNestSleepSystem.isSleepFurniture(it));
},
isTarinaiHiddenInNestBox(t) {
return Boolean(globalThis.TarinaiNestSleepSystem.isTarinaiHiddenInNestBox(this, t));
},
bedOccupancy(bed) {
return globalThis.TarinaiNestSleepSystem.bedOccupancy(this, bed) || 0;
},
bedComfort(bed) {
return globalThis.TarinaiNestSleepSystem.bedComfort(this, bed) ?? 0.7;
},
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;
const impulseMag = Math.hypot(vx, vy);
if (entity instanceof Tarinai && (entity.birthRitualTimer || 0) > 0.04) {
this.cancelBirthRitualOnForce?.(entity, impulseMag, {
reason: options.birthCancelReason || options.thought || "\u5f37\u3044\u885d\u6483\u3067\u7e41\u6b96\u304c\u4e2d\u65ad\u3055\u308c\u305f",
target: options.target || null,
fear: options.fearTimer ?? 1.0,
cause: options.cause || "impulse",
panic: options.panic !== false,
silentLog: !!options.silentBirthCancel,
});
}
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 sourceLabel = (src) => {
if (!src) return "";
if (src instanceof Tarinai || (src.name && src.radius && !src.type)) return `\u300C${String(src.name || "\u305F\u308A\u306A\u3044")}\u300D`;
if (src.type && typeof toolLabel === "function") return toolLabel(src.type);
if (src.type) return String(src.type);
if (src.name) return String(src.name);
return "";
};
let normalized = options.cause || cause || "\u885d\u7a81";
const rawCause = String(cause || "");
const isPlainCollision = String(normalized).includes("\u885d\u7a81") || String(normalized).includes("collision") || rawCause.includes("\u6025\u6fc0\u306a\u901f\u5ea6\u5909\u5316");
const label = sourceLabel(options.source);
if (isPlainCollision && label) normalized = `${label}\u3068\u306e\u885d\u7a81`;
const physicalHit = String(normalized).includes("\u885d\u7a81") || String(normalized).includes("collision") || rawCause.includes("\u6025\u6fc0\u306a\u901f\u5ea6\u5909\u5316") || String(normalized).includes("\u7206\u7af9") || String(normalized).includes("\u843d\u3061\u3066\u304d\u305f");
if (target instanceof Tarinai && (target.birthRitualTimer || 0) > 0.04 && physicalHit && amount >= (options.birthCancelDamageThreshold ?? 8)) {
this.cancelBirthRitualOnForce?.(target, Math.max(180, amount * 18), {
reason: "\u5f37\u3044\u885d\u6483\u3067\u7e41\u6b96\u304c\u4e2d\u65ad\u3055\u308c\u305f",
target: options.source || null,
fear: 1.0,
cause: "impact_damage",
panic: true,
silentLog: !!options.silentBirthCancel,
});
}
if (physicalHit) {
target.lastPhysicalCollisionDamageAt = this.time || 0;
}
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 (!deterministicChance(this, "fight-defeat", chance, victim, attacker, damage)) return false;
victim.defeatedById = attacker.id;
victim.fightWinnerId = attacker.id;
attacker.fightWinnerId = attacker.id;
attacker.defeatedById = null;
victim.fightTimer = Math.min(victim.fightTimer || 0, deterministicRange(this, "fight-defeat-victim-timer", 0.20, 0.42, victim, attacker));
attacker.fightTimer = Math.min(attacker.fightTimer || 0, deterministicRange(this, "fight-defeat-attacker-timer", 0.20, 0.42, victim, attacker));
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 * deterministicRange(this, "fight-defeat-knockback-x", 38, 76, victim, attacker);
victim.vy += dy / d * deterministicRange(this, "fight-defeat-knockback-y", 22, 56, victim, attacker);
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.maybeApplyTarinaiChampionByFightRecord?.(winner, loser);
this.recordFamily?.(winner);
this.recordFamily?.(loser);
this.markFightPairCooldown?.(winner, loser, CONFIG.fightCooldown + 8);
for (const t of [winner, loser]) {
t.fightCooldown = Math.max(t.fightCooldown || 0, CONFIG.fightCooldown + deterministicRange(this, "fight-outcome-cooldown", 4, 8, t, winner, loser));
clearForcedBehaviorQueue(t, e => e && e.id === "fight_rival");
}
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: -deterministicRange(this, "fight-win-safety-relief", 12, 22, winner, loser), fulfill: -deterministicRange(this, "fight-win-fulfill-relief", 8, 16, winner, loser) });
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] });
}
},
maybeApplyTarinaiChampionByFightRecord(tarinai, other = null) {
if (!tarinai || tarinai.dead || tarinai.isTarinaiChampion || tarinai.isZunchiSlave) return false;
const wins = Number(tarinai.totalFightWins || 0);
const losses = Number(tarinai.totalFightLosses || 0);
const total = wins + losses;
if (total < 6) return false;
const winRate = total > 0 ? wins / total : 0;
if (winRate < 0.75) return false;
if (!tarinai.becomeTarinaiChampion?.({ fightRecord: true })) return false;
this.spawnBubble?.(tarinai.x, tarinai.y - tarinai.radius * 1.35, "\u738B\u8005", "rgba(144,96,24,0.84)");
this.log?.(`${tarinai.name}\u306F\u55A7\u5629${total}\u56DE\u3001\u52DD\u7387${Math.round(winRate * 100)}%\u3067\u305F\u308A\u306A\u3044\u738B\u8005\u306B\u306A\u3063\u305F\u3002`, "fight", { participants: other ? [tarinai, other] : [tarinai] });
return true;
},
maybeApplyZunchiSlaveByFightRecord(tarinai, other = null) {
if (!tarinai || tarinai.dead || tarinai.isTarinaiChampion || 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) {
return global.TarinaiImpactResponseSystem.itemDropImpact(this, it);
},
limitBallChasers() {
if (!this.itemCounts?.ball && !this.itemCounts?.balloon) 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" && t.target.type !== "balloon")) 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((ball.type === "balloon" ? "水風船" : "ボール") + "が混み合っているので眺めている");
t.wanderAngle = Math.atan2(t.y - ball.y, t.x - ball.x) + deterministicRange(this, "ball-chaser-release-angle", -0.45, 0.45, t, ball);
}
}
},
pokeBall(ball, x, y) {
if (!ball || ball.dead || (ball.type !== "ball" && ball.type !== "balloon")) 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 ?? deterministicAngle(this, "ball-poke-overlap-base", ball, x, y)) + deterministicRange(this, "ball-poke-overlap-jitter", -0.55, 0.55, ball, x, y);
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 isBalloon = ball.type === "balloon";
const impulse = (isBalloon ? 88 : 145) + ball.pokeCombo * (isBalloon ? 38 : 64) + Math.min(isBalloon ? 160 : 260, currentSpeed * (isBalloon ? 0.18 : 0.24));
ball.vx = (ball.vx || 0) + nx * impulse;
ball.vy = (ball.vy || 0) + ny * impulse;
ball.spinVelocity = clamp((ball.spinVelocity || 0) + (nx >= 0 ? 1 : -1) * ((isBalloon ? 2.4 : 5.6) + ball.pokeCombo * (isBalloon ? 0.55 : 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: isBalloon ? "rgba(94,188,236,0.56)" : "rgba(255,255,255,0.58)" }));
if (!isBalloon) this.log("ボールをつついて転がした。", "observe", { eventType: "ball_poke" });
return true;
},
resolveBallBallCollisions(dt) {
return global.TarinaiCollisionResponseSystem.resolveBallBallCollisions(this, dt);
},
resolveBallInteractions(dt) {
return global.TarinaiCollisionResponseSystem.resolveBallInteractions(this, dt);
},
resolveTarinaiHighSpeedCollisions(dt) {
return global.TarinaiCollisionResponseSystem.resolveTarinaiHighSpeedCollisions(this, dt);
}
}));
})(typeof window !== "undefined" ? window : globalThis);