/* 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 }); } } } }; });