fertig-classic-games/src/games/angrybirds/AngryBirdsLogic.js

586 lines
21 KiB
JavaScript

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