/* ISLE SLINGERS -- physics/collision/index.js (submodule: collision) * Modularization pass (behavior-neutral split of the original * js/core.js monolith). Installs its units into ONE shared physics scope `P` * that every physics submodule sees -- the same single-scope semantics the * monolith had. Must load before js/physics/index.js (the aggregator that * attaches the public ISLE_CORE API). Surface installed: crushCircleVsBox, collideBirdWorld, collideBirdBlocks, collideBirdEnemies, REST_VEL_CLAMP, resolveBoxPair. */ (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) { P.crushCircleVsBox = function(g, box, ent, isEnemy) { var cx = Math.max(box.x - box.w / 2, Math.min(ent.x, box.x + box.w / 2)); var cy = Math.max(box.y - box.h / 2, Math.min(ent.y, box.y + box.h / 2)); var dx = ent.x - cx, dy = ent.y - cy; var d2 = dx * dx + dy * dy; if (d2 >= ent.r * ent.r) return; var d = Math.sqrt(d2) || 0.001; var nx = dx / d, ny = dy / d; var overlap = ent.r - d; ent.x += nx * overlap * 0.9; ent.y += ny * overlap * 0.9; // impulse: relative velocity along normal var rvx = (ent.vx || 0) - (box.vx || 0), rvy = (ent.vy || 0) - (box.vy || 0); var rel = rvx * nx + rvy * ny; if (rel < -30) { var relSpeed = -rel; ent.vx -= nx * rel * 1.4; ent.vy -= ny * rel * 1.4; if (isEnemy) { // crush damage: a fast box hurts what it lands on. // Added in the physics pass: the direction gate is velocity-aware -- the BOX // must be the body moving into the enemy (boxAlong > 30). The old gate // (ny < -0.3, enemy above the box) only let SIDE plows crush: a block // dropped STRAIGHT DOWN onto an enemy never registered ("falling // objects can't kill"). Vertical drops and side plows share this path. // Two sub-fixes, both watched in test/fallingkill.test.mjs: // (a) per-enemy crush cooldown (0.12s, same idea as the bird-hit // gate): a box resting on an enemy must not machine-gun damage // every frame; one landing = one crush instance. // (b) inelastic transfer: the box sheds its approach velocity into // the enemy (mass-scaled), so a block does not phase through // the enemy at terminal velocity after the hit. var spec = P.ENEMY_TYPES[ent.kind]; var boxSpeed = Math.hypot(box.vx || 0, box.vy || 0); var boxAlong = (box.vx || 0) * nx + (box.vy || 0) * ny; // box velocity toward the enemy if (boxSpeed > spec.crushVel && boxAlong > 30) { if (g.time - (ent.lastCrushT || -9) > 0.12) { P.damageEnemy(g, ent, (boxSpeed - spec.crushVel) * 0.004 * spec.crushDmg + 0.35); g.events.push({ type: 'crush', x: ent.x, y: ent.y }); ent.lastCrushT = g.time; // inelastic: box loses a mass-scaled share of its normal velocity var entM = ent.r / 24; // mogg~1, grum~1.3, king~1.6 var share = entM / ((box.mass || 1) + entM); box.vx -= nx * boxAlong * share; box.vy -= ny * boxAlong * share; } } } else { // bird: transfer momentum into the box var dmg = relSpeed * 0.32 * (ent.mass || 3) * 0.25; if (dmg > 25) P.damageBlock(g, box, dmg, 'bird'); box.vx += nx * rel * 0.05 * (ent.mass || 3); box.vy += ny * rel * 0.05 * (ent.mass || 3); } } else if (rel < 0) { // slow resting contact: remove the normal approach velocity so a // perched enemy/box pair stops oscillating under gravity ent.vx -= nx * rel; ent.vy -= ny * rel; } } P.collideBirdWorld = function(g, b) { if (b.y + b.r > P.WORLD.ground) { var impact = -b.vy; b.y = P.WORLD.ground - b.r; if (b.vy > 0) b.vy = -b.vy * P.BIRD_BOUNCE; b.vx *= P.BIRD_GROUND_FRICTION; if (impact > 250) { g.events.push({ type: 'bird_ground', x: b.x, y: P.WORLD.ground, strength: impact }); g.camera.shake = Math.max(g.camera.shake, 5); } if (Math.abs(b.vy) < 30) b.vy = 0; } if (b.x - b.r < 0) { b.x = b.r; b.vx = Math.abs(b.vx) * 0.3; } } P.collideBirdBlocks = function(g, b) { var i; for (i = 0; i < g.blocks.length; i++) { var bl = g.blocks[i]; if (bl.hp <= 0) continue; var cx = Math.max(bl.x - bl.w / 2, Math.min(b.x, bl.x + bl.w / 2)); var cy = Math.max(bl.y - bl.h / 2, Math.min(b.y, bl.y + bl.h / 2)); var dx = b.x - cx, dy = b.y - cy; var d2 = dx * dx + dy * dy; if (d2 >= b.r * b.r) continue; var d = Math.sqrt(d2) || 0.001; var nx = dx / d, ny = dy / d; var overlap = b.r - d; b.x += nx * overlap; b.y += ny * overlap; var rvx = b.vx - bl.vx, rvy = b.vy - bl.vy; var rel = rvx * nx + rvy * ny; if (rel < 0) { var relSpeed = -rel; // impulse: j = -(1+e)*rel / (1/m1 + 1/m2) var invM = 1 / b.mass + 1 / bl.mass; var j = -(1 + P.BIRD_BOUNCE) * rel / invM; b.vx += (j / b.mass) * nx; b.vy += (j / b.mass) * ny; bl.vx -= (j / bl.mass) * nx; bl.vy -= (j / bl.mass) * ny; g.stats.impacts++; var dmg = relSpeed * 0.55 * (b.mass / 3) * P.MATERIALS[bl.mat].dmgFactor; if (dmg > 30) { P.damageBlock(g, bl, dmg, 'bird'); g.events.push({ type: 'hit', x: cx, y: cy, mat: bl.mat, strength: relSpeed }); g.camera.shake = Math.max(g.camera.shake, Math.min(12, relSpeed / 90)); } b.vx *= 0.985; b.vy *= 0.985; } } } P.collideBirdEnemies = function(g, b) { var i; for (i = 0; i < g.enemies.length; i++) { var e = g.enemies[i]; if (e.dead) continue; var dx = e.x - b.x, dy = e.y - b.y; var rr = e.r + b.r; var d2 = dx * dx + dy * dy; if (d2 >= rr * rr) continue; var d = Math.sqrt(d2) || 0.001; var nx = dx / d, ny = dy / d; var rel = (b.vx - e.vx) * nx + (b.vy - e.vy) * ny; if (rel > 0) { e.vx += nx * rel * 0.8; e.vy += ny * rel * 0.8 - 120; // per-target hit cooldown so one pass-through = one damage instance if (g.time - (e.lastHit || -9) > 0.12) { var speed = Math.hypot(b.vx, b.vy); // retuned 2026-09-28 (playtest finding): the old curve // gave ~0.9 dmg to a soft wren lob — a mogg (hp1) could not be // killed without a perfect full-power hit. Bumped so a decent // medium shot connects; the king (hp3) still survives one // full-power wren hit (pinned in test/sim.test.mjs). var dmg = 0.35 + speed / 800 * (b.mass / 3); P.damageEnemy(g, e, dmg); e.lastHit = g.time; g.events.push({ type: 'bird_hit_enemy', x: e.x, y: e.y, strength: speed }); } b.vx *= 0.92; b.vy *= 0.92; } } } // Resting-contact clamp: inelastic contacts + zeroing below this speed, // so stacked blocks settle to exactly still instead of oscillating under // gravity re-injection. P.REST_VEL_CLAMP = 30; // |v| below this on a resting contact is zeroed P.resolveBoxPair = function(g, a, c) { var ox = (a.w / 2 + c.w / 2) - Math.abs(a.x - c.x); var oy = (a.h / 2 + c.h / 2) - Math.abs(a.y - c.y); if (ox <= 0 || oy <= 0) return; if (ox < oy) { // side contact: push apart along x var n = c.x >= a.x ? 1 : -1; // direction from a toward c a.x -= n * ox * 0.5; c.x += n * ox * 0.5; var relv = (c.vx - a.vx) * n; if (relv < 0) { // approaching var avg = (a.vx + c.vx) / 2; a.vx = avg; c.vx = avg; } a.vx *= 0.9; c.vx *= 0.9; } else { // stacked contact: push apart along y, then kill resting velocity var n2 = c.y >= a.y ? 1 : -1; a.y -= n2 * oy * 0.5; c.y += n2 * oy * 0.5; var relv2 = (c.vy - a.vy) * n2; if (relv2 < -P.REST_VEL_CLAMP) { // real impact: inelastic common velocity. // Added in the physics pass: mass-WEIGHTED (was a plain (a.vy+c.vy)/2), so a // heavy block landing on a light plank transfers its real momentum // through the stack (chain-crush). A 22.6-mass stone at v over a // 7.2-mass plank now drives the plank to ~0.76v instead of 0.5v. var mA2 = a.mass || 1, mC2 = c.mass || 1; var avgY = (a.vy * mA2 + c.vy * mC2) / (mA2 + mC2); a.vy = avgY; c.vy = avgY; } else if (relv2 < 0) { // resting: top takes the bottom's velocity (kills the relative // velocity), then zero both below the clamp so stacks stop // oscillating under gravity re-injection var top = n2 === 1 ? a : c; var bot = n2 === 1 ? c : a; top.vy = bot.vy; if (Math.abs(top.vy) < P.REST_VEL_CLAMP) { top.vy = 0; bot.vy = 0; } } else { // separating / at rest: clamp near-zero velocities if (Math.abs(a.vy) < 2) a.vy = 0; if (Math.abs(c.vy) < 2) c.vy = 0; } } } }; });