'use strict';
/* ═══════════════════════════════════════════════════════════════════
AngryBirdsSim — Angry Birds Physics
Real projectile physics: RK4, drag, wind, gravity by planet.
Blocks: AABB with impulse collisions and destruction.
Pigs: circle targets with HP system.
6 levels with increasing difficulty.
═══════════════════════════════════════════════════════════════════ */
const AB_PLANETS = {
earth: { g: 9.81, label: 'Земля ', sky1: '#0f1923', sky2: '#1a3a2a', ground: '#3d6b47', g_label: '9.81' },
moon: { g: 1.62, label: 'Луна ', sky1: '#000008', sky2: '#0a0a18', ground: '#7a7a66', g_label: '1.62' },
mars: { g: 3.71, label: 'Марс ', sky1: '#2a0800', sky2: '#5c1e00', ground: '#7a3010', g_label: '3.71' },
jupiter: { g: 24.79, label: 'Юпитер ', sky1: '#1a0a00', sky2: '#3d1e00', ground: '#5c3220', g_label: '24.79' },
};
const AB_MATS = {
wood: { color: '#b5651d', border: '#7a3f0a', hpMax: 120, mass: 2.0, debris: '#8B4513' },
stone: { color: '#7a7a7a', border: '#444', hpMax: 320, mass: 6.0, debris: '#555' },
glass: { color: '#a8d8ea', border: '#5badd4', hpMax: 45, mass: 0.8, debris: '#d4eef8' },
};
const AB_BIRDS = {
normal: { color: '#e63946', r: 18, mass: 1.0, Cd: 0.28, label: 'Красная' },
heavy: { color: '#888', r: 23, mass: 4.2, Cd: 0.45, label: 'Тяжёлая' },
fast: { color: '#ffd166', r: 13, mass: 0.55, Cd: 0.08, label: 'Жёлтая' },
};
const _PX_M = 42; // pixels per metre (base scale, adjusted in _layout)
/* ── Level definitions ── */
/* rx = metres right of slingshot, gy = metres above ground (bottom of block) */
function _buildLevels() {
return [
/* 1 — Tutorial: one wooden column, one pig */
{ planet: 'earth', wind: 0, birdType: 'normal', birds: 3,
blocks: [
{ mat: 'wood', rx: 7.2, gy: 0, w: 0.55, h: 1.9 },
{ mat: 'wood', rx: 6.8, gy: 1.9, w: 1.2, h: 0.38 },
],
pigs: [{ rx: 7.2, gy: 2.35 }] },
/* 2 — Glass tower, 2 pigs */
{ planet: 'earth', wind: 0, birdType: 'normal', birds: 4,
blocks: [
{ mat: 'glass', rx: 6.6, gy: 0, w: 0.45, h: 1.6 },
{ mat: 'glass', rx: 8.2, gy: 0, w: 0.45, h: 1.6 },
{ mat: 'glass', rx: 6.3, gy: 1.6, w: 2.5, h: 0.4 },
{ mat: 'glass', rx: 7.3, gy: 2.0, w: 0.55, h: 1.3 },
],
pigs: [{ rx: 6.8, gy: 2.05 }, { rx: 7.3, gy: 3.4 }] },
/* 3 — Wind, wooden house */
{ planet: 'earth', wind: 5, birdType: 'normal', birds: 4,
blocks: [
{ mat: 'wood', rx: 6.5, gy: 0, w: 0.5, h: 2.1 },
{ mat: 'wood', rx: 9.1, gy: 0, w: 0.5, h: 2.1 },
{ mat: 'wood', rx: 6.2, gy: 2.1, w: 3.4, h: 0.45 },
{ mat: 'wood', rx: 7.1, gy: 2.55, w: 0.5, h: 1.6 },
{ mat: 'wood', rx: 8.5, gy: 2.55, w: 0.5, h: 1.6 },
{ mat: 'wood', rx: 6.8, gy: 4.15, w: 2.5, h: 0.4 },
],
pigs: [{ rx: 7.8, gy: 2.6 }, { rx: 7.8, gy: 4.6 }] },
/* 4 — Moon, stone fortress, heavy bird */
{ planet: 'moon', wind: 0, birdType: 'heavy', birds: 3,
blocks: [
{ mat: 'stone', rx: 7.0, gy: 0, w: 0.6, h: 2.6 },
{ mat: 'stone', rx: 9.6, gy: 0, w: 0.6, h: 2.6 },
{ mat: 'stone', rx: 6.7, gy: 2.6, w: 3.5, h: 0.6 },
{ mat: 'stone', rx: 8.2, gy: 3.2, w: 0.6, h: 1.6 },
],
pigs: [{ rx: 7.5, gy: 3.2 }, { rx: 8.5, gy: 4.8 }] },
/* 5 — Mars, headwind, mixed materials, fast bird */
{ planet: 'mars', wind: -4, birdType: 'fast', birds: 4,
blocks: [
{ mat: 'stone', rx: 6.5, gy: 0, w: 0.5, h: 1.3 },
{ mat: 'wood', rx: 6.5, gy: 1.3, w: 0.5, h: 1.6 },
{ mat: 'stone', rx: 8.2, gy: 0, w: 0.5, h: 1.3 },
{ mat: 'wood', rx: 8.2, gy: 1.3, w: 0.5, h: 1.6 },
{ mat: 'stone', rx: 6.2, gy: 2.9, w: 2.9, h: 0.5 },
{ mat: 'wood', rx: 7.4, gy: 3.4, w: 0.7, h: 1.1 },
],
pigs: [{ rx: 6.8, gy: 3.4 }, { rx: 8.8, gy: 3.4 }] },
/* 6 — Jupiter, strong wind, multi-level, 3 pigs */
{ planet: 'jupiter', wind: 7, birdType: 'normal', birds: 5,
blocks: [
{ mat: 'stone', rx: 6.2, gy: 0, w: 0.5, h: 1.6 },
{ mat: 'wood', rx: 7.7, gy: 0, w: 0.5, h: 1.6 },
{ mat: 'stone', rx: 9.2, gy: 0, w: 0.5, h: 1.6 },
{ mat: 'stone', rx: 5.9, gy: 1.6, w: 1.2, h: 0.5 },
{ mat: 'wood', rx: 7.4, gy: 1.6, w: 2.3, h: 0.5 },
{ mat: 'stone', rx: 8.9, gy: 1.6, w: 1.2, h: 0.5 },
{ mat: 'stone', rx: 6.7, gy: 2.1, w: 0.5, h: 1.6 },
{ mat: 'stone', rx: 8.7, gy: 2.1, w: 0.5, h: 1.6 },
{ mat: 'wood', rx: 6.4, gy: 3.7, w: 3.1, h: 0.45 },
],
pigs: [{ rx: 6.4, gy: 2.15 }, { rx: 7.7, gy: 2.15 }, { rx: 8.9, gy: 2.15 }] },
];
}
/* ══════════════════════════════════════════════════════════════════ */
class AngryBirdsSim {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.W = 0; this.H = 0;
this.levelIdx = 0;
this.state = 'aim'; // 'aim' | 'flying' | 'settling' | 'win' | 'lose'
this.bird = null;
this.birdsLeft = [];
this.blocks = [];
this.pigs = [];
this.score = 0;
this._particles = [];
this._raf = null;
this._lastTs = null;
this._settleTimer = 0;
this._previewPath = [];
this._drag = { pulling: false, mx: 0, my: 0 }; // mx/my updated in _layout()
/* layout (computed in _layout) */
this._gY = 0; // ground Y (canvas px)
this._sX = 0; // sling X
this._sY = 0; // sling Y (bird rest position)
this._sc = _PX_M; // px per metre
this._levels = _buildLevels();
this._stars = Array.from({ length: 70 }, () => ({
x: Math.random(), y: Math.random() * 0.7, r: 0.5 + Math.random() * 1.5, a: 0.3 + Math.random() * 0.7,
}));
this.onUpdate = null;
this._ready = false; // true after first _initLevel
new ResizeObserver(() => { this.fit(); if (!this._raf) this.draw(); })
.observe(canvas.parentElement || canvas);
}
/* ── PUBLIC API ─────────────────────────────────────────────── */
fit() {
const dpr = window.devicePixelRatio || 1;
const el = this.canvas.parentElement || this.canvas;
const W = el.clientWidth || 800;
const H = el.clientHeight || 480;
this.canvas.width = W * dpr;
this.canvas.height = H * dpr;
this.canvas.style.width = W + 'px';
this.canvas.style.height = H + 'px';
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
this.W = W; this.H = H;
this._layout();
/* Only init on first open — resize must NOT reset the game */
if (!this._ready) { this._ready = true; this._initLevel(); }
}
loadLevel(n) {
this.levelIdx = Math.max(0, Math.min(n, this._levels.length - 1));
this._ready = true; // ensure _initLevel runs even before first fit
this._initLevel();
}
restart() { this._ready = true; this._initLevel(); }
start() {
if (this._raf) return;
this._lastTs = null;
const tick = (ts) => {
const dt = this._lastTs ? Math.min((ts - this._lastTs) / 1000, 0.05) : 0.016;
this._lastTs = ts;
this._update(dt);
this.draw();
this._raf = requestAnimationFrame(tick);
};
this._raf = requestAnimationFrame(tick);
}
stop() {
if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; }
}
draw() {
if (!this.W) return;
this._drawBackground();
this._drawSlingshot();
this._drawBlocks();
this._drawPigs();
if (this.bird) this._drawBird(this.bird);
this._drawParticles();
if (this.state === 'aim' && this._drag.pulling) this._drawPreview();
this._drawSlingshotRubber();
this._drawQueue();
this._drawHUD();
if (this.state === 'win' || this.state === 'lose') this._drawOverlay();
}
info() {
const lvl = this._levels[this.levelIdx];
const planet = lvl ? AB_PLANETS[lvl.planet] : AB_PLANETS.earth;
return {
level: this.levelIdx + 1,
birds: this.birdsLeft.length + (this.bird ? 1 : 0),
pigs: this.pigs.filter(p => !p.destroyed).length,
score: this.score,
planet: planet ? planet.label : '—',
g: planet ? planet.g_label : '—',
};
}
/* ── MOUSE HANDLERS ─────────────────────────────────────────── */
handleMouseDown(e) {
if (this.state !== 'aim' || !this.bird) return;
const { x, y } = this._evt(e);
if (Math.hypot(x - this._sX, y - this._sY) < 54) {
this._drag.pulling = true;
this._clampDrag(x, y);
this._updatePreview();
}
}
handleMouseMove(e) {
if (!this._drag.pulling) return;
const { x, y } = this._evt(e);
this._clampDrag(x, y);
this._updatePreview();
this.draw();
}
handleMouseUp(e) {
if (!this._drag.pulling) return;
this._drag.pulling = false;
const dx = this._drag.mx - this._sX;
const dy = this._drag.my - this._sY;
if (Math.hypot(dx, dy) < 12) { this.draw(); return; }
const K = 5.8; // px stretch px/s velocity
this._launch(-dx * K, -dy * K);
}
/* ── PRIVATE ────────────────────────────────────────────────── */
_layout() {
this._gY = Math.round(this.H * 0.80);
this._sX = Math.round(this.W * 0.17);
this._sY = this._gY - 68;
this._sc = Math.max(34, Math.min(50, this.H / 11));
/* keep drag position at sling so rubber band doesn't snap to (0,0) */
if (!this._drag.pulling) { this._drag.mx = this._sX; this._drag.my = this._sY; }
}
_initLevel() {
if (!this.W) return;
const lvl = this._levels[this.levelIdx];
if (!lvl) return;
this.state = 'aim';
this.score = 0;
this._particles = [];
this._previewPath = [];
this._settleTimer = 0;
this._drag.pulling = false;
this.birdsLeft = Array(lvl.birds - 1).fill(lvl.birdType);
this.bird = this._spawnBird(lvl.birdType);
this._placeBirdAtSling(this.bird);
const sc = this._sc;
this.blocks = lvl.blocks.map((b, i) => {
const mat = AB_MATS[b.mat] || AB_MATS.wood;
return {
id: i, mat: b.mat,
x: this._sX + b.rx * sc,
y: this._gY - b.gy * sc - b.h * sc,
w: b.w * sc, h: b.h * sc,
vx: 0, vy: 0, angle: 0, angVel: 0,
hp: mat.hpMax, maxHp: mat.hpMax,
mass: mat.mass * b.w * b.h,
destroyed: false, onGround: true, // start stationary; set false on impulse
};
});
this.pigs = lvl.pigs.map((p, i) => ({
id: i,
x: this._sX + p.rx * sc,
y: this._gY - p.gy * sc - 20,
vx: 0, vy: 0,
r: 20, hp: 100, maxHp: 100,
destroyed: false, flash: 0, onGround: true, // start stationary
}));
if (this.onUpdate) this.onUpdate(this.info());
}
_spawnBird(type) {
const def = AB_BIRDS[type] || AB_BIRDS.normal;
return {
type, color: def.color,
x: 0, y: 0, vx: 0, vy: 0,
r: def.r, mass: def.mass, Cd: def.Cd,
trail: [], launched: false, destroyed: false,
};
}
_placeBirdAtSling(bird) {
bird.x = this._sX; bird.y = this._sY;
bird.vx = 0; bird.vy = 0;
bird.launched = false; bird.trail = [];
}
_launch(vx, vy) {
if (!this.bird) return;
this.bird.vx = vx; this.bird.vy = vy;
this.bird.launched = true;
this.state = 'flying';
this._emit('launch', this.bird.x, this.bird.y, this.bird.color, 8);
if (this.onUpdate) this.onUpdate(this.info());
}
_clampDrag(mx, my) {
let dx = mx - this._sX;
let dy = my - this._sY;
if (dx > 5) dx = 5; // mostly left only
const dist = Math.hypot(dx, dy);
const maxPull = 80;
if (dist > maxPull) { dx = dx / dist * maxPull; dy = dy / dist * maxPull; }
this._drag.mx = this._sX + dx;
this._drag.my = this._sY + dy;
}
/* ── UPDATE LOOP ─────────────────────────────────────────────── */
_update(dt) {
if (this.state === 'flying') {
this._stepBird(dt);
this._collisions();
if (this.bird && (this.bird.destroyed || this._offScreen(this.bird))) {
this.bird = null;
this.state = 'settling';
this._settleTimer = 1.5;
}
}
if (this.state === 'flying' || this.state === 'settling') {
this._stepBlocks(dt);
this._stepPigs(dt);
}
if (this.state === 'settling') {
this._settleTimer -= dt;
if (this._settleTimer <= 0) {
if (this.pigs.every(p => p.destroyed)) {
this._win();
} else if (!this.birdsLeft.length) {
this.state = 'lose';
if (this.onUpdate) this.onUpdate(this.info());
} else {
this.bird = this._spawnBird(this.birdsLeft.shift());
this._placeBirdAtSling(this.bird);
this.state = 'aim';
if (this.onUpdate) this.onUpdate(this.info());
}
}
}
this._stepParticles(dt);
}
_planet() {
const lvl = this._levels[this.levelIdx];
return AB_PLANETS[lvl?.planet] || AB_PLANETS.earth;
}
_stepBird(dt) {
const bird = this.bird;
if (!bird?.launched) return;
const g = this._planet().g * this._sc; // px/s²
const lvl = this._levels[this.levelIdx];
const wind = (lvl.wind || 0) * this._sc * 0.22; // px/s² wind accel
/* Quadratic drag in px-space (empirical, looks right) */
const spd = Math.hypot(bird.vx, bird.vy);
const kD = 2.8e-5 * bird.Cd;
const ax = (wind - kD * spd * bird.vx) / bird.mass;
const ay = (g - kD * spd * bird.vy) / bird.mass;
/* Simple Euler (fast enough for game) */
bird.vx += ax * dt;
bird.vy += ay * dt;
bird.x += bird.vx * dt;
bird.y += bird.vy * dt;
/* Trail */
bird.trail.push({ x: bird.x, y: bird.y });
if (bird.trail.length > 22) bird.trail.shift();
/* Ground bounce / stop */
if (bird.y + bird.r >= this._gY) {
bird.y = this._gY - bird.r;
bird.vy *= -0.32;
bird.vx *= 0.72;
this._emit('impact', bird.x, this._gY, '#a0d080', 5);
if (Math.abs(bird.vy) < 22) bird.destroyed = true;
}
}
_stepBlocks(dt) {
const g = this._planet().g * this._sc;
for (const b of this.blocks) {
if (b.destroyed) continue;
if (!b.onGround) {
b.vy += g * dt;
b.x += b.vx * dt; b.y += b.vy * dt;
b.angle += b.angVel * dt;
b.vx *= 0.992;
if (b.y + b.h >= this._gY) {
b.y = this._gY - b.h;
b.vy *= -0.22; b.vx *= 0.65; b.angVel *= 0.45;
if (Math.abs(b.vy) < 18) { b.vy = 0; b.onGround = true; }
}
} else {
// sleeping on ground: slide + check if kicked into air
if (Math.abs(b.vx) > 0.5 || Math.abs(b.vy) > 0.5) b.onGround = false;
b.vx *= 0.82; b.x += b.vx * dt;
}
}
}
_stepPigs(dt) {
const g = this._planet().g * this._sc;
for (const p of this.pigs) {
if (p.destroyed) continue;
if (p.flash > 0) p.flash -= dt;
if (p.onGround) {
// wake up if kicked
if (Math.abs(p.vx) > 0.5 || Math.abs(p.vy) > 0.5) p.onGround = false;
p.vx *= 0.82; p.x += p.vx * dt;
continue;
}
p.vy += g * dt;
p.x += p.vx * dt; p.y += p.vy * dt;
if (p.y + p.r >= this._gY) {
p.y = this._gY - p.r; p.vy *= -0.18; p.vx *= 0.65;
if (Math.abs(p.vy) < 10) { p.vy = 0; p.onGround = true; }
}
}
}
_collisions() {
const bird = this.bird;
if (!bird?.launched) return;
/* Bird vs Blocks */
for (const b of this.blocks) {
if (b.destroyed) continue;
const cx = Math.max(b.x, Math.min(bird.x, b.x + b.w));
const cy = Math.max(b.y, Math.min(bird.y, b.y + b.h));
const dx = bird.x - cx, dy = bird.y - cy;
const dist = Math.hypot(dx, dy);
if (dist >= bird.r) continue;
const nx = dist > 0.5 ? dx / dist : 0;
const ny = dist > 0.5 ? dy / dist : -1;
const vRel = (bird.vx - b.vx) * nx + (bird.vy - b.vy) * ny;
if (vRel >= 0) continue;
const e = 0.28;
const j = -(1 + e) * vRel / (1 / bird.mass + 1 / b.mass);
bird.vx += j / bird.mass * nx;
bird.vy += j / bird.mass * ny;
b.vx -= j / b.mass * nx;
b.vy -= j / b.mass * ny;
b.angVel += (dx * (-j / b.mass * ny) - dy * (-j / b.mass * nx)) * 0.015;
b.onGround = false; // wake up block — now subject to gravity
const dmg = Math.abs(j) * 0.18;
b.hp -= dmg;
this.score += Math.max(0, Math.floor(dmg * 3));
this._emit('hit', cx, cy, AB_MATS[b.mat]?.debris || '#888', 5);
if (b.hp <= 0) {
b.destroyed = true;
this.score += 500;
this._emit('destroy', b.x + b.w / 2, b.y + b.h / 2, AB_MATS[b.mat]?.debris || '#888', 15);
}
bird.x += nx * (bird.r - dist + 1);
bird.y += ny * (bird.r - dist + 1);
}
/* Bird vs Pigs */
for (const p of this.pigs) {
if (p.destroyed) continue;
const dx = bird.x - p.x, dy = bird.y - p.y;
const dist = Math.hypot(dx, dy);
const minD = bird.r + p.r;
if (dist >= minD) continue;
const nx = dist > 0.5 ? dx / dist : 0;
const ny = dist > 0.5 ? dy / dist : -1;
const pigMass = 2.2;
const vRel = (bird.vx - p.vx) * nx + (bird.vy - p.vy) * ny;
if (vRel >= 0) continue;
const e = 0.15;
const j = -(1 + e) * vRel / (1 / bird.mass + 1 / pigMass);
bird.vx += j / bird.mass * nx;
bird.vy += j / bird.mass * ny;
p.vx -= j / pigMass * nx;
p.vy -= j / pigMass * ny;
p.onGround = false; // wake up pig
const dmg = Math.abs(j) * 0.28;
p.hp -= dmg; p.flash = 0.35;
if (p.hp <= 0) {
p.destroyed = true;
this.score += 5000;
this._emit('destroy', p.x, p.y, '#4ade80', 20);
} else {
this._emit('hit', p.x, p.y, '#86efac', 5);
}
bird.x += nx * (minD - dist + 1);
bird.y += ny * (minD - dist + 1);
}
}
_win() {
this.state = 'win';
this.score += this.birdsLeft.length * 3000;
this._emit('confetti', this.W / 2, this.H * 0.35, '#ffd166', 50);
if (this.onUpdate) this.onUpdate(this.info());
}
_offScreen(b) {
return b.x > this.W + 60 || b.x < -60 || b.y > this.H + 60;
}
/* ── PARTICLES ───────────────────────────────────────────────── */
_emit(type, x, y, color, n) {
const confetti = ['#ffd166', '#ef476f', '#06d6e0', '#7bf5a4', '#9b5de5'];
for (let i = 0; i < n; i++) {
const a = Math.random() * Math.PI * 2;
const spd = type === 'confetti' ? 80 + Math.random() * 200
: type === 'destroy' ? 90 + Math.random() * 220
: 45 + Math.random() * 100;
this._particles.push({
x, y,
vx: Math.cos(a) * spd,
vy: Math.sin(a) * spd - (type === 'destroy' || type === 'confetti' ? 100 : 20),
r: type === 'confetti' ? 4 + Math.random() * 5 : 2 + Math.random() * 4,
color: type === 'confetti' ? confetti[i % confetti.length] : color,
gravity: type === 'confetti' ? 180 : 300,
life: 1, maxLife: 0.5 + Math.random() * 0.9,
});
}
}
_stepParticles(dt) {
for (const p of this._particles) {
p.x += p.vx * dt; p.y += p.vy * dt;
p.vy += p.gravity * dt; p.vx *= 0.97;
p.life -= dt / p.maxLife;
}
this._particles = this._particles.filter(p => p.life > 0);
}
/* ── PREVIEW PATH ────────────────────────────────────────────── */
_updatePreview() {
const dx = this._drag.mx - this._sX;
const dy = this._drag.my - this._sY;
const K = 5.8;
const vx0 = -dx * K, vy0 = -dy * K;
const g = this._planet().g * this._sc;
const lvl = this._levels[this.levelIdx];
const wind = (lvl?.wind || 0) * this._sc * 0.22;
this._previewPath = [];
let x = this._sX, y = this._sY, vx = vx0, vy = vy0;
const dt = 0.028;
for (let i = 0; i < 38; i++) {
vx += wind * dt; vy += g * dt;
x += vx * dt; y += vy * dt;
if (y > this._gY || x > this.W) break;
this._previewPath.push({ x, y, a: 1 - i / 38 });
}
}
/* ── DRAWING ─────────────────────────────────────────────────── */
_drawBackground() {
const ctx = this.ctx;
const pl = this._planet();
ctx.clearRect(0, 0, this.W, this.H);
/* Sky */
const sky = ctx.createLinearGradient(0, 0, 0, this._gY);
sky.addColorStop(0, pl.sky1); sky.addColorStop(1, pl.sky2);
ctx.fillStyle = sky; ctx.fillRect(0, 0, this.W, this._gY);
/* Stars */
for (const s of this._stars) {
ctx.beginPath(); ctx.arc(s.x * this.W, s.y * this._gY, s.r, 0, Math.PI * 2);
ctx.fillStyle = `rgba(255,255,255,${s.a})`; ctx.fill();
}
/* Ground */
const gnd = ctx.createLinearGradient(0, this._gY, 0, this.H);
gnd.addColorStop(0, pl.ground);
gnd.addColorStop(1, this._shade(pl.ground, 0.45));
ctx.fillStyle = gnd; ctx.fillRect(0, this._gY, this.W, this.H - this._gY);
ctx.strokeStyle = 'rgba(255,255,255,0.10)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(0, this._gY); ctx.lineTo(this.W, this._gY); ctx.stroke();
/* Wind arrow (visual, centred top) */
const lvl = this._levels[this.levelIdx];
if (lvl?.wind) {
const dir = lvl.wind > 0 ? 1 : -1;
const len = Math.min(Math.abs(lvl.wind) * 7, 70);
const wx = this.W * 0.5, wy = 22;
ctx.strokeStyle = 'rgba(255,255,255,0.5)'; ctx.lineWidth = 2.5;
ctx.setLineDash([5, 3]);
ctx.beginPath(); ctx.moveTo(wx - dir * len / 2, wy); ctx.lineTo(wx + dir * len / 2, wy); ctx.stroke();
ctx.setLineDash([]);
const ax = wx + dir * len / 2;
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.beginPath(); ctx.moveTo(ax, wy); ctx.lineTo(ax - dir*9, wy-5); ctx.lineTo(ax - dir*9, wy+5); ctx.closePath(); ctx.fill();
}
}
_drawSlingshot() {
const ctx = this.ctx;
const sx = this._sX, sy = this._sY, gY = this._gY;
ctx.strokeStyle = '#6b3a1f'; ctx.lineWidth = 9; ctx.lineCap = 'round';
ctx.beginPath(); ctx.moveTo(sx, gY); ctx.lineTo(sx - 2, sy + 14); ctx.stroke();
ctx.beginPath(); ctx.moveTo(sx - 12, gY + 4); ctx.lineTo(sx - 18, sy - 12); ctx.stroke();
ctx.beginPath(); ctx.moveTo(sx + 12, gY + 4); ctx.lineTo(sx + 18, sy - 12); ctx.stroke();
ctx.fillStyle = '#4a2510';
ctx.beginPath(); ctx.arc(sx - 18, sy - 12, 5.5, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(sx + 18, sy - 12, 5.5, 0, Math.PI * 2); ctx.fill();
}
_drawSlingshotRubber() {
const ctx = this.ctx;
const sx = this._sX, sy = this._sY;
const bx = (this._drag.pulling && this.bird) ? this._drag.mx : (this.bird ? this.bird.x : sx);
const by = (this._drag.pulling && this.bird) ? this._drag.my : (this.bird ? this.bird.y : sy);
ctx.strokeStyle = '#7a4020'; ctx.lineWidth = 3; ctx.lineCap = 'round';
ctx.beginPath(); ctx.moveTo(sx - 18, sy - 12); ctx.lineTo(bx, by); ctx.stroke();
ctx.beginPath(); ctx.moveTo(sx + 18, sy - 12); ctx.lineTo(bx, by); ctx.stroke();
}
_drawQueue() {
const birds = this.birdsLeft;
if (!birds.length) return;
const qy = this._gY + 26;
const gap = 28;
const startX = this._sX + 30; // right of sling handle
for (let i = 0; i < Math.min(birds.length, 8); i++) {
const def = AB_BIRDS[birds[i]] || AB_BIRDS.normal;
this._drawBirdShape(startX + i * gap, qy, def.r * 0.6, def.color, 0.65);
}
}
_drawBird(bird) {
const ctx = this.ctx;
/* trail */
for (let i = 0; i < bird.trail.length; i++) {
const t = bird.trail[i];
ctx.beginPath(); ctx.arc(t.x, t.y, bird.r * 0.38, 0, Math.PI * 2);
ctx.fillStyle = `rgba(255,255,255,${(i / bird.trail.length) * 0.35})`; ctx.fill();
}
this._drawBirdShape(bird.x, bird.y, bird.r, bird.color, 1);
}
_drawBirdShape(x, y, r, color, alpha) {
const ctx = this.ctx;
ctx.save(); ctx.globalAlpha = alpha;
const g = ctx.createRadialGradient(x - r * 0.3, y - r * 0.35, r * 0.1, x, y, r);
g.addColorStop(0, this._lighten(color, 60)); g.addColorStop(1, color);
ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fillStyle = g; ctx.fill();
ctx.strokeStyle = this._shade(color, 0.6); ctx.lineWidth = 1.5; ctx.stroke();
/* eyes */
ctx.fillStyle = '#fff';
ctx.beginPath(); ctx.arc(x + r * 0.26, y - r * 0.18, r * 0.24, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#1a1a1a';
ctx.beginPath(); ctx.arc(x + r * 0.33, y - r * 0.15, r * 0.11, 0, Math.PI * 2); ctx.fill();
/* eyebrow (angry) */
ctx.strokeStyle = '#333'; ctx.lineWidth = Math.max(1.5, r * 0.13); ctx.lineCap = 'round';
ctx.beginPath(); ctx.moveTo(x + r * 0.08, y - r * 0.4); ctx.lineTo(x + r * 0.52, y - r * 0.28); ctx.stroke();
ctx.restore();
}
_drawBlocks() {
const ctx = this.ctx;
for (const b of this.blocks) {
if (b.destroyed) continue;
const mat = AB_MATS[b.mat] || AB_MATS.wood;
const hp = b.hp / b.maxHp;
ctx.save();
ctx.translate(b.x + b.w / 2, b.y + b.h / 2); ctx.rotate(b.angle);
ctx.fillStyle = mat.color; ctx.globalAlpha = 0.45 + hp * 0.55;
ctx.fillRect(-b.w / 2, -b.h / 2, b.w, b.h);
ctx.globalAlpha = 1;
ctx.strokeStyle = mat.border; ctx.lineWidth = 1.5;
ctx.strokeRect(-b.w / 2, -b.h / 2, b.w, b.h);
if (hp < 0.55) {
ctx.strokeStyle = `rgba(0,0,0,${(1 - hp) * 0.55})`; ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(-b.w * 0.12, -b.h * 0.35); ctx.lineTo(b.w * 0.18, b.h * 0.12);
ctx.moveTo(b.w * 0.08, -b.h * 0.22); ctx.lineTo(-b.w * 0.2, b.h * 0.3);
ctx.stroke();
}
ctx.restore();
}
}
_drawPigs() {
const ctx = this.ctx;
for (const p of this.pigs) {
if (p.destroyed) continue;
const flash = p.flash > 0;
ctx.save();
const grd = ctx.createRadialGradient(p.x - p.r * 0.3, p.y - p.r * 0.3, p.r * 0.1, p.x, p.y, p.r);
grd.addColorStop(0, flash ? '#ffcc44' : '#7bf5a4');
grd.addColorStop(1, flash ? '#ff6600' : '#22c55e');
ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
ctx.fillStyle = grd; ctx.fill();
ctx.strokeStyle = '#166534'; ctx.lineWidth = 2; ctx.stroke();
/* snout */
ctx.beginPath(); ctx.ellipse(p.x, p.y + p.r * 0.28, p.r * 0.38, p.r * 0.26, 0, 0, Math.PI * 2);
ctx.fillStyle = flash ? '#ffdd88' : '#4ade80'; ctx.fill();
ctx.fillStyle = '#166534';
ctx.beginPath(); ctx.arc(p.x - p.r * 0.13, p.y + p.r * 0.25, p.r * 0.07, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(p.x + p.r * 0.13, p.y + p.r * 0.25, p.r * 0.07, 0, Math.PI * 2); ctx.fill();
/* eyes */
ctx.fillStyle = '#fff';
ctx.beginPath(); ctx.arc(p.x - p.r * 0.26, p.y - p.r * 0.1, p.r * 0.23, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(p.x + p.r * 0.26, p.y - p.r * 0.1, p.r * 0.23, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#111';
ctx.beginPath(); ctx.arc(p.x - p.r * 0.22, p.y - p.r * 0.08, p.r * 0.1, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(p.x + p.r * 0.3, p.y - p.r * 0.08, p.r * 0.1, 0, Math.PI * 2); ctx.fill();
/* HP bar */
if (p.hp < p.maxHp) {
const bw = p.r * 2.2, bh = 5, bx = p.x - bw / 2, by = p.y - p.r - 11;
ctx.fillStyle = 'rgba(0,0,0,0.45)'; ctx.fillRect(bx, by, bw, bh);
ctx.fillStyle = `hsl(${120 * p.hp / p.maxHp},88%,42%)`;
ctx.fillRect(bx, by, bw * p.hp / p.maxHp, bh);
}
ctx.restore();
}
}
_drawParticles() {
const ctx = this.ctx;
for (const p of this._particles) {
ctx.save(); ctx.globalAlpha = Math.max(0, p.life) * 0.88;
ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
ctx.fillStyle = p.color; ctx.fill(); ctx.restore();
}
}
_drawPreview() {
const ctx = this.ctx;
ctx.save();
for (const pt of this._previewPath) {
ctx.beginPath(); ctx.arc(pt.x, pt.y, 2.5, 0, Math.PI * 2);
ctx.fillStyle = `rgba(255,255,255,${pt.a * 0.55})`; ctx.fill();
}
ctx.restore();
}
_drawHUD() {
const ctx = this.ctx;
const pl = this._planet();
const lvl = this._levels[this.levelIdx];
/* Score — top right */
ctx.font = 'bold 20px Manrope,sans-serif'; ctx.textAlign = 'right';
ctx.fillStyle = 'rgba(255,255,255,0.95)';
ctx.fillText(this.score.toLocaleString('ru') + ' очков', this.W - 14, 30);
/* Level — top left */
ctx.textAlign = 'left'; ctx.font = 'bold 15px Manrope,sans-serif';
ctx.fillStyle = 'rgba(255,255,255,0.9)';
ctx.fillText(`Уровень ${this.levelIdx + 1} / ${this._levels.length}`, 14, 30);
/* Planet + g — second line, readable */
ctx.font = '13px Manrope,sans-serif'; ctx.fillStyle = 'rgba(255,255,255,0.72)';
ctx.fillText(`${pl.label.replace(/