Files
Learn_System/frontend/js/labs/angrybirds.js
T
Maxim Dolgolyov 6afe928c0d feat(labs): visual polish wave — LabFX foundation + 33 sims juiced up
ФУНДАМЕНТ (4 новых файла):
- _fx_core.js: LabFX namespace, glow.drawGlow, glow.pulse, haptic, shake
- _fx_particles.js: пул 1500 объектов, 6 shapes (dot/spark/ring/smoke/splash/dust)
- _fx_motion.js: tween + 12 easings + critically-damped spring
- _fx_sound.js: 9 procedural synth-звуков (click/tick/whoosh/chime/fizz/spark/bounce/pour/drone), Web Audio API
- Sound toggle в шапке lab.html с localStorage-persist

UX МИКРО (CSS + JS):
- Button states: hover scale+brightness, active scale-down, disabled grayscale
- Slider polish: custom thumb с тенью, filled-track gradient, hover/active
- Focus rings через :focus-visible
- Tooltip system .tt-host data-tt= с 400ms hover, fade-in
- Marching ants для selection
- Loading skeleton с shimmer
- Empty state .sim-empty-* паттерн
- Toast: progress bar внизу, icons по типу
- Cursor states utility classes
- View Transitions API для smooth sim-switch, fallback на CSS fade

PHASE 2 — визуальные эффекты для 33 симуляций:

Physics motion: projectile (launch whoosh + landing splash/shake/haptic + target chime), pendulum (max-extension tick + bob glow), collision (bounce + sparks + shake), angrybirds (whoosh/bounce/fizz/chime + confetti), newton (rocket flame trail + scene transitions), forcesandbox (spring glow + impact sparks)

Physics fields: emfield (field-lines glow + particle trail + lightning при high field + Gauss-drag haptic + rod motion sparks), circuit (energized-wire glow ∝ current + LED bloom + short-circuit shake/spark + heat shimmer smoke + place/erase/switch sounds), opticsbench (beam glow на всех режимах + caustics dust у фокуса + TIR/Brewster one-shot sounds)

Thermo+waves: waves (mode-switch whoosh + Mach-cone particles + spectrum harmonic chime + waveform glow), hydrostatics (pour sound + splash при погружении + valve click), isoprocess (PV-trail dust), heatengine (drone цикла + hot/cold reservoir smoke/dust + phase-change ticks), radioactive (Geiger tick throttle + decay sparks + half-life chime + α/β/γ glow)

Chemistry: chemsandbox (pour splash + fizz bubbles/dust/spark по типу реакции + shake при горении), equilibrium/electrolysis/reactions/titration/flask/ionexchange/redox (pour/fizz/spark/chime по событиям, частицы при ключевых моментах), stoichiometry (fizz bubbles + recipe-change click)

Math+geom: geometry (tool-click + object-create tick + locus glow + challenge confetti via LabFX + haptic), triangle (vertex-drop tick + special-point glow), stereo (figure-change whoosh + cross-section chime, no particles — Three.js), trigcircle (drag-haptic + pitch∝angle tick + sin/cos glow), graph/graphtransform/quadratic (slider-tick throttled + curve glow + discriminant-cross chime), probability (bounce + finish-chime burst), normaldist (pulsing shade + bell glow)

Bio+misc: celldivision (phase-change whoosh + prophase dust + anaphase poles spark + cytokinesis chime), photosynthesis (photon tick + ATP chime + glucose sparkle), bohratom (electron-jump chime ∝ levels + photon spark), orbitals/crystal (mode-change whoosh — Three.js, sound only), logic (gate-place + wire-connect + LED bloom + HIGH wire flowing dots + preset chime), gas/brownian/diffusion/states (preset whoosh, throttled tick по событиям)

Все LabFX вызовы обёрнуты в if (window.LabFX) guard — graceful degradation если фундамент не загружен. 47 JS файлов прошли syntax check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 13:58:49 +03:00

938 lines
35 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;
if (window.LabFX) LabFX.particles.update(dt);
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();
/* LabFX: particles overlay */
if (window.LabFX) LabFX.particles.draw(this.ctx);
}
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);
/* LabFX: launch sound */
if (window.LabFX) LabFX.sound.play('whoosh', { pitch: 0.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);
/* LabFX: bird-block collision */
if (window.LabFX) {
LabFX.sound.play('bounce');
LabFX.particles.emit({
ctx: this.ctx, x: cx, y: cy,
count: 15, color: '#FFD166', speed: 80,
spread: Math.PI * 2, life: 400, shape: 'spark',
});
}
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);
/* LabFX: block destroyed */
if (window.LabFX) {
LabFX.sound.play('fizz');
LabFX.particles.emit({
ctx: this.ctx, x: b.x + b.w / 2, y: b.y + b.h / 2,
count: 25, color: AB_MATS[b.mat]?.debris || '#888',
speed: 100, spread: Math.PI * 2, life: 700, shape: 'splash',
});
}
}
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);
/* LabFX: pig defeated */
if (window.LabFX) {
LabFX.sound.play('chime');
LabFX.particles.emit({
ctx: this.ctx, x: p.x, y: p.y,
count: 30, color: ['#06D6E0', '#FFD166', '#EF476F'],
speed: 130, spread: Math.PI * 2, life: 800,
glow: true, shape: 'spark',
});
}
} 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 ── */