tarinai/js/item_dynamic_ball_system.js
2026-06-26 12:46:32 +09:00

249 lines
11 KiB
JavaScript

"use strict";
// Layer: entity-runtime/items/dynamic-system
// Owns ball item motion and obstacle bounce behavior. Item.prototype ball
// methods are compatibility facades that delegate to this system.
(function (global) {
function update(item, dt, worldRef) {
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);
const groundFriction = Math.pow(0.72, dt);
item.vx *= groundFriction;
item.vy *= groundFriction;
}
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;
}
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);
// 回転体は大きな輪郭を設計できるため、中心が近傍セル外でも当たる可能性がある。
// 中心ではなく外接半径で追加走査して、長い棒・大きな円形にも衝突させる。
if (Array.isArray(worldRef.items)) {
for (const it of worldRef.items) {
if (!it || it.dead || it === item || it.type !== "rotator" || seen.has(it)) continue;
const reach = worldRef.rotatorExtent?.(it) || Math.max(64, it.r || 64);
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") {
resolveCircleBounce(item, it, (it.r || 20) * 1.08 + (item.r || 18), 0.82, worldRef);
}
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;
const slow = Math.pow(0.16, Math.max(0.016, dt || 0.016));
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 {
item.vx = (item.vx || 0) + nx * 24;
item.vy = (item.vy || 0) + ny * 24;
}
item.vx *= 0.96;
item.vy *= 0.96;
item.spinVelocity = clamp((item.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * 5.5, -38, 38);
emitBounce(item, worldRef, obstacle);
}
function resolveRectBounce(item, rect, worldRef, source = null) {
if (!rect) return;
const hitRadius = (item.r || 18) + 2;
const restitution = Number.isFinite(rect.restitution) ? rect.restitution : 0.78;
const velocityDamp = rect.bounce ? 1.0 : 0.94;
const minBounceSpeed = Math.max(0, Number(rect.minBounceSpeed || 0) || 0);
if (rect.oriented && global.TarinaiPhysics?.bounceCircleOffRect) {
const bounced = global.TarinaiPhysics.bounceCircleOffRect(item, rect, hitRadius, restitution, worldRef, { minBounceSpeed });
if (!bounced) return;
item.vx *= velocityDamp;
item.vy *= velocityDamp;
emitBounce(item, worldRef, source || rect.item || rect);
return;
}
const ensureMinNormalSpeed = (nx, ny) => {
if (!(rect.bounce && minBounceSpeed > 0)) return;
const normalSpeed = (item.vx || 0) * nx + (item.vy || 0) * ny;
if (normalSpeed >= minBounceSpeed) return;
const add = minBounceSpeed - normalSpeed;
item.vx = (item.vx || 0) + nx * add;
item.vy = (item.vy || 0) + ny * add;
};
const cx = clamp(item.x, rect.left, rect.right);
const cy = clamp(item.y, rect.top, rect.bottom);
let dx = item.x - cx;
let dy = item.y - cy;
let d = Math.hypot(dx, dy);
if (d >= hitRadius) {
const px = item.prevX;
const py = item.prevY;
const expanded = { left: rect.left - hitRadius, right: rect.right + hitRadius, top: rect.top - hitRadius, bottom: rect.bottom + hitRadius };
if (!Number.isFinite(px) || !Number.isFinite(py) || !worldRef?.segmentIntersectsRect?.(px, py, item.x, item.y, expanded)) return;
let nx = 0;
let ny = 0;
const vx = item.vx || 0;
const vy = item.vy || 0;
const candidates = [];
const sx = item.x - px;
const sy = item.y - py;
if (px < expanded.left && sx > 0) candidates.push({ nx: -1, ny: 0, t: (expanded.left - px) / Math.max(sx, 0.001) });
if (px > expanded.right && sx < 0) candidates.push({ nx: 1, ny: 0, t: (px - expanded.right) / Math.max(-sx, 0.001) });
if (py < expanded.top && sy > 0) candidates.push({ nx: 0, ny: -1, t: (expanded.top - py) / Math.max(sy, 0.001) });
if (py > expanded.bottom && sy < 0) candidates.push({ nx: 0, ny: 1, t: (py - expanded.bottom) / Math.max(-sy, 0.001) });
if (candidates.length) {
candidates.sort((a, b) => a.t - b.t);
nx = candidates[0].nx;
ny = candidates[0].ny;
} else if (Math.abs(vx) >= Math.abs(vy)) {
nx = vx >= 0 ? -1 : 1;
} else {
ny = vy >= 0 ? -1 : 1;
}
const toward = vx * nx + vy * ny;
if (nx < 0) item.x = expanded.left - 0.5;
else if (nx > 0) item.x = expanded.right + 0.5;
if (ny < 0) item.y = expanded.top - 0.5;
else if (ny > 0) item.y = expanded.bottom + 0.5;
if (nx) item.y = clamp(item.y, expanded.top, expanded.bottom);
if (ny) item.x = clamp(item.x, expanded.left, expanded.right);
if (toward < 0) {
item.vx = vx - (1 + restitution) * toward * nx;
item.vy = vy - (1 + restitution) * toward * ny;
} else {
item.vx = vx + nx * 18;
item.vy = vy + ny * 18;
}
ensureMinNormalSpeed(nx, ny);
item.vx *= velocityDamp;
item.vy *= velocityDamp;
item.spinVelocity = clamp((item.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * 6.2, -38, 38);
emitBounce(item, worldRef, source || rect.item || rect);
return;
}
if (d < 0.001) {
const left = Math.abs(item.x - rect.left);
const right = Math.abs(rect.right - item.x);
const top = Math.abs(item.y - rect.top);
const bottom = Math.abs(rect.bottom - item.y);
const m = Math.min(left, right, top, bottom);
if (m === left) { dx = -1; dy = 0; }
else if (m === right) { dx = 1; dy = 0; }
else if (m === top) { dx = 0; dy = -1; }
else { dx = 0; dy = 1; }
d = 1;
}
const nx = dx / d;
const ny = dy / d;
const toward = (item.vx || 0) * nx + (item.vy || 0) * ny;
item.x = cx + nx * (hitRadius + 0.5);
item.y = cy + 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 {
item.vx = (item.vx || 0) + nx * 18;
item.vy = (item.vy || 0) + ny * 18;
}
ensureMinNormalSpeed(nx, ny);
item.vx *= velocityDamp;
item.vy *= velocityDamp;
item.spinVelocity = clamp((item.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * 6.2, -38, 38);
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;
const color = (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)");
worldRef?.effects?.push(new Effect("ring", item.x, item.y, { size: Math.max(13, (item.r || 18) * 0.82), life: 0.18, color }));
}
global.TarinaiItemDynamicBallSystem = Object.freeze({
update,
resolveObstacleCollisions,
applyHayDrag,
resolveCircleBounce,
resolveRectBounce,
emitBounce,
});
})(typeof window !== "undefined" ? window : globalThis);