Fixes applied and verified (Hermies Qwen, RC-0D193E): - Rubber band no longer tracks the bird after launch (bug #14, renderer.js) - Launch direction fixed: toWorld divides by camera zoom (bug #15, main.js) - Balance retune: stone hp 3400->1400, dmgFactor 0.35->0.8, plus an enemy-hit damage floor so soft lobs still kill (core.js) - New test/balance.test.mjs: RED on pre-fix constants, GREEN after Suite: 27/27 headless + 10/10 real-browser (real mouse events, real canvas). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
658 lines
23 KiB
JavaScript
658 lines
23 KiB
JavaScript
/* ISLE SLINGERS — core.js
|
|
* Pure, deterministic game core: physics, rules, scoring, events.
|
|
* No DOM, no audio, no Math.random — the renderer consumes state.events
|
|
* for visual-only effects. Loadable in browser (window.ISLE_CORE) or node.
|
|
*/
|
|
(function (root, factory) {
|
|
var api = factory();
|
|
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
|
else root.ISLE_CORE = api;
|
|
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
|
'use strict';
|
|
|
|
var WORLD = { w: 2400, ground: 900 };
|
|
var VIEW = { w: 1280, h: 720 }; // base viewport (renderer renders at this size,
|
|
// then CSS-scales; camera math lives in the core)
|
|
|
|
var MATERIALS = {
|
|
wood: { hp: 1300, density: 1.0, restitution: 0.18, dmgFactor: 0.9, score: 500, maxVel: 520 },
|
|
ice: { hp: 480, density: 0.7, restitution: 0.05, dmgFactor: 1.6, score: 800, maxVel: 560 },
|
|
// stone: retuned 2026-09-28 (RC-0D193E, Commander bug #3 "blocks and pig
|
|
// bad guys too hard to kill"): at hp 3400 with dmgFactor 0.35 a 70x70
|
|
// cube took ~11 full-power direct hits — stone walls were functionally
|
|
// unbreakable, so every enemy behind one was unkillable. Now ~2-3 hits.
|
|
stone: { hp: 1400, density: 2.2, restitution: 0.04, dmgFactor: 0.8, score: 300, maxVel: 480 }
|
|
};
|
|
|
|
var BIRD_TYPES = {
|
|
wren: { r: 20, mass: 3.0, label: 'Wren', ability: 'fury' }, // tap: horizontal burst
|
|
zippy: { r: 17, mass: 2.2, label: 'Zippy', ability: 'dive' }, // tap: steep dive
|
|
boulder: { r: 26, mass: 6.5, label: 'Boulder', ability: 'quake' } // tap: radial shock
|
|
};
|
|
|
|
var ENEMY_TYPES = {
|
|
mogg: { hp: 1, r: 24, score: 5000, crushVel: 330, crushDmg: 1.2 },
|
|
grum: { hp: 2, r: 32, score: 8000, crushVel: 380, crushDmg: 1.0 },
|
|
king: { hp: 3, r: 38, score: 15000, crushVel: 430, crushDmg: 0.85 }
|
|
};
|
|
|
|
var GRAVITY = 1500;
|
|
var LAUNCH_POWER = 11.5; // velocity per px of pull
|
|
var MAX_PULL = 150;
|
|
var MIN_PULL = 14;
|
|
var AIR_DRAG = 0.999;
|
|
var BIRD_BOUNCE = 0.34;
|
|
var BIRD_GROUND_FRICTION = 0.86;
|
|
var REST_SPEED = 14; // below this a bird counts as settling
|
|
var SETTLE_TIME = 0.9; // seconds under REST_SPEED before bird is spent
|
|
var BIRD_MAX_AGE = 12;
|
|
var ABILITY_CD = 0.35; // min seconds after launch before ability usable
|
|
var BIRD_BONUS = 10000;
|
|
|
|
// ------------------------------------------------------------------ state
|
|
|
|
function createGame(levelDef) {
|
|
var g = {
|
|
level: levelDef,
|
|
world: { w: WORLD.w, ground: WORLD.ground },
|
|
time: 0,
|
|
score: 0,
|
|
birds: [],
|
|
blocks: [],
|
|
enemies: [],
|
|
events: [], // visual/audio events for the renderer
|
|
scorePopups: [], // {x,y,text,age}
|
|
hadEnemies: levelDef.enemies.length > 0,
|
|
camera: { x: levelDef.sling.x + 260, y: 560, zoom: 1, shake: 0, snap: true },
|
|
status: 'ready', // ready | flying | won | lost
|
|
activeBird: -1,
|
|
launched: 0,
|
|
wonAt: null,
|
|
lostAt: null,
|
|
stats: { impacts: 0, destroyedBlocks: 0, destroyedEnemies: 0, abilityUses: 0, distance: 0 }
|
|
};
|
|
g.blocks = levelDef.blocks.map(function (b, i) {
|
|
var m = MATERIALS[b.mat];
|
|
return {
|
|
id: i, x: b.x, y: b.y, w: b.w, h: b.h, mat: b.mat,
|
|
hp: m.hp, maxHp: m.hp,
|
|
vx: 0, vy: 0, rest: 1,
|
|
mass: m.density * b.w * b.h / 1400
|
|
};
|
|
});
|
|
g.enemies = levelDef.enemies.map(function (e, i) {
|
|
var t = ENEMY_TYPES[e.kind];
|
|
// level data y is the CENTER (the level-integrity test treats it the
|
|
// same way); spawning at e.y - r made every enemy drop 24-38px on
|
|
// load — a visible "objects moving on their own" wobble at start
|
|
return {
|
|
id: i, kind: e.kind, x: e.x, y: e.y, r: t.r,
|
|
hp: t.hp, maxHp: t.hp, vx: 0, vy: 0, rest: 1, dead: false, deathAge: 0
|
|
};
|
|
});
|
|
var i;
|
|
for (i = 0; i < levelDef.birds.length; i++) {
|
|
g.birds.push(makeBird(levelDef.birds[i], i));
|
|
}
|
|
g.activeBird = g.birds.length ? 0 : -1;
|
|
if (g.activeBird >= 0) {
|
|
var fb = g.birds[0];
|
|
fb.state = 'aiming';
|
|
fb.x = levelDef.sling.x;
|
|
fb.y = levelDef.sling.y;
|
|
}
|
|
return g;
|
|
}
|
|
|
|
function makeBird(kind, idx) {
|
|
var t = BIRD_TYPES[kind];
|
|
return {
|
|
id: idx, kind: kind, r: t.r, mass: t.mass,
|
|
x: 0, y: 0, vx: 0, vy: 0,
|
|
state: 'queued', // queued | aiming | flying | spent
|
|
abilityUsed: false,
|
|
settleTimer: 0, age: 0,
|
|
launchT: 0
|
|
};
|
|
}
|
|
|
|
function activeBird(g) { return g.activeBird >= 0 ? g.birds[g.activeBird] : null; }
|
|
|
|
// ---------------------------------------------------------------- aiming
|
|
|
|
// Returns {vx,vy,pull} if a valid launch for the given drag, else null.
|
|
function aimVector(g, sling, drag) {
|
|
var dx = sling.x - drag.x, dy = sling.y - drag.y;
|
|
var len = Math.sqrt(dx * dx + dy * dy);
|
|
if (len < MIN_PULL) return null;
|
|
var cl = Math.min(len, MAX_PULL);
|
|
var v = cl * LAUNCH_POWER;
|
|
return { vx: (dx / len) * v, vy: (dy / len) * v, pull: cl };
|
|
}
|
|
|
|
// Pure trajectory preview: identical integrator + substepping as the live
|
|
// sim (2x 1/120 per output point), so the dotted preview matches the real
|
|
// flight exactly. Gravity + drag only; stops at the ground.
|
|
function trajectory(g, sx, sy, vx, vy, steps) {
|
|
var pts = [];
|
|
var x = sx, y = sy, i, sub;
|
|
for (i = 0; i < steps; i++) {
|
|
for (sub = 0; sub < 2; sub++) {
|
|
var dt = 1 / 120;
|
|
var nvy = vy + GRAVITY * dt;
|
|
x += vx * dt;
|
|
y += (vy + nvy) * 0.5 * dt;
|
|
vx *= AIR_DRAG;
|
|
vy = nvy;
|
|
}
|
|
if (y > WORLD.ground - 18) { pts.push({ x: x, y: WORLD.ground - 18, last: true }); break; }
|
|
pts.push({ x: x, y: y, last: false });
|
|
}
|
|
return pts;
|
|
}
|
|
|
|
function launch(g, dragPos) {
|
|
var b = activeBird(g);
|
|
if (!b || b.state !== 'aiming' || g.status !== 'ready') return false;
|
|
var v = aimVector(g, { x: g.level.sling.x, y: g.level.sling.y }, dragPos);
|
|
if (!v) return false;
|
|
b.x = g.level.sling.x; b.y = g.level.sling.y;
|
|
b.vx = v.vx; b.vy = v.vy;
|
|
b.state = 'flying';
|
|
b.launchT = g.time;
|
|
g.status = 'flying';
|
|
g.launched++;
|
|
g.events.push({ type: 'launch', x: b.x, y: b.y });
|
|
return true;
|
|
}
|
|
|
|
function triggerAbility(g) {
|
|
var b = activeBird(g);
|
|
if (!b || b.state !== 'flying' || b.abilityUsed) return false;
|
|
if (g.time - b.launchT < ABILITY_CD) return false;
|
|
var kind = b.kind;
|
|
if (kind === 'wren') {
|
|
b.vx = b.vx * 0.4 + 980;
|
|
g.events.push({ type: 'ability_fury', x: b.x, y: b.y });
|
|
} else if (kind === 'zippy') {
|
|
b.vy = Math.max(b.vy, 0) + 1050;
|
|
b.vx *= 0.55;
|
|
g.events.push({ type: 'ability_dive', x: b.x, y: b.y });
|
|
} else if (kind === 'boulder') {
|
|
var R = 150;
|
|
var i, d, f, ent;
|
|
for (i = 0; i < g.blocks.length; i++) {
|
|
var bl = g.blocks[i];
|
|
if (bl.hp <= 0) continue;
|
|
d = Math.hypot(bl.x - b.x, bl.y - b.y) - (bl.w + bl.h) / 4;
|
|
if (d < R) { f = 1 - Math.max(0, d) / R; damageBlock(g, bl, 120 + 300 * f, 'quake'); }
|
|
}
|
|
for (i = 0; i < g.enemies.length; i++) {
|
|
ent = g.enemies[i];
|
|
if (ent.dead) continue;
|
|
d = Math.hypot(ent.x - b.x, ent.y - b.y) - ent.r;
|
|
if (d < R) { f = 1 - Math.max(0, d) / R; damageEnemy(g, ent, 0.7 + 1.6 * f); }
|
|
}
|
|
g.camera.shake = Math.max(g.camera.shake, 14);
|
|
g.events.push({ type: 'ability_quake', x: b.x, y: b.y, r: R });
|
|
}
|
|
b.abilityUsed = true;
|
|
g.stats.abilityUses++;
|
|
return true;
|
|
}
|
|
|
|
// ---------------------------------------------------------------- damage
|
|
|
|
function damageBlock(g, bl, dmg, src) {
|
|
if (bl.hp <= 0) return;
|
|
bl.hp -= dmg;
|
|
if (bl.hp <= 0) {
|
|
bl.hp = 0;
|
|
g.score += MATERIALS[bl.mat].score;
|
|
g.stats.destroyedBlocks++;
|
|
g.scorePopups.push({ x: bl.x, y: bl.y, text: '+' + MATERIALS[bl.mat].score, age: 0 });
|
|
g.events.push({ type: 'break', x: bl.x, y: bl.y, mat: bl.mat, big: bl.w * bl.h > 12000 });
|
|
g.camera.shake = Math.max(g.camera.shake, 4 + bl.w * bl.h / 4000);
|
|
} else if (dmg > 40) {
|
|
g.events.push({ type: 'crack', x: bl.x, y: bl.y, mat: bl.mat });
|
|
}
|
|
}
|
|
|
|
function damageEnemy(g, e, dmg) {
|
|
if (e.dead) return;
|
|
e.hp -= dmg;
|
|
if (e.hp <= 0) {
|
|
e.dead = true;
|
|
e.deathAge = 0;
|
|
g.score += ENEMY_TYPES[e.kind].score;
|
|
g.stats.destroyedEnemies++;
|
|
g.scorePopups.push({ x: e.x, y: e.y - 30, text: '+' + ENEMY_TYPES[e.kind].score, age: 0 });
|
|
g.events.push({ type: 'pop', x: e.x, y: e.y, kind: e.kind });
|
|
g.camera.shake = Math.max(g.camera.shake, 7);
|
|
}
|
|
}
|
|
|
|
// ----------------------------------------------------------------- step
|
|
|
|
function step(g, dt) {
|
|
g.time += dt;
|
|
if (g.status === 'won' || g.status === 'lost') {
|
|
// let physics settle for a beat so the win/lose screen lands naturally
|
|
stepPhysics(g, dt);
|
|
updatePopups(g, dt);
|
|
decayCamera(g, dt);
|
|
return;
|
|
}
|
|
stepPhysics(g, dt);
|
|
updatePopups(g, dt);
|
|
decayCamera(g, dt);
|
|
updateCameraTarget(g);
|
|
checkEndStates(g);
|
|
}
|
|
|
|
function stepPhysics(g, dt) {
|
|
var i, b;
|
|
|
|
// active bird flight
|
|
b = activeBird(g);
|
|
if (b && b.state === 'flying') {
|
|
b.age += dt;
|
|
var nvy = b.vy + GRAVITY * dt;
|
|
b.x += b.vx * dt;
|
|
b.y += (b.vy + nvy) * 0.5 * dt;
|
|
b.vx *= AIR_DRAG;
|
|
b.vy = nvy;
|
|
g.stats.distance = Math.max(g.stats.distance, b.x);
|
|
collideBirdWorld(g, b);
|
|
collideBirdBlocks(g, b);
|
|
collideBirdEnemies(g, b);
|
|
// settle / spend detection (low speed anywhere = spent, whether on
|
|
// the ground or perched on a beam; jitter-tolerant accumulation)
|
|
var sp = Math.hypot(b.vx, b.vy);
|
|
if (sp < REST_SPEED) b.settleTimer += dt;
|
|
else b.settleTimer = Math.max(0, b.settleTimer - dt * 4);
|
|
if (b.settleTimer > SETTLE_TIME || b.age > BIRD_MAX_AGE || b.x > WORLD.w + 200 || b.x < -200) {
|
|
spendBird(g, b);
|
|
}
|
|
}
|
|
|
|
// blocks: gravity + integration + collisions
|
|
for (i = 0; i < g.blocks.length; i++) {
|
|
var bl = g.blocks[i];
|
|
if (bl.hp <= 0) continue;
|
|
bl.vy = Math.min(bl.vy + GRAVITY * dt, MATERIALS[bl.mat].maxVel);
|
|
bl.vx *= 0.995;
|
|
bl.x += bl.vx * dt;
|
|
bl.y += bl.vy * dt;
|
|
// ground
|
|
var halfH = bl.h / 2;
|
|
if (bl.y + halfH > WORLD.ground) {
|
|
var impact = bl.vy;
|
|
bl.y = WORLD.ground - halfH;
|
|
if (impact > 90) {
|
|
bl.vy = -impact * MATERIALS[bl.mat].restitution;
|
|
bl.vx *= 0.8;
|
|
if (impact > 240) {
|
|
damageBlock(g, bl, (impact - 240) * 0.45, 'ground');
|
|
g.events.push({ type: 'block_impact', x: bl.x, y: WORLD.ground, mat: bl.mat, strength: impact });
|
|
}
|
|
bl.rest = 0;
|
|
} else {
|
|
bl.vy = 0;
|
|
bl.vx *= 0.82; // rolling friction
|
|
if (Math.abs(bl.vx) < 4) { bl.vx = 0; bl.rest = 1; }
|
|
}
|
|
}
|
|
// walls (keep the mess on the island)
|
|
if (bl.x - bl.w / 2 < 0) { bl.x = bl.w / 2; bl.vx = Math.abs(bl.vx) * 0.2; }
|
|
if (bl.x + bl.w / 2 > WORLD.w) { bl.x = WORLD.w - bl.w / 2; bl.vx = -Math.abs(bl.vx) * 0.2; }
|
|
}
|
|
// block vs block (positional + 1D impulse, 2 relaxation passes)
|
|
var pass, j, a, c;
|
|
for (pass = 0; pass < 2; pass++) {
|
|
for (i = 0; i < g.blocks.length; i++) {
|
|
a = g.blocks[i];
|
|
if (a.hp <= 0) continue;
|
|
for (j = i + 1; j < g.blocks.length; j++) {
|
|
c = g.blocks[j];
|
|
if (c.hp <= 0) continue;
|
|
resolveBoxPair(g, a, c);
|
|
}
|
|
}
|
|
}
|
|
// block vs bird (crush)
|
|
if (b && b.state === 'flying') {
|
|
for (i = 0; i < g.blocks.length; i++) {
|
|
var blk = g.blocks[i];
|
|
if (blk.hp <= 0) continue;
|
|
crushCircleVsBox(g, blk, b, false);
|
|
}
|
|
}
|
|
|
|
// enemies: settle on ground/blocks, take crush damage
|
|
for (i = 0; i < g.enemies.length; i++) {
|
|
var e = g.enemies[i];
|
|
if (e.dead) { e.deathAge += dt; continue; }
|
|
e.vy = Math.min(e.vy + GRAVITY * dt, 700);
|
|
e.x += e.vx * dt;
|
|
e.y += e.vy * dt;
|
|
e.vx *= 0.9;
|
|
if (e.y + e.r > WORLD.ground) {
|
|
if (e.vy > 420) { e.vy = -e.vy * 0.25; e.vx *= 0.6; }
|
|
else { e.vy = 0; e.y = WORLD.ground - e.r; e.vx *= 0.8; }
|
|
}
|
|
for (j = 0; j < g.blocks.length; j++) {
|
|
var bb = g.blocks[j];
|
|
if (bb.hp <= 0) continue;
|
|
crushCircleVsBox(g, bb, e, true);
|
|
}
|
|
}
|
|
}
|
|
|
|
function crushCircleVsBox(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: fast falling boxes hurt what they land on
|
|
var spec = ENEMY_TYPES[ent.kind];
|
|
var boxSpeed = Math.hypot(box.vx || 0, box.vy || 0);
|
|
if (boxSpeed > spec.crushVel && ny < -0.3) {
|
|
damageEnemy(g, ent, (boxSpeed - spec.crushVel) * 0.004 * spec.crushDmg + 0.35);
|
|
g.events.push({ type: 'crush', x: ent.x, y: ent.y });
|
|
}
|
|
} else {
|
|
// bird: transfer momentum into the box
|
|
var dmg = relSpeed * 0.32 * (ent.mass || 3) * 0.25;
|
|
if (dmg > 25) 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;
|
|
}
|
|
}
|
|
|
|
function collideBirdWorld(g, b) {
|
|
if (b.y + b.r > WORLD.ground) {
|
|
var impact = -b.vy;
|
|
b.y = WORLD.ground - b.r;
|
|
if (b.vy > 0) b.vy = -b.vy * BIRD_BOUNCE;
|
|
b.vx *= BIRD_GROUND_FRICTION;
|
|
if (impact > 250) {
|
|
g.events.push({ type: 'bird_ground', x: b.x, y: 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; }
|
|
}
|
|
|
|
function collideBirdBlocks(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 + 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) * MATERIALS[bl.mat].dmgFactor;
|
|
if (dmg > 30) {
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
|
|
function collideBirdEnemies(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 (RC-0D193E, Commander bug #3): 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);
|
|
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.
|
|
var REST_VEL_CLAMP = 30; // |v| below this on a resting contact is zeroed
|
|
|
|
function resolveBoxPair(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 < -REST_VEL_CLAMP) {
|
|
// real impact: inelastic average (blocks do not bounce off each other)
|
|
var avgY = (a.vy + c.vy) / 2;
|
|
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) < 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;
|
|
}
|
|
}
|
|
}
|
|
|
|
function spendBird(g, b) {
|
|
b.state = 'spent';
|
|
b.vx = 0; b.vy = 0;
|
|
g.events.push({ type: 'bird_gone', x: b.x, y: b.y });
|
|
// next bird onto the sling
|
|
var next = -1;
|
|
for (var i = g.activeBird + 1; i < g.birds.length; i++) {
|
|
if (g.birds[i].state === 'queued') { next = i; break; }
|
|
}
|
|
g.activeBird = next;
|
|
if (next >= 0) {
|
|
var nb = g.birds[next];
|
|
nb.state = 'aiming';
|
|
nb.x = g.level.sling.x; nb.y = g.level.sling.y;
|
|
g.status = 'ready';
|
|
g.events.push({ type: 'bird_load', x: nb.x, y: nb.y, kind: nb.kind });
|
|
}
|
|
}
|
|
|
|
function checkEndStates(g) {
|
|
if (g.status === 'won' || g.status === 'lost') return;
|
|
var alive = 0;
|
|
for (var i = 0; i < g.enemies.length; i++) if (!g.enemies[i].dead) alive++;
|
|
if (g.hadEnemies && alive === 0 && g.launched > 0) {
|
|
g.status = 'won';
|
|
g.wonAt = g.time;
|
|
g.score += BIRD_BONUS * birdsRemaining(g);
|
|
g.events.push({ type: 'level_won', x: g.camera.x, y: 400 });
|
|
return;
|
|
}
|
|
var b = activeBird(g);
|
|
var inFlight = false;
|
|
for (var k = 0; k < g.birds.length; k++) if (g.birds[k].state === 'flying') inFlight = true;
|
|
if (!inFlight && !b && g.status === 'flying') {
|
|
// last bird spent, enemies remain
|
|
g.status = 'lost';
|
|
g.lostAt = g.time;
|
|
g.events.push({ type: 'level_lost', x: g.camera.x, y: 400 });
|
|
}
|
|
}
|
|
|
|
function birdsRemaining(g) {
|
|
var n = 0;
|
|
for (var i = 0; i < g.birds.length; i++) if (g.birds[i].state === 'queued') n++;
|
|
return n;
|
|
}
|
|
|
|
function starsFor(g) {
|
|
var th = g.level.stars;
|
|
if (!th) return 0;
|
|
if (g.score >= th[2]) return 3;
|
|
if (g.score >= th[1]) return 2;
|
|
if (g.score >= th[0] || g.status === 'won') return 1;
|
|
return 0;
|
|
}
|
|
|
|
function updatePopups(g, dt) {
|
|
for (var i = g.scorePopups.length - 1; i >= 0; i--) {
|
|
g.scorePopups[i].age += dt;
|
|
g.scorePopups[i].y -= 40 * dt;
|
|
if (g.scorePopups[i].age > 1.4) g.scorePopups.splice(i, 1);
|
|
}
|
|
}
|
|
|
|
function updateCameraTarget(g) {
|
|
var b = activeBird(g);
|
|
var cam = g.camera;
|
|
var tx, ty, tz;
|
|
if (b && b.state === 'flying') {
|
|
// in flight: follow the slinger 1:1 (the bird IS the point of interest)
|
|
tx = Math.max(420, Math.min(WORLD.w - 420, b.x + 220));
|
|
ty = Math.max(380, Math.min(WORLD.ground - 160, b.y + 60));
|
|
tz = 1;
|
|
} else {
|
|
// aiming / waiting: frame the slingshot AND the whole action zone
|
|
// (live blocks + enemies) so launch point and target are always
|
|
// visible at once — zooming out when the level needs it. Without
|
|
// this the sling was visible but the fort was off-screen (or
|
|
// vice-versa mid-flight), making aiming pure guesswork.
|
|
var left = g.level.sling.x - 190;
|
|
var right = left + 300;
|
|
for (var i = 0; i < g.blocks.length; i++) {
|
|
var bl = g.blocks[i];
|
|
if (bl.hp > 0) right = Math.max(right, bl.x + bl.w / 2 + 90);
|
|
}
|
|
for (var k = 0; k < g.enemies.length; k++) {
|
|
var en = g.enemies[k];
|
|
if (!en.dead) right = Math.max(right, en.x + en.r + 90);
|
|
}
|
|
var span = right - left;
|
|
tz = Math.min(1, VIEW.w / span);
|
|
tx = left + span / 2;
|
|
// ground at ~78% down the view, whatever the zoom
|
|
ty = Math.max(300, Math.min(WORLD.ground, WORLD.ground - (0.28 * VIEW.h) / tz));
|
|
}
|
|
if (cam.snap) { // first frame of a level: land on target, no pan
|
|
cam.x = tx; cam.y = ty; cam.zoom = tz; cam.snap = false;
|
|
return;
|
|
}
|
|
cam.x += (tx - cam.x) * 0.06;
|
|
cam.y += (ty - cam.y) * 0.06;
|
|
cam.zoom = (cam.zoom || 1) + (tz - (cam.zoom || 1)) * 0.08;
|
|
}
|
|
|
|
function decayCamera(g, dt) {
|
|
if (g.camera.shake > 0) {
|
|
g.camera.shake *= Math.pow(0.03, dt); // fast decay
|
|
if (g.camera.shake < 0.3) g.camera.shake = 0;
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------------ API
|
|
|
|
return {
|
|
WORLD: WORLD,
|
|
VIEW: VIEW,
|
|
MATERIALS: MATERIALS,
|
|
BIRD_TYPES: BIRD_TYPES,
|
|
ENEMY_TYPES: ENEMY_TYPES,
|
|
GRAVITY: GRAVITY,
|
|
MAX_PULL: MAX_PULL,
|
|
MIN_PULL: MIN_PULL,
|
|
LAUNCH_POWER: LAUNCH_POWER,
|
|
BIRD_BONUS: BIRD_BONUS,
|
|
createGame: createGame,
|
|
activeBird: activeBird,
|
|
aimVector: aimVector,
|
|
trajectory: trajectory,
|
|
launch: launch,
|
|
triggerAbility: triggerAbility,
|
|
step: step,
|
|
starsFor: starsFor,
|
|
birdsRemaining: birdsRemaining,
|
|
AIR_DRAG: AIR_DRAG
|
|
};
|
|
});
|