"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; }, damageStructuresInRadius(x, y, radius, damageFn, breaker = null) { let hit = 0; for (const structure of this.items || []) { if (!structure || structure.dead || !structure.isStructure) continue; const d = Math.max(1, distXY(structure.x, structure.y, x, y)); if (d > radius + (structure.r || 18)) 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, structure, d) : Number(damageFn || 0); if (damage <= 0) continue; if (typeof structure.damage === "function") structure.damage(damage, breaker, this); else if (window.TarinaiNeedSystem?.damageStructure) window.TarinaiNeedSystem.damageStructure.call(structure, damage, breaker, this); else { structure.hp = Math.max(0, Number(structure.hp || 0) - damage); if (structure.hp <= 0) structure.dead = true; } hit += 1; } if (hit > 0) { this.drawListDirty = true; this.markTerrainDirty?.("structure-damage"); } 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); } } t.addStress ? t.addStress(rand(8, 15), { threshold: 8 }) : (t.stress = clamp(t.stress + rand(8, 15), 0, 130)); t.fearTimer = Math.max(t.fearTimer, 0.58); const dx = t.x - x; const dy = t.y - y; const d = Math.hypot(dx, dy) || 1; if (d > blastRadius) continue; const p = clamp(1 - d / blastRadius, 0, 1); t.damage(5 + p * 18, "\u7206\u7af9"); t.addStress ? t.addStress(22 * p, { threshold: 8 }) : (t.stress = clamp(t.stress + 22 * p, 0, 130)); t.hurtTimer = Math.max(t.hurtTimer, 1.5 + p * 1.8); t.surpriseTimer = Math.max(t.surpriseTimer, 0.85); if (t.enterPanic) t.enterPanic({ target: { x, y }, reason: "爆竹の爆風でパニックになっている", 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: "爆竹の爆風でパニックになっている", 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: "爆竹の爆風でパニックになっている", }); 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)"); } 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), }); this.damageStructuresInRadius(x, y, blastRadius, p => 35 + p * 90, it); let blastedBalls = 0; for (const ball of this.items) { if (!ball || ball.dead || ball.type !== "ball") continue; let dx = ball.x - x; let dy = ball.y - y; let d = Math.hypot(dx, dy) || 1; if (d > blastRadius * 1.18) continue; const p = clamp(1 - d / (blastRadius * 1.18), 0, 1); if (d < 0.001) { const a = rand(0, Math.PI * 2); dx = Math.cos(a); dy = Math.sin(a); d = 1; } const blast = 430 + p * 960; this.applyImpulse(ball, dx / d * blast + rand(-80, 80), dy / d * blast + rand(-80, 80)); const speed = Math.hypot(ball.vx || 0, ball.vy || 0); ball.spinVelocity = clamp((ball.spinVelocity || 0) + rand(-24, 24), -38, 38); ball.lastPokedAt = this.time || 0; ball.pokeCombo = Math.max(ball.pokeCombo || 0, 5); this.effects.push(new Effect("ring", ball.x, ball.y, { size: Math.max(18, ball.r * 1.2), life: 0.24, color: "rgba(255,230,116,0.58)" })); blastedBalls += 1; } audio.explode(); this.log(blastedBalls ? "\u7206\u7af9\u304c\u6d3e\u624b\u306b\u306f\u3058\u3051\u3001\u30dc\u30fc\u30eb\u304c\u6025\u52a0\u901f\u3057\u305f\u3002" : "\u7206\u7af9\u304c\u6d3e\u624b\u306b\u306f\u3058\u3051\u3001\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"); o.addStress ? o.addStress(22 * q, { threshold: 8 }) : (o.stress = clamp(o.stress + 22 * q, 0, 130)); let nx = dx / d; let ny = dy / d; if (!Number.isFinite(nx) || !Number.isFinite(ny) || Math.abs(nx) + Math.abs(ny) < 0.001) { const a = rand(0, Math.PI * 2); nx = Math.cos(a); ny = Math.sin(a); } const 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: "爆発病の爆発に巻き込まれている", }); 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: "爆発病の爆発に巻き込まれている", 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: "爆発病の爆発に巻き込まれている", 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, }); this.damageStructuresInRadius(x, y, blastRadius, q => 28 + q * 76, t); for (const ball of this.items) { if (!ball || ball.dead || ball.type !== "ball") continue; let dx = ball.x - x; let dy = ball.y - y; let d = Math.hypot(dx, dy) || 1; if (d > blastRadius * 1.18) continue; const 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.items.push(it); this.itemCounts.zunchi = (this.itemCounts.zunchi || 0) + 1; } 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 || "たりない"}から溜まっていたずんちが弾け飛んだ。`, "accident", { participants: source?.name ? [source] : [] }); audio.place?.("zunchi"); this.rebuildSpatial?.(true); }, spawnZunchi(x, y) { const zunchiLimit = CONFIG.zunchiLimit ?? 56; if ((this.itemCounts.zunchi || 0) >= zunchiLimit) { let oldest = null; for (const it of this.items) { if (it.type !== "zunchi" || it.dead) continue; if (!oldest || (it.age || 0) > (oldest.age || 0)) oldest = it; } if (oldest) oldest.amount = 0; } const it = new Item("zunchi", clamp(x, 40, this.w - 40), clamp(y, 40, this.h - 40)); it.amount = 260; this.items.push(it); this.itemCounts.zunchi = (this.itemCounts.zunchi || 0) + 1; 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 && (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); }, bestBedFor(t, maxDist = 360) { const choose = (kind) => { let best = null; let bestScore = Infinity; for (const bed of this.nearbyItems(t.x, t.y, maxDist)) { if (bed.dead || !this.isSleepFurniture(bed) || bed.type !== kind) continue; if (bed.type === "nest_box" && t?.insideNestBoxId !== bed.id) { const occupants = this.nestBoxOccupants ? this.nestBoxOccupants(bed, Infinity) : []; if (occupants.length >= this.nestBoxCapacity(bed)) continue; } if (t?.shouldAvoidTarget && t.shouldAvoidTarget(bed)) continue; const d = dist(t, bed); const occ = this.bedOccupancy(bed); const comfort = this.bedComfort(bed); const crowdPenalty = bed.type === "nest_box" ? Math.max(0, occ - this.nestBoxCapacity(bed) + 1) * 120 : Math.max(0, occ - 3) * 34; const score = d - comfort * (bed.type === "nest_box" ? 72 : 46) + crowdPenalty; if (score < bestScore) { best = bed; bestScore = score; } } return best; }; // Prefer an available nest box, then fall back to a hay bed. return choose("nest_box") || choose("bed"); }, relationNotice(aId, bId, kind, cooldown = 30) { const key = [aId, bId].sort().join(":") + `:${kind}`; const last = this.relationNotices?.[key] || -Infinity; if (this.time - last < cooldown) return false; if (!this.relationNotices) this.relationNotices = {}; this.relationNotices[key] = this.time; return true; }, applyImpulse(entity, vx = 0, vy = 0, options = {}) { if (!entity || entity.dead) return false; if (!Number.isFinite(vx) || !Number.isFinite(vy)) return false; if (entity instanceof Tarinai || "impulseVx" in entity || "impulseVy" in entity) { entity.impulseVx = (Number.isFinite(entity.impulseVx) ? entity.impulseVx : 0) + vx; entity.impulseVy = (Number.isFinite(entity.impulseVy) ? entity.impulseVy : 0) + vy; } else { entity.vx = (Number.isFinite(entity.vx) ? entity.vx : 0) + vx; entity.vy = (Number.isFinite(entity.vy) ? entity.vy : 0) + vy; } if (options.panic && entity instanceof Tarinai) { if (entity.enterPanic) entity.enterPanic({ target: options.target || null, reason: options.thought || "パニックになっている", fear: options.fearTimer ?? 0.55, wake: true, cause: options.cause || "impulse" }); else { entity.setActionState?.("panic", { target: options.target || null, reason: options.thought || "パニックになっている", wake: true }); entity.fearTimer = Math.max(entity.fearTimer || 0, options.fearTimer ?? 0.55); } } return true; }, applyKnockback(entity, sourceX, sourceY, strength = 0, options = {}) { if (!entity || !Number.isFinite(strength)) return false; let dx = (entity.x || 0) - sourceX; let dy = (entity.y || 0) - sourceY; let d = Math.hypot(dx, dy); if (!Number.isFinite(d) || d < 0.001) { const a = Number.isFinite(options.angle) ? options.angle : rand(0, Math.PI * 2); dx = Math.cos(a); dy = Math.sin(a); d = 1; } const random = options.random || 0; return this.applyImpulse( entity, dx / d * strength + rand(-random, random), dy / d * strength + rand(-random, random), options, ); }, applyImpactDamage(target, amount, cause = "\u885d\u7a81", options = {}) { if (!target || target.dead || !Number.isFinite(amount) || amount <= 0) return false; const normalized = options.cause || cause || "\u885d\u7a81"; 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: "喧嘩に負けてパニックになっている", fear: 1.1, wake: true, cause: "fight_lost" }); else { victim.setActionState?.("panic", { target: attacker, reason: "喧嘩に負けてパニックになっている", 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); winner.stress = clamp(winner.stress - rand(9, 18), 0, 130); if (this.relationNotice(winner.id, loser.id, "fight-result", 8)) { this.log(`${loser.name}\u306f${winner.name}\u306b\u8ca0\u3051\u3001\u305d\u306e\u3053\u3068\u3092\u899a\u3048\u305f\u3002`, "fight", { participants: [loser, winner] }); } }, 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); t.stress = clamp((t.stress || 0) + 10 + p * 18, 0, 130); t.adjustPersonality?.("openness", -(0.020 + p * 0.030), "after being struck by genkotsu."); t.thought = "げんこつがこわい"; } else { t.thought = isStone ? "石が落ちてきてパニックになっている" : "落ちてきた道具に当たって混乱している"; } t.hurtTimer = Math.max(t.hurtTimer, isGenkotsu ? 3.4 : (isStone ? 2.7 : 1.25)); t.fallTimer = Math.max(t.fallTimer, isGenkotsu ? 0.86 : (isStone ? 0.62 : 0.26)); t.fallMax = Math.max(t.fallMax || t.fallTimer, t.fallTimer); t.fallDir = nx >= 0 ? 1 : -1; hit += 1; } const antHit = this.damageAntsInRadius(it.x, it.y, radius + 54, p => damage * (0.35 + p * 0.65), reason, { push: p => push * (0.62 + p * 1.25), }); const structureHit = this.damageStructuresInRadius(it.x, it.y, radius + 54, p => damage * (0.45 + p * 0.85), it); 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 || structureHit > 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"); } }, countBallChasers(ball, except = null) { if (!ball) return 0; let count = 0; for (const t of this.tarinai || []) { if (!t || t === except || t.dead) continue; if (t.state === "play_ball" && t.target === ball) count += 1; } return count; }, limitBallChasers() { if (!this.itemCounts?.ball) return; const groups = new Map(); for (const t of this.tarinai || []) { if (!t || t.dead || t.state !== "play_ball" || !t.target || t.target.dead || t.target.type !== "ball") continue; const arr = groups.get(t.target) || []; arr.push(t); groups.set(t.target, arr); } for (const [ball, arr] of groups) { if (arr.length <= 5) continue; arr.sort((a, b) => { const ap = ((a.currentPersonality?.openness || 0) >= 0.5) ? -120 : 0; const bp = ((b.currentPersonality?.openness || 0) >= 0.5) ? -120 : 0; return dist(a, ball) + ap - (dist(b, ball) + bp); }); for (const t of arr.slice(5)) { t.goIdle("ボールが混み合っているので眺めている"); 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.items || []).filter(it => it && !it.dead && it.type === "ball"); for (const a of balls) { const ar = a.r || 18; const avx = a.vx || 0; const avy = a.vy || 0; const aSpeed = Math.hypot(avx, avy); const range = ar * 2 + aSpeed * Math.max(0.016, dt || 0.016) + 52; for (const b of this.nearbyItems(a.x, a.y, range) || []) { if (!b || b === a || b.dead || b.type !== "ball") continue; const key = a.id < b.id ? `${a.id}:${b.id}` : `${b.id}:${a.id}`; if (now - (this.ballCollisionMemo.get(key) || -999) < 0.055) continue; const br = b.r || 18; const hitRadius = ar + br; let dx = b.x - a.x; let dy = b.y - a.y; let d = Math.hypot(dx, dy); let hitX = a.x; let hitY = a.y; if (d > hitRadius) { const ax0 = Number.isFinite(a.prevX) ? a.prevX : a.x; const ay0 = Number.isFinite(a.prevY) ? a.prevY : a.y; const bx0 = Number.isFinite(b.prevX) ? b.prevX : b.x; const by0 = Number.isFinite(b.prevY) ? b.prevY : b.y; const rvx = (a.x - ax0) - (b.x - bx0); const rvy = (a.y - ay0) - (b.y - by0); const rx = ax0 - bx0; const ry = ay0 - by0; const aa = rvx * rvx + rvy * rvy; if (aa <= 0.0001) continue; const bb = 2 * (rx * rvx + ry * rvy); const cc = rx * rx + ry * ry - hitRadius * hitRadius; const disc = bb * bb - 4 * aa * cc; if (disc < 0) continue; const u = clamp((-bb - Math.sqrt(disc)) / (2 * aa), 0, 1); const axHit = ax0 + (a.x - ax0) * u; const ayHit = ay0 + (a.y - ay0) * u; const bxHit = bx0 + (b.x - bx0) * u; const byHit = by0 + (b.y - by0) * u; dx = bxHit - axHit; dy = byHit - ayHit; d = Math.hypot(dx, dy); hitX = (axHit + bxHit) * 0.5; hitY = (ayHit + byHit) * 0.5; if (d > hitRadius + 0.5) continue; } else { hitX = (a.x + b.x) * 0.5; hitY = (a.y + b.y) * 0.5; } if (d < 0.001) { const rvx = (b.vx || 0) - (a.vx || 0); const rvy = (b.vy || 0) - (a.vy || 0); const rs = Math.hypot(rvx, rvy); if (rs > 0.001) { dx = rvx / rs; dy = rvy / rs; d = 1; } else { const ang = rand(0, Math.PI * 2); dx = Math.cos(ang); dy = Math.sin(ang); d = 1; } } const nx = dx / d; const ny = dy / d; const rvx = (b.vx || 0) - (a.vx || 0); const rvy = (b.vy || 0) - (a.vy || 0); const relNormal = rvx * nx + rvy * ny; if (relNormal > 0 && d >= hitRadius - 0.5) continue; this.ballCollisionMemo.set(key, now); const restitution = 0.86; const impulse = -(1 + restitution) * relNormal / 2; if (Number.isFinite(impulse)) { a.vx = (a.vx || 0) - impulse * nx; a.vy = (a.vy || 0) - impulse * ny; b.vx = (b.vx || 0) + impulse * nx; b.vy = (b.vy || 0) + impulse * ny; } const overlap = Math.max(0, hitRadius - d + 0.6); if (overlap > 0) { a.x -= nx * overlap * 0.5; a.y -= ny * overlap * 0.5; b.x += nx * overlap * 0.5; b.y += ny * overlap * 0.5; } const tangent = nx * ((a.vy || 0) - (b.vy || 0)) - ny * ((a.vx || 0) - (b.vx || 0)); a.spinVelocity = clamp((a.spinVelocity || 0) - tangent / Math.max(14, ar) * 1.8, -38, 38); b.spinVelocity = clamp((b.spinVelocity || 0) + tangent / Math.max(14, br) * 1.8, -38, 38); this.effects.push(new Effect("ring", hitX, hitY, { size: Math.max(13, hitRadius * 0.38), life: 0.16, color: "rgba(255,255,255,0.44)" })); } } }, resolveBallInteractions(dt) { if (!this.itemCounts?.ball) return; for (const ball of this.items) { if (!ball || ball.dead || ball.type !== "ball") continue; const ballSpeed = Math.hypot(ball.vx || 0, ball.vy || 0); const sweep = ballSpeed * Math.max(0.016, dt || 0.016) + 84; const range = (ball.r || 18) + 72 + sweep; const px = Number.isFinite(ball.prevX) ? ball.prevX : ball.x; const py = Number.isFinite(ball.prevY) ? ball.prevY : ball.y; const sx = ball.x - px; const sy = ball.y - py; const segLenSq = sx * sx + sy * sy; for (const t of this.nearbyTarinai(ball.x, ball.y, range)) { if (!t || t.dead || t.state === "sleep" || this.isTarinaiHiddenInNestBox(t)) continue; let hitX = ball.x; let hitY = ball.y; if (segLenSq > 1) { const u = clamp(((t.x - px) * sx + (t.y - py) * sy) / segLenSq, 0, 1); hitX = px + sx * u; hitY = py + sy * u; } let d = Math.max(0.001, distXY(hitX, hitY, t.x, t.y)); const hitDistance = (ball.r || 18) + t.radius * 0.74; if (d > hitDistance) continue; const now = this.time || 0; const repeat = ball.lastKickerId === t.id && now - (ball.lastKickedAt || -999) < 0.18; if (repeat && ballSpeed < 300) continue; let nx = (hitX - t.x) / d; let ny = (hitY - t.y) / d; if (!Number.isFinite(nx) || !Number.isFinite(ny) || Math.abs(nx) + Math.abs(ny) < 0.001) { if (ballSpeed > 0.01) { nx = (ball.vx || 0) / ballSpeed; ny = (ball.vy || 0) / ballSpeed; } else { nx = t.facingDir ? t.facingDir() : 1; ny = rand(-0.22, 0.22); } } const isDangerous = ballSpeed >= 320; if (isDangerous && now - (t.lastBallDamageAt || -999) > 0.46) { const bvx = ballSpeed > 0.01 ? (ball.vx || 0) / ballSpeed : nx; const bvy = ballSpeed > 0.01 ? (ball.vy || 0) / ballSpeed : ny; const damage = clamp((ballSpeed - 285) / 18, 3.5, 38); t.lastBallDamageAt = now; this.applyImpulse(t, (ball.vx || 0) * 0.20, (ball.vy || 0) * 0.20, { panic: true, target: { x: ball.x, y: ball.y }, fearTimer: 0.95, thought: "高速のボールにぶつかってパニックになっている", }); this.applyImpactDamage(t, damage, "\u885d\u7a81"); const sensitiveHit = t.shouldApplyPersonalityBehavior?.("neuroticism", 1) ? 1.35 : (t.shouldApplyPersonalityBehavior?.("neuroticism", -1) ? 0.82 : 1.0); const stressGain = clamp(damage * 0.55 * sensitiveHit, 3, sensitiveHit > 1 ? 24 : 18); t.addStress ? t.addStress(stressGain, { threshold: 8 }) : (t.stress = clamp(t.stress + stressGain, 0, 130)); if (t.enterPanic) t.enterPanic({ target: { x: ball.x, y: ball.y }, reason: "高速のボールにぶつかってパニックになっている", fear: 0.95, stress: 0, wake: true, cause: "fast_ball_hit" }); else t.setActionState?.("panic", { target: { x: ball.x, y: ball.y }, reason: "高速のボールにぶつかってパニックになっている", 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; t.stress = clamp(t.stress - playRelief + sensitiveBump, 0, 130); if (playful && playIntent) { t.loneliness = clamp(t.loneliness - 0.9, 0, 100); t.affection = clamp(t.affection + 0.18, 0, 100); } t.goodMode = playIntent || playful ? "smile" : t.goodMode; if (playIntent && now > (t.nextPlayBubbleAt || 0)) { t.nextPlayBubbleAt = now + rand(2.0, 3.8); this.spawnBubble(t.x, t.y - t.radius * 1.15, pick(["!", "\u306f\u3046", "?" ]), "rgba(70,96,50,0.76)"); } if (playful && this.relationNotice(t.id, ball.id || "ball", "ball-kick", 16)) this.log(`${t.name}\u306f\u30dc\u30fc\u30eb\u3092\u8ffd\u3044\u304b\u3051\u3066\u5f3e\u3044\u305f\u3002`, "observe", { participants: [t] }); this.effects.push(new Effect("ring", ball.x, ball.y, { size: Math.max(12, ball.r * 0.80), life: 0.20, color: "rgba(170,210,95,0.50)" })); } } }, resolveTarinaiHighSpeedCollisions(dt) { const threshold = 150; const now = this.time || 0; if (!this.tarinaiCollisionMemo) this.tarinaiCollisionMemo = new Map(); for (const a of this.tarinai || []) { if (!a || a.dead || this.isTarinaiHiddenInNestBox(a)) continue; const avx = a.vx || 0; const avy = a.vy || 0; const aSpeed = Math.hypot(avx, avy); if (aSpeed < threshold) continue; const px = Number.isFinite(a.prevX) ? a.prevX : a.x; const py = Number.isFinite(a.prevY) ? a.prevY : a.y; const sx = a.x - px; const sy = a.y - py; const segLenSq = sx * sx + sy * sy; const range = (a.radius || 22) * 2.5 + aSpeed * Math.max(0.016, dt || 0.016) + 64; for (const b of this.nearbyTarinai(a.x, a.y, range)) { if (!b || b === a || b.dead || this.isTarinaiHiddenInNestBox(b)) continue; const key = a.id < b.id ? `${a.id}:${b.id}` : `${b.id}:${a.id}`; if (now - (this.tarinaiCollisionMemo.get(key) || -999) < 0.42) continue; const bvx = b.vx || 0; const bvy = b.vy || 0; const relVx = avx - bvx; const relVy = avy - bvy; const relSpeed = Math.hypot(relVx, relVy); if (relSpeed < threshold) continue; let hitX = a.x; let hitY = a.y; if (segLenSq > 1) { const u = clamp(((b.x - px) * sx + (b.y - py) * sy) / segLenSq, 0, 1); hitX = px + sx * u; hitY = py + sy * u; } const d = Math.max(0.001, distXY(hitX, hitY, b.x, b.y)); const hitDistance = (a.radius || 22) * 0.74 + (b.radius || 22) * 0.74; if (d > hitDistance) continue; this.tarinaiCollisionMemo.set(key, now); const impactor = aSpeed >= Math.hypot(bvx, bvy) ? a : b; const target = impactor === a ? b : a; const ivx = impactor.vx || 0; const ivy = impactor.vy || 0; const impactSpeed = Math.hypot(ivx, ivy) || relSpeed || 1; let nx = impactSpeed > 0.001 ? ivx / impactSpeed : (target.x - impactor.x) / Math.max(1, distXY(target.x, target.y, impactor.x, impactor.y)); let ny = impactSpeed > 0.001 ? ivy / impactSpeed : (target.y - impactor.y) / Math.max(1, distXY(target.x, target.y, impactor.x, impactor.y)); if (!Number.isFinite(nx) || !Number.isFinite(ny) || Math.abs(nx) + Math.abs(ny) < 0.001) { const aa = rand(0, Math.PI * 2); nx = Math.cos(aa); ny = Math.sin(aa); } const damage = clamp((relSpeed - 118) / 26, 1.2, 22); this.applyImpulse(target, ivx * 0.20 + nx * 28, ivy * 0.20 + ny * 28, { panic: true, target: { x: impactor.x, y: impactor.y }, fearTimer: 0.55, thought: "高速でぶつかって混乱している", }); 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: "高速でぶつかって混乱している", fear: 0.75, wake: true, cause: "tarinai_highspeed_collision" }); else t.setActionState?.("panic", { target: { x: impactor.x, y: impactor.y }, reason: "高速でぶつかって混乱している", 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);