fd29acbbdd
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>
856 lines
32 KiB
JavaScript
856 lines
32 KiB
JavaScript
'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})`;
|
||
}
|
||
}
|