Звёздный страж — игра в одном HTML-файле
Короткий ответ: ниже один файл: <canvas> + игровой цикл на requestAnimationFrame, три состояния (start / play / over), спавн звёзд и астероидов по таймерам, столкновения проверяются по кругам, управление — пальцем (корабль держится чуть выше пальца). Скопируй всё в OneCompiler (вкладка HTML) → Run.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>Звёздный страж</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
height: 100%; overflow: hidden; background: #05070f;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif;
-webkit-user-select: none; user-select: none; -webkit-tap-highlight-color: transparent;
}
#game { position: relative; width: 100%; height: 100vh; height: 100dvh; overflow: hidden; touch-action: none; }
#board { display: block; width: 100%; height: 100%; }
#hud {
position: absolute; top: 0; left: 0; right: 0;
display: flex; justify-content: space-between; padding: 14px 18px;
font-size: 16px; font-weight: 800; letter-spacing: .5px;
color: #e9f0ff; text-shadow: 0 2px 8px rgba(0,0,0,.6); pointer-events: none;
}
#hudLives { color: #ff8f9c; }
#overlay {
position: absolute; inset: 0; display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 14px; padding: 26px;
text-align: center; background: rgba(5, 7, 15, .78);
}
#overlay.hidden { display: none; }
#ovTitle { font-size: clamp(22px, 7vw, 34px); color: #eaf3ff; }
#ovText { max-width: 340px; font-size: clamp(13px, 3.6vw, 16px); line-height: 1.5; color: #9fb0d4; }
#startBtn {
border: 0; border-radius: 999px; padding: 14px 34px; font-size: 17px; font-weight: 800;
color: #052014; background: linear-gradient(180deg, #8bf3c4, #2fc48d);
box-shadow: 0 10px 26px rgba(47, 196, 141, .35); cursor: pointer;
}
#startBtn:active { transform: translateY(1px); }
</style>
</head>
<body>
<div id="game">
<canvas id="board"></canvas>
<div id="hud">
<div id="hudScore">0</div>
<div id="hudLives">♥♥♥</div>
</div>
<div id="overlay">
<h1 id="ovTitle">Звёздный страж</h1>
<p id="ovText">Веди корабль пальцем по экрану: он держится чуть выше пальца. Собирай звёзды и уклоняйся от астероидов. У тебя 3 жизни.</p>
<button id="startBtn">Играть</button>
</div>
</div>
<script>
(function () {
"use strict";
const canvas = document.getElementById("board");
const ctx = canvas.getContext("2d");
const hudScore = document.getElementById("hudScore");
const hudLives = document.getElementById("hudLives");
const overlay = document.getElementById("overlay");
const ovTitle = document.getElementById("ovTitle");
const ovText = document.getElementById("ovText");
const startBtn = document.getElementById("startBtn");
const rand = (a, b) => a + Math.random() * (b - a);
const clamp = (v, a, b) => (v < a ? a : v > b ? b : v);
const keys = new Set();
let W = 0, H = 0, DPR = 1, bgStars = [];
const player = { x: 0, y: 0, r: 15, tx: 0, ty: 0 };
const game = {
state: "start", score: 0, lives: 3, time: 0, shake: 0, invuln: 0,
stars: [], rocks: [], parts: [], starTimer: 0, rockTimer: 0, best: 0
};
try { game.best = parseInt(localStorage.getItem("zvezdnyBest") || "0", 10) || 0; }
catch (e) { game.best = 0; }
// ---------- размеры экрана ----------
function resize() {
DPR = Math.min(window.devicePixelRatio || 1, 2);
W = canvas.clientWidth || window.innerWidth;
H = canvas.clientHeight || window.innerHeight;
canvas.width = Math.max(1, Math.round(W * DPR));
canvas.height = Math.max(1, Math.round(H * DPR));
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
bgStars = [];
const count = Math.round((W * H) / 9000);
for (let i = 0; i < count; i++) {
bgStars.push({ x: rand(0, W), y: rand(0, H), r: rand(0.4, 1.7), a: rand(0.25, 0.85), sp: rand(4, 16) });
}
player.r = clamp(Math.min(W, H) * 0.033, 12, 20);
if (game.state === "play") {
player.x = clamp(player.x, player.r, W - player.r);
player.y = clamp(player.y, player.r, H - player.r);
player.tx = clamp(player.tx, player.r, W - player.r);
player.ty = clamp(player.ty, player.r, H - player.r);
} else {
player.x = W / 2; player.y = H * 0.72;
player.tx = player.x; player.ty = player.y;
}
}
// ---------- интерфейс и состояния ----------
function updateHud() {
hudScore.textContent = String(game.score);
hudLives.textContent = "♥".repeat(Math.max(0, game.lives));
}
function startGame() {
game.state = "play";
game.score = 0; game.lives = 3; game.time = 0; game.shake = 0; game.invuln = 0;
game.stars.length = 0; game.rocks.length = 0; game.parts.length = 0;
game.starTimer = 0.4; game.rockTimer = 1.1;
player.x = W / 2; player.y = H * 0.72;
player.tx = player.x; player.ty = player.y;
overlay.classList.add("hidden");
updateHud();
}
function gameOver() {
game.state = "over";
if (game.score > game.best) {
game.best = game.score;
try { localStorage.setItem("zvezdnyBest", String(game.best)); } catch (e) {}
}
ovTitle.textContent = "Игра окончена";
ovText.innerHTML = "Звёзд собрано: <b>" + game.score + "</b><br>Рекорд: <b>" + game.best + "</b>";
startBtn.textContent = "Ещё раз";
overlay.classList.remove("hidden");
}
// ---------- создание объектов ----------
function fallSpeed() {
return H * rand(0.26, 0.34) * (1 + Math.min(game.time / 90, 0.5));
}
function spawnStar() {
const r = rand(9, 13);
game.stars.push({ x: rand(r + 8, Math.max(r + 9, W - r - 8)), y: -r - 12, r: r, vy: fallSpeed(), rot: rand(0, Math.PI) });
}
function spawnRock() {
const r = rand(12, 22);
const shape = [];
for (let i = 0; i < 9; i++) shape.push(rand(0.72, 1.1));
game.rocks.push({
x: rand(r + 8, Math.max(r + 9, W - r - 8)), y: -r - 12, r: r,
vy: fallSpeed() * 0.95, rot: rand(0, Math.PI * 2), spin: rand(-1.4, 1.4), shape: shape
});
}
function burst(x, y, color, count) {
for (let i = 0; i < count; i++) {
const a = rand(0, Math.PI * 2);
const sp = rand(40, 220);
game.parts.push({
x: x, y: y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp - 40,
life: rand(0.35, 0.75), max: 0.75, r: rand(1.5, 3.6), color: color
});
}
}
function updateParts(dt) {
for (let i = game.parts.length - 1; i >= 0; i--) {
const p = game.parts[i];
p.life -= dt;
if (p.life <= 0) { game.parts.splice(i, 1); continue; }
p.x += p.vx * dt;
p.y += p.vy * dt;
p.vy += 180 * dt;
p.vx *= 1 - Math.min(0.5, 1.2 * dt);
}
}
// ---------- логика кадра ----------
function update(dt) {
for (let i = 0; i < bgStars.length; i++) {
const s = bgStars[i];
s.y += s.sp * dt;
if (s.y > H + 2) { s.y = -2; s.x = rand(0, W); }
}
updateParts(dt);
if (game.shake > 0) game.shake = Math.max(0, game.shake - dt * 2.5);
if (game.state !== "play") return;
game.time += dt;
game.invuln = Math.max(0, game.invuln - dt);
const diff = 1 + Math.min(game.time / 26, 1.7);
// клавиатура — для проверки на компьютере
let kx = 0, ky = 0;
if (keys.has("arrowleft") || keys.has("a") || keys.has("ф")) kx -= 1;
if (keys.has("arrowright") || keys.has("d") || keys.has("в")) kx += 1;
if (keys.has("arrowup") || keys.has("w") || keys.has("ц")) ky -= 1;
if (keys.has("arrowdown") || keys.has("s") || keys.has("ы")) ky += 1;
if (kx !== 0 || ky !== 0) {
const sp = Math.min(W, H) * 0.95;
player.tx += kx * sp * dt;
player.ty += ky * sp * dt;
}
player.tx = clamp(player.tx, player.r, W - player.r);
player.ty = clamp(player.ty, player.r, H - player.r);
const k = Math.min(1, dt * 13);
player.x += (player.tx - player.x) * k;
player.y += (player.ty - player.y) * k;
player.x = clamp(player.x, player.r, W - player.r);
player.y = clamp(player.y, player.r, H - player.r);
// спавн: чем дальше, тем чаще
game.starTimer -= dt;
if (game.starTimer <= 0) {
spawnStar();
game.starTimer = rand(0.5, 0.9) / Math.min(diff, 1.5);
}
game.rockTimer -= dt;
if (game.rockTimer <= 0) {
spawnRock();
game.rockTimer = rand(1.0, 1.6) / Math.min(diff, 2.1);
}
// падение
for (let i = game.stars.length - 1; i >= 0; i--) {
const s = game.stars[i];
s.y += s.vy * dt;
s.rot += dt * 2.2;
if (s.y - s.r > H + 30) game.stars.splice(i, 1);
}
for (let i = game.rocks.length - 1; i >= 0; i--) {
const rk = game.rocks[i];
rk.y += rk.vy * dt;
rk.rot += rk.spin * dt;
if (rk.y - rk.r > H + 30) game.rocks.splice(i, 1);
}
// сбор звёзд
for (let i = game.stars.length - 1; i >= 0; i--) {
const s = game.stars[i];
const dx = s.x - player.x, dy = s.y - player.y, rr = s.r + player.r;
if (dx * dx + dy * dy < rr * rr) {
game.score += 1;
game.shake = Math.min(0.35, game.shake + 0.15);
burst(s.x, s.y, "#ffe27a", 10);
game.stars.splice(i, 1);
updateHud();
}
}
// столкновение с астероидами
if (game.invuln <= 0) {
for (let i = game.rocks.length - 1; i >= 0; i--) {
const rk = game.rocks[i];
const dx = rk.x - player.x, dy = rk.y - player.y, rr = rk.r * 0.85 + player.r;
if (dx * dx + dy * dy < rr * rr) {
game.lives -= 1;
game.invuln = 1.4;
game.shake = 1;
burst(player.x, player.y, "#ff6b8a", 18);
game.rocks.splice(i, 1);
updateHud();
if (game.lives <= 0) gameOver();
break;
}
}
}
}
// ---------- рисование ----------
function starPath(outer, inner) {
ctx.beginPath();
for (let i = 0; i < 10; i++) {
const r = i % 2 === 0 ? outer : inner;
const a = (i * Math.PI) / 5 - Math.PI / 2;
const x = Math.cos(a) * r, y = Math.sin(a) * r;
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.closePath();
}
function drawStar(s) {
ctx.save();
ctx.translate(s.x, s.y);
ctx.rotate(s.rot);
ctx.shadowColor = "#ffd24a";
ctx.shadowBlur = 14;
ctx.fillStyle = "#ffe27a";
starPath(s.r, s.r * 0.45);
ctx.fill();
ctx.restore();
}
function drawRock(rk) {
ctx.save();
ctx.translate(rk.x, rk.y);
ctx.rotate(rk.rot);
ctx.beginPath();
const n = rk.shape.length;
for (let i = 0; i < n; i++) {
const a = (i / n) * Math.PI * 2;
const r = rk.r * rk.shape[i];
const x = Math.cos(a) * r, y = Math.sin(a) * r;
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.closePath();
ctx.fillStyle = "#7a3550";
ctx.fill();
ctx.strokeStyle = "#ff6b8a";
ctx.lineWidth = 2;
ctx.stroke();
ctx.restore();
}
function drawShip() {
const r = player.r;
ctx.save();
ctx.translate(player.x, player.y);
if (game.invuln > 0) ctx.globalAlpha = 0.35 + 0.65 * Math.abs(Math.sin(game.invuln * 12));
ctx.shadowColor = "#5fd4ff";
ctx.shadowBlur = 16;
ctx.fillStyle = "#e6f7ff";
ctx.beginPath();
ctx.moveTo(0, -r * 1.25);
ctx.lineTo(r * 0.9, r * 0.95);
ctx.lineTo(0, r * 0.45);
ctx.lineTo(-r * 0.9, r * 0.95);
ctx.closePath();
ctx.fill();
ctx.restore();
}
function render() {
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, "#080d1c");
g.addColorStop(0.55, "#0b1430");
g.addColorStop(1, "#05070f");
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
for (let i = 0; i < bgStars.length; i++) {
const s = bgStars[i];
ctx.globalAlpha = s.a;
ctx.fillStyle = "#cfe3ff";
ctx.beginPath();
ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
ctx.save();
if (game.shake > 0) {
const m = game.shake * 9;
ctx.translate(rand(-m, m), rand(-m, m));
}
if (game.state !== "start") {
for (let i = 0; i < game.rocks.length; i++) drawRock(game.rocks[i]);
for (let i = 0; i < game.stars.length; i++) drawStar(game.stars[i]);
for (let i = 0; i < game.parts.length; i++) {
const p = game.parts[i];
ctx.globalAlpha = Math.max(0, p.life / p.max);
ctx.fillStyle = p.color;
ctx.beginPath();
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
if (game.state === "play") drawShip();
}
ctx.restore();
}
// ---------- управление ----------
function steerTo(clientX, clientY) {
const rect = canvas.getBoundingClientRect();
const x = clientX - rect.left;
const y = clientY - rect.top - Math.min(H * 0.1, 70); // корабль выше пальца
player.tx = clamp(x, player.r, W - player.r);
player.ty = clamp(y, player.r, H - player.r);
}
canvas.addEventListener("pointerdown", function (e) {
if (game.state !== "play") return;
steerTo(e.clientX, e.clientY);
if (canvas.setPointerCapture) {
try { canvas.setPointerCapture(e.pointerId); } catch (err) {}
}
e.preventDefault();
}, { passive: false });
canvas.addEventListener("pointermove", function (e) {
if (game.state !== "play") return;
steerTo(e.clientX, e.clientY);
e.preventDefault();
}, { passive: false });
window.addEventListener("keydown", function (e) {
const k = e.key.toLowerCase();
if (k === " " || k === "arrowup" || k === "arrowdown" || k === "arrowleft" || k === "arrowright") e.preventDefault();
keys.add(k);
if ((k === " " || k === "enter") && game.state !== "play") startGame();
});
window.addEventListener("keyup", function (e) {
keys.delete(e.key.toLowerCase());
});
startBtn.addEventListener("click", function () { startGame(); });
// ---------- игровой цикл ----------
let last = performance.now();
function frame(now) {
let dt = (now - last) / 1000;
last = now;
if (dt < 0) dt = 0;
if (dt > 0.05) dt = 0.05; // защита от «прыжка» после сворачивания
update(dt);
render();
requestAnimationFrame(frame);
}
resize();
updateHud();
window.addEventListener("resize", resize);
window.addEventListener("orientationchange", function () { setTimeout(resize, 150); });
document.addEventListener("visibilitychange", function () { last = performance.now(); });
requestAnimationFrame(frame);
})();
</script>
</body>
</html>
Как это устроено (принцип, который переносится на любую 2D-игру)
- Сцена.
<canvas>растянут на весь экран. Чтобы картинка не была мыльной на телефоне, внутренний размер умножается наdevicePixelRatio, а масштаб задаётся черезctx.setTransform(DPR,0,0,DPR,0,0)— дальше везде работаем в обычных CSS-пикселях. - Три состояния вместо флагов.
game.stateпринимает"start" | "play" | "over".updateиrenderспрашивают состояние и рисуют/считают только нужное. Это спасает от половины багов «после проигрыша всё ещё летит». - Игровой цикл с
dt.requestAnimationFrameдаёт время кадра;dt = (now - last)/1000— секунды с прошлого кадра. Все скорости умножаются наdt(y += vy * dt), поэтому игра идёт одинаково на 60 и на 120 Гц. Ограничениеdt ≤ 0.05не даёт объектам «телепортироваться» через экран после переключения вкладки. - Спавн по таймерам, а не по кадрам.
starTimer -= dt, при≤ 0создаём объект и заново ставим интервал. Сложность растёт коэффициентомdiff = 1 + min(time/26, 1.7), который делит интервал: чем дольше играешь, тем чаще падают камни. - Столкновения — круги и теорема Пифагора. Расстояние между центрами сравниваем с суммой радиусов: \(\sqrt{dx^2+dy^2} < r_1+r_2\). Корень не извлекаем, сравниваем квадраты — быстрее и без потери точности: \[ dx^{2}+dy^{2} < (r_1+r_2)^{2} \]
- Управление пальцем. Корабль не рисуется под пальцем (иначе его не видно), поэтому цель смещена вверх на ~10 % высоты экрана, а сам корабль плавно «догоняет» цель:
x += (tx - x) * min(1, dt*13). Это тот же приём сглаживания, что используют почти все аркады. - Обратная связь. Неуязвимость 1.4 с с мерцанием, тряска экрана (
ctx.translateслучайным сдвигом) и частицы — чтобы игрок мгновенно понимал, что произошло.
Проверка результата
- Собрал звезду → счёт +1, звёздочка исчезает, летят жёлтые частицы.
- Попал в астероид → минус одно сердце, экран трясётся, корабль мигает ~1.4 с и не может потерять второе сердце в этот момент.
- Сердца кончились → экран «Игра окончена» со счётом и рекордом; кнопка «Ещё раз» сбрасывает всё (объекты, таймеры, позицию).
- Повернул телефон →
resizeпересчитывает всё, корабль остаётся в пределах экрана.
Что подкрутить (для похожих заданий)
| Хочу | Где менять |
|---|---|
| Больше жизней | game.lives = 3 в startGame |
| Быстрее/медленнее объекты | fallSpeed(), множители 0.26…0.34 |
| Сложнее | делители Math.min(diff, 1.5) и Math.min(diff, 2.1) |
| Другой персонаж | функция drawShip() — рисуй любую фигуру |
| Бонусы (щит, замедление) | добавь массив powerups по образцу game.stars и флаг-таймер |
Если захочешь развить игру (стрельба, бонусы, уровни) — скажи, какой элемент добавить, и я покажу, как встроить его в эту же структуру.