'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(//g, '').trim()} g = ${pl.g} м/с²`, 14, 50); /* Wind reminder */ if (lvl?.wind) { ctx.font = '12px Manrope,sans-serif'; ctx.fillStyle = 'rgba(255,255,255,0.65)'; ctx.fillText(`Ветер ${lvl.wind > 0 ? '→' : '←'} ${Math.abs(lvl.wind)} м/с`, 14, 66); } /* Active bird type label near sling */ if (this.bird && this.state === 'aim') { const def = AB_BIRDS[this.bird.type] || AB_BIRDS.normal; ctx.font = '12px Manrope,sans-serif'; ctx.textAlign = 'center'; ctx.fillStyle = 'rgba(255,255,255,0.7)'; ctx.fillText(def.label, this._sX, this._gY + 50); } } _drawOverlay() { const ctx = this.ctx; const win = this.state === 'win'; ctx.fillStyle = win ? 'rgba(0,30,8,0.78)' : 'rgba(30,0,0,0.78)'; ctx.fillRect(0, 0, this.W, this.H); const cx = this.W / 2, cy = this.H / 2; ctx.textAlign = 'center'; ctx.font = 'bold 34px Manrope,sans-serif'; ctx.fillStyle = win ? '#7bf5a4' : '#ef476f'; ctx.fillText(win ? '✦ Уровень пройден!' : '✦ Попробуй ещё раз', cx, cy - 18); ctx.font = '17px Manrope,sans-serif'; ctx.fillStyle = 'rgba(255,255,255,0.7)'; ctx.fillText(win ? `Очки: ${this.score.toLocaleString('ru')}` : 'Свиньи выжили!', cx, cy + 18); ctx.font = '12px Manrope,sans-serif'; ctx.fillStyle = 'rgba(255,255,255,0.38)'; ctx.fillText('Выбери уровень или нажми «Сначала»', cx, cy + 48); } /* ── UTILS ───────────────────────────────────────────────────── */ _evt(e) { const r = this.canvas.getBoundingClientRect(); return { x: e.clientX - r.left, y: e.clientY - r.top }; } _expandHex(hex) { // Expand 3-digit (#abc #aabbcc) if (/^#[0-9a-fA-F]{3}$/.test(hex)) hex = '#' + hex[1]+hex[1] + hex[2]+hex[2] + hex[3]+hex[3]; return hex; } _shade(hex, f) { hex = this._expandHex(hex); const r = parseInt(hex.slice(1, 3), 16), g = parseInt(hex.slice(3, 5), 16), b = parseInt(hex.slice(5, 7), 16); return `rgb(${Math.floor(r*f)},${Math.floor(g*f)},${Math.floor(b*f)})`; } _lighten(hex, add) { hex = this._expandHex(hex); const r = Math.min(255, parseInt(hex.slice(1,3),16)+add); const g = Math.min(255, parseInt(hex.slice(3,5),16)+add); const b = Math.min(255, parseInt(hex.slice(5,7),16)+add); return `rgb(${r},${g},${b})`; } }