286 lines
14 KiB
JavaScript
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);
|