Загрузка данных
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<title>Space Miner Shooter — health pickups</title>
<style>
* { margin:0; padding:0; box-sizing:border-box; }
body { background:#000; overflow:hidden; font-family:monospace; cursor:crosshair; }
canvas { display:block; }
#hud {
position:fixed; top:190px; left:12px;
color:#0ff; font-size:14px; line-height:1.6;
text-shadow:0 0 6px #0ff;
pointer-events:none; user-select:none;
}
#hud .label { color:#888; }
#hud .gold { color:#fc4; }
#hud .green { color:#4f4; }
#hud .orange { color:#f80; }
#hint {
position:fixed; bottom:14px; left:50%; transform:translateX(-50%);
color:#0ff; font-size:13px; text-shadow:0 0 6px #0ff;
opacity:1; transition:opacity 1s; pointer-events:none;
user-select:none; text-align:center;
}
#over {
position:fixed; inset:0; display:none; flex-direction:column;
align-items:center; justify-content:center; color:#fff;
background:rgba(0,0,0,.75); text-align:center; font-size:20px;
}
#over h1 { color:#f44; margin-bottom:16px; letter-spacing:2px; }
#over button {
margin-top:20px; padding:10px 30px; background:#0ff; border:none;
font-family:monospace; font-size:16px; cursor:pointer;
}
</style>
</head>
<body>
<canvas id="game"></canvas>
<div id="hud"></div>
<div id="hint">W/S — тяга · A/D — поворот · Мышь — прицел · ЛКМ — пули · ПКМ — ракеты · 1/2/3 — прокачка</div>
<div id="over">
<h1>КОРАБЛЬ УНИЧТОЖЕН</h1>
<div id="finalStats"></div>
<button onclick="restart()">В БОЙ СНОВА</button>
</div>
<script>
// ============================================================
// SPACE MINER SHOOTER — v12
// • Аптечки падают с ботов (всегда +20) и с астероидов (30%, +25)
// • Пассивный реген: +1 HP/сек через 3 сек без урона
// • Пули: fireRate 50, damage 50
// • ЛКМ + ПКМ одновременно
// • Боты 3 фракций воюют и качаются
// ============================================================
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const hud = document.getElementById('hud');
const hintEl = document.getElementById('hint');
const overEl = document.getElementById('over');
const finalStats = document.getElementById('finalStats');
// ---------- Утилиты ----------
const rand = (a, b) => Math.random() * (b - a) + a;
const randI = (a, b) => Math.floor(rand(a, b + 1));
const dist = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
const mod = (n, m) => ((n % m) + m) % m;
function normalizeAngle(a) {
while (a > Math.PI) a -= Math.PI * 2;
while (a < -Math.PI) a += Math.PI * 2;
return a;
}
function angleLerp(current, target, t) {
const diff = normalizeAngle(target - current);
return normalizeAngle(current + diff * t);
}
// ---------- Фракции ----------
const FACTION_COLORS = [
{ stroke: '#ff3355', glow: '#ff3355', fill: 'rgba(255,50,85,0.15)' },
{ stroke: '#ffaa33', glow: '#ffaa33', fill: 'rgba(255,170,50,0.15)' },
{ stroke: '#cc66ff', glow: '#cc66ff', fill: 'rgba(200,100,255,0.15)' },
];
let factionCounter = 0;
// ---------- Константы ----------
const HEAL_PER_UPGRADE = 15;
const DESPAWN_DIST = 2000;
const MINIMAP_SIZE = 160;
const MINIMAP_X = 12;
const MINIMAP_Y = 12;
const MAX_TURRET_OFFSET = 120 * Math.PI / 180;
const MAX_BOT_LEVEL = 5;
// Лечение
const HEALTH_DROP_ENEMY = 20; // +20 HP с бота
const HEALTH_DROP_ASTEROID= 25; // +25 HP с астероида
const ASTEROID_HEAL_CHANCE= 0.30; // 30% что астероид дропнет аптечку
const HEALTH_PICKUP_LIFE = 1200; // 20 сек живёт аптечка
const HEALTH_MAGNET_RANGE = 150;
// Пассивный реген
const REGEN_DELAY = 180; // 3 сек без урона
const REGEN_INTERVAL = 60; // +1 HP в секунду
const REGEN_AMOUNT = 1;
const camera = { x: 0, y: 0 };
// ---------- Звёзды ----------
let stars = [];
function generateStars() {
stars = Array.from({ length: 160 }, () => ({
x: Math.random() * canvas.width,
y: Math.random() * canvas.height,
s: Math.random() * 1.5 + 0.3,
a: 0.3 + Math.random() * 0.5,
}));
}
function resize() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
generateStars();
}
window.addEventListener('resize', resize);
resize();
// ---------- Ввод ----------
const keys = {};
const mouse = {
x: canvas.width / 2,
y: canvas.height / 2 - 100,
leftDown: false,
rightDown: false,
};
function setKey(e, down) {
const code = e.code || '';
const key = e.key || '';
if (code === 'Space' || key === ' ') {
keys['Space'] = down;
if (e.preventDefault) e.preventDefault();
return;
}
if (code) keys[code] = down;
}
window.addEventListener('keydown', e => {
setKey(e, true);
if (e.code === 'Digit1') upgrade('damage');
if (e.code === 'Digit2') upgrade('fireRate');
if (e.code === 'Digit3') upgrade('speed');
if (e.code === 'KeyR' && gameState.gameOver) restart();
});
window.addEventListener('keyup', e => setKey(e, false));
window.addEventListener('mousemove', e => {
mouse.x = e.clientX;
mouse.y = e.clientY;
});
canvas.addEventListener('mousedown', e => {
if (e.button === 0) { mouse.leftDown = true; e.preventDefault(); }
if (e.button === 2) { mouse.rightDown = true; e.preventDefault(); }
});
canvas.addEventListener('mouseup', e => {
if (e.button === 0) mouse.leftDown = false;
if (e.button === 2) mouse.rightDown = false;
});
window.addEventListener('mouseup', e => {
if (e.button === 0) mouse.leftDown = false;
if (e.button === 2) mouse.rightDown = false;
});
window.addEventListener('blur', () => {
mouse.leftDown = false;
mouse.rightDown = false;
});
canvas.addEventListener('mouseleave', () => {
mouse.leftDown = false;
mouse.rightDown = false;
});
canvas.addEventListener('contextmenu', e => e.preventDefault());
canvas.addEventListener('touchstart', e => {
mouse.leftDown = true;
if (e.touches[0]) { mouse.x = e.touches[0].clientX; mouse.y = e.touches[0].clientY; }
e.preventDefault();
}, { passive:false });
canvas.addEventListener('touchend', () => { mouse.leftDown = false; }, { passive:true });
canvas.addEventListener('touchmove', e => {
if (e.touches[0]) {
mouse.x = e.touches[0].clientX;
mouse.y = e.touches[0].clientY;
}
}, { passive:true });
// ---------- Состояние ----------
let gameState = {};
function init() {
camera.x = -canvas.width / 2;
camera.y = -canvas.height / 2;
factionCounter = 0;
gameState = {
player: {
x: 0, y: 0,
vx: 0, vy: 0,
bodyAngle: -Math.PI / 2,
targetBodyAngle: -Math.PI / 2,
turretAngle: -Math.PI / 2,
radius: 14, maxHealth: 100, health: 100,
fireCooldown: 0,
fireRate: 50,
missileCooldown: 0,
missileFireRate: 75,
damage: 50,
thrust: 0.04,
reverseThrust: 0.03,
turnSpeed: 0.022,
bodySmooth: 0.18,
maxSpeed: 1.5,
invuln: 0,
lastDamageTime: -1000, // когда последний раз получал урон
regenTick: 0, // тикер регена
},
bullets: [], enemyBullets: [], missiles: [],
asteroids: [], enemies: [],
resources: [], healthPickups: [], particles: [], floaters: [],
score: 0, resourcesCount: 0,
upgrades: { damage: 0, fireRate: 0, speed: 0 },
upgradeCost: { damage: 5, fireRate: 5, speed: 5 },
asteroidTimer: 0, enemyTimer: 0,
gameOver: false, time: 0,
};
}
// ---------- Спавн ----------
function edgeSpawnAroundViewport(margin) {
const left = camera.x - margin;
const right = camera.x + canvas.width + margin;
const top = camera.y - margin;
const bottom = camera.y + canvas.height + margin;
const side = randI(0, 3);
if (side === 0) return { x: rand(left, right), y: top };
if (side === 1) return { x: right, y: rand(top, bottom) };
if (side === 2) return { x: rand(left, right), y: bottom };
return { x: left, y: rand(top, bottom) };
}
function spawnAsteroid() {
const pos = edgeSpawnAroundViewport(60);
const size = rand(20, 48);
const verts = randI(7, 11);
const points = [];
for (let i = 0; i < verts; i++) {
points.push({ a: (i / verts) * Math.PI * 2, r: size * rand(0.72, 1.15) });
}
gameState.asteroids.push({
x: pos.x, y: pos.y,
vx: rand(-0.75, 0.75), vy: rand(-0.75, 0.75),
radius: size, hp: size * 1.8, maxHp: size * 1.8,
rotation: rand(0, 6.28), rotSpeed: rand(-0.01, 0.01),
points, size,
});
}
function spawnEnemy() {
const pos = edgeSpawnAroundViewport(60);
const faction = factionCounter % 3;
factionCounter++;
const baseHp = 30;
const baseSpeed = 0.9 + Math.min(gameState.time / 10000, 0.5);
gameState.enemies.push({
x: pos.x, y: pos.y, vx: 0, vy: 0, angle: 0,
radius: 13,
hp: baseHp, maxHp: baseHp, baseHp,
level: 1,
faction,
damage: 8,
speed: baseSpeed, baseSpeed,
cooldown: rand(30, 90), fireRate: 90,
target: null,
});
}
// ---------- Аптечки ----------
function spawnHealthPickup(x, y, amount) {
const ang = rand(0, 6.28);
const spd = rand(0.5, 1.5);
gameState.healthPickups.push({
x, y,
vx: Math.cos(ang) * spd,
vy: Math.sin(ang) * spd,
amount,
life: HEALTH_PICKUP_LIFE,
maxLife: HEALTH_PICKUP_LIFE,
phase: rand(0, 6.28),
});
}
// ---------- Прокачка игрока ----------
function upgrade(type) {
if (gameState.gameOver) return;
const cost = gameState.upgradeCost[type];
if (gameState.resourcesCount < cost) return;
gameState.resourcesCount -= cost;
gameState.upgrades[type]++;
gameState.upgradeCost[type] = Math.floor(cost * 1.6);
const p = gameState.player;
if (type === 'damage') p.damage += 25;
if (type === 'fireRate') p.fireRate = Math.max(25, p.fireRate - 5);
if (type === 'speed') {
p.thrust += 0.005;
p.maxSpeed += 0.125;
p.maxHealth += HEAL_PER_UPGRADE;
p.health = Math.min(p.maxHealth, p.health + HEAL_PER_UPGRADE);
spawnFloater(p.x, p.y - 30, `+${HEAL_PER_UPGRADE} HP`, '#4f4');
}
for (let i = 0; i < 12; i++) {
gameState.particles.push({
x: p.x, y: p.y,
vx: rand(-3, 3), vy: rand(-3, 3),
life: 40, maxLife: 40, color: '#0ff', radius: 2,
});
}
}
// ---------- Прокачка бота ----------
function levelUpEnemy(e) {
if (e.level >= MAX_BOT_LEVEL) return;
e.level++;
e.maxHp = e.baseHp + (e.level - 1) * 15;
e.hp = e.maxHp;
e.damage = 8 + (e.level - 1) * 2;
e.speed = e.baseSpeed + (e.level - 1) * 0.15;
e.radius = 13 + (e.level - 1) * 1.5;
burst(e.x, e.y, FACTION_COLORS[e.faction].stroke, 15);
}
// ---------- Стрельба игрока ----------
function playerShoot() {
const p = gameState.player;
const muzzleX = p.x + Math.cos(p.turretAngle) * (p.radius + 6);
const muzzleY = p.y + Math.sin(p.turretAngle) * (p.radius + 6);
gameState.bullets.push({
x: muzzleX, y: muzzleY,
prevX: muzzleX, prevY: muzzleY,
vx: Math.cos(p.turretAngle) * 6.5,
vy: Math.sin(p.turretAngle) * 6.5,
damage: p.damage,
radius: 6,
life: 140,
});
burst(muzzleX, muzzleY, '#0ff', 5);
}
// ---------- Ракета игрока ----------
function fireMissile() {
const p = gameState.player;
const muzzleX = p.x + Math.cos(p.turretAngle) * (p.radius + 6);
const muzzleY = p.y + Math.sin(p.turretAngle) * (p.radius + 6);
gameState.missiles.push({
x: muzzleX, y: muzzleY,
prevX: muzzleX, prevY: muzzleY,
vx: Math.cos(p.turretAngle) * 5.5,
vy: Math.sin(p.turretAngle) * 5.5,
speed: 5.5,
damage: 40,
radius: 5,
life: 300,
turnRate: 0.08,
target: null,
});
burst(muzzleX, muzzleY, '#f80', 5);
}
function findNearestEnemyForMissile(from) {
const gs = gameState;
let best = null, bestD = Infinity;
for (const e of gs.enemies) {
const d = dist(from, e);
if (d < bestD) { bestD = d; best = e; }
}
return best;
}
function updateMissiles() {
const gs = gameState;
for (let i = gs.missiles.length - 1; i >= 0; i--) {
const m = gs.missiles[i];
m.prevX = m.x; m.prevY = m.y;
if (!m.target || m.target.hp <= 0) {
m.target = findNearestEnemyForMissile(m);
}
if (m.target) {
const desiredAngle = Math.atan2(m.target.y - m.y, m.target.x - m.x);
const currentAngle = Math.atan2(m.vy, m.vx);
const newAngle = angleLerp(currentAngle, desiredAngle, m.turnRate);
m.vx = Math.cos(newAngle) * m.speed;
m.vy = Math.sin(newAngle) * m.speed;
}
m.x += m.vx; m.y += m.vy; m.life--;
if (m.life <= 0) { gs.missiles.splice(i, 1); continue; }
let hit = false;
for (let j = gs.asteroids.length - 1; j >= 0; j--) {
const a = gs.asteroids[j];
if (dist(m, a) < a.radius + m.radius) {
a.hp -= m.damage;
burst(m.x, m.y, '#f80', 12);
hit = true;
if (a.hp <= 0) {
explodeAsteroid(a);
gs.asteroids.splice(j, 1);
}
break;
}
}
if (hit) { gs.missiles.splice(i, 1); continue; }
for (let k = gs.enemies.length - 1; k >= 0; k--) {
const e = gs.enemies[k];
if (dist(m, e) < e.radius + m.radius) {
e.hp -= m.damage;
burst(m.x, m.y, '#f80', 16);
gs.missiles.splice(i, 1);
if (e.hp <= 0) {
explodeEnemy(e, gs.player);
gs.enemies.splice(k, 1);
}
break;
}
}
}
}
// ---------- Урон игроку ----------
function damagePlayer(amount) {
const p = gameState.player;
if (p.invuln > 0) return;
p.health -= amount;
p.invuln = 20;
p.lastDamageTime = gameState.time; // ← запоминаем время урона
p.regenTick = 0;
if (p.health <= 0) {
p.health = 0;
gameState.gameOver = true;
showGameOver();
}
}
function showGameOver() {
overEl.style.display = 'flex';
finalStats.innerHTML =
`Очки: <span style="color:#0ff">${gameState.score}</span><br>` +
`Ресурсы: <span style="color:#fc4">${gameState.resourcesCount}</span><br>` +
`Время: ${Math.floor(gameState.time / 60)} сек`;
}
// ---------- Выбор цели ботом ----------
function findBotTarget(me) {
const gs = gameState;
let best = null, bestD = Infinity;
if (!gs.gameOver) {
const d = dist(me, gs.player) * 0.85;
if (d < bestD) { bestD = d; best = gs.player; }
}
for (const e of gs.enemies) {
if (e === me) continue;
if (e.faction === me.faction) continue;
const d = dist(me, e);
if (d < bestD) { bestD = d; best = e; }
}
return best;
}
// ---------- Обновление ----------
function update() {
const gs = gameState;
const p = gs.player;
gs.time++;
if (!gs.gameOver) {
if (keys['KeyA'] || keys['ArrowLeft']) p.targetBodyAngle -= p.turnSpeed;
if (keys['KeyD'] || keys['ArrowRight']) p.targetBodyAngle += p.turnSpeed;
p.targetBodyAngle = normalizeAngle(p.targetBodyAngle);
p.bodyAngle = angleLerp(p.bodyAngle, p.targetBodyAngle, p.bodySmooth);
let thrusting = false;
let reversing = false;
if (keys['KeyW'] || keys['ArrowUp']) {
p.vx += Math.cos(p.bodyAngle) * p.thrust;
p.vy += Math.sin(p.bodyAngle) * p.thrust;
thrusting = true;
}
if (keys['KeyS'] || keys['ArrowDown']) {
p.vx -= Math.cos(p.bodyAngle) * p.reverseThrust;
p.vy -= Math.sin(p.bodyAngle) * p.reverseThrust;
reversing = true;
}
p.vx *= 0.99; p.vy *= 0.99;
const spd = Math.hypot(p.vx, p.vy);
if (spd > p.maxSpeed) {
p.vx = p.vx / spd * p.maxSpeed;
p.vy = p.vy / spd * p.maxSpeed;
}
p.x += p.vx; p.y += p.vy;
const wmx = mouse.x + camera.x;
const wmy = mouse.y + camera.y;
const desiredTurret = Math.atan2(wmy - p.y, wmx - p.x);
let offset = normalizeAngle(desiredTurret - p.bodyAngle);
if (offset > MAX_TURRET_OFFSET) offset = MAX_TURRET_OFFSET;
if (offset < -MAX_TURRET_OFFSET) offset = -MAX_TURRET_OFFSET;
p.turretAngle = normalizeAngle(p.bodyAngle + offset);
if (p.fireCooldown > 0) p.fireCooldown--;
if (p.missileCooldown > 0) p.missileCooldown--;
if (p.invuln > 0) p.invuln--;
// === ПАССИВНЫЙ РЕГЕН ===
if (gs.time - p.lastDamageTime > REGEN_DELAY && p.health < p.maxHealth) {
p.regenTick++;
if (p.regenTick >= REGEN_INTERVAL) {
p.regenTick = 0;
p.health = Math.min(p.maxHealth, p.health + REGEN_AMOUNT);
}
} else {
p.regenTick = 0;
}
if ((mouse.leftDown || keys['Space']) && p.fireCooldown <= 0) {
playerShoot();
p.fireCooldown = p.fireRate;
}
if (mouse.rightDown && p.missileCooldown <= 0) {
fireMissile();
p.missileCooldown = p.missileFireRate;
}
p._thrusting = thrusting;
p._reversing = reversing;
}
camera.x = p.x - canvas.width / 2;
camera.y = p.y - canvas.height / 2;
gs.asteroidTimer--;
if (gs.asteroidTimer <= 0) {
spawnAsteroid();
gs.asteroidTimer = Math.max(20, 70 - Math.floor(gs.time / 600) * 8);
}
gs.enemyTimer--;
if (gs.enemyTimer <= 0) {
spawnEnemy();
gs.enemyTimer = Math.max(120, 400 - Math.floor(gs.time / 300) * 20);
}
for (let i = gs.bullets.length - 1; i >= 0; i--) {
const b = gs.bullets[i];
b.prevX = b.x; b.prevY = b.y;
b.x += b.vx; b.y += b.vy; b.life--;
if (b.life <= 0) { gs.bullets.splice(i, 1); continue; }
let hit = false;
for (let j = gs.asteroids.length - 1; j >= 0; j--) {
const a = gs.asteroids[j];
if (dist(b, a) < a.radius + b.radius) {
a.hp -= b.damage;
burst(b.x, b.y, '#ff0', 4);
hit = true;
if (a.hp <= 0) {
explodeAsteroid(a);
gs.asteroids.splice(j, 1);
}
break;
}
}
if (hit) { gs.bullets.splice(i, 1); continue; }
for (let k = gs.enemies.length - 1; k >= 0; k--) {
const e = gs.enemies[k];
if (dist(b, e) < e.radius + b.radius) {
e.hp -= b.damage;
burst(b.x, b.y, FACTION_COLORS[e.faction].stroke, 4);
gs.bullets.splice(i, 1);
if (e.hp <= 0) {
explodeEnemy(e, gs.player);
gs.enemies.splice(k, 1);
}
break;
}
}
}
updateMissiles();
for (let i = gs.enemyBullets.length - 1; i >= 0; i--) {
const b = gs.enemyBullets[i];
b.x += b.vx; b.y += b.vy; b.life--;
if (b.life <= 0) { gs.enemyBullets.splice(i, 1); continue; }
if (!gs.gameOver && dist(b, p) < p.radius + b.radius) {
damagePlayer(b.damage);
gs.enemyBullets.splice(i, 1);
burst(b.x, b.y, '#f44', 6);
continue;
}
let consumed = false;
for (let k = gs.enemies.length - 1; k >= 0; k--) {
const e = gs.enemies[k];
if (e === b.owner) continue;
if (e.faction === b.faction) continue;
if (dist(b, e) < e.radius + b.radius) {
e.hp -= b.damage;
burst(b.x, b.y, FACTION_COLORS[e.faction].stroke, 4);
gs.enemyBullets.splice(i, 1);
consumed = true;
if (e.hp <= 0) {
explodeEnemy(e, b.owner);
gs.enemies.splice(k, 1);
}
break;
}
}
if (consumed) continue;
}
for (let k = gs.enemies.length - 1; k >= 0; k--) {
const e = gs.enemies[k];
if (!gs.gameOver) {
const target = findBotTarget(e);
e.target = target;
if (target) {
const ang = Math.atan2(target.y - e.y, target.x - e.x);
e.angle = ang;
e.vx += Math.cos(ang) * 0.02;
e.vy += Math.sin(ang) * 0.02;
const s = Math.hypot(e.vx, e.vy);
if (s > e.speed) { e.vx = e.vx / s * e.speed; e.vy = e.vy / s * e.speed; }
}
}
e.x += e.vx; e.y += e.vy;
e.cooldown--;
if (e.cooldown <= 0 && e.target && !gs.gameOver) {
const a = Math.atan2(e.target.y - e.y, e.target.x - e.x);
gs.enemyBullets.push({
x: e.x, y: e.y,
vx: Math.cos(a) * 2.5, vy: Math.sin(a) * 2.5,
radius: 4, damage: e.damage, life: 360,
owner: e, faction: e.faction,
});
e.cooldown = e.fireRate;
}
if (!gs.gameOver && dist(e, p) < e.radius + p.radius) {
damagePlayer(15);
gs.enemies.splice(k, 1);
burst(e.x, e.y, FACTION_COLORS[e.faction].stroke, 14);
}
}
for (const a of gs.asteroids) {
a.x += a.vx; a.y += a.vy; a.rotation += a.rotSpeed;
if (!gs.gameOver && dist(a, p) < a.radius + p.radius) {
damagePlayer(10 + a.radius * 0.3);
const ang = Math.atan2(p.y - a.y, p.x - a.x);
p.vx += Math.cos(ang) * 1.5;
p.vy += Math.sin(ang) * 1.5;
}
}
// === РЕСУРСЫ ===
for (let i = gs.resources.length - 1; i >= 0; i--) {
const r = gs.resources[i];
r.life--;
if (!gs.gameOver) {
const d = dist(r, p);
if (d < HEALTH_MAGNET_RANGE) {
const ang = Math.atan2(p.y - r.y, p.x - r.x);
r.x += Math.cos(ang) * 4;
r.y += Math.sin(ang) * 4;
}
if (d < p.radius + 8) {
gs.resourcesCount++;
gs.score += 10;
spawnFloater(p.x, p.y - 30, `+1`, '#fc4');
burst(r.x, r.y, '#fc4', 8);
gs.resources.splice(i, 1);
continue;
}
}
if (r.life <= 0) gs.resources.splice(i, 1);
}
// === АПТЕЧКИ ===
for (let i = gs.healthPickups.length - 1; i >= 0; i--) {
const hp = gs.healthPickups[i];
hp.life--;
// лёгкий дрейф
hp.x += hp.vx; hp.y += hp.vy;
hp.vx *= 0.97; hp.vy *= 0.97;
if (!gs.gameOver) {
const d = dist(hp, p);
// притяжение
if (d < HEALTH_MAGNET_RANGE) {
const ang = Math.atan2(p.y - hp.y, p.x - hp.x);
const pull = 4 + (1 - d / HEALTH_MAGNET_RANGE) * 4;
hp.x += Math.cos(ang) * pull;
hp.y += Math.sin(ang) * pull;
}
// подбор
if (d < p.radius + 10) {
const before = p.health;
p.health = Math.min(p.maxHealth, p.health + hp.amount);
const healed = Math.round(p.health - before);
if (healed > 0) {
spawnFloater(p.x, p.y - 30, `+${healed} HP`, '#4f4');
}
burst(hp.x, hp.y, '#4f4', 10);
gs.healthPickups.splice(i, 1);
continue;
}
}
if (hp.life <= 0) gs.healthPickups.splice(i, 1);
}
for (let i = gs.particles.length - 1; i >= 0; i--) {
const pt = gs.particles[i];
pt.x += pt.vx; pt.y += pt.vy;
pt.vx *= 0.96; pt.vy *= 0.96;
pt.life--;
if (pt.life <= 0) gs.particles.splice(i, 1);
}
for (let i = gs.floaters.length - 1; i >= 0; i--) {
const f = gs.floaters[i];
f.y -= 0.6; f.life--;
if (f.life <= 0) gs.floaters.splice(i, 1);
}
for (let i = gs.asteroids.length - 1; i >= 0; i--) {
if (dist(gs.asteroids[i], p) > DESPAWN_DIST) gs.asteroids.splice(i, 1);
}
for (let i = gs.enemies.length - 1; i >= 0; i--) {
if (dist(gs.enemies[i], p) > DESPAWN_DIST) gs.enemies.splice(i, 1);
}
for (let i = gs.bullets.length - 1; i >= 0; i--) {
if (dist(gs.bullets[i], p) > DESPAWN_DIST) gs.bullets.splice(i, 1);
}
for (let i = gs.enemyBullets.length - 1; i >= 0; i--) {
if (dist(gs.enemyBullets[i], p) > DESPAWN_DIST) gs.enemyBullets.splice(i, 1);
}
for (let i = gs.missiles.length - 1; i >= 0; i--) {
if (dist(gs.missiles[i], p) > DESPAWN_DIST) gs.missiles.splice(i, 1);
}
for (let i = gs.resources.length - 1; i >= 0; i--) {
if (dist(gs.resources[i], p) > DESPAWN_DIST) gs.resources.splice(i, 1);
}
for (let i = gs.healthPickups.length - 1; i >= 0; i--) {
if (dist(gs.healthPickups[i], p) > DESPAWN_DIST) gs.healthPickups.splice(i, 1);
}
}
function spawnFloater(x, y, text, color) {
gameState.floaters.push({ x, y, text, color, life: 60, maxLife: 60 });
}
function explodeAsteroid(a) {
const gs = gameState;
gs.score += Math.floor(a.size / 5);
burst(a.x, a.y, '#fa0', 20);
// ресурсы
const drops = randI(1, 3);
for (let i = 0; i < drops; i++) {
gs.resources.push({
x: a.x + rand(-20, 20), y: a.y + rand(-20, 20),
vx: rand(-1, 1), vy: rand(-1, 1),
life: 600, radius: 6, phase: rand(0, 6.28),
});
}
// 💊 шанс аптечки
if (Math.random() < ASTEROID_HEAL_CHANCE) {
spawnHealthPickup(a.x, a.y, HEALTH_DROP_ASTEROID);
}
}
function explodeEnemy(e, killer) {
const gs = gameState;
burst(e.x, e.y, FACTION_COLORS[e.faction].stroke, 18);
// ресурс
gs.resources.push({
x: e.x, y: e.y, vx: rand(-0.5, 0.5), vy: rand(-0.5, 0.5),
life: 600, radius: 7, phase: rand(0, 6.28),
});
// 💊 аптечка всегда
spawnHealthPickup(e.x, e.y, HEALTH_DROP_ENEMY);
if (killer === gs.player) {
gs.score += 25;
}
if (killer && killer !== gs.player && killer.hp > 0) {
levelUpEnemy(killer);
}
}
function burst(x, y, color, count) {
for (let i = 0; i < count; i++) {
const ang = rand(0, 6.28), spd = rand(1, 5);
gameState.particles.push({
x, y, vx: Math.cos(ang) * spd, vy: Math.sin(ang) * spd,
life: randI(20, 45), maxLife: 45, color, radius: rand(1, 3),
});
}
}
function isVisible(obj, margin = 80) {
return obj.x + margin > camera.x &&
obj.x - margin < camera.x + canvas.width &&
obj.y + margin > camera.y &&
obj.y - margin < camera.y + canvas.height;
}
// ---------- Отрисовка ----------
function draw() {
const gs = gameState;
const p = gs.player;
ctx.fillStyle = '#03030a';
ctx.fillRect(0, 0, canvas.width, canvas.height);
drawStarsParallax();
ctx.save();
ctx.translate(-camera.x, -camera.y);
// ресурсы (жёлтые ромбы)
for (const r of gs.resources) {
if (!isVisible(r)) continue;
const glow = 0.5 + Math.sin(gs.time * 0.1 + r.phase) * 0.5;
ctx.save();
ctx.shadowColor = '#fc4';
ctx.shadowBlur = 12 * glow;
ctx.fillStyle = '#fc4';
ctx.beginPath();
ctx.moveTo(r.x, r.y - 6);
ctx.lineTo(r.x + 5, r.y);
ctx.lineTo(r.x, r.y + 6);
ctx.lineTo(r.x - 5, r.y);
ctx.closePath();
ctx.fill();
ctx.restore();
}
// === АПТЕЧКИ (зелёные крестики) ===
for (const hp of gs.healthPickups) {
if (!isVisible(hp, 30)) continue;
const pulse = 0.6 + Math.sin(gs.time * 0.12 + hp.phase) * 0.4;
const blink = hp.life < 180 ? (Math.floor(gs.time / 6) % 2 === 0 ? 0.35 : 1) : 1;
ctx.save();
ctx.globalAlpha = blink;
// аура
ctx.shadowColor = '#4f4';
ctx.shadowBlur = 14 * pulse;
// крестик
ctx.fillStyle = '#4f4';
ctx.fillRect(hp.x - 7, hp.y - 2.5, 14, 5);
ctx.fillRect(hp.x - 2.5, hp.y - 7, 5, 14);
// внутренний светлый крестик
ctx.shadowBlur = 0;
ctx.fillStyle = '#bfffbf';
ctx.fillRect(hp.x - 5, hp.y - 1.5, 10, 3);
ctx.fillRect(hp.x - 1.5, hp.y - 5, 3, 10);
// внешний контур
ctx.strokeStyle = 'rgba(180,255,180,0.7)';
ctx.lineWidth = 1;
ctx.strokeRect(hp.x - 7, hp.y - 2.5, 14, 5);
ctx.strokeRect(hp.x - 2.5, hp.y - 7, 5, 14);
ctx.restore();
}
for (const a of gs.asteroids) {
if (!isVisible(a, a.radius)) continue;
ctx.save();
ctx.translate(a.x, a.y);
ctx.rotate(a.rotation);
ctx.strokeStyle = a.hp < a.maxHp * 0.5 ? '#f84' : '#8aa';
ctx.fillStyle = 'rgba(60,70,90,0.4)';
ctx.lineWidth = 2;
ctx.beginPath();
for (let i = 0; i < a.points.length; i++) {
const pt = a.points[i];
const x = Math.cos(pt.a) * pt.r;
const y = Math.sin(pt.a) * pt.r;
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.closePath();
ctx.fill(); ctx.stroke();
ctx.restore();
}
for (const e of gs.enemies) {
if (!isVisible(e, e.radius + 20)) continue;
const fc = FACTION_COLORS[e.faction];
if (e.level > 1) {
ctx.save();
ctx.strokeStyle = fc.stroke;
ctx.globalAlpha = 0.25 + e.level * 0.1;
ctx.lineWidth = 1.5;
ctx.shadowColor = fc.glow;
ctx.shadowBlur = 8;
ctx.beginPath();
ctx.arc(e.x, e.y, e.radius + 6, 0, 6.28);
ctx.stroke();
ctx.restore();
}
ctx.save();
ctx.translate(e.x, e.y);
ctx.rotate(e.angle);
ctx.strokeStyle = fc.stroke;
ctx.fillStyle = fc.fill;
ctx.lineWidth = 2;
ctx.shadowColor = fc.glow;
ctx.shadowBlur = 6;
ctx.beginPath();
ctx.moveTo( e.radius * 0.92, 0);
ctx.lineTo(-e.radius * 0.62, -e.radius * 0.69);
ctx.lineTo(-e.radius * 0.31, 0);
ctx.lineTo(-e.radius * 0.62, e.radius * 0.69);
ctx.closePath();
ctx.fill(); ctx.stroke();
ctx.restore();
if (e.hp < e.maxHp) {
ctx.fillStyle = '#300';
ctx.fillRect(e.x - 14, e.y - 22, 28, 3);
ctx.fillStyle = fc.stroke;
ctx.fillRect(e.x - 14, e.y - 22, 28 * (e.hp / e.maxHp), 3);
}
if (e.level > 1) {
ctx.fillStyle = fc.stroke;
ctx.font = 'bold 11px monospace';
ctx.textAlign = 'center';
ctx.shadowColor = fc.glow;
ctx.shadowBlur = 6;
ctx.fillText(e.level, e.x, e.y - 27);
ctx.shadowBlur = 0;
}
}
ctx.textAlign = 'start';
ctx.save();
for (const b of gs.bullets) {
if (!isVisible(b)) continue;
ctx.strokeStyle = 'rgba(0,255,255,0.35)';
ctx.lineWidth = b.radius * 1.6;
ctx.beginPath();
ctx.moveTo(b.prevX ?? b.x, b.prevY ?? b.y);
ctx.lineTo(b.x, b.y);
ctx.stroke();
ctx.shadowColor = '#0ff';
ctx.shadowBlur = 20;
ctx.fillStyle = '#fff';
ctx.beginPath();
ctx.arc(b.x, b.y, b.radius, 0, 6.28);
ctx.fill();
ctx.shadowBlur = 0;
ctx.fillStyle = '#0ff';
ctx.beginPath();
ctx.arc(b.x, b.y, b.radius * 0.65, 0, 6.28);
ctx.fill();
}
ctx.restore();
ctx.save();
for (const m of gs.missiles) {
if (!isVisible(m, 60)) continue;
ctx.strokeStyle = 'rgba(255,140,40,0.5)';
ctx.lineWidth = m.radius * 1.4;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(m.x - m.vx * 3.5, m.y - m.vy * 3.5);
ctx.lineTo(m.x, m.y);
ctx.stroke();
ctx.lineCap = 'butt';
ctx.shadowColor = '#f80';
ctx.shadowBlur = 16;
ctx.fillStyle = '#f80';
ctx.beginPath();
ctx.arc(m.x, m.y, m.radius, 0, 6.28);
ctx.fill();
ctx.shadowBlur = 0;
ctx.fillStyle = '#fff';
ctx.beginPath();
ctx.arc(m.x, m.y, m.radius * 0.45, 0, 6.28);
ctx.fill();
}
ctx.restore();
ctx.save();
ctx.shadowBlur = 10;
for (const b of gs.enemyBullets) {
if (!isVisible(b)) continue;
const fc = FACTION_COLORS[b.faction] || FACTION_COLORS[0];
ctx.shadowColor = fc.glow;
ctx.fillStyle = fc.stroke;
ctx.beginPath();
ctx.arc(b.x, b.y, b.radius, 0, 6.28);
ctx.fill();
}
ctx.restore();
if (!gs.gameOver) {
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(p.bodyAngle);
if (p.invuln > 0 && Math.floor(p.invuln / 3) % 2 === 0) ctx.globalAlpha = 0.3;
ctx.strokeStyle = '#0ff';
ctx.fillStyle = 'rgba(0,255,255,0.1)';
ctx.lineWidth = 2;
ctx.shadowColor = '#0ff';
ctx.shadowBlur = 10;
ctx.beginPath();
ctx.moveTo(14, 0);
ctx.lineTo(-10, -9);
ctx.lineTo(-5, 0);
ctx.lineTo(-10, 9);
ctx.closePath();
ctx.fill(); ctx.stroke();
ctx.shadowBlur = 0;
if (p._thrusting) {
ctx.fillStyle = '#f80';
ctx.beginPath();
ctx.moveTo(-6, -4);
ctx.lineTo(-14 - Math.random() * 8, 0);
ctx.lineTo(-6, 4);
ctx.closePath();
ctx.fill();
} else if (p._reversing) {
ctx.fillStyle = '#48f';
ctx.beginPath();
ctx.moveTo(-4, -3);
ctx.lineTo(-10 - Math.random() * 5, 0);
ctx.lineTo(-4, 3);
ctx.closePath();
ctx.fill();
}
ctx.restore();
ctx.save();
ctx.translate(p.x, p.y);
ctx.strokeStyle = 'rgba(0,255,255,0.12)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(0, 0, p.radius + 22, p.bodyAngle - MAX_TURRET_OFFSET, p.bodyAngle + MAX_TURRET_OFFSET);
ctx.stroke();
ctx.restore();
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(p.turretAngle);
ctx.shadowColor = '#0ff';
ctx.shadowBlur = 8;
ctx.fillStyle = '#bff';
ctx.fillRect(6, -2.5, 14, 5);
ctx.fillStyle = '#0ff';
ctx.beginPath();
ctx.arc(0, 0, 6, 0, 6.28);
ctx.fill();
ctx.fillStyle = '#042';
ctx.beginPath();
ctx.arc(0, 0, 2.5, 0, 6.28);
ctx.fill();
ctx.shadowBlur = 0;
ctx.restore();
// HP bar
ctx.fillStyle = '#300';
ctx.fillRect(p.x - 22, p.y - 30, 44, 5);
ctx.fillStyle = p.health > 40 ? '#0f0' : '#f80';
ctx.fillRect(p.x - 22, p.y - 30, 44 * (p.health / p.maxHealth), 5);
// индикатор регена (зелёная полоска-«искры» над HP)
if (gs.time - p.lastDamageTime > REGEN_DELAY && p.health < p.maxHealth) {
const ratio = p.regenTick / REGEN_INTERVAL;
ctx.fillStyle = 'rgba(80,255,80,0.25)';
ctx.fillRect(p.x - 22, p.y - 34, 44, 2);
ctx.fillStyle = '#4f4';
ctx.fillRect(p.x - 22, p.y - 34, 44 * ratio, 2);
}
// индикатор готовности орудия
if (p.fireCooldown > 0) {
const ratio = 1 - p.fireCooldown / p.fireRate;
ctx.fillStyle = 'rgba(0,255,255,0.15)';
ctx.fillRect(p.x - 16, p.y + 22, 32, 3);
ctx.fillStyle = '#0ff';
ctx.fillRect(p.x - 16, p.y + 22, 32 * ratio, 3);
} else {
ctx.fillStyle = '#0ff';
ctx.fillRect(p.x - 16, p.y + 22, 32, 3);
}
}
for (const pt of gs.particles) {
if (!isVisible(pt, 10)) continue;
const a = pt.life / pt.maxLife;
ctx.globalAlpha = a;
ctx.fillStyle = pt.color;
ctx.beginPath();
ctx.arc(pt.x, pt.y, pt.radius * a, 0, 6.28);
ctx.fill();
}
ctx.globalAlpha = 1;
for (const f of gs.floaters) {
const a = f.life / f.maxLife;
ctx.globalAlpha = a;
ctx.fillStyle = f.color;
ctx.font = 'bold 14px monospace';
ctx.textAlign = 'center';
ctx.shadowColor = f.color;
ctx.shadowBlur = 8;
ctx.fillText(f.text, f.x, f.y);
ctx.shadowBlur = 0;
}
ctx.globalAlpha = 1;
ctx.textAlign = 'start';
ctx.restore();
drawMinimap();
if (!gs.gameOver) {
ctx.save();
ctx.strokeStyle = 'rgba(0,255,255,0.8)';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(mouse.x, mouse.y, 8, 0, 6.28);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(mouse.x - 12, mouse.y); ctx.lineTo(mouse.x - 4, mouse.y);
ctx.moveTo(mouse.x + 4, mouse.y); ctx.lineTo(mouse.x + 12, mouse.y);
ctx.moveTo(mouse.x, mouse.y - 12); ctx.lineTo(mouse.x, mouse.y - 4);
ctx.moveTo(mouse.x, mouse.y + 4); ctx.lineTo(mouse.x, mouse.y + 12);
ctx.stroke();
const wmx = mouse.x + camera.x;
const wmy = mouse.y + camera.y;
const desiredTurret = Math.atan2(wmy - p.y, wmx - p.x);
const off = normalizeAngle(desiredTurret - p.bodyAngle);
if (Math.abs(off) > MAX_TURRET_OFFSET) {
ctx.strokeStyle = 'rgba(255,80,80,0.9)';
ctx.beginPath();
ctx.arc(mouse.x, mouse.y, 14, 0, 6.28);
ctx.stroke();
}
ctx.restore();
}
updateHUD();
}
function drawStarsParallax() {
const px = -camera.x * 0.2;
const py = -camera.y * 0.2;
ctx.fillStyle = '#fff';
for (const s of stars) {
const x = mod(s.x + px, canvas.width);
const y = mod(s.y + py, canvas.height);
ctx.globalAlpha = s.a;
ctx.fillRect(x, y, s.s, s.s);
}
ctx.globalAlpha = 1;
}
function drawMinimap() {
const gs = gameState;
const p = gs.player;
const size = MINIMAP_SIZE;
const mx = MINIMAP_X;
const my = MINIMAP_Y;
const range = Math.max(canvas.width, canvas.height) * 2.5;
const scale = size / range;
const cx = mx + size / 2;
const cy = my + size / 2;
ctx.fillStyle = 'rgba(0,15,25,0.9)';
ctx.fillRect(mx, my, size, size);
ctx.strokeStyle = 'rgba(0,255,255,0.08)';
ctx.lineWidth = 1;
for (let i = 1; i < 4; i++) {
const gx = mx + (size / 4) * i;
const gy = my + (size / 4) * i;
ctx.beginPath();
ctx.moveTo(gx, my); ctx.lineTo(gx, my + size);
ctx.moveTo(mx, gy); ctx.lineTo(mx + size, gy);
ctx.stroke();
}
ctx.save();
ctx.beginPath();
ctx.rect(mx, my, size, size);
ctx.clip();
ctx.fillStyle = '#8aa';
for (const a of gs.asteroids) {
const dx = (a.x - p.x) * scale;
const dy = (a.y - p.y) * scale;
if (Math.abs(dx) > size / 2 || Math.abs(dy) > size / 2) continue;
ctx.fillRect(cx + dx - 1, cy + dy - 1, 2, 2);
}
for (const e of gs.enemies) {
const dx = (e.x - p.x) * scale;
const dy = (e.y - p.y) * scale;
if (Math.abs(dx) > size / 2 || Math.abs(dy) > size / 2) continue;
const fc = FACTION_COLORS[e.faction];
ctx.fillStyle = fc.stroke;
const sz = 2.5 + e.level * 0.5;
ctx.fillRect(cx + dx - sz / 2, cy + dy - sz / 2, sz, sz);
}
ctx.fillStyle = '#fc4';
for (const r of gs.resources) {
const dx = (r.x - p.x) * scale;
const dy = (r.y - p.y) * scale;
if (Math.abs(dx) > size / 2 || Math.abs(dy) > size / 2) continue;
ctx.fillRect(cx + dx - 1, cy + dy - 1, 2, 2);
}
// аптечки — маленькие зелёные крестики
ctx.fillStyle = '#4f4';
for (const hp of gs.healthPickups) {
const dx = (hp.x - p.x) * scale;
const dy = (hp.y - p.y) * scale;
if (Math.abs(dx) > size / 2 || Math.abs(dy) > size / 2) continue;
ctx.fillRect(cx + dx - 1, cy + dy - 3, 2, 6);
ctx.fillRect(cx + dx - 3, cy + dy - 1, 6, 2);
}
const vw = canvas.width * scale;
const vh = canvas.height * scale;
ctx.strokeStyle = 'rgba(0,255,255,0.55)';
ctx.lineWidth = 1;
ctx.strokeRect(cx - vw / 2, cy - vh / 2, vw, vh);
ctx.strokeStyle = '#0ff';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.lineTo(cx + Math.cos(p.bodyAngle) * 8, cy + Math.sin(p.bodyAngle) * 8);
ctx.stroke();
ctx.strokeStyle = 'rgba(252,200,60,0.6)';
ctx.beginPath();
ctx.arc(cx, cy, 9, p.bodyAngle - MAX_TURRET_OFFSET, p.bodyAngle + MAX_TURRET_OFFSET);
ctx.stroke();
ctx.strokeStyle = '#fc4';
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.lineTo(cx + Math.cos(p.turretAngle) * 11, cy + Math.sin(p.turretAngle) * 11);
ctx.stroke();
ctx.fillStyle = '#0ff';
ctx.shadowColor = '#0ff';
ctx.shadowBlur = 6;
ctx.beginPath();
ctx.arc(cx, cy, 3, 0, 6.28);
ctx.fill();
ctx.shadowBlur = 0;
ctx.restore();
ctx.strokeStyle = '#0ff';
ctx.lineWidth = 2;
ctx.shadowColor = '#0ff';
ctx.shadowBlur = 6;
ctx.strokeRect(mx, my, size, size);
ctx.shadowBlur = 0;
}
function updateHUD() {
const gs = gameState;
const p = gs.player;
const missileReady = p.missileCooldown <= 0;
const factionStats = [0, 0, 0];
let maxLevel = 1;
for (const e of gs.enemies) {
factionStats[e.faction]++;
if (e.level > maxLevel) maxLevel = e.level;
}
// статус регена
let regenStatus = '';
if (p.health < p.maxHealth) {
if (gs.time - p.lastDamageTime > REGEN_DELAY) {
regenStatus = `<span class="green">▲ реген активен</span>`;
} else {
const left = Math.ceil((REGEN_DELAY - (gs.time - p.lastDamageTime)) / 60);
regenStatus = `<span class="label">реген через ${left}с</span>`;
}
} else {
regenStatus = `<span class="green">HP полное</span>`;
}
hud.innerHTML =
`<div class="label">HP</div>` +
`<div>${Math.ceil(p.health)} / ${p.maxHealth}</div>` +
`<div style="font-size:11px">${regenStatus}</div><br>` +
`<div class="label">ОЧКИ</div>` +
`<div>${gs.score}</div><br>` +
`<div class="label">РЕСУРСЫ</div>` +
`<div class="gold">${gs.resourcesCount}</div><br>` +
`<div class="label">РАКЕТА <span class="orange">(ПКМ)</span></div>` +
`<div class="orange">${missileReady ? 'ГОТОВА' : `перезарядка ${Math.ceil(p.missileCooldown / 60 * 10) / 10}s`}</div><br>` +
`<div class="label">ФРАКЦИИ</div>` +
`<div><span style="color:${FACTION_COLORS[0].stroke}">●${factionStats[0]}</span> ` +
`<span style="color:${FACTION_COLORS[1].stroke}">●${factionStats[1]}</span> ` +
`<span style="color:${FACTION_COLORS[2].stroke}">●${factionStats[2]}</span> ` +
`<span style="color:#888">(макс.ур ${maxLevel})</span></div><br>` +
`<div class="label">ПРОКАЧКА</div>` +
`<div>[1] Урон ${p.damage} — ${gs.upgradeCost.damage}💰</div>` +
`<div>[2] Скорострельность ${(60/p.fireRate).toFixed(1)}/с — ${gs.upgradeCost.fireRate}💰</div>` +
`<div>[3] +${HEAL_PER_UPGRADE} HP + двиг — ${gs.upgradeCost.speed}💰</div>`;
}
function restart() {
overEl.style.display = 'none';
init();
}
function loop() {
if (!gameState.gameOver) {
update();
} else {
for (let i = gameState.particles.length - 1; i >= 0; i--) {
const pt = gameState.particles[i];
pt.x += pt.vx; pt.y += pt.vy;
pt.vx *= 0.96; pt.vy *= 0.96;
pt.life--;
if (pt.life <= 0) gameState.particles.splice(i, 1);
}
for (let i = gameState.floaters.length - 1; i >= 0; i--) {
const f = gameState.floaters[i];
f.y -= 0.6; f.life--;
if (f.life <= 0) gameState.floaters.splice(i, 1);
}
}
draw();
requestAnimationFrame(loop);
}
init();
loop();
setTimeout(() => { hintEl.style.opacity = '0'; }, 8000);
</script>
</body>
</html>