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