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>
223 lines
9.8 KiB
JavaScript
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 });
|
|
}
|
|
}
|
|
}
|
|
};
|
|
});
|