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);
|
|
|
|
|
item.x = clamp(item.x, p, worldRef.w - p);
|
|
|
|
|
item.y = clamp(item.y, p, worldRef.h - p);
|
2026-07-20 14:36:59 +09:00
|
|
|
const oilMul = global.TarinaiItemEnvironmentHazardSystem?.groundFrictionMultiplier?.(worldRef, item.x, item.y) ?? 1;
|
|
|
|
|
const groundFriction = Math.pow(1 - (1 - 0.72) * oilMul, dt);
|
2026-06-26 12:46:32 +09:00
|
|
|
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);
|
2026-07-18 13:06:02 +09:00
|
|
|
const items = worldRef.nearbyNonGrassItems?.(item.x, item.y, searchRadius) || worldRef.nearbyItems?.(item.x, item.y, searchRadius) || [];
|
2026-06-26 12:46:32 +09:00
|
|
|
const seen = new Set(items);
|
2026-07-11 21:13:39 +09:00
|
|
|
// \u5927\u304d\u306a\u6a5f\u69cb\u306f\u4e2d\u5fc3\u304c\u8fd1\u508d\u30bb\u30eb\u5916\u3067\u3082\u5f53\u305f\u308b\u53ef\u80fd\u6027\u304c\u3042\u308b\u3002
|
|
|
|
|
// \u5168item\u8d70\u67fb\u3067\u306f\u306a\u304f\u3001\u6a5f\u69cbtype bucket\u3060\u3051\u3092\u8ffd\u52a0\u78ba\u8a8d\u3059\u308b\u3002
|
2026-07-05 18:01:36 +09:00
|
|
|
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;
|
2026-07-18 22:15:26 +09:00
|
|
|
worldRef.queueEffect?.("ring", item.x, item.y, { size: Math.max(14, item.r * 0.95), life: 0.20, color: "rgba(214,184,96,0.42)" });
|
2026-06-26 12:46:32 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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-10 16:40:06 +09:00
|
|
|
const restitution = Number.isFinite(rect.restitution) ? rect.restitution : (item.type === "balloon" ? 0.82 : 1.25);
|
2026-07-03 00:45:22 +09:00
|
|
|
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-20 14:36:59 +09:00
|
|
|
const color = item.type === "balloon" ? "rgba(248,122,166,0.36)" : ((obstacle?.type === "bounce_fence" || obstacle?.type === "bounce_fence_v") ? "rgba(235,104,165,0.50)" : (obstacle?.type === "stone" ? "rgba(165,165,150,0.45)" : "rgba(160,116,64,0.42)"));
|
2026-07-18 22:15:26 +09:00
|
|
|
worldRef?.queueEffect?.("ring", item.x, item.y, { size: Math.max(13, (item.r || 18) * 0.82), life: 0.18, color });
|
2026-06-26 12:46:32 +09:00
|
|
|
}
|
|
|
|
|
|
2026-07-03 00:45:22 +09:00
|
|
|
function normalizeAngleLocal(angle = 0, fallback = 0) {
|
2026-07-10 16:40:06 +09:00
|
|
|
return normalizedItemAngle(angle, fallback);
|
2026-07-03 00:45:22 +09:00
|
|
|
}
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
2026-07-18 13:06:02 +09:00
|
|
|
return worldRef.nearbyNonGrassItems?.(cx, cy, radius) || worldRef.items || [];
|
2026-07-05 18:01:36 +09:00
|
|
|
}
|
|
|
|
|
|
2026-07-03 00:45:22 +09:00
|
|
|
function fanWindAt(fan, x, y, opts = {}) {
|
2026-07-15 14:44:29 +09:00
|
|
|
if (global.TarinaiSignalSystem?.powerOverride?.(fan) === false) return null;
|
2026-07-03 00:45:22 +09:00
|
|
|
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-15 14:44:29 +09:00
|
|
|
const powered = global.TarinaiSignalSystem?.powerOverride?.(item) !== false;
|
|
|
|
|
if (!powered) { item.vx = 0; item.vy = 0; return true; }
|
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);
|
2026-07-18 22:15:26 +09:00
|
|
|
worldRef?.queueEffect?.("ring", px, py, { size: 12 + 7 * Math.abs(phase), life: 0.18, color: "rgba(174,214,236,0.26)" });
|
2026-07-03 00:45:22 +09:00
|
|
|
}
|
|
|
|
|
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));
|
2026-07-18 22:15:26 +09:00
|
|
|
worldRef.queueEffect?.("flame", fire.x + ax * Math.max(12, fire.r || 14), fire.y + ay * Math.max(12, fire.r || 14), {
|
2026-07-03 20:57:02 +09:00
|
|
|
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),
|
2026-07-18 22:15:26 +09:00
|
|
|
});
|
2026-07-03 20:57:02 +09:00
|
|
|
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);
|
2026-07-10 16:40:06 +09:00
|
|
|
// Promote wind-driven objects out of the sleeping heap immediately. Without
|
|
|
|
|
// this, velocity accumulates while the target waits for its next scheduled
|
|
|
|
|
// update and the object jumps in periodic bursts.
|
|
|
|
|
global.TarinaiItemUpdateScheduler?.wakeItem?.(worldRef, target, 0.24);
|
2026-07-03 00:45:22 +09:00
|
|
|
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 });
|
2026-07-18 22:15:26 +09:00
|
|
|
worldRef?.queueEffect?.("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)" });
|
2026-07-03 00:45:22 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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);
|
2026-07-18 13:06:02 +09:00
|
|
|
for (const it of worldRef.nearbyNonGrassItems?.(balloon.x, balloon.y, radius) || worldRef.nearbyItems?.(balloon.x, balloon.y, radius) || []) {
|
2026-07-03 00:45:22 +09:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-11 21:13:39 +09:00
|
|
|
const WATER_BALLOON_SURPRISE_TEXT = "\u6c34\u98a8\u8239\u304c\u5272\u308c\u3066\u304a\u3069\u308d\u3044\u3066\u3044\u308b\u3002";
|
2026-07-03 00:45:22 +09:00
|
|
|
|
|
|
|
|
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) {
|
2026-07-18 22:15:26 +09:00
|
|
|
worldRef.queueEffect?.("fight", drop.x, drop.y, {
|
2026-07-03 00:45:22 +09:00
|
|
|
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,
|
2026-07-18 22:15:26 +09:00
|
|
|
});
|
2026-07-03 00:45:22 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
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;
|
2026-07-18 22:15:26 +09:00
|
|
|
worldRef.queueEffect?.("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 });
|
2026-07-03 00:45:22 +09:00
|
|
|
}
|
|
|
|
|
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);
|
2026-07-11 21:13:39 +09:00
|
|
|
if (typeof t.addStress === "function") t.addStress(0.18 + strength * 0.42, "\u6c34\u98a8\u8239\u304c\u5272\u308c\u3066\u9a5a\u3044\u305f");
|
2026-07-03 00:45:22 +09:00
|
|
|
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;
|
2026-07-18 22:15:26 +09:00
|
|
|
worldRef?.queueEffect?.("ring", balloon.x, balloon.y, { size: Math.max(32, (balloon.r || 20) * 1.9), life: 0.28, color: "rgba(76,178,236,0.62)" });
|
2026-07-03 00:45:22 +09:00
|
|
|
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);
|
2026-07-18 22:15:26 +09:00
|
|
|
worldRef?.queueEffect?.("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 });
|
2026-07-03 00:45:22 +09:00
|
|
|
}
|
|
|
|
|
spawnWaterFromBalloon(balloon, worldRef);
|
2026-07-11 21:13:39 +09:00
|
|
|
const sourceLabel = cause?.type === "fire" || cause?.type === "flame_firecracker" ? "\u706b"
|
|
|
|
|
: (cause?.type === "firecracker" ? "\u7206\u7af9"
|
|
|
|
|
: (cause?.type === "shoot" ? "\u5c04\u6483"
|
|
|
|
|
: (cause?.type === "genkotsu" ? "\u3052\u3093\u3053\u3064"
|
|
|
|
|
: (cause && typeof isPinType === "function" && isPinType(cause.type) ? "\u92f2" : "\u5f37\u3044\u885d\u6483"))));
|
2026-07-03 00:45:22 +09:00
|
|
|
if (worldRef?.relationNotice?.(balloon.id || "balloon", cause?.id || sourceLabel, "water-balloon-pop", 1.4)) {
|
2026-07-11 21:13:39 +09:00
|
|
|
worldRef.log?.(`\u6c34\u98a8\u8239\u304c${sourceLabel}\u306b\u89e6\u308c\u3066\u5272\u308c\u305f\u3002`, "accident", { participants: [] });
|
2026-07-03 00:45:22 +09:00
|
|
|
}
|
|
|
|
|
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,
|
2026-07-11 21:13:39 +09:00
|
|
|
fanWindAt,
|
2026-07-03 00:45:22 +09:00
|
|
|
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);
|