586 lines
21 KiB
JavaScript
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 };
|