Isle-Slingers/js/physics/topple/index.js
Relay dff5dd5b30 Clean release scrub: remove internal fleet references
Removes internal process references so the repo is presentable to an
outside audience: dropped the internal build-retrospective doc
(process log with internal terms, not useful to an outside reader),
and reworded remaining internal references in code comments and
module docs (agents.md cards, architecture.md, MODULES.md,
index.html, package.json) to generic descriptions of what changed
and when, with no behavior change -- full 47/47 suite re-run green
after the scrub, every edit verified comment/doc-only via diff.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 14:36:52 +00:00

223 lines
9.8 KiB
JavaScript

/* ISLE SLINGERS -- physics/topple/index.js (submodule: topple)
* Board tip/topple physics. Boards tip and topple end-over-end (design call: FULL
* CONTINUOUS ROTATION). A resting block whose centre of mass lies OUTSIDE the
* x-span of whatever supports it (ground, or the top face of a block below)
* begins pivoting about the support-edge corner on the side the COM leans to.
* The pivot is a fixed point; the board rotates about it under gravity
* (rigid body, point-mass COM) -- a smooth continuous swing, no snap. When the
* far end strikes the ground it lands: impact speed = angVel * r drives damage
* to the board itself and to anything in the swept path (blocks take impact,
* enemies can be killed). The board then rests at its landed angle.
*
* Adds a single rotational degree of freedom (ang, angVel, pivot) to blocks.
* Blocks that never overhang keep ang=0 forever, so pre-S-3 behaviour is
* exactly preserved for supported stacks (proven by topple.test arm 3).
*
* Installs into the shared physics scope P (same single-scope semantics as the
* monolith). Must load before js/physics/index.js. Surface installed:
* P.toppleBlocks, P.blockSupportSpan
*/
(function (root, factory) {
var part = factory();
if (typeof module !== 'undefined' && module.exports) module.exports = part;
else (root.ISLE_PHYSICS_PARTS = root.ISLE_PHYSICS_PARTS || []).push(part);
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
'use strict';
return function (P) {
// The x-span [lo, hi] that supports the block, built from the ground and the
// top faces of live blocks whose top is at (or just below) this block's
// bottom and which overlap it in x. Returns null if the block is free-
// falling (nothing directly under it).
P.blockSupportSpan = function(g, bl) {
var bottom = bl.y + bl.h / 2;
var lo = null, hi = null;
var i, o, oTop, oBot, oLo, oHi;
// ground support: if the block's bottom sits on the ground line
if (Math.abs(bottom - P.WORLD.ground) < 2 && Math.abs(bl.vy) < P.REST_SPEED) {
lo = bl.x - bl.w / 2; hi = bl.x + bl.w / 2;
}
for (i = 0; i < g.blocks.length; i++) {
o = g.blocks[i];
if (o === bl || o.hp <= 0) continue;
oTop = o.y - o.h / 2; // top face of the support block (unrotated support)
oBot = o.y + o.h / 2;
oLo = o.x - o.w / 2; oHi = o.x + o.w / 2;
// o supports bl when o's top face is within a hair below bl's bottom
if (bottom - oTop > 2 || oTop - bottom > 4) continue; // not touching
if (oBot < bottom - 4) continue;
// x-overlap between bl and o
var ovlLo = Math.max(bl.x - bl.w / 2, oLo);
var ovlHi = Math.min(bl.x + bl.w / 2, oHi);
if (ovlLo >= ovlHi) continue;
lo = (lo === null) ? ovlLo : Math.min(lo, ovlLo);
hi = (hi === null) ? ovlHi : Math.max(hi, ovlHi);
}
if (lo === null || hi === null) return null;
return { lo: lo, hi: hi };
}
P.toppleBlocks = function(g, dt) {
var i, bl, sp, margin = 2;
for (i = 0; i < g.blocks.length; i++) {
bl = g.blocks[i];
if (bl.hp <= 0) continue;
if (bl.pivoting) {
// support destroyed or block removed mid-swing -> free fall, keep angle
if (!P.supportStillAlive(g, bl)) { bl.pivoting = false; bl.angVel = 0; continue; }
P.integrateTopple(g, bl, dt);
continue;
}
// candidate for tipping: must be (nearly) at rest to evaluate support
if (Math.abs(bl.vy) > P.REST_SPEED * 2 || Math.abs(bl.vx) > P.REST_SPEED * 2) continue;
if (bl.toppled) {
// anti-livelock: a swung-and-settled board rests until a REAL knock
// (displacement > 6px from its settled spot, or a damaging hit, which
// clears the flag in damageBlock). Gravity settling never moves it
// that far, so the knife-edge re-tip loop is closed.
if (Math.abs(bl.x - (bl.restX || bl.x)) + Math.abs(bl.y - (bl.restY || bl.y)) >= 6) {
bl.toppled = false;
} else {
continue;
}
}
sp = P.blockSupportSpan(g, bl);
if (!sp) continue; // free-falling or in the air
var comX = bl.x;
if (comX > sp.hi + margin) {
// COM leans right of the support span -> pivot about the support's
// top-right corner (the edge of what's underneath, not the board edge)
P.beginTopple(g, bl, sp.hi, bl.y + bl.h / 2);
} else if (comX < sp.lo - margin) {
P.beginTopple(g, bl, sp.lo, bl.y + bl.h / 2);
}
}
}
P.beginTopple = function(g, bl, px, py) {
bl.pivoting = true;
bl.restX = bl.x; bl.restY = bl.y; // where it sits if the swing settles back
bl.pivotX = px; bl.pivotY = py;
// vector from pivot to COM at the start of the swing
bl.pvx = bl.x - px; bl.pvy = bl.y - py;
bl.ang = 0; bl.angVel = 0;
bl.rest = 0;
g.events.push({ type: 'topple_start', x: bl.x, y: bl.y, mat: bl.mat });
}
P.integrateTopple = function(g, bl, dt) {
var r2 = bl.pvx * bl.pvx + bl.pvy * bl.pvy;
if (r2 < 1e-6) { bl.pivoting = false; return; }
// rotate the pivot->COM vector by the current angle to get the live COM
var c = Math.cos(bl.ang), s = Math.sin(bl.ang);
var comX = bl.pivotX + bl.pvx * c - bl.pvy * s;
var comY = bl.pivotY + bl.pvx * s + bl.pvy * c;
// torque per mass about the pivot: g * (COMx - pivotX); I/m = r2
var alpha = P.GRAVITY * (comX - bl.pivotX) / r2;
bl.angVel += alpha * dt;
bl.ang += bl.angVel * dt;
bl.x = comX; bl.y = comY;
// landing: the far (non-pivot) corners hit the ground line
var hw = bl.w / 2, hh = bl.h / 2;
var cx = [ -hw, hw, hw, -hw ], cy = [ -hh, -hh, hh, hh ];
var landed = false, k, rx0, ry0, rx, ry;
for (k = 0; k < 4; k++) {
// corner vector = (pivot->COM) + (COM->corner), rotated by ang
rx0 = bl.pvx + cx[k]; ry0 = bl.pvy + cy[k];
rx = rx0 * c - ry0 * s;
ry = rx0 * s + ry0 * c;
if (bl.pivotY + ry >= P.WORLD.ground - 0.5) { landed = true; break; }
}
if (landed) { P.landTopple(g, bl); return; }
// settle-back: the pivot is too high for either end to reach the ground,
// so the board is a damped-free pendulum -- when it swings back through
// the flat angle, it lands flat on its support (the physical resolution)
if (bl.ang < 0 && bl.angVel > 0 && bl.ang > -0.06) P.settleBack(g, bl);
else if (bl.ang > 0 && bl.angVel < 0 && bl.ang < 0.06) P.settleBack(g, bl);
}
// true while the block the pivot sits on is still alive (or the pivot is on
// the ground line). Used to detect a support destroyed mid-swing.
P.supportStillAlive = function(g, bl) {
if (Math.abs(bl.pivotY - P.WORLD.ground) < 2) return true;
var i, o, oTop;
for (i = 0; i < g.blocks.length; i++) {
o = g.blocks[i];
if (o === bl || o.hp <= 0) continue;
oTop = o.y - o.h / 2;
if (Math.abs(oTop - bl.pivotY) < 3) {
if (bl.pivotX >= o.x - o.w / 2 - 1 && bl.pivotX <= o.x + o.w / 2 + 1) return true;
}
}
return false;
}
// the board swung back to flat on its support: stop the rotation, rest it
// flat (COM returns to its exact start point, so no position drift)
P.settleBack = function(g, bl) {
bl.angVel = 0;
bl.ang = 0;
bl.pivoting = false;
bl.x = bl.restX; bl.y = bl.restY; // damped pendulum: it sits back where it was
bl.toppled = true; // anti-livelock: don't re-tip until knocked or damaged
bl.vx = 0; bl.vy = 0; bl.rest = 1;
g.events.push({ type: 'topple_settle', x: bl.x, y: bl.y, mat: bl.mat });
}
P.landTopple = function(g, bl) {
// impact speed of the COM = tangential speed about the pivot
var r = Math.hypot(bl.pvx, bl.pvy);
var impact = Math.abs(bl.angVel) * r;
// settle: stop the swing, keep the landed angle (board rests propped/flat)
bl.angVel = 0;
bl.pivoting = false;
bl.vx = 0; bl.vy = 0; bl.rest = 1;
// clamp COM onto the ground line if it dipped below
if (bl.y + bl.h / 2 > P.WORLD.ground) bl.y = P.WORLD.ground - bl.h / 2;
if (impact > 60) {
// the board itself takes impact damage from the slam
P.damageBlock(g, bl, (impact - 60) * 0.35, 'topple');
g.events.push({ type: 'topple_land', x: bl.x, y: bl.y, mat: bl.mat, strength: impact });
g.camera.shake = Math.max(g.camera.shake, Math.min(14, impact / 60));
// anything in the swept path takes it too: check the board's current
// rotated extent against live blocks and enemies
P.topplePathDamage(g, bl, impact);
}
}
P.topplePathDamage = function(g, bl, impact) {
// rotated corners of the landed board
var c = Math.cos(bl.ang), s = Math.sin(bl.ang);
var hw = bl.w / 2, hh = bl.h / 2;
var cx = [ -hw, hw, hw, -hw ], cy = [ -hh, -hh, hh, hh ];
var xs = [], ys = [];
for (var k = 0; k < 4; k++) {
xs.push(bl.x + cx[k] * c - cy[k] * s);
ys.push(bl.y + cx[k] * s + cy[k] * c);
}
var minX = Math.min.apply(null, xs), maxX = Math.max.apply(null, xs);
var minY = Math.min.apply(null, ys), maxY = Math.max.apply(null, ys);
var i, o, spec;
// blocks in the landed footprint
for (i = 0; i < g.blocks.length; i++) {
o = g.blocks[i];
if (o === bl || o.hp <= 0) continue;
if (o.x + o.w / 2 < minX || o.x - o.w / 2 > maxX) continue;
if (o.y + o.h / 2 < minY || o.y - o.h / 2 > maxY) continue;
P.damageBlock(g, o, impact * 0.4 * P.MATERIALS[o.mat].dmgFactor, 'topple');
}
// enemies in the swept footprint can be killed (objective b)
for (i = 0; i < g.enemies.length; i++) {
o = g.enemies[i];
if (o.dead) continue;
if (o.x + o.r < minX || o.x - o.r > maxX) continue;
if (o.y + o.r < minY || o.y - o.r > maxY) continue;
spec = P.ENEMY_TYPES[o.kind];
if (impact > spec.crushVel) {
P.damageEnemy(g, o, (impact - spec.crushVel) * 0.004 * spec.crushDmg + 0.35);
g.events.push({ type: 'crush', x: o.x, y: o.y });
}
}
}
};
});