/* ISLE SLINGERS — renderer.js * Canvas 2D renderer: procedural art, camera, particles, screen shake. * Consumes state.events (visual effects) and reports them via onEvent for audio. */ (function (root, factory) { var api = factory(); if (typeof module !== 'undefined' && module.exports) module.exports = api; else root.ISLE_RENDERER = api; })(typeof globalThis !== 'undefined' ? globalThis : this, function () { 'use strict'; var VIEW_W = 1280, VIEW_H = 720; function mulberry32(a) { return function () { a |= 0; a = a + 0x6D2B79F5 | 0; var t = Math.imul(a ^ a >>> 15, 1 | a); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; return ((t ^ t >>> 14) >>> 0) / 4294967296; }; } function Renderer(canvas, opts) { this.canvas = canvas; this.ctx = canvas.getContext('2d'); this.onEvent = (opts && opts.onEvent) || function () {}; this.particles = []; this.rng = mulberry32(1234); this.fit(); this._onResize = this.fit.bind(this); if (typeof window !== 'undefined') window.addEventListener('resize', this._onResize); } Renderer.prototype.fit = function () { var dpr = (typeof window !== 'undefined' && window.devicePixelRatio) || 1; this.dpr = dpr; this.scale = 1; if (typeof window !== 'undefined') { var s = Math.min(window.innerWidth / VIEW_W, window.innerHeight / VIEW_H); this.scale = s; this.canvas.style.width = (VIEW_W * s) + 'px'; this.canvas.style.height = (VIEW_H * s) + 'px'; } this.canvas.width = VIEW_W * dpr; this.canvas.height = VIEW_H * dpr; }; Renderer.prototype.destroy = function () { if (typeof window !== 'undefined') window.removeEventListener('resize', this._onResize); }; // ------------------------------------------------------------ particles Renderer.prototype._spawn = function (n, fn) { for (var i = 0; i < n; i++) { var p = fn(this.rng); if (p) this.particles.push(p); } }; Renderer.prototype.handleEvents = function (events) { var self = this; for (var i = 0; i < events.length; i++) { var e = events[i]; switch (e.type) { case 'hit': case 'block_impact': this._spawn(8, function (r) { var ang = r() * Math.PI * 2, sp = 60 + r() * 220; return { kind: 'spark', x: e.x, y: e.y, vx: Math.cos(ang) * sp, vy: Math.sin(ang) * sp - 80, life: 0.5, maxLife: 0.5, size: 2 + r() * 3, color: '#ffe9b0' }; }); this._spawn(6, function (r) { var matColor = e.mat === 'wood' ? '#b98a4e' : e.mat === 'ice' ? '#bfe8ff' : '#9aa0a8'; var ang2 = r() * Math.PI * 2, sp2 = 40 + r() * 160; return { kind: 'debris', x: e.x, y: e.y, vx: Math.cos(ang2) * sp2, vy: Math.sin(ang2) * sp2 - 120, life: 1.2, maxLife: 1.2, size: 3 + r() * 5, rot: r() * 6.3, vr: (r() - 0.5) * 8, color: matColor }; }); break; case 'break': { var mat = e.mat; var big = e.big ? 26 : 14; var color = mat === 'wood' ? '#b98a4e' : mat === 'ice' ? '#bfe8ff' : '#9aa0a8'; this._spawn(big, function (r) { var ang = r() * Math.PI * 2, sp = 80 + r() * 300; return { kind: 'debris', x: e.x, y: e.y, vx: Math.cos(ang) * sp, vy: Math.sin(ang) * sp - 160, life: 1.5, maxLife: 1.5, size: 3 + r() * 6, rot: r() * 6.3, vr: (r() - 0.5) * 10, color: color }; }); this._spawn(6, function (r) { var ang = r() * Math.PI * 2, sp = 30 + r() * 90; return { kind: 'dust', x: e.x, y: e.y, vx: Math.cos(ang) * sp, vy: Math.sin(ang) * sp * 0.4 - 30, life: 0.7, maxLife: 0.7, size: 6 + r() * 8, color: 'rgba(200,190,170,0.5)' }; }); break; } case 'crack': this._spawn(3, function (r) { var ang = r() * Math.PI * 2, sp = 20 + r() * 60; return { kind: 'debris', x: e.x, y: e.y, vx: Math.cos(ang) * sp, vy: -40 - r() * 60, life: 0.8, maxLife: 0.8, size: 2 + r() * 3, rot: r() * 6.3, vr: 4, color: '#c9b391' }; }); break; case 'pop': this._spawn(14, function (r) { var ang = r() * Math.PI * 2, sp = 60 + r() * 200; return { kind: 'dust', x: e.x, y: e.y, vx: Math.cos(ang) * sp, vy: Math.sin(ang) * sp - 60, life: 0.8, maxLife: 0.8, size: 5 + r() * 7, color: e.kind === 'king' ? 'rgba(240,200,90,0.8)' : 'rgba(126,196,96,0.8)' }; }); this._spawn(8, function (r) { var ang = r() * Math.PI * 2, sp = 90 + r() * 240; return { kind: 'spark', x: e.x, y: e.y, vx: Math.cos(ang) * sp, vy: Math.sin(ang) * sp, life: 0.4, maxLife: 0.4, size: 2 + r() * 2, color: '#fff2c4' }; }); break; case 'bird_ground': this._spawn(7, function (r) { var ang = -Math.PI / 2 + (r() - 0.5) * 1.6, sp = 40 + r() * 120; return { kind: 'dust', x: e.x, y: e.y, vx: Math.cos(ang) * sp, vy: Math.sin(ang) * sp, life: 0.6, maxLife: 0.6, size: 5 + r() * 6, color: 'rgba(190,170,130,0.55)' }; }); break; case 'crush': this._spawn(6, function (r) { var ang = r() * Math.PI, sp = 30 + r() * 100; return { kind: 'dust', x: e.x, y: e.y, vx: Math.cos(ang) * sp * (r() > 0.5 ? 1 : -1), vy: -30 - r() * 80, life: 0.5, maxLife: 0.5, size: 4 + r() * 5, color: 'rgba(180,180,170,0.5)' }; }); break; case 'ability_fury': this._spawn(10, function (r) { return { kind: 'spark', x: e.x - r() * 60, y: e.y + (r() - 0.5) * 40, vx: -300 - r() * 200, vy: (r() - 0.5) * 120, life: 0.35, maxLife: 0.35, size: 2 + r() * 2, color: '#ffd9a8' }; }); break; case 'ability_dive': this._spawn(10, function (r) { return { kind: 'spark', x: e.x + (r() - 0.5) * 50, y: e.y - r() * 40, vx: (r() - 0.5) * 100, vy: -200 - r() * 150, life: 0.35, maxLife: 0.35, size: 2 + r() * 2, color: '#fff6c8' }; }); break; case 'ability_quake': this._spawn(24, function (r) { var ang = r() * Math.PI * 2, sp = 120 + r() * 260; return { kind: 'dust', x: e.x + Math.cos(ang) * 30, y: Math.max(e.y, 0) , vx: Math.cos(ang) * sp, vy: -Math.abs(Math.sin(ang)) * sp * 0.7 - 40, life: 0.9, maxLife: 0.9, size: 5 + r() * 7, color: 'rgba(196,178,140,0.6)' }; }); this.ring = { x: e.x, y: e.y, r: 20, max: e.r, life: 0.5, maxLife: 0.5 }; break; case 'bird_gone': this._spawn(10, function (r) { var ang = r() * Math.PI * 2, sp = 20 + r() * 60; return { kind: 'feather', x: e.x, y: e.y, vx: Math.cos(ang) * sp, vy: Math.sin(ang) * sp - 40, life: 1.4, maxLife: 1.4, size: 4 + r() * 4, rot: r() * 6.3, vr: (r() - 0.5) * 6, color: 'rgba(235,235,240,0.9)' }; }); break; case 'confetti_burst': self.confetti = []; self._spawn(80, function (r) { var ang = -Math.PI / 2 + (r() - 0.5) * 1.8, sp = 200 + r() * 420; var cols = ['#ffd23e', '#ff5d5d', '#5dc8ff', '#7de07d', '#e08cff']; return { kind: 'confetti', x: e.x, y: e.y, vx: Math.cos(ang) * sp, vy: Math.sin(ang) * sp, life: 2.4, maxLife: 2.4, size: 4 + r() * 5, rot: r() * 6.3, vr: (r() - 0.5) * 12, color: cols[(r() * cols.length) | 0] }; }); break; } self.onEvent(e); } }; // ------------------------------------------------------------ drawing var BIRD_COLORS = { wren: { body: '#e2452e', belly: '#f7d9b0', beak: '#f5a623' }, zippy: { body: '#f2c51d', belly: '#fff3c4', beak: '#e07f12' }, boulder: { body: '#31507f', belly: '#9db8d9', beak: '#d98e2b' } }; Renderer.prototype.drawBird = function (ctx, b, t) { var c = BIRD_COLORS[b.kind]; var r = b.r; ctx.save(); ctx.translate(b.x, b.y); var tilt = 0; if (b.state === 'flying') tilt = Math.atan2(b.vy, Math.max(40, b.vx)) * 0.35; ctx.rotate(tilt); // tail ctx.fillStyle = c.body; ctx.beginPath(); ctx.moveTo(-r * 0.8, -r * 0.2); ctx.lineTo(-r * 1.5, -r * 0.55); ctx.lineTo(-r * 1.45, 0.1 * r); ctx.lineTo(-r * 1.5, r * 0.35); ctx.lineTo(-r * 0.8, r * 0.25); ctx.closePath(); ctx.fill(); // body ctx.beginPath(); ctx.arc(0, 0, r, 0, Math.PI * 2); ctx.fill(); // belly ctx.fillStyle = c.belly; ctx.beginPath(); ctx.ellipse(r * 0.1, r * 0.42, r * 0.62, r * 0.42, 0, 0, Math.PI * 2); ctx.fill(); // wing ctx.fillStyle = 'rgba(0,0,0,0.18)'; ctx.beginPath(); ctx.ellipse(-r * 0.35, r * 0.05, r * 0.5, r * 0.3, -0.5, 0, Math.PI * 2); ctx.fill(); // eyes ctx.fillStyle = '#fff'; ctx.beginPath(); ctx.arc(r * 0.38, -r * 0.28, r * 0.3, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.arc(r * 0.72, -r * 0.24, r * 0.26, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#222'; ctx.beginPath(); ctx.arc(r * 0.46, -r * 0.26, r * 0.13, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.arc(r * 0.78, -r * 0.22, r * 0.12, 0, Math.PI * 2); ctx.fill(); // beak ctx.fillStyle = c.beak; ctx.beginPath(); ctx.moveTo(r * 0.85, -r * 0.05); ctx.lineTo(r * 1.45, r * 0.08); ctx.lineTo(r * 0.8, r * 0.22); ctx.closePath(); ctx.fill(); // angry brow ctx.strokeStyle = '#3a2a20'; ctx.lineWidth = Math.max(2, r * 0.14); ctx.beginPath(); ctx.moveTo(r * 0.12, -r * 0.62); ctx.lineTo(r * 0.9, -r * 0.42); ctx.stroke(); // ability-ready flash if (b.state === 'flying' && !b.abilityUsed && b.age > 0.35) { ctx.strokeStyle = 'rgba(255,255,190,' + (0.5 + 0.5 * Math.sin(t * 10)).toFixed(2) + ')'; ctx.lineWidth = 2.5; ctx.beginPath(); ctx.arc(0, 0, r + 7, 0, Math.PI * 2); ctx.stroke(); } ctx.restore(); }; Renderer.prototype.drawMog = function (ctx, e, t) { var r = e.r; var body = e.kind === 'grum' ? '#5d9e4a' : e.kind === 'king' ? '#4f8f3e' : '#7ec06a'; ctx.save(); ctx.translate(e.x, e.y); if (e.dead) { ctx.globalAlpha = Math.max(0, 1 - e.deathAge * 2.2); ctx.scale(1 + e.deathAge * 0.6, Math.max(0.1, 1 - e.deathAge * 1.4)); } // body ctx.fillStyle = body; ctx.beginPath(); ctx.arc(0, 0, r, 0, Math.PI * 2); ctx.fill(); // snout ctx.fillStyle = e.kind === 'king' ? '#d8c07a' : '#cfe8b8'; ctx.beginPath(); ctx.ellipse(0, r * 0.3, r * 0.55, r * 0.4, 0, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = 'rgba(0,0,0,0.35)'; ctx.beginPath(); ctx.arc(-r * 0.2, r * 0.28, r * 0.09, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.arc(r * 0.2, r * 0.28, r * 0.09, 0, Math.PI * 2); ctx.fill(); // eyes if (e.dead) { ctx.strokeStyle = '#233'; ctx.lineWidth = 2.5; [-1, 1].forEach(function (s) { ctx.beginPath(); ctx.moveTo(s * r * 0.28 - 5, -r * 0.35 - 5); ctx.lineTo(s * r * 0.28 + 5, -r * 0.35 + 5); ctx.moveTo(s * r * 0.28 + 5, -r * 0.35 - 5); ctx.lineTo(s * r * 0.28 - 5, -r * 0.35 + 5); ctx.stroke(); }); } else { ctx.fillStyle = '#fff'; ctx.beginPath(); ctx.arc(-r * 0.3, -r * 0.3, r * 0.24, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.arc(r * 0.3, -r * 0.3, r * 0.24, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#223'; var blink = Math.sin(t * 1.7 + e.id * 2.1) > 0.985 ? 0.15 : 1; ctx.beginPath(); ctx.ellipse(-r * 0.28, -r * 0.28, r * 0.1, r * 0.1 * blink, 0, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.ellipse(r * 0.32, -r * 0.28, r * 0.1, r * 0.1 * blink, 0, 0, Math.PI * 2); ctx.fill(); } // ears ctx.fillStyle = body; ctx.beginPath(); ctx.arc(-r * 0.55, -r * 0.75, r * 0.22, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.arc(r * 0.55, -r * 0.75, r * 0.22, 0, Math.PI * 2); ctx.fill(); if (e.kind === 'king') { ctx.fillStyle = '#f2c230'; ctx.beginPath(); ctx.moveTo(-r * 0.5, -r * 0.82); ctx.lineTo(-r * 0.35, -r * 1.25); ctx.lineTo(-r * 0.15, -r * 0.95); ctx.lineTo(0, -r * 1.3); ctx.lineTo(r * 0.15, -r * 0.95); ctx.lineTo(r * 0.35, -r * 1.25); ctx.lineTo(r * 0.5, -r * 0.82); ctx.closePath(); ctx.fill(); } else if (e.kind === 'grum') { ctx.fillStyle = '#6b4a2a'; ctx.fillRect(-r * 0.55, -r * 0.95, r * 1.1, r * 0.22); } ctx.restore(); }; Renderer.prototype.drawBlock = function (ctx, bl) { if (bl.hp <= 0) return; var x = bl.x - bl.w / 2, y = bl.y - bl.h / 2; // S-3: a tipped board draws rotated about its centre (COM). Supported // stacks keep ang=0, so the transform is a no-op and output is identical. var rotated = !!bl.ang; if (rotated) { ctx.save(); ctx.translate(bl.x, bl.y); ctx.rotate(bl.ang); ctx.translate(-bl.x, -bl.y); } var styles = { wood: { fill: '#b98a4e', edge: '#8a5f2e', line: 'rgba(90,60,25,0.5)' }, ice: { fill: 'rgba(168,220,250,0.82)', edge: 'rgba(120,180,220,0.9)', line: 'rgba(255,255,255,0.55)' }, stone: { fill: '#9aa0a8', edge: '#6d7278', line: 'rgba(70,75,82,0.5)' } }; var s = styles[bl.mat]; ctx.fillStyle = s.fill; ctx.fillRect(x, y, bl.w, bl.h); ctx.strokeStyle = s.edge; ctx.lineWidth = 3; ctx.strokeRect(x + 1.5, y + 1.5, bl.w - 3, bl.h - 3); // plank lines / grain ctx.strokeStyle = s.line; ctx.lineWidth = 1.5; if (bl.mat === 'wood') { if (bl.h > bl.w) { ctx.beginPath(); ctx.moveTo(x + bl.w * 0.3, y + 4); ctx.lineTo(x + bl.w * 0.35, y + bl.h - 4); ctx.moveTo(x + bl.w * 0.7, y + 4); ctx.lineTo(x + bl.w * 0.62, y + bl.h - 4); ctx.stroke(); } else { for (var px = x + bl.w / 4; px < x + bl.w - 8; px += bl.w / 4) { ctx.beginPath(); ctx.moveTo(px, y + 4); ctx.lineTo(px, y + bl.h - 4); ctx.stroke(); } } } else if (bl.mat === 'stone') { var rnd = mulberry32(bl.id * 97 + 13); ctx.fillStyle = 'rgba(70,75,82,0.35)'; for (var i = 0; i < 6; i++) { ctx.beginPath(); ctx.arc(x + rnd() * bl.w, y + rnd() * bl.h, 1.5 + rnd() * 2, 0, Math.PI * 2); ctx.fill(); } } else { ctx.beginPath(); ctx.moveTo(x + bl.w * 0.15, y + 6); ctx.lineTo(x + bl.w * 0.4, y + bl.h * 0.4); ctx.stroke(); } // cracks by damage var dmg = 1 - bl.hp / bl.maxHp; if (dmg > 0.15) { var rnd2 = mulberry32(bl.id * 31 + 7); ctx.strokeStyle = 'rgba(30,25,20,0.55)'; ctx.lineWidth = 2; var nCracks = dmg > 0.6 ? 4 : dmg > 0.35 ? 2 : 1; for (var c = 0; c < nCracks; c++) { var cx0 = x + rnd2() * bl.w, cy0 = y; ctx.beginPath(); ctx.moveTo(cx0, cy0); var segs = 3; for (var sgm = 1; sgm <= segs; sgm++) { cx0 += (rnd2() - 0.5) * bl.w * 0.4; ctx.lineTo(cx0, cy0 + (bl.h / segs) * sgm); } ctx.stroke(); } } if (rotated) ctx.restore(); }; Renderer.prototype.drawSling = function (ctx, sling, bird, drag) { var sx = sling.x, sy = sling.y; ctx.save(); ctx.strokeStyle = '#6d4a26'; ctx.lineCap = 'round'; ctx.lineWidth = 14; ctx.beginPath(); ctx.moveTo(sx, sy + 130); ctx.lineTo(sx, sy - 18); ctx.moveTo(sx, sy - 18); ctx.lineTo(sx - 26, sy - 62); ctx.moveTo(sx, sy - 18); ctx.lineTo(sx + 26, sy - 62); ctx.stroke(); ctx.strokeStyle = '#8a5f2e'; ctx.lineWidth = 5; ctx.beginPath(); ctx.moveTo(sx, sy + 130); ctx.lineTo(sx, sy - 10); ctx.stroke(); // rubber bands: taut toward the bird ONLY while it is being aimed. // The moment it launches the sling must let go — a band that keeps // tracking the flying bird reads as "the sling never released it" // (bug #14). In flight (or with no bird) the bands hang relaxed. if (bird && bird.state === 'aiming') { var bx = drag ? drag.x : bird.x; var by = drag ? drag.y : bird.y; ctx.strokeStyle = '#4a3018'; ctx.lineWidth = 7; ctx.beginPath(); ctx.moveTo(sx - 26, sy - 62); ctx.lineTo(bx - 6, by); ctx.stroke(); ctx.lineWidth = 9; ctx.beginPath(); ctx.moveTo(sx + 26, sy - 62); ctx.lineTo(bx + 6, by); ctx.stroke(); } else { ctx.strokeStyle = '#4a3018'; ctx.lineWidth = 7; ctx.beginPath(); ctx.moveTo(sx - 26, sy - 62); ctx.quadraticCurveTo(sx, sy - 30, sx + 26, sy - 62); ctx.stroke(); } ctx.restore(); }; Renderer.prototype.drawPalm = function (ctx, x, ground, s) { ctx.save(); ctx.translate(x, ground); ctx.strokeStyle = '#7a5a33'; ctx.lineWidth = 14 * s; ctx.lineCap = 'round'; ctx.beginPath(); ctx.moveTo(0, 0); ctx.quadraticCurveTo(10 * s, -70 * s, 26 * s, -118 * s); ctx.stroke(); ctx.strokeStyle = '#3f9d4f'; ctx.lineWidth = 9 * s; for (var i = 0; i < 6; i++) { var ang = -Math.PI / 2 + (i - 2.5) * 0.5; ctx.beginPath(); ctx.moveTo(26 * s, -118 * s); ctx.quadraticCurveTo(26 * s + Math.cos(ang) * 45 * s, -118 * s + Math.sin(ang) * 30 * s - 12 * s, 26 * s + Math.cos(ang) * 72 * s, -118 * s + Math.sin(ang) * 48 * s + 14 * s); ctx.stroke(); } ctx.fillStyle = '#8a5a2a'; ctx.beginPath(); ctx.arc(26 * s, -112 * s, 7 * s, 0, Math.PI * 2); ctx.fill(); ctx.restore(); }; Renderer.prototype.draw = function (state, view) { var ctx = this.ctx; var t = state.time; var cam = state.camera; var dpr = this.dpr; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // sky var sky = ctx.createLinearGradient(0, 0, 0, VIEW_H); sky.addColorStop(0, '#7ec8f0'); sky.addColorStop(0.55, '#b8e4f7'); sky.addColorStop(1, '#e8f7ff'); ctx.fillStyle = sky; ctx.fillRect(0, 0, VIEW_W, VIEW_H); // sun ctx.fillStyle = 'rgba(255,240,180,0.9)'; ctx.beginPath(); ctx.arc(VIEW_W - 140, 110, 46, 0, Math.PI * 2); ctx.fill(); // world transform: camera pan + shake var shX = 0, shY = 0; if (cam.shake > 0) { shX = (this.rng() - 0.5) * cam.shake * 2; shY = (this.rng() - 0.5) * cam.shake * 2; } ctx.save(); // world transform: camera pan + zoom + shake (shake applied in screen // space so its magnitude does not scale with the zoom) var zoom = (cam && cam.zoom) || 1; ctx.translate(VIEW_W / 2 + shX, VIEW_H / 2 + shY); ctx.scale(zoom, zoom); ctx.translate(-cam.x, -cam.y); var G = state.world.ground; // distant island silhouette (parallax-lite via fixed offset) ctx.fillStyle = 'rgba(122,178,140,0.35)'; ctx.beginPath(); ctx.ellipse(1200, G + 30, 900, 90, 0, Math.PI, 0); ctx.fill(); // island ground ctx.fillStyle = '#c9a36a'; ctx.beginPath(); ctx.moveTo(-400, G); ctx.quadraticCurveTo(1200, G - 90, 2800, G); ctx.lineTo(2800, G + 400); ctx.lineTo(-400, G + 400); ctx.closePath(); ctx.fill(); // grass ctx.fillStyle = '#67b34d'; ctx.beginPath(); ctx.moveTo(-400, G); ctx.quadraticCurveTo(1200, G - 90, 2800, G); ctx.quadraticCurveTo(1200, G - 44, -400, G); ctx.closePath(); ctx.fill(); // decor if (state.level.decor) { var d = state.level.decor; for (var p = 0; p < (d.palms || []).length; p++) this.drawPalm(ctx, d.palms[p], G - 40, 1); for (var f = 0; f < (d.flowers || []).length; f++) { var fx = d.flowers[f]; ctx.fillStyle = '#e2557b'; ctx.beginPath(); ctx.arc(fx, G - 48, 5, 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle = '#3f9d4f'; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(fx, G - 44); ctx.lineTo(fx, G - 30); ctx.stroke(); } } // queued birds at the beach for (var qb = 0; qb < state.birds.length; qb++) { var qb2 = state.birds[qb]; if (qb2.state === 'queued') { var bx = state.level.sling.x - 130 - (state.birds.length - 1 - qb) * 56; this.drawBird(ctx, { x: bx, y: G - 30 - qb2.r * 0.4, r: qb2.r, kind: qb2.kind, state: 'queued', vx: 0, vy: 0, age: t }, t); } } // slingshot back arm + bird + front arm (bird sits between the bands) var ab = (view && view.activeBird) || null; var drag = (view && view.drag) || null; this.drawSling(ctx, state.level.sling, ab, drag); if (ab && (ab.state === 'aiming' || ab.state === 'flying')) { var bb = { x: drag && ab.state === 'aiming' ? drag.x : ab.x, y: drag && ab.state === 'aiming' ? drag.y : ab.y, r: ab.r, kind: ab.kind, state: ab.state, vx: ab.vx, vy: ab.vy, age: ab.age, abilityUsed: ab.abilityUsed }; this.drawBird(ctx, bb, t); } // trajectory preview if (view && view.traj) { for (var tp = 0; tp < view.traj.length; tp++) { var pt = view.traj[tp]; ctx.fillStyle = 'rgba(255,255,255,' + (0.75 - tp * 0.02).toFixed(2) + ')'; ctx.beginPath(); ctx.arc(pt.x, pt.y, Math.max(2, 6 - tp * 0.12), 0, Math.PI * 2); ctx.fill(); } } // blocks for (var bl = 0; bl < state.blocks.length; bl++) this.drawBlock(ctx, state.blocks[bl]); // enemies for (var en = 0; en < state.enemies.length; en++) this.drawMog(ctx, state.enemies[en], t); // spent birds lie on the ground; S-5: LANDED birds are live bodies too -- // they stay in the world (perched, falling, rolling) until level end. for (var sp = 0; sp < state.birds.length; sp++) { var sb = state.birds[sp]; if (sb.state === 'spent' && !sb.gone) this.drawBird(ctx, sb, t); else if (sb.state === 'landed') this.drawBird(ctx, sb, t); } // score popups ctx.textAlign = 'center'; for (var pp = 0; pp < state.scorePopups.length; pp++) { var pop = state.scorePopups[pp]; ctx.globalAlpha = Math.max(0, 1 - pop.age / 1.4); ctx.fillStyle = '#fff'; ctx.strokeStyle = 'rgba(0,0,0,0.5)'; ctx.lineWidth = 4; ctx.font = 'bold 30px system-ui, sans-serif'; ctx.strokeText(pop.text, pop.x, pop.y); ctx.fillText(pop.text, pop.x, pop.y); ctx.globalAlpha = 1; } // quake ring if (this.ring) { var rg = this.ring; rg.life -= (1 / 60); var k = 1 - rg.life / rg.maxLife; ctx.strokeStyle = 'rgba(220,200,160,' + (0.7 * (1 - k)).toFixed(2) + ')'; ctx.lineWidth = 6; ctx.beginPath(); ctx.arc(rg.x, rg.y, 20 + k * (rg.max - 20), 0, Math.PI * 2); ctx.stroke(); if (rg.life <= 0) this.ring = null; } // particles var dtP = 1 / 60; for (var pi = this.particles.length - 1; pi >= 0; pi--) { var pt2 = this.particles[pi]; pt2.life -= dtP; if (pt2.life <= 0) { this.particles.splice(pi, 1); continue; } pt2.vy += (pt2.kind === 'dust' ? 160 : 900) * dtP; pt2.x += pt2.vx * dtP; pt2.y += pt2.vy * dtP; var a = Math.max(0, pt2.life / pt2.maxLife); if (pt2.kind === 'debris' || pt2.kind === 'feather' || pt2.kind === 'confetti') { pt2.rot += (pt2.vr || 0) * dtP; ctx.save(); ctx.translate(pt2.x, pt2.y); ctx.rotate(pt2.rot); ctx.globalAlpha = a; ctx.fillStyle = pt2.color; if (pt2.kind === 'feather') { ctx.beginPath(); ctx.ellipse(0, 0, pt2.size * 1.4, pt2.size * 0.6, 0, 0, Math.PI * 2); ctx.fill(); } else { ctx.fillRect(-pt2.size / 2, -pt2.size / 2, pt2.size, pt2.size * (pt2.kind === 'debris' ? 0.7 : 1)); } ctx.restore(); ctx.globalAlpha = 1; } else { ctx.globalAlpha = a; ctx.fillStyle = pt2.color; ctx.beginPath(); ctx.arc(pt2.x, pt2.y, pt2.size * (pt2.kind === 'dust' ? (1.6 - a * 0.6) : a), 0, Math.PI * 2); ctx.fill(); ctx.globalAlpha = 1; } } // confetti (screen-anchored burst from win) if (this.confetti) { for (var ci = this.confetti.length - 1; ci >= 0; ci--) { var c2 = this.confetti[ci]; c2.life -= dtP; if (c2.life <= 0) { this.confetti.splice(ci, 1); continue; } c2.vy += 700 * dtP; c2.x += c2.vx * dtP; c2.y += c2.vy * dtP; c2.rot += (c2.vr || 0) * dtP; var ca = Math.max(0, Math.min(1, c2.life / 0.6)); ctx.save(); ctx.translate(c2.x, c2.y); ctx.rotate(c2.rot); ctx.globalAlpha = ca; ctx.fillStyle = c2.color; ctx.fillRect(-c2.size / 2, -c2.size / 2, c2.size, c2.size * 0.6); ctx.restore(); ctx.globalAlpha = 1; } if (this.confetti.length === 0) this.confetti = null; } ctx.restore(); }; return { Renderer: Renderer, VIEW_W: VIEW_W, VIEW_H: VIEW_H }; });