Files
Learn_System/frontend/js/labs/angrybirds.js
T
Maxim Dolgolyov fd29acbbdd feat: WebSocket real-time + rAF render gate + guest board + screen picker
Classroom performance:
- WebSocket server (ws-server.js) for low-latency cursor & stroke preview
  Replaces HTTP POST per event → eliminates per-message auth overhead
  Session member cache (30s TTL) avoids SQLite query per WS message
  Fallback to HTTP POST when WS not connected
- Cursor throttle reduced 100ms → 33ms (~30fps)
- Stroke preview throttle reduced 50ms → 20ms
- whiteboard.js: render() is now rAF-gated (_doRender/_rafPending)
  Multiple render() calls within one frame collapse into one repaint
  document.hidden check — zero CPU when tab is in background
  visibilitychange listener restores canvas on tab focus

Guest board:
- guestClassroom.js route: public token-based read-only access
- guest-board.html: name entry + read-only whiteboard + SSE
- SSE: addGuestClient/removeGuestClient/emitToGuests

Screen share picker:
- Discord-style modal with tab switching (screen/window/tab)
- Live video preview before confirming share
- useExistingScreenStream() in ClassroomRTC

Fullscreen exit overlay:
- #cr-fs-exit-overlay button inside cr-board-wrap
- Visible only via CSS :fullscreen selector (touchpad users)

File sharing from library:
- Teacher picks file from library, sends as styled card in chat
- crDownloadLibraryFile() fetches with Bearer auth

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 18:04:59 +03:00

856 lines
32 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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: 'Земля <svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>', sky1: '#0f1923', sky2: '#1a3a2a', ground: '#3d6b47', g_label: '9.81' },
moon: { g: 1.62, label: 'Луна <svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" fill="currentColor" stroke="none"/></svg>', sky1: '#000008', sky2: '#0a0a18', ground: '#7a7a66', g_label: '1.62' },
mars: { g: 3.71, label: 'Марс <svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" fill="currentColor" stroke="none"/></svg>', sky1: '#2a0800', sky2: '#5c1e00', ground: '#7a3010', g_label: '3.71' },
jupiter: { g: 24.79, label: 'Юпитер <svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" fill="currentColor" stroke="none"/></svg>', 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 <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> 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(/<svg[\s\S]*?<\/svg>/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 <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> #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})`;
}
}