tarinai/js/physics_world_system.js
2026-07-16 22:12:03 +09:00

558 lines
30 KiB
JavaScript

"use strict";
// Layer: physics/world
// Central schema, serialization, and orchestration layer for physical tools.
// Physical state lives only in item.physicsBody / item.physicsConstraint.
(function (global) {
const BODY_TYPES = new Set(["rotator", "poison_block", "reciprocator"]);
const LINK_TYPES = new Set(["rope", "rod", "spring", "wire", "insulated_wire"]);
const num = global.TarinaiCoreHelpers?.finiteOr || ((value, fallback = 0) => {
const n = Number(value);
return Number.isFinite(n) ? n : fallback;
});
function bool(value, fallback = true) {
if (value == null) return !!fallback;
return !!value;
}
const clamp = global.TarinaiCoreHelpers?.clampNumber || ((value, min, max) => Math.max(min, Math.min(max, num(value, min))));
function isBodyType(typeOrItem) {
const type = typeof typeOrItem === "string" ? typeOrItem : String(typeOrItem?.type || "");
return BODY_TYPES.has(type);
}
function isConstraintType(typeOrItem) {
const type = typeof typeOrItem === "string" ? typeOrItem : String(typeOrItem?.type || "");
return LINK_TYPES.has(type);
}
function isPhysicsType(typeOrItem) { return isBodyType(typeOrItem) || isConstraintType(typeOrItem); }
function defaultSegments(type) {
if (type === "poison_block") return [[-52, -20, 52, -20], [52, -20, 52, 20], [52, 20, -52, 20], [-52, 20, -52, -20]];
if (type === "rotator") return [[-78, 0, 78, 0], [0, -52, 0, 52]];
return [[-78, 0, 78, 0]];
}
function defaultKind(type) {
if (type === "rotator") return "rotational";
if (type === "reciprocator") return "linear";
if (type === "poison_block") return "passive";
return "none";
}
function cloneSegments(segments, fallback) {
const source = Array.isArray(segments) && segments.length ? segments : fallback;
const out = [];
for (const seg of source || []) {
if (!Array.isArray(seg) || seg.length < 4) continue;
const x1 = clamp(seg[0], -420, 420), y1 = clamp(seg[1], -420, 420);
const x2 = clamp(seg[2], -420, 420), y2 = clamp(seg[3], -420, 420);
if (Math.hypot(x2 - x1, y2 - y1) < 4) continue;
out.push([x1, y1, x2, y2]);
if (out.length >= 128) break;
}
return out.length ? out : (fallback || [[-78, 0, 78, 0]]).map(seg => seg.slice());
}
function defaultBody(item) {
const type = String(item?.type || "");
const x = num(item?.x), y = num(item?.y), angle = num(item?.angle);
return {
type,
kind: defaultKind(type),
pose: { x, y, angle },
velocity: { x: 0, y: 0, angular: 0, linear: 0 },
motor: { powered: type !== "poison_block", speed: type === "rotator" ? Math.PI * 0.65 : (type === "reciprocator" ? 92 : 0), direction: 1 },
rail: type === "reciprocator" ? { axisAngle: angle, travel: 150, phase: 0, anchorX: x, anchorY: y } : null,
shape: { model: "segments", thickness: type === "poison_block" ? 11 : 12, segments: cloneSegments(null, defaultSegments(type)), version: 0 },
collision: { solid: true, hazard: type === "poison_block" },
hazard: type === "poison_block" ? { kind: "poison", damage: 7 } : null,
sleep: { awakeUntil: 0 },
mass: type === "poison_block" ? 0.45 : 1,
inertia: type === "poison_block" ? 3200 : 22000,
};
}
function normalizeBody(body, item) {
const type = String(item?.type || body?.type || "");
const base = defaultBody({ type, x: item?.x, y: item?.y, angle: item?.angle });
const out = body && typeof body === "object" ? body : base;
out.type = type;
out.kind = out.kind || base.kind;
out.pose = out.pose && typeof out.pose === "object" ? out.pose : base.pose;
out.velocity = out.velocity && typeof out.velocity === "object" ? out.velocity : base.velocity;
out.motor = out.motor && typeof out.motor === "object" ? out.motor : base.motor;
out.rail = type === "reciprocator" ? ((out.rail && typeof out.rail === "object") ? out.rail : base.rail) : null;
out.shape = out.shape && typeof out.shape === "object" ? out.shape : base.shape;
out.collision = out.collision && typeof out.collision === "object" ? out.collision : base.collision;
out.sleep = out.sleep && typeof out.sleep === "object" ? out.sleep : base.sleep;
out.hazard = type === "poison_block" ? ((out.hazard && typeof out.hazard === "object") ? out.hazard : base.hazard) : null;
out.mass = Math.max(0.2, num(out.mass, base.mass));
out.inertia = Math.max(1, num(out.inertia, base.inertia));
out.pose.x = num(out.pose.x, item?.x);
out.pose.y = num(out.pose.y, item?.y);
out.pose.angle = num(out.pose.angle, item?.angle);
out.velocity.x = num(out.velocity.x);
out.velocity.y = num(out.velocity.y);
out.velocity.angular = num(out.velocity.angular);
out.velocity.linear = num(out.velocity.linear);
out.motor.powered = type === "poison_block" ? false : out.motor.powered !== false;
out.motor.speed = type === "rotator" ? num(out.motor.speed, base.motor.speed) : Math.max(0, num(out.motor.speed, base.motor.speed));
out.motor.direction = Math.sign(num(out.motor.direction, 1)) || 1;
if (out.rail) {
out.rail.axisAngle = num(out.rail.axisAngle, out.pose.angle);
out.rail.travel = Math.max(24, num(out.rail.travel, 150));
out.rail.phase = clamp(out.rail.phase, -1, 1);
out.rail.anchorX = num(out.rail.anchorX, out.pose.x);
out.rail.anchorY = num(out.rail.anchorY, out.pose.y);
}
out.shape.model = "segments";
out.shape.thickness = Math.max(4, Math.min(34, num(out.shape.thickness, base.shape.thickness)));
out.shape.segments = cloneSegments(out.shape.segments, defaultSegments(type));
out.shape.version = Number(out.shape.version || 0) || 0;
out.collision.solid = out.collision.solid !== false;
out.collision.hazard = type === "poison_block" ? out.collision.hazard !== false : false;
if (out.hazard) {
out.hazard.kind = out.hazard.kind || "poison";
out.hazard.damage = Math.max(1, num(out.hazard.damage, 7));
}
out.sleep.awakeUntil = num(out.sleep.awakeUntil);
// During the mechanical solver we intentionally mutate item.x/y/angle as
// a scratch pose, then commit that pose back to physicsBody at the end of
// the step. Do not let scalar()/setScalar() normalization overwrite the
// scratch pose with the old body pose while the step is in progress.
if (item._physicsStepScratch !== true) {
item.x = out.pose.x;
item.y = out.pose.y;
item.angle = out.pose.angle;
}
return out;
}
function itemPoseShouldFeedBody(item, opts = {}) {
return opts.syncFromLegacy === true || item?._physicsStepScratch === true || item?.playerHeld === true || item?._heldByPlayer === true || item?._physicsExternalPoseDirty === true;
}
function ensureBody(item, worldRef = null, opts = {}) {
if (!item || item.dead || !isBodyType(item)) return null;
item.world = worldRef || item.world || null;
if (!item.physicsBody || typeof item.physicsBody !== "object" || item.physicsBody.type !== item.type || opts.rebuild === true) {
item.physicsBody = defaultBody(item);
} else if (itemPoseShouldFeedBody(item, opts)) {
const sx = num(item.x);
const sy = num(item.y);
const sa = num(item.angle);
item.physicsBody.pose = item.physicsBody.pose || { x: sx, y: sy, angle: sa };
item.physicsBody.pose.x = sx;
item.physicsBody.pose.y = sy;
item.physicsBody.pose.angle = sa;
item._physicsExternalPoseDirty = false;
}
return item.physicsBody;
}
function scalar(item, key, fallback = 0) {
const b = ensureBody(item, item?.world || null, { syncFromLegacy: false });
if (!b) return fallback;
switch (key) {
case "thickness": return Math.max(4, Math.min(34, num(b.shape?.thickness, fallback)));
case "motorOn": return b.motor?.powered !== false;
case "motorSpeed": return num(b.motor?.speed, fallback);
case "spin": return num(b.velocity?.angular, fallback);
case "xv": return num(b.velocity?.x, fallback);
case "yv": return num(b.velocity?.y, fallback);
case "slideSpeed": return num(b.velocity?.linear, fallback);
case "solid": return b.collision?.solid !== false;
case "damage": return Math.max(1, num(b.hazard?.damage, fallback));
case "mass": return Math.max(0.2, num(b.mass, fallback));
case "inertia": return Math.max(1, num(b.inertia, fallback));
case "railOn": return b.motor?.powered !== false;
case "railMotorSpeed": return Math.max(0, num(b.motor?.speed, fallback));
case "railTravel": return Math.max(24, num(b.rail?.travel, fallback));
case "railPhase": return clamp(b.rail?.phase, -1, 1);
case "railDir": return Math.sign(num(b.motor?.direction, fallback || 1)) || 1;
case "railAxis": return num(b.rail?.axisAngle, fallback);
case "railAnchorX": return num(b.rail?.anchorX, fallback);
case "railAnchorY": return num(b.rail?.anchorY, fallback);
case "awakeUntil": return num(b.sleep?.awakeUntil, fallback);
default: return fallback;
}
}
function setScalar(item, key, value, reason = "physics-set") {
const b = ensureBody(item, item?.world || null, { syncFromLegacy: false });
if (!b) return false;
b.velocity = b.velocity || {};
b.motor = b.motor || {};
b.rail = b.rail || (item.type === "reciprocator" ? { axisAngle: num(item.angle), travel: 150, phase: 0, anchorX: num(item.x), anchorY: num(item.y) } : null);
b.shape = b.shape || { model: "segments", thickness: item.type === "poison_block" ? 11 : 12, segments: defaultSegments(item.type), version: 0 };
b.collision = b.collision || { solid: true, hazard: item.type === "poison_block" };
b.sleep = b.sleep || { awakeUntil: 0 };
if (item.type === "poison_block") b.hazard = b.hazard || { kind: "poison", damage: 7 };
switch (key) {
case "thickness": b.shape.thickness = Math.max(4, Math.min(34, num(value, item.type === "poison_block" ? 11 : 12))); break;
case "motorOn": b.motor.powered = item.type === "poison_block" ? false : !!value; break;
case "motorSpeed": b.motor.speed = item.type === "rotator" ? num(value) : Math.max(0, num(value)); break;
case "spin": b.velocity.angular = num(value); break;
case "xv": b.velocity.x = num(value); break;
case "yv": b.velocity.y = num(value); break;
case "slideSpeed": b.velocity.linear = num(value); break;
case "solid": b.collision.solid = !!value; break;
case "damage": b.hazard = b.hazard || { kind: "poison", damage: 7 }; b.hazard.damage = Math.max(1, num(value, 7)); break;
case "mass": b.mass = Math.max(0.12, num(value, 0.45)); break;
case "inertia": b.inertia = Math.max(1, num(value, item.type === "poison_block" ? 3200 : 36000)); break;
case "railOn": b.motor.powered = !!value; break;
case "railMotorSpeed": b.motor.speed = Math.max(0, num(value, 92)); break;
case "railTravel": if (b.rail) b.rail.travel = Math.max(24, num(value, 150)); break;
case "railPhase": if (b.rail) b.rail.phase = clamp(value, -1, 1); break;
case "railDir": b.motor.direction = Math.sign(num(value, 1)) || 1; break;
case "railAxis": if (b.rail) b.rail.axisAngle = num(value, b.pose?.angle); break;
case "railAnchorX": if (b.rail) b.rail.anchorX = num(value, b.pose?.x); break;
case "railAnchorY": if (b.rail) b.rail.anchorY = num(value, b.pose?.y); break;
case "awakeUntil": b.sleep.awakeUntil = num(value); break;
default: return false;
}
return true;
}
function segments(item) { return cloneSegments(ensureBody(item, item?.world || null, { syncFromLegacy: false })?.shape?.segments, defaultSegments(item?.type)); }
function setSegments(item, next, reason = "shape-edited") {
const b = ensureBody(item, item?.world || null, { syncFromLegacy: false });
if (!b) return false;
b.shape = b.shape || {};
b.shape.model = "segments";
b.shape.segments = cloneSegments(next, defaultSegments(item.type));
return true;
}
function syncPoseFromItem(item) {
if (!item) return null;
// Capture the item pose before ensureBody()/normalizeBody() can write the
// current body pose back to the item. This is required for scratch-solver
// movement and direct player grabbing.
const sx = num(item.x);
const sy = num(item.y);
const sa = num(item.angle);
const b = item.physicsBody && item.physicsBody.type === item.type
? item.physicsBody
: ensureBody(item, item?.world || null, { syncFromLegacy: false });
if (!b) return null;
b.pose = b.pose || { x: sx, y: sy, angle: sa };
b.pose.x = sx;
b.pose.y = sy;
b.pose.angle = sa;
item.x = sx;
item.y = sy;
item.angle = sa;
return b;
}
function applyPoseToItem(item) {
const b = item?.physicsBody && item.physicsBody.type === item.type
? item.physicsBody
: ensureBody(item, item?.world || null, { syncFromLegacy: false });
if (!b) return false;
const before = `${num(item.x).toFixed(3)}:${num(item.y).toFixed(3)}:${num(item.angle).toFixed(5)}`;
item.x = num(b.pose?.x, item.x);
item.y = num(b.pose?.y, item.y);
item.angle = num(b.pose?.angle, item.angle);
const after = `${num(item.x).toFixed(3)}:${num(item.y).toFixed(3)}:${num(item.angle).toFixed(5)}`;
return before !== after;
}
function endpointToPlain(endpoint) {
if (!endpoint) return null;
return {
kind: endpoint.kind || "item",
id: endpoint.id || "",
type: endpoint.type || "",
label: endpoint.label || "",
liveToken: endpoint.liveToken ?? null,
localX: num(endpoint.localX),
localY: num(endpoint.localY),
center: !!endpoint.center,
x: num(endpoint.x),
y: num(endpoint.y),
attachT: Number.isFinite(Number(endpoint.attachT)) ? num(endpoint.attachT) : null,
portId: String(endpoint.portId || ""),
fuzzyResolve: endpoint.fuzzyResolve === true,
supportLost: endpoint.supportLost === true,
_ref: endpoint._ref || null,
_supportItemId: endpoint._supportItemId || "",
_supportCheckedVersion: Number(endpoint._supportCheckedVersion || -1) || -1,
_supportAlive: endpoint._supportAlive !== false,
};
}
function defaultConstraint(item) {
const x = num(item?.x), y = num(item?.y);
return {
type: item?.type,
kind: item?.type === "rope" ? "flexible-distance" : (item?.type === "rod" ? "rigid-distance" : (item?.type === "spring" ? "spring-distance" : "signal-wire")),
endpoints: [null, null],
length: 80,
mid: { x, y: y + 10, vx: 0, vy: 0 },
sleep: { awakeUntil: 0 },
particles: null,
};
}
function normalizeConstraint(c, item) {
const base = defaultConstraint(item);
const out = c && typeof c === "object" ? c : base;
out.type = item?.type;
out.kind = out.kind || base.kind;
out.endpoints = Array.isArray(out.endpoints) ? out.endpoints : [null, null];
out.endpoints[0] = endpointToPlain(out.endpoints[0]);
out.endpoints[1] = endpointToPlain(out.endpoints[1]);
out.length = Math.max(24, num(out.length, 80));
out.mid = out.mid && typeof out.mid === "object" ? out.mid : base.mid;
out.mid.x = num(out.mid.x, item?.x);
out.mid.y = num(out.mid.y, item?.y);
out.mid.vx = num(out.mid.vx);
out.mid.vy = num(out.mid.vy);
out.sleep = out.sleep && typeof out.sleep === "object" ? out.sleep : base.sleep;
out.sleep.awakeUntil = num(out.sleep.awakeUntil);
out.particles = Array.isArray(out.particles) ? out.particles : null;
item.r = Math.max(18, (item.type === "rod" || item.type === "spring" || item.type === "wire" || item.type === "insulated_wire") ? out.length * 0.5 : (num(item.r, out.length * 0.5) || out.length * 0.5));
return out;
}
function ensureConstraint(item, worldRef = null, opts = {}) {
if (!item || item.dead || !isConstraintType(item)) return null;
item.world = worldRef || item.world || null;
if (!item.physicsConstraint || typeof item.physicsConstraint !== "object" || item.physicsConstraint.type !== item.type || opts.rebuild === true) {
item.physicsConstraint = defaultConstraint(item);
}
return item.physicsConstraint;
}
function endpoint(item, index = 0) { return ensureConstraint(item, item?.world || null, { syncFromLegacy: false })?.endpoints?.[index] || null; }
function setEndpoint(item, index, value) {
const c = ensureConstraint(item, item?.world || null, { syncFromLegacy: false });
if (!c) return false;
c.endpoints[index] = endpointToPlain(value);
return true;
}
function linkScalar(item, key, fallback = 0) {
const c = ensureConstraint(item, item?.world || null, { syncFromLegacy: false });
if (!c) return fallback;
switch (key) {
case "len": return Math.max(24, num(c.length, fallback));
case "midX": return num(c.mid?.x, fallback);
case "midY": return num(c.mid?.y, fallback);
case "midVx": return num(c.mid?.vx, fallback);
case "midVy": return num(c.mid?.vy, fallback);
case "awakeUntil": return num(c.sleep?.awakeUntil, fallback);
default: return fallback;
}
}
function setLinkScalar(item, key, value) {
const c = ensureConstraint(item, item?.world || null, { syncFromLegacy: false });
if (!c) return false;
c.mid = c.mid || { x: num(item.x), y: num(item.y), vx: 0, vy: 0 };
c.sleep = c.sleep || { awakeUntil: 0 };
switch (key) {
case "len": c.length = Math.max(24, num(value, 80)); item.r = Math.max(18, c.length * 0.5); break;
case "midX": c.mid.x = num(value, item.x); break;
case "midY": c.mid.y = num(value, item.y); break;
case "midVx": c.mid.vx = num(value); break;
case "midVy": c.mid.vy = num(value); break;
case "awakeUntil": c.sleep.awakeUntil = num(value); break;
default: return false;
}
return true;
}
function particles(item) {
const c = ensureConstraint(item, item?.world || null, { syncFromLegacy: false });
if (!c) return null;
return c.particles;
}
function setParticles(item, list) {
const c = ensureConstraint(item, item?.world || null, { syncFromLegacy: false });
if (!c) return false;
c.particles = Array.isArray(list) ? list : null;
return true;
}
function bodyLikelyActive(item) {
if (!item || item.dead) return false;
if (item.playerHeld || item._heldByPlayer || item._physicsExternalPoseDirty) return true;
const b = item.physicsBody || null;
const v = b?.velocity || {};
const motor = b?.motor || {};
if (item.type === "rotator") return motor.powered !== false || Math.abs(num(v.angular ?? item.spin)) > 0.004;
if (item.type === "reciprocator") return motor.powered !== false || Math.abs(num(v.linear ?? item.slideSpeed)) > 0.08;
if (item.type === "poison_block") {
const now = Number(item.world?.time || 0) || 0;
return now < num(b?.sleep?.awakeUntil ?? item.awakeUntil, -Infinity)
|| Math.hypot(num(v.x ?? item.vx), num(v.y ?? item.vy)) > 0.08
|| Math.abs(num(v.angular ?? item.spin)) > 0.004;
}
return false;
}
function collectPhysicsItems(worldRef, opts = {}) {
if (!worldRef?.itemsOfType) return { bodies: [], constraints: [] };
const bodies = worldRef._physicsBodiesScratch || (worldRef._physicsBodiesScratch = []);
const constraints = worldRef._physicsConstraintsScratch || (worldRef._physicsConstraintsScratch = []);
bodies.length = 0;
constraints.length = 0;
worldRef.ensureItemBuckets?.("physics-world-system");
const maxBodies = Math.max(1, Number(opts.maxItems || Infinity) || Infinity);
const maxConstraints = Math.max(0, Number(opts.maxLinks || Infinity) || Infinity);
const bodyCandidates = worldRef._physicsBodyCandidatesScratch || (worldRef._physicsBodyCandidatesScratch = []);
bodyCandidates.length = 0;
for (const type of BODY_TYPES) {
for (const item of worldRef.itemsOfType(type) || []) {
if (!item || item.dead) continue;
bodyCandidates.push(item);
}
}
bodyCandidates.sort((a, b) => Number(bodyLikelyActive(b)) - Number(bodyLikelyActive(a)));
for (const item of bodyCandidates) {
if (bodies.length >= maxBodies) break;
bodies.push(item);
}
for (const type of LINK_TYPES) {
for (const item of worldRef.itemsOfType(type) || []) {
if (!item || item.dead) continue;
if (constraints.length >= maxConstraints) break;
constraints.push(item);
}
if (constraints.length >= maxConstraints) break;
}
return { bodies, constraints };
}
function prepareWorld(worldRef, opts = {}) {
const { bodies, constraints } = collectPhysicsItems(worldRef, opts);
for (const item of bodies) {
ensureBody(item, worldRef, { syncFromLegacy: item?.playerHeld === true || item?._heldByPlayer === true || item?._physicsExternalPoseDirty === true });
normalizeBodyState(item);
}
for (const item of constraints) {
ensureConstraint(item, worldRef, { syncFromLegacy: false });
normalizeConstraintState(item);
}
return { bodies, constraints, bodyCount: bodies.length, constraintCount: constraints.length };
}
function updateWorld(worldRef, dt = 0.016, opts = {}) {
if (!worldRef?.itemsOfType) return { ran: 0, bodies: 0, constraints: 0 };
const profiler = global.TarinaiPerf;
const end = profiler.begin("update.physicsWorld.central") || null;
try {
const frame = prepareWorld(worldRef, opts);
const mechanicalStats = global.TarinaiMechanicalSystem.updateWorld(worldRef, dt, {
maxPairs: opts.maxPairs || 320,
maxLinks: 0,
skipLinks: true,
maxItems: opts.maxItems || 220,
maxSubsteps: opts.maxSubsteps || 5,
focusThreshold: opts.focusThreshold || 44,
maxSubstepPairs: opts.maxSubstepPairs || 140,
maxSubstepFenceItems: opts.maxSubstepFenceItems || 32,
statePrepared: true,
}) || { ran: 0, active: 0 };
const constraintRan = global.TarinaiConstraintSystem.updateWorld(worldRef, dt, { maxLinks: opts.maxLinks || 220, statePrepared: true }) || 0;
const constraintChanged = Number(worldRef._constraintWorldStats?.changed || 0) || 0;
const postConstraintPairs = constraintChanged ? (global.TarinaiMechanicalSystem.resolveMechanicalPairs(worldRef, dt, { maxPairs: opts.postMaxPairs || 110 }) || 0) : 0;
const stats = { ran: (mechanicalStats?.ran || 0) + constraintRan, bodies: frame.bodyCount, constraints: frame.constraintCount, mechanical: mechanicalStats, constraintRan, constraintChanged, postConstraintPairs };
return stats;
} finally { if (end) end(); }
}
function compactItemExtra(item, tarinaiIndex = new Map(), itemIndex = new Map()) {
if (!item || !isPhysicsType(item)) return null;
if (isBodyType(item)) {
const b = ensureBody(item, item.world || null);
return { pf: 2, body: {
type: b.type,
kind: b.kind,
pose: [Math.round(num(b.pose.x)), Math.round(num(b.pose.y)), Math.round(num(b.pose.angle) * 1000)],
vel: [Math.round(num(b.velocity.x) * 10), Math.round(num(b.velocity.y) * 10), Math.round(num(b.velocity.angular) * 1000), Math.round(num(b.velocity.linear) * 10)],
motor: [b.motor.powered ? 1 : 0, Math.round(num(b.motor.speed) * 10), Math.round(num(b.motor.direction || 1))],
rail: b.rail ? [Math.round(num(b.rail.axisAngle) * 1000), Math.round(num(b.rail.travel) * 10), Math.round(num(b.rail.phase) * 1000), Math.round(num(b.rail.anchorX)), Math.round(num(b.rail.anchorY))] : null,
shape: [Math.round(num(b.shape.thickness) * 10), cloneSegments(b.shape.segments, defaultSegments(item.type)).map(seg => seg.map(v => Math.round(num(v))))],
collision: [b.collision.solid ? 1 : 0, b.collision.hazard ? 1 : 0],
hazard: b.hazard ? [b.hazard.kind || "", Math.round(num(b.hazard.damage, 7) * 10)] : null,
mass: Math.round(num(b.mass, 1) * 10),
inertia: Math.round(num(b.inertia, 1)),
} };
}
const c = ensureConstraint(item, item.world || null);
const epToSave = ep => {
if (!ep) return null;
const kind = ep.kind || "item";
const refIdx = kind === "tarinai" ? (tarinaiIndex.get(ep.id) ?? -1) : (itemIndex.get(ep.id) ?? -1);
return { kind, refIdx, id: ep.id || "", type: ep.type || "", label: ep.label || "", token: ep.liveToken ?? null, lx: Math.round(num(ep.localX)), ly: Math.round(num(ep.localY)), center: ep.center ? 1 : 0, x: Math.round(num(ep.x)), y: Math.round(num(ep.y)), t: Number.isFinite(Number(ep.attachT)) ? Math.round(num(ep.attachT) * 1000) : null, port: String(ep.portId || "") };
};
return { pf: 2, constraint: { type: c.type, kind: c.kind, endpoints: [epToSave(c.endpoints?.[0]), epToSave(c.endpoints?.[1])], length: Math.round(num(c.length, 80)), mid: [Math.round(num(c.mid?.x)), Math.round(num(c.mid?.y)), Math.round(num(c.mid?.vx) * 10), Math.round(num(c.mid?.vy) * 10)] } };
}
function restoreBodyExtra(item, extra) {
const p = extra?.body;
if (!item || !p || !isBodyType(item)) return false;
const poseArr = Array.isArray(p.pose) ? p.pose : [];
const velArr = Array.isArray(p.vel) ? p.vel : [];
const motorArr = Array.isArray(p.motor) ? p.motor : [];
const railArr = Array.isArray(p.rail) ? p.rail : null;
const shapeArr = Array.isArray(p.shape) ? p.shape : [];
const collArr = Array.isArray(p.collision) ? p.collision : [];
const hazardArr = Array.isArray(p.hazard) ? p.hazard : null;
const body = defaultBody(item);
body.kind = p.kind || body.kind;
body.pose = { x: num(poseArr[0], item.x), y: num(poseArr[1], item.y), angle: num(poseArr[2]) / 1000 };
body.velocity = { x: num(velArr[0]) / 10, y: num(velArr[1]) / 10, angular: num(velArr[2]) / 1000, linear: num(velArr[3]) / 10 };
body.motor = { powered: bool(motorArr[0], true), speed: num(motorArr[1]) / 10, direction: Math.sign(num(motorArr[2], 1)) || 1 };
body.rail = railArr ? { axisAngle: num(railArr[0]) / 1000, travel: Math.max(24, num(railArr[1], 1500) / 10), phase: clamp(num(railArr[2]) / 1000, -1, 1), anchorX: num(railArr[3], item.x), anchorY: num(railArr[4], item.y) } : null;
body.shape = { model: "segments", thickness: Math.max(4, Math.min(34, num(shapeArr[0], item.type === "poison_block" ? 110 : 120) / 10)), segments: cloneSegments(shapeArr[1], defaultSegments(item.type)), version: 0 };
body.collision = { solid: collArr[0] !== 0, hazard: collArr[1] !== 0 };
body.hazard = hazardArr ? { kind: hazardArr[0] || "poison", damage: Math.max(1, num(hazardArr[1], 70) / 10) } : null;
body.mass = Math.max(0.2, num(p.mass, Math.round(num(body.mass, 1) * 10)) / 10);
body.inertia = Math.max(1, num(p.inertia, body.inertia));
item.physicsBody = normalizeBody(body, item);
applyPoseToItem(item);
return true;
}
function restoreConstraintExtra(item, extra, tarinaiList = [], itemList = []) {
const p = extra?.constraint;
if (!item || !p || !isConstraintType(item)) return false;
const epFromSave = ep => {
if (!ep) return null;
const kind = ep.kind || "item";
const refIdx = Number(ep.refIdx);
const indexedTarget = Number.isInteger(refIdx) && refIdx >= 0 ? (kind === "tarinai" ? tarinaiList[refIdx] : itemList[refIdx]) : null;
return endpointToPlain({ kind, id: indexedTarget?.id || ep.id || "", type: ep.type || indexedTarget?.type || "", label: ep.label || indexedTarget?.name || "", liveToken: ep.token ?? indexedTarget?.liveToken ?? null, _ref: indexedTarget || null, localX: num(ep.lx), localY: num(ep.ly), center: !!ep.center, x: num(ep.x, item.x || 0), y: num(ep.y, item.y || 0), attachT: ep.t == null ? null : clamp(num(ep.t) / 1000, 0, 1), portId: String(ep.port || ""), fuzzyResolve: !indexedTarget && !(Number.isInteger(refIdx) && refIdx >= 0) });
};
const midArr = Array.isArray(p.mid) ? p.mid : [];
item.physicsConstraint = normalizeConstraint({ type: item.type, kind: p.kind || (item.type === "rope" ? "flexible-distance" : (item.type === "rod" ? "rigid-distance" : (item.type === "spring" ? "spring-distance" : "signal-wire"))), endpoints: [epFromSave(p.endpoints?.[0]), epFromSave(p.endpoints?.[1])], length: Math.max(24, num(p.length, 80)), mid: { x: num(midArr[0], item.x || 0), y: num(midArr[1], item.y || 0), vx: num(midArr[2]) / 10, vy: num(midArr[3]) / 10 }, sleep: { awakeUntil: 0 }, particles: null }, item);
return true;
}
function applyCompactExtra(item, extra, tarinaiList = [], itemList = []) {
if (!item || !extra || typeof extra !== "object" || extra.pf !== 2) return false;
if (isBodyType(item)) return restoreBodyExtra(item, extra);
if (isConstraintType(item)) return restoreConstraintExtra(item, extra, tarinaiList, itemList);
return false;
}
function invalidateItem(item, reason = "physics-edited") {
if (!item) return false;
if (isBodyType(item)) {
ensureBody(item, item.world || null, { rebuild: false, syncFromLegacy: false });
global.TarinaiMechanicalSystem.invalidateGeometry(item);
return true;
}
if (isConstraintType(item)) {
ensureConstraint(item, item.world || null, { rebuild: false, syncFromLegacy: false });
item._linkLastStamp = "";
return true;
}
return false;
}
// Schema normalization entry points.
const normalizeBodyState = (item, body = item?.physicsBody) => { if (item && body) { item.physicsBody = normalizeBody(body, item); return item.physicsBody; } return null; };
const applyBodyState = (item, body = item?.physicsBody, opts = {}) => { if (item && body) { item.physicsBody = normalizeBody(body, item); if (opts.invalidateShape === true) global.TarinaiMechanicalSystem.invalidateGeometry(item); return applyPoseToItem(item); } return false; };
const normalizeConstraintState = (item, c = item?.physicsConstraint) => { if (item && c) { item.physicsConstraint = normalizeConstraint(c, item); return item.physicsConstraint; } return null; };
const applyConstraintState = (item, c = item?.physicsConstraint) => { if (item && c) { item.physicsConstraint = normalizeConstraint(c, item); return true; } return false; };
const bodyApi = Object.freeze({
isBodyType, isConstraintType, isPhysicsType,
ensureBody, ensureConstraint,
normalizeBodyState, applyBodyState, normalizeConstraintState, applyConstraintState,
compactItemExtra, applyCompactExtra,
invalidateItem,
scalar, setScalar, segments, setSegments, syncPoseFromItem,
endpoint, setEndpoint, linkScalar, setLinkScalar, particles, setParticles,
});
global.TarinaiPhysicsBodySystem = bodyApi;
global.TarinaiPhysicsWorldSystem = Object.freeze({ updateWorld });
})(typeof window !== "undefined" ? window : globalThis);