tarinai/js/item_dynamic_ball_system.js

651 lines
32 KiB
JavaScript
Raw Normal View History

2026-06-26 12:46:32 +09:00
"use strict";
// Layer: entity-runtime/items/dynamic-system
2026-07-03 00:45:22 +09:00
// Owns ball-like item motion, balloon drift/pop behavior, and fan wind.
// Item.prototype ball/balloon/fan methods delegate to this system.
2026-06-26 12:46:32 +09:00
(function (global) {
2026-07-03 00:45:22 +09:00
function isBallLike(item) {
return !!item && !item.dead && (item.type === "ball" || item.type === "balloon");
}
2026-06-26 12:46:32 +09:00
function update(item, dt, worldRef) {
2026-07-03 00:45:22 +09:00
if (!isBallLike(item)) return false;
if (item.type === "balloon") return updateBalloon(item, dt, worldRef);
return updateBall(item, dt, worldRef);
}
function updateBall(item, dt, worldRef) {
2026-06-26 12:46:32 +09:00
if (!item || item.dead || item.type !== "ball") return false;
item.prevX = item.x;
item.prevY = item.y;
const moving = Math.hypot(item.vx || 0, item.vy || 0);
if (moving > 0.04) {
item.x += (item.vx || 0) * dt;
item.y += (item.vy || 0) * dt;
const p = Math.max(28, CONFIG.worldPadding || 30);
if (item.x < p) { item.x = p; item.vx = Math.abs(item.vx || 0) * 0.72; item.spinVelocity *= -0.72; }
if (item.x > worldRef.w - p) { item.x = worldRef.w - p; item.vx = -Math.abs(item.vx || 0) * 0.72; item.spinVelocity *= -0.72; }
if (item.y < p) { item.y = p; item.vy = Math.abs(item.vy || 0) * 0.72; item.spinVelocity *= -0.72; }
if (item.y > worldRef.h - p) { item.y = worldRef.h - p; item.vy = -Math.abs(item.vy || 0) * 0.72; item.spinVelocity *= -0.72; }
resolveObstacleCollisions(item, dt, worldRef);
2026-06-29 16:21:58 +09:00
global.TarinaiItemDynamicPinSystem.tryBallPickup(item, worldRef);
2026-06-26 12:46:32 +09:00
item.x = clamp(item.x, p, worldRef.w - p);
item.y = clamp(item.y, p, worldRef.h - p);
const groundFriction = Math.pow(0.72, dt);
item.vx *= groundFriction;
item.vy *= groundFriction;
}
2026-06-29 16:21:58 +09:00
global.TarinaiItemDynamicPinSystem.tryBallPickup(item, worldRef);
2026-06-26 12:46:32 +09:00
item.spin = (item.spin || 0) + (item.spinVelocity || 0) * dt + (item.vx || 0) * dt / Math.max(8, item.r || 18);
item.spinVelocity *= Math.pow(0.65, dt);
if (Math.hypot(item.vx || 0, item.vy || 0) < 0.18) { item.vx = 0; item.vy = 0; }
return true;
}
2026-07-03 00:45:22 +09:00
function updateBalloon(item, dt, worldRef) {
if (!item || item.dead || item.type !== "balloon") return false;
const frameDt = Math.max(0.016, Number(dt || 0.016) || 0.016);
item.prevX = item.x;
item.prevY = item.y;
// A balloon should never be completely inert: it slowly wobbles, then fans
// and collisions can turn that small drift into visible motion.
const phase = (worldRef?.time || 0) * 1.35 + (item.seed || 0) * 0.017 + (item.balloonWobble || 0);
item.vx = (item.vx || 0) + Math.cos(phase * 0.83) * 6.5 * frameDt + Math.sin(phase * 0.31) * 2.4 * frameDt;
item.vy = (item.vy || 0) + Math.sin(phase * 0.71) * 5.4 * frameDt;
const maxSpeed = 560;
const speedBefore = Math.hypot(item.vx || 0, item.vy || 0);
if (speedBefore > maxSpeed) {
const s = maxSpeed / speedBefore;
item.vx *= s;
item.vy *= s;
}
const moving = Math.hypot(item.vx || 0, item.vy || 0);
if (moving > 0.04) {
item.x += (item.vx || 0) * dt;
item.y += (item.vy || 0) * dt;
const p = Math.max(28, CONFIG.worldPadding || 30);
if (item.x < p) { item.x = p; item.vx = Math.abs(item.vx || 0) * 0.58; item.spinVelocity *= -0.44; }
if (item.x > worldRef.w - p) { item.x = worldRef.w - p; item.vx = -Math.abs(item.vx || 0) * 0.58; item.spinVelocity *= -0.44; }
if (item.y < p) { item.y = p; item.vy = Math.abs(item.vy || 0) * 0.58; item.spinVelocity *= -0.44; }
if (item.y > worldRef.h - p) { item.y = worldRef.h - p; item.vy = -Math.abs(item.vy || 0) * 0.58; item.spinVelocity *= -0.44; }
resolveObstacleCollisions(item, dt, worldRef);
item.x = clamp(item.x, p, worldRef.w - p);
item.y = clamp(item.y, p, worldRef.h - p);
const airFriction = Math.pow(0.47, frameDt);
item.vx *= airFriction;
item.vy *= airFriction;
}
checkBalloonHazards(item, worldRef);
item.spin = (item.spin || 0) + (item.spinVelocity || 0) * frameDt + (item.vx || 0) * frameDt / Math.max(12, item.r || 20) * 0.42;
item.spinVelocity *= Math.pow(0.58, frameDt);
if (Math.hypot(item.vx || 0, item.vy || 0) < 0.08) { item.vx = 0; item.vy = 0; }
return true;
}
2026-06-26 12:46:32 +09:00
function resolveObstacleCollisions(item, dt, worldRef) {
if (!worldRef?.nearbyItems) return;
const speed = Math.hypot(item.vx || 0, item.vy || 0);
const searchRadius = Math.max(150, (item.r || 18) + speed * Math.max(dt || 0.016, 0.016) + 120);
const items = worldRef.nearbyItems(item.x, item.y, searchRadius) || [];
const seen = new Set(items);
2026-07-03 00:45:22 +09:00
// 大きな機構は中心が近傍セル外でも当たる可能性がある。
2026-07-05 18:01:36 +09:00
// 全item走査ではなく、機構type bucketだけを追加確認する。
const mechanicalTypes = ["rotator", "poison_block", "reciprocator"];
for (const type of mechanicalTypes) {
const bucket = typeof worldRef.itemsOfType === "function" ? worldRef.itemsOfType(type) : (worldRef.items || []);
for (const it of bucket || []) {
2026-06-29 16:21:58 +09:00
if (!it || it.dead || it === item || !global.TarinaiMechanicalSystem.isMechanicalType(it.type) || seen.has(it)) continue;
const reach = global.TarinaiMechanicalSystem.reach(it) || worldRef.rotatorExtent?.(it) || Math.max(64, it.r || 64);
2026-06-26 12:46:32 +09:00
if (distXY(item.x, item.y, it.x, it.y) <= searchRadius + reach + 36) {
items.push(it);
seen.add(it);
}
}
}
for (const it of items) {
if (!it || it === item || it.dead) continue;
if (it.type === "bed") {
applyHayDrag(item, it, dt, worldRef);
} else if (it.type === "stone") {
2026-07-03 00:45:22 +09:00
resolveCircleBounce(item, it, (it.r || 20) * 1.08 + (item.r || 18), item.type === "balloon" ? 0.48 : 0.82, worldRef);
} else if (it.type === "fan" && item.type === "balloon") {
const wind = fanWindAt(it, item.x, item.y);
if (wind) {
const a = itemAngleFor(it);
item.vx += Math.cos(a) * 38 * wind.falloff * Math.max(0.016, dt || 0.016);
item.vy += Math.sin(a) * 38 * wind.falloff * Math.max(0.016, dt || 0.016);
}
2026-06-26 12:46:32 +09:00
}
const rects = worldRef.solidObstacleRects ? worldRef.solidObstacleRects(it) : [];
if (!rects.length) continue;
for (const rect of rects) resolveRectBounce(item, rect, worldRef, it);
}
}
function applyHayDrag(item, bed, dt, worldRef) {
const rx = Math.max(26, (bed.r || 37) * 1.72 + (item.r || 18) * 0.40);
const ry = Math.max(18, (bed.r || 37) * 0.92 + (item.r || 18) * 0.34);
const dx = item.x - bed.x;
const dy = item.y - bed.y;
const inside = (dx * dx) / (rx * rx) + (dy * dy) / (ry * ry) <= 1;
if (!inside) return;
2026-07-03 00:45:22 +09:00
const slow = Math.pow(item.type === "balloon" ? 0.28 : 0.16, Math.max(0.016, dt || 0.016));
2026-06-26 12:46:32 +09:00
item.vx *= slow;
item.vy *= slow;
item.spinVelocity *= Math.pow(0.24, Math.max(0.016, dt || 0.016));
if ((item.lastHaySlowLogAt || -999) + 3.5 < (worldRef.time || 0) && Math.hypot(item.vx || 0, item.vy || 0) > 120) {
item.lastHaySlowLogAt = worldRef.time || 0;
worldRef.effects?.push(new Effect("ring", item.x, item.y, { size: Math.max(14, item.r * 0.95), life: 0.20, color: "rgba(214,184,96,0.42)" }));
}
}
function resolveCircleBounce(item, obstacle, hitRadius, restitution, worldRef) {
let dx = item.x - obstacle.x;
let dy = item.y - obstacle.y;
let d = Math.hypot(dx, dy);
if (d >= hitRadius) {
const px = item.prevX;
const py = item.prevY;
if (!Number.isFinite(px) || !Number.isFinite(py)) return;
const sx = item.x - px;
const sy = item.y - py;
const len2 = sx * sx + sy * sy;
if (len2 <= 0.0001) return;
const t = clamp(((obstacle.x - px) * sx + (obstacle.y - py) * sy) / len2, 0, 1);
const cx = px + sx * t;
const cy = py + sy * t;
dx = cx - obstacle.x;
dy = cy - obstacle.y;
d = Math.hypot(dx, dy);
if (d >= hitRadius) return;
if (d < 0.001) {
const speed = Math.hypot(item.vx || 0, item.vy || 0);
if (speed > 0.001) { dx = -(item.vx || 0) / speed; dy = -(item.vy || 0) / speed; d = 1; }
else { dx = -sx / Math.sqrt(len2); dy = -sy / Math.sqrt(len2); d = 1; }
}
}
if (d < 0.001) {
const speed = Math.hypot(item.vx || 0, item.vy || 0);
if (speed > 0.001) { dx = -(item.vx || 0) / speed; dy = -(item.vy || 0) / speed; d = 1; }
else { dx = Math.cos(item.seed || 0); dy = Math.sin(item.seed || 0); d = 1; }
}
const nx = dx / d;
const ny = dy / d;
const toward = (item.vx || 0) * nx + (item.vy || 0) * ny;
item.x = obstacle.x + nx * (hitRadius + 0.5);
item.y = obstacle.y + ny * (hitRadius + 0.5);
if (toward < 0) {
item.vx = (item.vx || 0) - (1 + restitution) * toward * nx;
item.vy = (item.vy || 0) - (1 + restitution) * toward * ny;
} else {
2026-07-03 00:45:22 +09:00
const push = item.type === "balloon" ? 12 : 24;
item.vx = (item.vx || 0) + nx * push;
item.vy = (item.vy || 0) + ny * push;
2026-06-26 12:46:32 +09:00
}
2026-07-03 00:45:22 +09:00
const damp = item.type === "balloon" ? 0.84 : 0.96;
item.vx *= damp;
item.vy *= damp;
item.spinVelocity = clamp((item.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * (item.type === "balloon" ? 2.2 : 5.5), -38, 38);
2026-06-26 12:46:32 +09:00
emitBounce(item, worldRef, obstacle);
}
function resolveRectBounce(item, rect, worldRef, source = null) {
if (!rect) return;
2026-07-01 14:41:05 +09:00
const bounceCircleOffRect = global.TarinaiPhysics?.bounceCircleOffRect;
if (typeof bounceCircleOffRect !== "function") return;
2026-06-26 12:46:32 +09:00
const hitRadius = (item.r || 18) + 2;
2026-07-03 00:45:22 +09:00
const restitution = Number.isFinite(rect.restitution) ? rect.restitution : (item.type === "balloon" ? 0.54 : 0.78);
const velocityDamp = rect.bounce ? 1.0 : (item.type === "balloon" ? 0.82 : 0.94);
2026-06-26 12:46:32 +09:00
const minBounceSpeed = Math.max(0, Number(rect.minBounceSpeed || 0) || 0);
2026-07-01 14:41:05 +09:00
const bounced = bounceCircleOffRect(item, rect, hitRadius, restitution, worldRef, {
minBounceSpeed: rect.bounce ? minBounceSpeed : 0,
2026-07-03 00:45:22 +09:00
fallbackImpulse: item.type === "balloon" ? 10 : 18,
spinKick: item.type === "balloon" ? 2.4 : 6.2,
2026-07-01 14:41:05 +09:00
spinLimit: 38,
});
if (!bounced) return;
2026-06-26 12:46:32 +09:00
item.vx *= velocityDamp;
item.vy *= velocityDamp;
emitBounce(item, worldRef, source || rect.item || rect);
}
function emitBounce(item, worldRef, obstacle) {
const now = worldRef?.time || 0;
if ((item.lastObstacleBounceAt || -999) + 0.08 > now) return;
item.lastObstacleBounceAt = now;
2026-07-03 00:45:22 +09:00
const color = item.type === "balloon" ? "rgba(248,122,166,0.36)" : ((obstacle?.type === "bounce_fence" || obstacle?.type === "bounce_fence_v") ? "rgba(82,153,230,0.46)" : (obstacle?.type === "stone" ? "rgba(165,165,150,0.45)" : "rgba(160,116,64,0.42)"));
2026-06-26 12:46:32 +09:00
worldRef?.effects?.push(new Effect("ring", item.x, item.y, { size: Math.max(13, (item.r || 18) * 0.82), life: 0.18, color }));
}
2026-07-03 00:45:22 +09:00
function normalizeAngleLocal(angle = 0, fallback = 0) {
if (typeof normalizedItemAngle === "function") return normalizedItemAngle(angle, fallback);
const twoPi = Math.PI * 2;
const n = Number(angle);
const base = Number.isFinite(n) ? n : (Number.isFinite(Number(fallback)) ? Number(fallback) : 0);
return ((base % twoPi) + twoPi) % twoPi;
}
2026-07-03 20:57:02 +09:00
function fanWindAngleFor(item) {
return normalizeAngleLocal(item?.angle, item?.fanSwingCenter ?? 0);
}
function ensureFanBodyAngle(item) {
if (!item || item.type !== "fan") return 0;
const body = Number(item.fanBodyAngle);
if (Number.isFinite(body)) return normalizeAngleLocal(body, item.angle || 0);
item.fanBodyAngle = normalizeAngleLocal(item.angle || item.fanSwingCenter || 0, 0);
return item.fanBodyAngle;
}
2026-07-03 00:45:22 +09:00
function updateFanSwing(item, dt) {
if (!item || item.dead || item.type !== "fan") return false;
2026-07-03 20:57:02 +09:00
ensureFanBodyAngle(item);
2026-07-03 00:45:22 +09:00
if (!item.fanSwingOn) {
2026-07-03 20:57:02 +09:00
item.fanSwingCenter = Number.isFinite(Number(item.fanSwingCenter)) ? Number(item.fanSwingCenter) : fanWindAngleFor(item);
item.angle = normalizeAngleLocal(item.fanSwingCenter, item.angle || 0);
2026-07-03 00:45:22 +09:00
return false;
}
2026-07-03 20:57:02 +09:00
const center = normalizeAngleLocal(item.fanSwingCenter, fanWindAngleFor(item));
2026-07-03 00:45:22 +09:00
const range = clamp(Number(item.fanSwingRange ?? (35 * Math.PI / 180)) || 0, 0, 150 * Math.PI / 180);
const speed = clamp(Number(item.fanSwingSpeed ?? (48 * Math.PI / 180)) || 0, 0, 360 * Math.PI / 180);
item.fanSwingCenter = center;
item.fanSwingRange = range;
item.fanSwingSpeed = speed;
item.fanSwingPhase = Number(item.fanSwingPhase || 0) + speed * Math.max(0.016, Number(dt || 0.016) || 0.016);
item.angle = normalizeAngleLocal(center + Math.sin(item.fanSwingPhase || 0) * range, center);
return true;
}
2026-07-05 18:01:36 +09:00
const FAN_WIND_ITEM_TYPES = Object.freeze(["balloon", "ball", "pushpin", "oshibyo", "water", "zunda_juice", "mercury", "zunchi", "fire"]);
function fanWindItemCandidates(fan, worldRef, cx, cy, radius) {
if (!worldRef) return [];
if (typeof worldRef.itemsOfType === "function") {
const out = [];
const seen = new Set();
const r2 = Math.max(1, Number(radius || 0) || 0) ** 2;
for (const type of FAN_WIND_ITEM_TYPES) {
for (const it of worldRef.itemsOfType(type) || []) {
if (!it || it.dead || it === fan || seen.has(it.id || it)) continue;
const dx = Number(it.x || 0) - cx;
const dy = Number(it.y || 0) - cy;
const reach = radius + Math.max(0, Number(it.r || it.radius || 0) || 0);
if (dx * dx + dy * dy > Math.max(r2, reach * reach)) continue;
seen.add(it.id || it);
out.push(it);
}
}
return out;
}
return worldRef.nearbyItems?.(cx, cy, radius) || worldRef.items || [];
}
2026-07-03 00:45:22 +09:00
function fanWindAt(fan, x, y, opts = {}) {
if (!fan || fan.dead || fan.type !== "fan") return null;
const range = Math.max(80, Number(opts.range || 300) || 300);
const spread = Math.max(0.18, Number(opts.spread || 0.62) || 0.62);
const dx = x - fan.x;
const dy = y - fan.y;
2026-07-03 20:57:02 +09:00
const angle = fanWindAngleFor(fan);
2026-07-03 00:45:22 +09:00
const ax = Math.cos(angle);
const ay = Math.sin(angle);
const along = dx * ax + dy * ay;
if (along < 8 || along > range) return null;
const lateral = Math.abs(-dx * ay + dy * ax);
const halfWidth = 26 + along * Math.tan(spread);
if (lateral > halfWidth) return null;
const distFalloff = clamp(1 - along / range, 0, 1);
const edgeFalloff = clamp(1 - lateral / Math.max(1, halfWidth), 0, 1);
const falloff = Math.pow(distFalloff, 0.64) * Math.pow(edgeFalloff, 0.34);
if (falloff <= 0.02) return null;
return { angle, along, lateral, halfWidth, range, falloff };
}
function updateFan(item, dt, worldRef) {
if (!item || item.dead || item.type !== "fan") return false;
const frameDt = Math.max(0.016, Number(dt || 0.016) || 0.016);
const now = worldRef?.time || 0;
item.prevX = item.x;
item.prevY = item.y;
2026-07-03 20:57:02 +09:00
ensureFanBodyAngle(item);
2026-07-03 00:45:22 +09:00
item.fanSpin = (item.fanSpin || 0) + 11.5 * frameDt;
item.fanPulse = (item.fanPulse || 0) + 2.2 * frameDt;
updateFanSwing(item, frameDt);
2026-07-03 20:57:02 +09:00
const angle = fanWindAngleFor(item);
2026-07-03 00:45:22 +09:00
const ax = Math.cos(angle);
const ay = Math.sin(angle);
const range = 320;
const searchRadius = range + 40;
2026-07-05 18:01:36 +09:00
const queryX = item.x + ax * range * 0.45;
const queryY = item.y + ay * range * 0.45;
const nearbyItems = fanWindItemCandidates(item, worldRef, queryX, queryY, searchRadius);
2026-07-03 00:45:22 +09:00
for (const target of nearbyItems) {
if (!target || target.dead || target === item) continue;
const wind = fanWindAt(item, target.x, target.y, { range });
if (!wind) continue;
applyFanToItem(item, target, wind, frameDt, worldRef);
}
2026-07-05 18:01:36 +09:00
const nearbyTarinai = worldRef?.nearbyTarinai?.(queryX, queryY, searchRadius) || [];
2026-07-03 00:45:22 +09:00
for (const t of nearbyTarinai) {
if (!t || t.dead || worldRef?.isTarinaiHiddenInNestBox?.(t)) continue;
const wind = fanWindAt(item, t.x, t.y, { range });
if (!wind) continue;
applyFanToTarinai(item, t, wind, frameDt, worldRef);
}
if ((item.lastFanEffectAt || -999) + 0.18 < now) {
item.lastFanEffectAt = now;
const phase = Math.sin(item.fanPulse || 0);
const px = item.x + ax * (42 + 24 * phase);
const py = item.y + ay * (42 + 24 * phase);
worldRef?.effects?.push(new Effect("ring", px, py, { size: 12 + 7 * Math.abs(phase), life: 0.18, color: "rgba(174,214,236,0.26)" }));
}
return true;
}
2026-07-03 20:57:02 +09:00
function spreadFireByFan(fan, fire, wind, dt, worldRef) {
if (!fan || !fire || !worldRef || fire.dead || fire.type !== "fire") return false;
const now = worldRef.time || 0;
const falloff = Math.max(0, Number(wind?.falloff || 0) || 0);
if (falloff < 0.16 || (fire.amount || 0) < 18) return false;
const interval = Math.max(0.10, 0.34 - falloff * 0.18);
if ((fire.lastFanSpreadAt || -999) + interval > now) return false;
fire.lastFanSpreadAt = now;
const ax = Math.cos(wind.angle);
const ay = Math.sin(wind.angle);
const side = global.deterministicRange(worldRef, "fan-fire-side", -22, 22, fan, fire, Math.round(now * 10));
const dist = global.deterministicRange(worldRef, "fan-fire-distance", 58, 118, fan, fire, Math.round(now * 10)) * (0.82 + falloff * 0.58);
const x = clamp((fire.x || 0) + ax * dist - ay * side, CONFIG.worldPadding || 30, Math.max(CONFIG.worldPadding || 30, (worldRef.w || 0) - (CONFIG.worldPadding || 30)));
const y = clamp((fire.y || 0) + ay * dist + ax * side, CONFIG.worldPadding || 30, Math.max(CONFIG.worldPadding || 30, (worldRef.h || 0) - (CONFIG.worldPadding || 30)));
const spawned = global.TarinaiItemDynamicToolSystem?.spawnFireAt?.(worldRef, x, y, {
radius: Math.max(8, (fire.r || 14) * (0.55 + falloff * 0.22)),
amount: Math.max(18, Math.min(86, (fire.amount || 60) * (0.20 + falloff * 0.22))),
life: 2.2 + falloff * 3.4,
source: fan,
mergeRadius: 24,
});
if (spawned) {
// A fan should carry embers forward instead of merely dimming the source flame.
// The source loses a little fuel, while the downwind flame handles normal fire contacts.
fire.amount = Math.max(4, (fire.amount || 0) - (2.2 + falloff * 3.8));
worldRef.effects?.push(new Effect("flame", fire.x + ax * Math.max(12, fire.r || 14), fire.y + ay * Math.max(12, fire.r || 14), {
size: Math.max(7, (fire.r || 14) * 0.72),
life: 0.22,
color: "rgba(255,126,54,0.58)",
vx: ax * (80 + falloff * 120),
vy: ay * (80 + falloff * 120),
}));
worldRef.markItemBucketsDirty?.("fan-fire-spread");
worldRef.markSpatialDirty?.("fan-fire-spread");
worldRef.drawListDirty = true;
return true;
}
return false;
}
2026-07-03 00:45:22 +09:00
function applyFanToItem(fan, target, wind, dt, worldRef) {
const type = target.type || "";
let massFactor = 0;
if (type === "balloon") massFactor = 1.85;
else if (type === "ball") massFactor = 0.72;
else if (type === "pushpin" || type === "oshibyo") massFactor = 0.90;
else if (type === "water" || type === "zunda_juice" || type === "mercury") massFactor = 0.42;
else if (type === "zunchi") massFactor = 0.30;
2026-07-03 20:57:02 +09:00
else if (type === "fire") {
spreadFireByFan(fan, target, wind, dt, worldRef);
return;
}
2026-07-03 00:45:22 +09:00
else return;
const force = 236 * wind.falloff * massFactor;
const ax = Math.cos(wind.angle);
const ay = Math.sin(wind.angle);
target.prevX = Number.isFinite(target.prevX) ? target.prevX : target.x;
target.prevY = Number.isFinite(target.prevY) ? target.prevY : target.y;
target.vx = clamp((target.vx || 0) + ax * force * dt, -820, 820);
target.vy = clamp((target.vy || 0) + ay * force * dt, -820, 820);
if (type === "balloon") target.spinVelocity = clamp((target.spinVelocity || 0) + 3.2 * wind.falloff * dt, -12, 12);
else if (type === "ball") target.spinVelocity = clamp((target.spinVelocity || 0) + 5.4 * wind.falloff * dt, -38, 38);
worldRef?.markSpatialDirty?.("fan-wind-item");
}
function applyFanToTarinai(fan, t, wind, dt, worldRef) {
const ax = Math.cos(wind.angle);
const ay = Math.sin(wind.angle);
const radius = t.radius || 18;
const speed = 30 * wind.falloff;
t.vx = clamp((t.vx || 0) + ax * speed * dt, -360, 360);
t.vy = clamp((t.vy || 0) + ay * speed * dt, -360, 360);
t.fanBreezeTimer = Math.max(t.fanBreezeTimer || 0, 0.35);
if (wind.falloff > 0.58 && (worldRef?.time || 0) - (t.lastFanSurpriseAt || -999) > 2.2) {
t.lastFanSurpriseAt = worldRef?.time || 0;
t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.12);
if (typeof t.spawnBubble === "function") t.spawnBubble("~", { life: 0.55 });
worldRef?.effects?.push(new Effect("ring", t.x + ax * radius * 0.4, t.y + ay * radius * 0.2, { size: Math.max(12, radius * 0.72), life: 0.16, color: "rgba(174,214,236,0.28)" }));
}
}
function checkBalloonHazards(balloon, worldRef) {
if (!balloon || balloon.dead || balloon.type !== "balloon" || !worldRef?.nearbyItems) return;
const now = worldRef.time || 0;
if ((balloon.balloonPoppedAt || -999) + 0.5 > now) return;
const radius = Math.max(26, (balloon.r || 20) + 22);
for (const it of worldRef.nearbyItems(balloon.x, balloon.y, radius) || []) {
if (!it || it.dead || it === balloon) continue;
2026-07-03 20:57:02 +09:00
if ((typeof isPinType === "function" && isPinType(it.type) && it.pinState !== "lodged") || it.type === "fire" || it.type === "flame_firecracker" || it.type === "firecracker") {
const extra = it.type === "fire" ? 16 : (it.type === "firecracker" || it.type === "flame_firecracker" ? 8 : 4);
const hit = distXY(balloon.x, balloon.y, it.x, it.y) <= (balloon.r || 20) + (it.r || 10) + extra;
2026-07-03 00:45:22 +09:00
if (hit) {
popBalloon(balloon, worldRef, it);
return;
}
}
}
}
const WATER_BALLOON_SURPRISE_TEXT = "水風船が割れておどろいている。";
function tarinaiOpennessValue(t) {
const v = (typeof global.effectivePersonalityValue === "function")
? global.effectivePersonalityValue(t, "openness")
: (t?.currentPersonality?.openness ?? t?.birthPersonality?.openness ?? t?.personality?.openness ?? 0);
return Number(v) || 0;
}
function shouldPanicFromWaterBalloon(t, strength, worldRef, balloon) {
const openness = tarinaiOpennessValue(t);
if (openness >= -0.08) return false;
if (openness <= -0.48) return true;
const chance = clamp(0.32 + Math.max(0, -openness) * 0.70 + Math.max(0, strength) * 0.26, 0.34, 0.92);
return deterministicChance(worldRef || t?.world || null, "water-balloon-low-openness-panic", chance, t, balloon, Math.round(strength * 1000));
}
function setWaterBalloonSurpriseBehavior(t, target, panic = false) {
if (!t || t.dead) return false;
t.lastPanicDetail = WATER_BALLOON_SURPRISE_TEXT;
t.thought = WATER_BALLOON_SURPRISE_TEXT;
if (typeof global.setBehavior === "function") {
global.setBehavior(t, {
actionId: panic ? "panic_escape" : "water_balloon_surprise",
need: "safety",
label: WATER_BALLOON_SURPRISE_TEXT,
text: WATER_BALLOON_SURPRISE_TEXT,
reason: WATER_BALLOON_SURPRISE_TEXT,
target: target || null,
phase: "perform",
source: "behavior",
tiedNeeds: ["safety"],
lockSeconds: panic ? 2.6 : 1.1,
minDuration: panic ? 1.2 : 0.55,
});
return true;
}
if (typeof global.setBehaviorText === "function") {
global.setBehaviorText(t, { need: "safety", actionId: panic ? "panic_escape" : "water_balloon_surprise", actionLabel: WATER_BALLOON_SURPRISE_TEXT, reasonText: WATER_BALLOON_SURPRISE_TEXT, target: target || null, phase: "perform", source: "behavior" });
return true;
}
return false;
}
function spawnWaterFromBalloon(balloon, worldRef) {
if (!balloon || !worldRef) return 0;
const baseR = Math.max(14, Number(balloon.r || 20) || 20);
2026-07-03 20:57:02 +09:00
const count = Math.max(12, Math.min(22, Math.round(baseR * 0.68)));
2026-07-03 00:45:22 +09:00
let spawned = 0;
for (let i = 0; i < count; i++) {
if (typeof global.Item !== "function") break;
const a = deterministicAngle(worldRef || balloon, "water-balloon-splash-angle", balloon, i);
2026-07-03 20:57:02 +09:00
const d = deterministicRange(worldRef || balloon, "water-balloon-splash-dist", baseR * 0.12, baseR * 0.95, balloon, i);
const speed = deterministicRange(worldRef || balloon, "water-balloon-drop-speed", 280, 760, balloon, i);
const x = balloon.x + Math.cos(a) * d;
const y = balloon.y + Math.sin(a) * d;
const vx = Math.cos(a) * speed + (Number(balloon.vx || 0) || 0) * 0.28;
const vy = Math.sin(a) * speed + (Number(balloon.vy || 0) || 0) * 0.28;
const flight = deterministicRange(worldRef || balloon, "water-balloon-drop-flight", 0.42, 0.88, balloon, i);
const amount = deterministicRange(worldRef || balloon, "water-balloon-drop-amount", 24, 58, balloon, i);
const drop = global.TarinaiWeatherSystem?.createRainWaterItem
? global.TarinaiWeatherSystem.createRainWaterItem(worldRef, { x, y, amount, vx, vy, splashFlightTimer: flight })
: new global.Item("water", x, y);
if (!Number.isFinite(Number(drop.amount))) drop.amount = amount;
if (!Number.isFinite(Number(drop.vx))) drop.vx = vx;
if (!Number.isFinite(Number(drop.vy))) drop.vy = vy;
if (!Number.isFinite(Number(drop.splashFlightTimer))) drop.splashFlightTimer = flight;
2026-07-09 16:08:11 +09:00
const addedDrop = worldRef.addItem?.(drop, "water-balloon-splash", { terrain: false }) || null;
if (!addedDrop) continue;
spawned += 1;
2026-07-03 00:45:22 +09:00
if (i < 8) {
worldRef.effects?.push(new Effect("fight", drop.x, drop.y, {
size: deterministicRange(worldRef || balloon, "water-balloon-drop-spark-size", 3.5, 7.0, balloon, i),
life: deterministicRange(worldRef || balloon, "water-balloon-drop-spark-life", 0.24, 0.42, balloon, i),
color: "rgba(96,196,244,0.66)",
vx: drop.vx * 0.42,
vy: drop.vy * 0.42,
}));
}
}
if (spawned) {
worldRef.markItemBucketsDirty?.("water-balloon-splash");
worldRef.markSpatialDirty?.("water-balloon-splash");
worldRef.markTerrainDirtyAt?.(balloon.x, balloon.y, Math.max(70, baseR * 5.2), "water-balloon-splash");
}
return spawned;
}
function updateWaterDropMotion(item, dt, worldRef) {
if (!item || item.dead || item.type !== "water") return false;
const frameDt = Math.max(0.016, Number(dt || 0.016) || 0.016);
const speed = Math.hypot(item.vx || 0, item.vy || 0);
if (speed <= 0.05 && !(item.splashFlightTimer > 0)) return false;
item.prevX = Number.isFinite(Number(item.x)) ? item.x : 0;
item.prevY = Number.isFinite(Number(item.y)) ? item.y : 0;
item.x += (Number(item.vx || 0) || 0) * frameDt;
item.y += (Number(item.vy || 0) || 0) * frameDt;
const pad = Math.max(28, CONFIG.worldPadding || 30);
const r = Math.max(5, Number(item.r || 8) || 8);
if (worldRef) {
if (item.x < pad) { item.x = pad; item.vx = Math.abs(item.vx || 0) * 0.30; }
if (item.x > worldRef.w - pad) { item.x = worldRef.w - pad; item.vx = -Math.abs(item.vx || 0) * 0.30; }
if (item.y < pad) { item.y = pad; item.vy = Math.abs(item.vy || 0) * 0.30; }
if (item.y > worldRef.h - pad) { item.y = worldRef.h - pad; item.vy = -Math.abs(item.vy || 0) * 0.30; }
}
item.splashFlightTimer = Math.max(0, Number(item.splashFlightTimer || 0) - frameDt);
const damp = item.splashFlightTimer > 0 ? Math.pow(0.78, frameDt * 60) : Math.pow(0.58, frameDt * 60);
item.vx *= damp;
item.vy *= damp;
if (Math.hypot(item.vx || 0, item.vy || 0) < 8 && item.splashFlightTimer <= 0) {
item.vx = 0;
item.vy = 0;
}
if (worldRef && ((item.lastSplashTrailAt || -999) + 0.12 < (worldRef.time || 0)) && Math.hypot(item.vx || 0, item.vy || 0) > 72) {
item.lastSplashTrailAt = worldRef.time || 0;
worldRef.effects?.push(new Effect("fight", item.x, item.y, { size: Math.max(2.8, r * 0.55), life: 0.18, color: "rgba(112,206,248,0.36)", vx: -(item.vx || 0) * 0.05, vy: -(item.vy || 0) * 0.05 }));
}
worldRef?.markSpatialDirty?.("water-drop-motion");
worldRef?.markTerrainDirtyAt?.(item.x, item.y, Math.max(28, r * 3.2), "water-drop-motion");
if (worldRef) worldRef.drawListDirty = true;
return true;
}
function applyWaterBalloonSurprise(balloon, worldRef) {
for (const t of worldRef?.nearbyTarinai?.(balloon.x, balloon.y, 165) || []) {
if (!t || t.dead || worldRef?.isTarinaiHiddenInNestBox?.(t)) continue;
const d = Math.max(1, distXY(balloon.x, balloon.y, t.x, t.y));
const strength = clamp(1 - d / 165, 0, 1);
t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.34 + strength * 0.44);
if (typeof t.addStress === "function") t.addStress(0.18 + strength * 0.42, "水風船が割れて驚いた");
if (typeof t.spawnBubble === "function" && strength > 0.35) t.spawnBubble("!", { life: 0.72 });
const awayX = (t.x - balloon.x) / d;
const awayY = (t.y - balloon.y) / d;
t.vx = clamp((t.vx || 0) + awayX * 68 * strength, -360, 360);
t.vy = clamp((t.vy || 0) + awayY * 68 * strength, -360, 360);
const panic = shouldPanicFromWaterBalloon(t, strength, worldRef, balloon);
if (panic) {
const threat = { x: balloon.x, y: balloon.y, dead: true, type: "water_balloon_pop" };
const destination = t.panicDestination?.(threat, true) || { x: t.x + awayX * 190, y: t.y + awayY * 190, dead: false, panic: true };
t.enterPanic?.({
threat,
destination,
target: destination,
reason: WATER_BALLOON_SURPRISE_TEXT,
cause: "water_balloon_pop",
fearTimer: 1.35 + strength * 1.30,
stress: 14 + strength * 24,
surpriseTimer: 0.70 + strength * 0.45,
wake: true,
forceDestination: true,
bubble: "!!",
bubbleCooldown: 0.7,
}) || t.setActionState?.("panic", { target: destination, reason: WATER_BALLOON_SURPRISE_TEXT, wake: true, sleeping: false });
t.fearTimer = Math.max(t.fearTimer || 0, 1.85 + strength * 1.40);
t.panicStartedAt = worldRef?.time || t.panicStartedAt || 0;
t.panicHardStopAt = Math.max(t.panicHardStopAt || 0, (worldRef?.time || 0) + 5.5);
t.lastPanicCause = "water_balloon_pop";
setWaterBalloonSurpriseBehavior(t, destination, true);
} else {
setWaterBalloonSurpriseBehavior(t, null, false);
}
}
}
function popBalloon(balloon, worldRef, cause = null) {
if (!balloon || balloon.dead) return;
const now = worldRef?.time || 0;
balloon.balloonPoppedAt = now;
// Item.dead is a getter based on amount, so mark the water balloon gone
// through amount/hp instead of assigning to .dead directly.
balloon.amount = 0;
balloon.hp = 0;
worldRef?.effects?.push(new Effect("ring", balloon.x, balloon.y, { size: Math.max(32, (balloon.r || 20) * 1.9), life: 0.28, color: "rgba(76,178,236,0.62)" }));
for (let i = 0; i < 9; i++) {
const a = deterministicAngle(worldRef || balloon, "water-balloon-pop-piece", balloon, i);
const s = deterministicRange(worldRef || balloon, "water-balloon-pop-speed", 24, 96, balloon, i);
worldRef?.effects?.push(new Effect("fight", balloon.x, balloon.y, { size: deterministicRange(worldRef || balloon, "water-balloon-pop-size", 4, 10, balloon, i), life: 0.28, color: "rgba(95,204,246,0.72)", vx: Math.cos(a) * s, vy: Math.sin(a) * s }));
}
spawnWaterFromBalloon(balloon, worldRef);
2026-07-03 20:57:02 +09:00
const sourceLabel = cause?.type === "fire" || cause?.type === "flame_firecracker" ? "火"
: (cause?.type === "firecracker" ? "爆竹"
: (cause?.type === "shoot" ? "射撃"
: (cause?.type === "genkotsu" ? "げんこつ"
: (cause && typeof isPinType === "function" && isPinType(cause.type) ? "鋲" : "強い衝撃"))));
2026-07-03 00:45:22 +09:00
if (worldRef?.relationNotice?.(balloon.id || "balloon", cause?.id || sourceLabel, "water-balloon-pop", 1.4)) {
worldRef.log?.(`水風船が${sourceLabel}に触れて割れた。`, "accident", { participants: [] });
}
applyWaterBalloonSurprise(balloon, worldRef);
worldRef?.markSpatialDirty?.("water-balloon-pop");
}
2026-06-26 12:46:32 +09:00
global.TarinaiItemDynamicBallSystem = Object.freeze({
update,
2026-07-03 00:45:22 +09:00
updateFan,
updateWaterDropMotion,
2026-06-26 12:46:32 +09:00
resolveObstacleCollisions,
2026-07-03 00:45:22 +09:00
popBalloon,
2026-06-26 12:46:32 +09:00
});
})(typeof window !== "undefined" ? window : globalThis);