541 lines
30 KiB
JavaScript
541 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"]);
|
|
|
|
function num(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;
|
|
}
|
|
function clamp(value, min, max) {
|
|
return 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 = 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);
|
|
markBodyChanged(item, opts.rebuild === true ? "rebuild" : "init");
|
|
} 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;
|
|
}
|
|
const body = normalizeBody(item.physicsBody, item);
|
|
item.physicsBody = body;
|
|
return body;
|
|
}
|
|
|
|
function markBodyChanged(item, reason = "physics-body") {
|
|
if (!item) return;
|
|
}
|
|
|
|
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))); b.shape.version = (Number(b.shape.version || 0) || 0) + 1; break;
|
|
case "motorOn": b.motor.powered = item.type === "poison_block" ? false : !!value; break;
|
|
case "motorSpeed": b.motor.speed = 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;
|
|
}
|
|
normalizeBody(b, item);
|
|
markBodyChanged(item, reason);
|
|
if (key === "thickness" || key === "solid") global.TarinaiMechanicalSystem.invalidateGeometry(item);
|
|
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));
|
|
b.shape.version = (Number(b.shape.version || 0) || 0) + 1;
|
|
markBodyChanged(item, reason);
|
|
global.TarinaiMechanicalSystem.invalidateGeometry(item);
|
|
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 = 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 = 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,
|
|
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" : "rigid-distance",
|
|
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" ? 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);
|
|
}
|
|
item.physicsConstraint = normalizeConstraint(item.physicsConstraint, 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 collectPhysicsItems(worldRef) {
|
|
const bodies = [];
|
|
const constraints = [];
|
|
if (!worldRef?.itemsOfType) return { bodies, constraints };
|
|
worldRef.ensureItemBuckets?.("physics-world-system");
|
|
for (const type of BODY_TYPES) for (const item of worldRef.itemsOfType(type) || []) if (item && !item.dead) bodies.push(item);
|
|
for (const type of LINK_TYPES) for (const item of worldRef.itemsOfType(type) || []) if (item && !item.dead) constraints.push(item);
|
|
return { bodies, constraints };
|
|
}
|
|
function prepareWorld(worldRef, opts = {}) {
|
|
const { bodies, constraints } = collectPhysicsItems(worldRef);
|
|
for (const item of bodies) ensureBody(item, worldRef, { syncFromLegacy: item?.playerHeld === true || item?._heldByPlayer === true || item?._physicsExternalPoseDirty === true });
|
|
for (const item of constraints) ensureConstraint(item, worldRef, { syncFromLegacy: false });
|
|
const frame = { time: num(worldRef?.time), bodies, constraints, bodyCount: bodies.length, constraintCount: constraints.length, sync: { bodyReads: 0, bodyWrites: 0, constraintReads: 0, constraintWrites: 0 } };
|
|
worldRef._physicsWorldFrame = frame;
|
|
return frame;
|
|
}
|
|
function commitWorld(worldRef, frame = worldRef?._physicsWorldFrame, opts = {}) {
|
|
if (!frame) return { bodies: 0, constraints: 0, moved: 0 };
|
|
let moved = 0;
|
|
for (const item of frame.bodies || []) {
|
|
const before = `${num(item.x).toFixed(3)}:${num(item.y).toFixed(3)}:${num(item.angle).toFixed(5)}`;
|
|
ensureBody(item, worldRef, { syncFromLegacy: false });
|
|
applyPoseToItem(item);
|
|
const after = `${num(item.x).toFixed(3)}:${num(item.y).toFixed(3)}:${num(item.angle).toFixed(5)}`;
|
|
if (before !== after) moved += 1;
|
|
}
|
|
for (const item of frame.constraints || []) ensureConstraint(item, worldRef, { syncFromLegacy: false });
|
|
if (opts.markDirty && moved) worldRef?.markSpatialDirty?.("physics-world-commit");
|
|
return { bodies: frame.bodies?.length || 0, constraints: frame.constraints?.length || 0, moved };
|
|
}
|
|
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, maxSubsteps: opts.maxSubsteps || 5, focusThreshold: opts.focusThreshold || 44 }) || { ran: 0, active: 0 };
|
|
const constraintRan = global.TarinaiConstraintSystem.updateWorld(worldRef, dt, { maxLinks: opts.maxLinks || 220 }) || 0;
|
|
const postConstraintPairs = constraintRan ? (global.TarinaiMechanicalSystem.resolveMechanicalPairs(worldRef, dt, { maxPairs: opts.postMaxPairs || 110 }) || 0) : 0;
|
|
const committed = commitWorld(worldRef, frame);
|
|
const stats = { ran: (mechanicalStats?.ran || 0) + constraintRan, bodies: frame.bodyCount, constraints: frame.constraintCount, mechanical: mechanicalStats, constraintRan, postConstraintPairs, committed, sync: frame.sync || null };
|
|
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 };
|
|
};
|
|
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), 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" : "rigid-distance"), 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);
|
|
markBodyChanged(item, reason);
|
|
return true;
|
|
}
|
|
if (isConstraintType(item)) {
|
|
ensureConstraint(item, item.world || null, { rebuild: false, syncFromLegacy: false });
|
|
item._linkLastStamp = "";
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
function purgeLegacyPhysicsStorage(item) {
|
|
if (!item || typeof item !== "object") return false;
|
|
// Physical state is schema-only now; remove any data fields left by older experiments.
|
|
const bodyKeys = ["rotator" + "Thickness", "rotator" + "Segments", "rotator" + "Powered", "rotator" + "Speed", "rotator" + "AngularVelocity", "poison" + "CollisionEnabled", "poison" + "Damage", "poison" + "Mass", "poison" + "Inertia", "reciprocator" + "Powered", "reciprocator" + "Speed", "reciprocator" + "Travel", "reciprocator" + "Phase", "reciprocator" + "Direction", "reciprocator" + "AxisAngle", "reciprocator" + "Velocity", "reciprocator" + "AnchorX", "reciprocator" + "AnchorY", "_physics" + "AwakeUntil"];
|
|
const linkKeys = ["link" + "A", "link" + "B", "link" + "Length", "link" + "MidX", "link" + "MidY", "link" + "MidVX", "link" + "MidVY", "_link" + "AwakeUntil", "rope" + "Particles"];
|
|
for (const key of bodyKeys.concat(linkKeys)) { try { delete item[key]; } catch (_) {} }
|
|
if (isBodyType(item)) ensureBody(item, item.world || null, { syncFromLegacy: false });
|
|
if (isConstraintType(item)) ensureConstraint(item, item.world || null, { syncFromLegacy: false });
|
|
return true;
|
|
}
|
|
|
|
// 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, markBodyChanged, normalizeConstraintState, applyConstraintState,
|
|
compactItemExtra, applyCompactExtra, purgeLegacyPhysicsStorage,
|
|
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);
|