Isle-Slingers/js/render/index.js

606 lines
24 KiB
JavaScript
Raw Permalink Normal View History

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