Files
Learn_System/frontend/js/labs/angrybirds.js
T
Maxim Dolgolyov ae31e4c4e8 refactor: distribute lab-init.js into 34 engine files
lab-init.js: 4098 -> 543 lines (infrastructure + THEORY only)

Each sim's _open*() + UI helpers moved to its engine file:
graph.js, projectile.js, collision.js, magnetic.js, triangle.js,
geometry.js, trigcircle.js, gas.js (molphys), coulomb.js, circuit.js,
reactions.js (chemistry), newton.js (dynamics), chemsandbox.js,
celldivision.js, photosynthesis.js, angrybirds.js, quadratic.js,
normaldist.js, graphtransform.js, pendulum.js, equilibrium.js,
thinlens.js, mirror.js, isoprocess.js, titration.js, refraction.js,
probability.js, bohratom.js, electrolysis.js, waves.js,
crystal.js, orbitals.js, stereo.js, hydrostatics.js

All 34 engine files syntax-checked OK.
2026-05-08 14:54:54 +03:00

905 lines
34 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})`;
}
}
/* ─── lab UI init ─────────────────────────────────── */
var angryBirdsSim = null;
function _openAngryBirds() {
document.getElementById('sim-topbar-title').textContent = 'Angry Birds Physics';
_simShow('sim-angrybirds');
_simShow('ctrl-angrybirds');
requestAnimationFrame(() => requestAnimationFrame(() => {
const c = document.getElementById('angrybirds-canvas');
if (!angryBirdsSim) {
angryBirdsSim = new AngryBirdsSim(c);
angryBirdsSim.onUpdate = _abUpdateUI;
c.addEventListener('mousedown', e => angryBirdsSim.handleMouseDown(e));
c.addEventListener('mousemove', e => angryBirdsSim.handleMouseMove(e));
c.addEventListener('mouseup', e => angryBirdsSim.handleMouseUp(e));
c.addEventListener('mouseleave', e => angryBirdsSim.handleMouseUp(e));
_addTouchSupport(c, angryBirdsSim);
}
angryBirdsSim.fit();
angryBirdsSim.start();
}));
}
function abLevel(n, btn) {
document.querySelectorAll('.ab-lvl-btn').forEach(b => b.classList.remove('active'));
if (btn) btn.classList.add('active');
if (angryBirdsSim) angryBirdsSim.loadLevel(n);
}
function angryBirdsRestart() {
if (angryBirdsSim) angryBirdsSim.restart();
}
function _abUpdateUI(info) {
const v = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
v('abbar-v1', info.level);
v('abbar-v2', info.birds);
v('abbar-v3', info.pigs);
v('abbar-v4', info.score.toLocaleString('ru'));
v('abbar-v5', info.planet);
/* sync level button highlight */
document.querySelectorAll('.ab-lvl-btn').forEach((b, i) => {
b.classList.toggle('active', i === (info.level - 1));
});
}
/* ── quadratic ── */