Isle-Slingers/js/physics/topple/index.js
Relay dc68c6e3dd Physics upgrade + repo modularization (RC-375D84)
Delivered by Hermies Qwen, gated and deployed by Relay.

- Board tip/topple: full continuous rotation when a board's center of
  mass passes its support edge (Q1: smooth pivot swing, no discrete
  snap). Falling boards deal impact damage and can kill enemies in
  their fall path.
- Falling-object impact damage: blocks and birds can now kill enemies
  from vertical drops and side plows (velocity-aware crush gate),
  chain momentum transfers correctly through stacks (mass-weighted
  contact), repeated impacts finish tougher enemies.
- Landed-bird physics: a bird resting on a board remains a real
  physical body under continuous gravity (Q2: no lifetime timer). If
  its platform is destroyed it falls, damages what it lands on
  (chain reaction), and can kill enemies.
- Repo modularized: folders = modules, subfolders = submodules,
  architecture.md + MODULES.md at root, an agents.md card in every
  module and submodule (17 total).
- Test suite grown from 27 to 47 (20 new RED-watched tests for the
  above behaviors), all green. Golden differential against the
  original core confirmed the only behavioral changes are the
  intended ones.

Live at http://10.244.136.121:8093/, independently re-verified by
Relay (full suite re-run, module structure check, headless Chromium
smoke) before this push.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 13:21:18 +00:00

223 lines
9.8 KiB
JavaScript

/* ISLE SLINGERS -- physics/topple/index.js (submodule: topple)
* RC-375D84 S-3. Boards tip and topple end-over-end (Commander Q1: 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 });
}
}
}
};
});