tarinai/js/tarinai_update_step_movement.js
2026-07-10 16:40:06 +09:00

286 lines
14 KiB
JavaScript

"use strict";
// Layer: entity-runtime/tarinai/update-step
// Owns sunbath/runtime movement, collisions, and nest-box occupancy refresh.
(function (global) {
const SHOCK_DAMAGE_DELTA_THRESHOLD = 205;
const SHOCK_DAMAGE_COOLDOWN = 0.46;
function combinedVelocity(t) {
return {
vx: (Number.isFinite(t?.vx) ? t.vx : 0) + (Number.isFinite(t?.impulseVx) ? t.impulseVx : 0),
vy: (Number.isFinite(t?.vy) ? t.vy : 0) + (Number.isFinite(t?.impulseVy) ? t.impulseVy : 0),
};
}
function applyVelocityShockDamage(t, before, dt) {
if (!t || t.dead) return false;
const now = t.world?.time || 0;
const after = combinedVelocity(t);
const baseline = before || (Number.isFinite(t._lastPhysicalVelocityX) && Number.isFinite(t._lastPhysicalVelocityY)
? { vx: t._lastPhysicalVelocityX, vy: t._lastPhysicalVelocityY }
: after);
const delta = Math.hypot(after.vx - baseline.vx, after.vy - baseline.vy);
t._lastPhysicalVelocityX = after.vx;
t._lastPhysicalVelocityY = after.vy;
// A bounce fence is designed to launch rather than injure. Its deliberate
// velocity discontinuity must not be interpreted as generic collision
// shock damage on the following movement update.
if (now - Number(t.lastBounceFenceImpulseAt || -999) < 0.62) return false;
if (delta < SHOCK_DAMAGE_DELTA_THRESHOLD) return false;
if (now - (t.lastVelocityShockDamageAt || -999) < SHOCK_DAMAGE_COOLDOWN) return false;
if (now - (t.lastPhysicalCollisionDamageAt || -999) < SHOCK_DAMAGE_COOLDOWN) return false;
const amount = Math.min(42, Math.max(1.0, (delta - SHOCK_DAMAGE_DELTA_THRESHOLD) / 14));
const source = (now - Number(t.lastSolidObstacleCollisionAt || -999) < 0.75) ? (t.lastSolidObstacleCollisionSource || null) : null;
t.lastVelocityShockDamageAt = now;
t.world?.applyImpactDamage?.(t, amount, "\u6025\u6fc0\u306a\u901f\u5ea6\u5909\u5316", { cause: "\u885d\u7a81", source }) || t.damage?.(amount, "\u885d\u7a81");
t.hurtTimer = Math.max(t.hurtTimer || 0, amount > 15 ? 1.9 : 0.9);
t.fallTimer = Math.max(t.fallTimer || 0, amount > 12 ? 0.72 : 0.28);
t.fallMax = Math.max(t.fallMax || 0, t.fallTimer || 0);
t.fearTimer = Math.max(t.fearTimer || 0, 0.45);
if (t.world?.spawnFallEffect) t.world.spawnFallEffect(t.x, t.y + (t.radius || 22) * 0.55, Math.min(1.8, Math.max(0.5, amount / 18)));
return true;
}
function numeric(v, fallback = 0) {
return Number.isFinite(Number(v)) ? Number(v) : fallback;
}
function markSleepExternalMotion(t, dt, reason = "sleep-external-motion") {
if (!t || !(t.state === "sleep" || t.sleeping)) return false;
const world = t.world || null;
const now = Number(world?.time || 0) || 0;
const vx = numeric(t.vx);
const vy = numeric(t.vy);
const ivx = numeric(t.impulseVx);
const ivy = numeric(t.impulseVy);
const speed = Math.hypot(vx + ivx, vy + ivy);
const recentlyHit = now - Number(t.lastSolidObstacleCollisionAt || t.lastPhysicalCollisionDamageAt || -999) < 0.35;
if (speed > 0.55 || recentlyHit) {
const hold = speed > 18 ? 1.15 : (speed > 3 ? 0.75 : 0.42);
t._sleepExternalMotionUntil = Math.max(Number(t._sleepExternalMotionUntil || 0) || 0, now + hold);
return true;
}
return now < Number(t._sleepExternalMotionUntil || 0);
}
function sleepExternalMotionActive(t) {
const now = Number(t?.world?.time || 0) || 0;
return Boolean(t && (t.state === "sleep" || t.sleeping) && now < Number(t._sleepExternalMotionUntil || 0));
}
function wakeSleepingFromExternalMotion(t, reason = "\u5916\u529b\u3067\u76ee\u304c\u899a\u3081\u305f") {
if (!t || t.dead || t.sleepDisease || !(t.state === "sleep" || t.sleeping)) return false;
const world = t.world || null;
if (world?.isTarinaiHiddenInNestBox?.(t)) return false;
const now = Number(world?.time || 0) || 0;
if (now < Number(t._sleepExternalWakeSuppressUntil || -999)) return false;
t._sleepExternalWakeSuppressUntil = now + 0.45;
t._sleepExternalMotionUntil = 0;
t.consumePendingGrassBedOnWake?.("\u5916\u529b\u3067\u8d77\u304d\u305f");
t.sleeping = false;
t.sleepSession = null;
t.target = null;
t.targetKey = "";
t.awakeLockTimer = Math.max(t.awakeLockTimer || 0, 5.2);
t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.45);
t.fearTimer = Math.max(t.fearTimer || 0, 0.18);
t.setActionState?.("idle", { target: null, clearTarget: true, reason, wake: true, sleeping: false });
t.thought = reason;
t.pinchThoughtTimer = Math.max(t.pinchThoughtTimer || 0, 1.0);
if (t.bubble && now >= Number(t._sleepExternalWakeBubbleAt || -999) + 0.6) {
t._sleepExternalWakeBubbleAt = now;
t.bubble("!", 0.55, "rgba(88,82,118,0.78)");
}
return true;
}
function passivePhysicalMotion(t, dt, context = {}) {
if (!t || t.dead) return { done: true };
const world = t.world;
const beforeX = numeric(t.x);
const beforeY = numeric(t.y);
const beforeVelocity = Number.isFinite(t._lastPhysicalVelocityX) && Number.isFinite(t._lastPhysicalVelocityY)
? { vx: t._lastPhysicalVelocityX, vy: t._lastPhysicalVelocityY }
: combinedVelocity(t);
// Nest boxes intentionally hide and pin sleepers. Visible beds, eating, and
// other locked states still need a passive physics shell so external
// impulses/mechanical contacts are not delayed until the next low-frequency
// full update.
if (world?.isTarinaiHiddenInNestBox?.(t)) {
t.vx = 0;
t.vy = 0;
t.impulseVx = 0;
t.impulseVy = 0;
t.updateNestBoxPresence?.(dt);
return { done: false, passive: true };
}
let moveX = 0;
let moveY = 0;
const state = String(t.state || "");
const sleepExternal = markSleepExternalMotion(t, dt, "passive-physical");
if ((state === "sleep" || t.sleeping) && t.target && !t.target.dead && t.isSleepFurniture?.(t.target) && !globalThis.TarinaiToolRuntime?.isNestContainerItem?.(t.target, { alive: false })) {
const comfort = world?.bedComfort ? world.bedComfort(t.target) : 0.72;
const spot = t.sleepSpotFor ? t.sleepSpotFor(t.target) : t.target;
const dx = numeric(spot?.x, beforeX) - beforeX;
const dy = numeric(spot?.y, beforeY) - beforeY;
const d = Math.hypot(dx, dy);
const strongExternal = sleepExternal && Math.hypot(numeric(t.vx) + numeric(t.impulseVx), numeric(t.vy) + numeric(t.impulseVy)) > 1.2;
// Beds should gently keep a sleeper nearby, but must not pin the body.
// Under external force the restoring pull is deliberately weak so a
// sleeping Tarinai behaves like a physical object instead of snapping back
// to the sleep spot every smooth/full update.
if (d > 1.2) {
const restore = strongExternal ? (2.0 + comfort * 2.0) : (12 + comfort * 14);
const maxRestore = strongExternal ? Math.min(d, 2.8) : d;
const step = Math.min(maxRestore, dt * restore);
moveX += dx / d * step;
moveY += dy / d * step;
} else if (!strongExternal && d > 0.05 && Math.hypot(t.vx || 0, t.vy || 0, t.impulseVx || 0, t.impulseVy || 0) < 0.6) {
moveX += dx;
moveY += dy;
}
}
const vx = numeric(t.vx);
const vy = numeric(t.vy);
const impulseVx = numeric(t.impulseVx);
const impulseVy = numeric(t.impulseVy);
moveX += (vx + impulseVx) * dt;
moveY += (vy + impulseVy) * dt;
const movedDistance = Math.hypot(moveX, moveY);
if (movedDistance > 0.001) {
if (world?.moveTarinaiWithCollision) {
world.moveTarinaiWithCollision(t, moveX, moveY, {
dt,
reason: state === "sleep" ? "tarinai-sleep-physical" : "tarinai-passive-physical",
maxChecks: context.cadence?.lane === "sleep" ? 18 : 22,
maxStep: 10,
});
} else {
t.x = beforeX + moveX;
t.y = beforeY + moveY;
}
}
const bodyDrag = state === "sleep" ? (sleepExternalMotionActive(t) ? 0.86 : 0.55) : (state === "eat" ? 0.42 : 0.66);
t.vx = Math.abs(numeric(t.vx) * Math.pow(bodyDrag, dt * 60)) < 0.025 ? 0 : numeric(t.vx) * Math.pow(bodyDrag, dt * 60);
t.vy = Math.abs(numeric(t.vy) * Math.pow(bodyDrag, dt * 60)) < 0.025 ? 0 : numeric(t.vy) * Math.pow(bodyDrag, dt * 60);
const impulseDrag = Math.pow(0.58, dt * 3.2);
t.impulseVx = Math.abs(impulseVx) < 0.35 ? 0 : impulseVx * impulseDrag;
t.impulseVy = Math.abs(impulseVy) < 0.35 ? 0 : impulseVy * impulseDrag;
applyVelocityShockDamage(t, beforeVelocity, dt);
t.updateNestBoxPresence?.(dt);
const dx = numeric(t.x) - beforeX;
const dy = numeric(t.y) - beforeY;
const lockedPassiveContact = /^(sleep|eat|sunbath|birth_ritual|breed|reproduction)$/.test(state);
if (world?.resolveSolidObstacleCollision && !world.isTarinaiHiddenInNestBox?.(t) && (lockedPassiveContact || sleepExternalMotionActive(t))) {
// A moving mechanical item can hit a sleeping Tarinai while the Tarinai's
// own velocity is zero. In that case swept movement will never run, so
// do a small contact pass every passive-physics tick.
const beforeContactX = numeric(t.x);
const beforeContactY = numeric(t.y);
const pushed = world.resolveSolidObstacleCollision(t, {
maxPasses: 2,
searchRadius: Math.max(72, (numeric(t.radius, 22) || 22) + 120),
maxChecks: context.cadence?.lane === "sleep" ? 18 : 22,
slop: 0.28,
reason: state === "eat" ? "tarinai-eat-passive-contact" : (state === "sleep" ? "tarinai-sleep-passive-contact" : "tarinai-locked-passive-contact"),
});
if (pushed) {
t.lastSolidObstacleCollisionAt = world.time || 0;
markSleepExternalMotion(t, dt, "sleep-contact-push");
const cdx = numeric(t.x) - beforeContactX;
const cdy = numeric(t.y) - beforeContactY;
if (Math.hypot(cdx, cdy) > 0.05) {
const pushCarry = state === "sleep" ? 0.085 : (state === "eat" ? 0.070 : 0.040);
t.vx = clamp(numeric(t.vx) + cdx / Math.max(0.016, dt) * pushCarry, -170, 170);
t.vy = clamp(numeric(t.vy) + cdy / Math.max(0.016, dt) * pushCarry, -170, 170);
}
}
} else if (dx * dx + dy * dy <= 0.25 && world?.resolveFenceCollision && !world.isTarinaiHiddenInNestBox?.(t)) {
const now = world.time || 0;
if (now >= (t._nextPassiveSolidRelaxAt || 0)) {
const jitter = stableUnit(t.id || t.familyKey || 0, "passive-solid-relax") * 0.55;
t._nextPassiveSolidRelaxAt = now + 1.20 + jitter;
world.resolveFenceCollision(t);
}
}
if (state === "sleep" || t.sleeping) {
const finalDx = numeric(t.x) - beforeX;
const finalDy = numeric(t.y) - beforeY;
const rawSpeed = Math.hypot(numeric(t.vx) + numeric(t.impulseVx), numeric(t.vy) + numeric(t.impulseVy));
const movedByExternal = Math.hypot(finalDx, finalDy) > 4.2 || rawSpeed > 26 || (sleepExternalMotionActive(t) && Math.hypot(finalDx, finalDy) > 2.2);
if (movedByExternal) wakeSleepingFromExternalMotion(t, "\u52d5\u304b\u3055\u308c\u3066\u76ee\u304c\u899a\u3081\u305f");
}
return { done: false, passive: true };
}
function update(t, dt, context = {}) {
if (!t || t.dead) return { done: true };
if (t.playerHeld || t._heldByPlayer) {
t.vx = 0;
t.vy = 0;
t.impulseVx = 0;
t.impulseVy = 0;
t.target = null;
t.targetKey = "";
t.updateNestBoxPresence?.(dt);
t._lastPhysicalVelocityX = 0;
t._lastPhysicalVelocityY = 0;
return { done: false, held: true };
}
const motionDt = Math.max(0.001, Math.min(0.05, Number(context.motionDt ?? dt) || 0.016));
if (context.motionOnly && /^(sleep|eat|sunbath|birth_ritual|breed|reproduction|intimidate|ant_intimidate)$/.test(String(t.state || ""))) {
return passivePhysicalMotion(t, motionDt, context);
}
const beforeX = Number.isFinite(t.x) ? t.x : 0;
const beforeY = Number.isFinite(t.y) ? t.y : 0;
const current = combinedVelocity(t);
const before = Number.isFinite(t._lastPhysicalVelocityX) && Number.isFinite(t._lastPhysicalVelocityY)
? { vx: t._lastPhysicalVelocityX, vy: t._lastPhysicalVelocityY }
: current;
global.TarinaiSunbathSystem.updateOne(t, motionDt);
t.maintainTargetProgress(motionDt);
const prevMotionOnlyFlag = t._motionOnlyPhysicsPass;
if (context.motionOnly) t._motionOnlyPhysicsPass = true;
try {
t.move(motionDt);
} finally {
if (context.motionOnly) t._motionOnlyPhysicsPass = prevMotionOnlyFlag;
}
if (t.ammoCollisionKnockback) t.ammoCollisionKnockback(motionDt);
applyVelocityShockDamage(t, before, motionDt);
t.updateNestBoxPresence(motionDt);
// t.move() calls world.moveTarinaiWithCollision(), which already performs
// swept solid-obstacle resolution. Avoid a second full collision pass and
// keep only a staggered low-frequency relax pass for near-stationary bodies
// so old overlaps still self-heal.
const world = t.world;
if (world?.resolveFenceCollision) {
const dx = (Number.isFinite(t.x) ? t.x : 0) - beforeX;
const dy = (Number.isFinite(t.y) ? t.y : 0) - beforeY;
const movedSq = dx * dx + dy * dy;
if (movedSq <= 0.25 && !world.isTarinaiHiddenInNestBox?.(t)) {
const now = world.time || 0;
if (now >= (t._nextSolidRelaxAt || 0)) {
const jitter = stableUnit(t.id || t.familyKey || 0, "solid-relax") * 0.45;
t._nextSolidRelaxAt = now + 0.85 + jitter;
world.resolveFenceCollision(t);
}
}
}
return { done: false };
}
global.TarinaiMovementUpdateStep = Object.freeze({ update, markSleepExternalMotion, sleepExternalMotionActive, wakeSleepingFromExternalMotion });
})(typeof window !== "undefined" ? window : globalThis);