// Angry Birds — game rules over the rigid-body solver. Zero imports besides // the physics module, no Phaser, no DOM, no timers. Shared by the client // scene, the level editor, the offline generator and the verifier. // // ── WHAT MAKES IT READ AS ANGRY BIRDS ─────────────────────────────────────── // Not the birds. The two things that actually matter are: // // * MATERIALS WITH DISTINCT DENSITY. Wood, stone and ice differ in mass, // friction and fragility, so a structure has a load path and a support // beam worth aiming at. Uniform blocks turn every level into "hit it // harder". // * DAMAGE AS A HEALTH MODEL, NOT INSTANT SHATTERING. A block accumulates // damage from impacts above its threshold and cracks through visible // stages before breaking. Pigs take damage the same way, which is why // they die to DEBRIS LANDING ON THEM rather than only to direct hits — // the collapse doing the killing is the whole feel of the game. Clones // that destroy a block on any contact feel wrong immediately. // // ── UNITS ─────────────────────────────────────────────────────────────────── // Pixels and seconds in the level's own logical space (level.world). The scene // maps that to the 1920x1080 canvas with a camera; nothing in here knows about // the screen. // // Impulse units: contact impulses are mass x velocity in the solver's units, // where a 24x80 wood plank masses ~1050. A resting contact in a tall stack // runs a few 1e4; a solid bird hit runs 1e6+. Damage thresholds live in that // space — see calibrateDamage() in tools/verifyAngryBirds.js, which pins the // separation between "resting" and "hit" so a tower can never crush itself. import { PHYS, createWorld, addBox, addCircle, removeBody, substep, isSettled, applyExplosion, cloneWorld, hashWorld, wakeBody, } from './AngryBirdsPhysics.js'; export const TUNING = { GROUND_Y: 900, SLING_X: 260, SLING_Y: 700, SLING_HEIGHT: 150, // fork height above the ground contact MAX_DRAW: 150, // px the band can be pulled back SPEED_PER_DRAW: 13.5, // draw px -> launch px/s (150 * 13.5 = 2025) MIN_DRAW: 12, // below this a release is treated as a cancel MAX_SHOT_TIME: 12, // s before a shot is force-resolved SETTLE_GRACE: 0.35, // s of stillness required after the world sleeps DESPAWN_X: -400, // bodies past these bounds are removed DESPAWN_X_MAX: 4200, DESPAWN_Y: 1600, DAMAGE_SCALE: 1 / 9000, // impulse above threshold -> hp DEBRIS_MIN_IMPULSE: 60000, }; // hp/threshold are in solver impulse units (see the header note). export const MATERIALS = { wood: { id: 'wood', density: 0.55, friction: 0.55, restitution: 0.10, hp: 42, threshold: 150000, points: 500, }, stone: { id: 'stone', density: 1.35, friction: 0.62, restitution: 0.05, hp: 105, threshold: 320000, points: 1000, }, ice: { id: 'ice', density: 0.32, friction: 0.18, restitution: 0.14, hp: 18, threshold: 90000, points: 300, }, // TNT is deliberately fragile: it is a trigger, not a wall. tnt: { id: 'tnt', density: 0.6, friction: 0.5, restitution: 0.08, hp: 10, threshold: 70000, points: 800, blast: { radius: 260, power: 1500, damage: 60 }, }, }; export const PIG = { density: 0.5, friction: 0.5, restitution: 0.25, hp: 26, threshold: 80000, points: 5000, }; export const BIRDS = { red: { id: 'red', name: 'Red', r: 17, density: 2.2, restitution: 0.35, ability: 'none', blurb: 'No trick. Just a bird.', }, chuck: { id: 'chuck', name: 'Chuck', r: 15, density: 1.9, restitution: 0.3, ability: 'dart', params: { mult: 2.15 }, blurb: 'Tap to dart forward at speed. Best against wood.', }, blue: { id: 'blue', name: 'Blue', r: 13, density: 1.5, restitution: 0.35, ability: 'split', params: { count: 3, spreadDeg: 14 }, blurb: 'Tap to split into three. Shreds ice.', }, bomb: { id: 'bomb', name: 'Bomb', r: 21, density: 3.1, restitution: 0.2, ability: 'blast', params: { radius: 240, power: 1400, damage: 45 }, blurb: 'Tap to detonate. Levels stone.', }, matilda: { id: 'matilda', name: 'Matilda', r: 19, density: 2.2, restitution: 0.25, ability: 'egg', params: { radius: 170, power: 900, damage: 30, kick: 620 }, blurb: 'Tap to drop an egg bomb and climb.', }, terence: { id: 'terence', name: 'Terence', r: 27, density: 4.4, restitution: 0.15, ability: 'none', blurb: 'Enormous. Goes through things.', }, hal: { id: 'hal', name: 'Hal', r: 16, density: 2.0, restitution: 0.3, ability: 'boomerang', params: { spin: 26 }, blurb: 'Tap to boomerang back. Hits from behind.', }, bubbles: { id: 'bubbles', name: 'Bubbles', r: 12, density: 1.6, restitution: 0.4, ability: 'inflate', params: { scale: 3.0, push: 520 }, blurb: 'Tap to inflate and shove everything apart.', }, }; export const SCORING = { PIG: PIG.points, BIRD_LEFT: 10000, }; // ── Level construction ────────────────────────────────────────────────────── /** * Build a live state from a level definition. * * Level schema: * { version, level, name, episode, world: {w, h, groundY}, * birds: ['red', 'chuck', ...], * blocks: [{ x, y, w, h, angle?, material }], * pigs: [{ x, y, r }], * terrain?: [{ x, y, w, h, angle? }], // static scenery * stars: [oneStar, twoStar, threeStar] } */ export function createState(level) { const groundY = level.world?.groundY ?? TUNING.GROUND_Y; const world = createWorld(); const state = { level, world, groundY, blocks: new Map(), // bodyId -> { material, hp, maxHp, w, h } pigs: new Map(), // bodyId -> { hp, maxHp, r } birdQueue: (level.birds ?? ['red']).slice(), birdIndex: 0, activeBirds: [], // bodyIds currently in flight abilityUsed: false, phase: 'aim', // aim | flight | resolving | won | lost score: 0, shotTime: 0, settleTimer: 0, trails: [], // completed shot trails, for the aiming ghost liveTrail: [], events: [], }; // Ground: wide enough that nothing rolls off the end of the world. addBox(world, { x: (level.world?.w ?? 1920) / 2, y: groundY + 400, w: 12000, h: 800, isStatic: true, friction: 0.75, restitution: 0.05, }); for (const t of level.terrain ?? []) { addBox(world, { x: t.x, y: t.y, w: t.w, h: t.h, angle: t.angle ?? 0, isStatic: true, friction: t.friction ?? 0.7, restitution: 0.05, }); } for (const b of level.blocks ?? []) { const mat = MATERIALS[b.material] ?? MATERIALS.wood; const body = addBox(world, { x: b.x, y: b.y, w: b.w, h: b.h, angle: b.angle ?? 0, density: mat.density, friction: mat.friction, restitution: mat.restitution, }); body.userData = { kind: 'block', material: mat.id }; state.blocks.set(body.id, { material: mat.id, hp: mat.hp, maxHp: mat.hp, w: b.w, h: b.h }); } for (const p of level.pigs ?? []) { const r = p.r ?? 22; // Bigger pigs are tougher, so a boss pig reads as one at a glance. const scale = r / 22; const body = addCircle(world, { x: p.x, y: p.y, r, density: PIG.density, friction: PIG.friction, restitution: PIG.restitution, }); body.userData = { kind: 'pig' }; const hp = PIG.hp * scale; state.pigs.set(body.id, { hp, maxHp: hp, r }); } return state; } // ── Aiming and launching ──────────────────────────────────────────────────── export function slingAnchor(state) { return { x: state.level.world?.slingX ?? TUNING.SLING_X, y: (state.level.world?.slingY ?? (state.groundY - TUNING.SLING_HEIGHT)), }; } /** Clamp a raw drag point to the band's reach. Returns the clamped point. */ export function clampDraw(state, px, py) { const a = slingAnchor(state); let dx = px - a.x; let dy = py - a.y; const d = Math.hypot(dx, dy); if (d > TUNING.MAX_DRAW) { dx = (dx / d) * TUNING.MAX_DRAW; dy = (dy / d) * TUNING.MAX_DRAW; } return { x: a.x + dx, y: a.y + dy, draw: Math.min(d, TUNING.MAX_DRAW) }; } /** Launch velocity for a drag point — the bird flies OPPOSITE the pull. */ export function drawToVelocity(state, px, py) { const a = slingAnchor(state); const c = clampDraw(state, px, py); return { vx: (a.x - c.x) * TUNING.SPEED_PER_DRAW, vy: (a.y - c.y) * TUNING.SPEED_PER_DRAW, draw: c.draw, }; } export function currentBird(state) { if (state.birdIndex >= state.birdQueue.length) return null; return BIRDS[state.birdQueue[state.birdIndex]] ?? BIRDS.red; } export function birdsRemaining(state) { return Math.max(0, state.birdQueue.length - state.birdIndex); } function spawnBird(state, def, x, y, vx, vy) { const body = addCircle(state.world, { x, y, r: def.r, density: def.density, friction: 0.4, restitution: def.restitution, }); body.userData = { kind: 'bird', bird: def.id }; body.vx = vx; body.vy = vy; state.activeBirds.push(body.id); return body; } /** Fire the queued bird. Returns false if the draw was too short to count. */ export function launch(state, px, py) { if (state.phase !== 'aim') return false; const def = currentBird(state); if (!def) return false; const { vx, vy, draw } = drawToVelocity(state, px, py); if (draw < TUNING.MIN_DRAW) return false; const a = slingAnchor(state); spawnBird(state, def, a.x, a.y, vx, vy); state.birdIndex += 1; state.abilityUsed = false; state.phase = 'flight'; state.shotTime = 0; state.settleTimer = 0; state.liveTrail = [{ x: a.x, y: a.y }]; state.events.push({ t: 'birdLaunched', bird: def.id, vx, vy }); return true; } /** * Fire the in-flight bird's ability. One per shot, as in the original — the * limit is what makes timing the tap a decision rather than a reflex. */ export function useAbility(state) { if (state.phase !== 'flight' || state.abilityUsed) return false; const id = state.activeBirds[0]; const body = state.world.byId.get(id); if (!body) return false; const def = BIRDS[body.userData?.bird] ?? BIRDS.red; if (def.ability === 'none') return false; state.abilityUsed = true; const p = def.params ?? {}; switch (def.ability) { case 'dart': { const sp = Math.hypot(body.vx, body.vy) || 1; body.vx = (body.vx / sp) * sp * p.mult; body.vy = (body.vy / sp) * sp * p.mult; break; } case 'split': { const ang = Math.atan2(body.vy, body.vx); const sp = Math.hypot(body.vx, body.vy); const spread = (p.spreadDeg * Math.PI) / 180; for (let i = 1; i < p.count; i += 1) { const off = (i % 2 === 1 ? 1 : -1) * spread * Math.ceil(i / 2); spawnBird(state, def, body.x, body.y, Math.cos(ang + off) * sp, Math.sin(ang + off) * sp); } break; } case 'blast': { detonate(state, body.x, body.y, p.radius, p.power, p.damage); killBody(state, body); break; } case 'egg': { detonate(state, body.x, body.y + def.r + 8, p.radius, p.power, p.damage); body.vy = -p.kick; // Matilda climbs as the egg drops break; } case 'boomerang': { body.vx = -body.vx * 1.15; body.omega = p.spin; break; } case 'inflate': { // Grow in place and shove neighbours apart. Rebuilding the body is // simpler than mutating radius + mass in the solver, and keeps the // invariant that a body's mass properties never change mid-life. const grown = spawnBird(state, def, body.x, body.y, body.vx, body.vy); grown.userData = { kind: 'bird', bird: def.id, inflated: true }; state.activeBirds = state.activeBirds.filter((b) => b !== body.id); removeBody(state.world, body); const bigger = addCircle(state.world, { x: grown.x, y: grown.y, r: def.r * p.scale, density: def.density * 0.35, friction: 0.5, restitution: def.restitution, }); bigger.userData = { kind: 'bird', bird: def.id, inflated: true }; bigger.vx = grown.vx; bigger.vy = grown.vy; state.activeBirds = state.activeBirds.filter((b) => b !== grown.id); removeBody(state.world, grown); state.activeBirds.unshift(bigger.id); applyExplosion(state.world, bigger.x, bigger.y, def.r * p.scale * 2.2, p.push); break; } default: break; } state.events.push({ t: 'abilityUsed', bird: def.id, x: body.x, y: body.y }); return true; } // ── Damage ────────────────────────────────────────────────────────────────── function crackStage(hp, maxHp) { const f = hp / maxHp; if (f > 2 / 3) return 0; if (f > 1 / 3) return 1; return 2; } function killBody(state, body) { state.activeBirds = state.activeBirds.filter((b) => b !== body.id); state.blocks.delete(body.id); state.pigs.delete(body.id); removeBody(state.world, body); } function detonate(state, x, y, radius, power, damage) { state.events.push({ t: 'explosion', x, y, radius }); const hit = applyExplosion(state.world, x, y, radius, power); for (const { body, falloff } of hit) { const dmg = damage * falloff; if (state.blocks.has(body.id)) damageBlock(state, body, dmg); else if (state.pigs.has(body.id)) damagePig(state, body, dmg); } } function damageBlock(state, body, amount) { const rec = state.blocks.get(body.id); if (!rec) return; const before = crackStage(rec.hp, rec.maxHp); rec.hp -= amount; const after = crackStage(Math.max(rec.hp, 0), rec.maxHp); if (rec.hp <= 0) { const mat = MATERIALS[rec.material]; state.score += mat.points; state.events.push({ t: 'blockDestroyed', id: body.id, material: rec.material, x: body.x, y: body.y }); // TNT takes the neighbourhood with it. Remove it BEFORE detonating so it // cannot damage itself and recurse. const blast = mat.blast; killBody(state, body); if (blast) detonate(state, body.x, body.y, blast.radius, blast.power, blast.damage); return; } if (after !== before) { state.events.push({ t: 'blockCracked', id: body.id, stage: after, x: body.x, y: body.y }); } } function damagePig(state, body, amount) { const rec = state.pigs.get(body.id); if (!rec) return; const before = crackStage(rec.hp, rec.maxHp); rec.hp -= amount; if (rec.hp <= 0) { state.score += SCORING.PIG; state.events.push({ t: 'pigKilled', id: body.id, x: body.x, y: body.y }); killBody(state, body); return; } const after = crackStage(rec.hp, rec.maxHp); if (after !== before) { state.events.push({ t: 'pigHurt', id: body.id, stage: after, x: body.x, y: body.y }); } } // Convert this substep's contact impulses into damage. Reading the solver's // per-contact peak (rather than a body's total) is what lets debris landing on // a pig hurt it exactly as much as the geometry says it should. function applyContactDamage(state) { for (const c of state.world.contactList) { // impactImpulse, never maxImpulse — see the note in Physics.prestep. The // accumulated impulse carries the weight of everything stacked above, so // using it would make tall towers damage themselves at rest. const imp = c.impactImpulse; if (imp <= 0) continue; for (const body of [c.a, c.b]) { const block = state.blocks.get(body.id); if (block) { const mat = MATERIALS[block.material]; if (imp > mat.threshold) damageBlock(state, body, (imp - mat.threshold) * TUNING.DAMAGE_SCALE); continue; } const pig = state.pigs.get(body.id); if (pig && imp > PIG.threshold) { damagePig(state, body, (imp - PIG.threshold) * TUNING.DAMAGE_SCALE); } } } } function despawnStrays(state) { for (const b of [...state.world.bodies]) { if (b.isStatic) continue; if (b.x > TUNING.DESPAWN_X && b.x < TUNING.DESPAWN_X_MAX && b.y < TUNING.DESPAWN_Y) continue; if (state.pigs.has(b.id)) { // A pig that falls off the world still counts as dealt with. state.score += SCORING.PIG; state.events.push({ t: 'pigKilled', id: b.id, x: b.x, y: b.y, offscreen: true }); } killBody(state, b); } } // ── The frame ─────────────────────────────────────────────────────────────── /** * Advance the sim. Returns an ordered event list the renderer replays as FX and * sound — the same contract GooTower and Peggle use. */ export function stepSim(state, dt) { state.events = []; if (state.phase === 'won' || state.phase === 'lost') return state.events; const clamped = Math.min(dt, 0.05); const steps = Math.max(1, Math.round(clamped / PHYS.SUBSTEP_DT)); for (let i = 0; i < steps; i += 1) { substep(state.world, PHYS.SUBSTEP_DT); applyContactDamage(state); } despawnStrays(state); if (state.phase === 'flight' || state.phase === 'resolving') { state.shotTime += clamped; const lead = state.world.byId.get(state.activeBirds[0]); if (lead) state.liveTrail.push({ x: lead.x, y: lead.y }); if (state.pigs.size === 0) { finish(state, true); return state.events; } const still = isSettled(state.world); state.settleTimer = still ? state.settleTimer + clamped : 0; const resolved = state.settleTimer >= TUNING.SETTLE_GRACE || state.shotTime >= TUNING.MAX_SHOT_TIME; if (resolved) { if (state.liveTrail.length > 2) state.trails.push(state.liveTrail); state.liveTrail = []; // Spent birds are cleared so they can't prop a structure up forever. for (const id of [...state.activeBirds]) { const b = state.world.byId.get(id); if (b) killBody(state, b); } state.activeBirds = []; state.events.push({ t: 'shotEnded', score: state.score }); if (state.pigs.size === 0) finish(state, true); else if (birdsRemaining(state) === 0) finish(state, false); else state.phase = 'aim'; } } return state.events; } function finish(state, won) { if (won) { const bonus = birdsRemaining(state) * SCORING.BIRD_LEFT; state.score += bonus; state.phase = 'won'; state.events.push({ t: 'won', score: state.score, birdBonus: bonus, stars: starsFor(state) }); } else { state.phase = 'lost'; state.events.push({ t: 'lost', score: state.score }); } } export function starsFor(state, score = state.score) { const cuts = state.level.stars ?? []; let stars = 0; for (const c of cuts) if (score >= c) stars += 1; return Math.min(3, Math.max(state.phase === 'won' ? 1 : 0, stars)); } export const isWon = (state) => state.phase === 'won'; export const isLost = (state) => state.phase === 'lost'; // ── Determinism helpers ───────────────────────────────────────────────────── export function cloneState(state) { return { ...state, world: cloneWorld(state.world), blocks: new Map([...state.blocks].map(([k, v]) => [k, { ...v }])), pigs: new Map([...state.pigs].map(([k, v]) => [k, { ...v }])), birdQueue: state.birdQueue.slice(), activeBirds: state.activeBirds.slice(), trails: state.trails.map((t) => t.slice()), liveTrail: state.liveTrail.slice(), events: [], }; } export function hashState(state) { let h = hashWorld(state.world); const mixInt = (v) => { h = (Math.imul(h ^ (v | 0), 0x01000193) >>> 0); }; mixInt(state.score); mixInt(state.birdIndex); mixInt(state.pigs.size); mixInt(state.blocks.size); for (const [id, b] of [...state.blocks].sort((p, q) => p[0] - q[0])) { mixInt(id); mixInt(Math.round(b.hp * 1000)); } for (const [id, p] of [...state.pigs].sort((a, b) => a[0] - b[0])) { mixInt(id); mixInt(Math.round(p.hp * 1000)); } return h >>> 0; } /** * Dry-run a shot on a clone and report what it would achieve. This is what the * winnability bot samples over and what the generator measures star thresholds * from; it never touches the live state. */ export function simulateShot(state, px, py, opts = {}) { const sim = cloneState(state); if (!launch(sim, px, py)) return null; const abilityAt = opts.abilityAt ?? -1; let t = 0; let guard = 0; while (sim.phase === 'flight' && guard < 4000) { if (abilityAt >= 0 && t >= abilityAt && !sim.abilityUsed) useAbility(sim); stepSim(sim, 1 / 60); t += 1 / 60; guard += 1; } return { score: sim.score - state.score, pigsKilled: state.pigs.size - sim.pigs.size, blocksDestroyed: state.blocks.size - sim.blocks.size, won: sim.phase === 'won', finalScore: sim.score, state: sim, }; } export { PHYS, wakeBody };