«В гуще врагов» — это уже не статичный пример, а классический мини-выживач на JavaScript и Canvas. Игрок свободно двигается по полю во все четыре стороны, целится мышью, стреляет в направление курсора и отбивается от врагов, которые атакуют со всех сторон с разной скоростью. На телефоне работают экранный джойстик и кнопка стрельбы.

Что получится в конце

Полноценный survival-прототип: свободное перемещение, стрельба по курсору, волны врагов, рост сложности, счёт, рекорд и рестарт после поражения.

Сначала попробуйте готовую игру

Запустите демо в статье. На компьютере зажмите левую кнопку мыши для стрельбы и подвигайтесь WASD или стрелками. На телефоне используйте джойстик слева и кнопку «Огонь» справа.

Открыть игру отдельно

Структура проекта

Вся игра собрана в трёх файлах, без библиотек и сборщиков.

v-gusche-vragov/
├── source.html
├── style.css
└── game.js

HTML: интерфейс и игровое поле

Разметка содержит HUD со счётом, рекордом и текущей волной, а также Canvas с фокусом и aria-подписью. Через data-swarm-* JavaScript получает ссылки на элементы и обновляет интерфейс без лишних селекторов.

<main class="swarm" data-swarm-game>
  <section class="swarm__stats" aria-label="Показатели игры">
    <p data-swarm-score>Счёт: 0</p>
    <p data-swarm-best>Рекорд: 0</p>
    <p data-swarm-wave>Волна: 1</p>
  </section>

  <canvas data-swarm-canvas width="820" height="460" tabindex="0"></canvas>
  <p class="swarm__status" data-swarm-status aria-live="polite">
    Нажмите на поле и удерживайте левую кнопку мыши для стрельбы.
  </p>
</main>

CSS: стиль в духе сайта и других игр

Оформление выполнено в общей тёмной палитре: карточный контейнер, мягкие бордеры, чипы для подсказок управления и контрастный Canvas. Визуально игра теперь ближе к остальным игровым страницам проекта.

JavaScript: состояние выживача

Основная логика хранится в едином объекте состояния: позиция игрока, массив пуль, массив врагов, счёт, волна, таймер спавна и режим игры.

let state = {
  player: { x: canvas.width * 0.5, y: canvas.height * 0.5, r: 14, speed: 250 },
  bullets: [],
  enemies: [],
  score: 0,
  wave: 1,
  spawnTimer: 0,
  mode: "playing",
};

Движение во все четыре стороны

Игрок управляется через WASD и стрелки по двум осям сразу. Вектор нормализуется, поэтому диагональ не даёт лишнего ускорения. Позиция жёстко ограничивается границами поля.

if (keys.has("ArrowLeft") || keys.has("KeyA")) axisX -= 1;
if (keys.has("ArrowRight") || keys.has("KeyD")) axisX += 1;
if (keys.has("ArrowUp") || keys.has("KeyW")) axisY -= 1;
if (keys.has("ArrowDown") || keys.has("KeyS")) axisY += 1;

Прицел мышью и стрельба в курсор

Курсор переводится в координаты Canvas, затем из позиции игрока считается вектор прицеливания. Пуля получает скорость по этому вектору, поэтому летит ровно туда, куда смотрит мышь. Стрельба идёт при удержании левой кнопки мыши с небольшим кулдауном.

let direction = normalize(pointer.x - state.player.x, pointer.y - state.player.y);
aim.x = direction.x;
aim.y = direction.y;

if (isFiring && fireCooldown <= 0) {
  shoot();
  fireCooldown = 0.11;
}

Враги со всех сторон и разная скорость

Каждый враг появляется с одной из четырёх сторон экрана и движется в сторону игрока. Скорость берётся из диапазона случайных значений и дополнительно растёт вместе с волной.

let side = Math.floor(Math.random() * 4);
let enemy = {
  x: 0,
  y: 0,
  r: randomBetween(10, 16),
  speed: randomBetween(78, 132) + state.wave * randomBetween(7, 11),
};

Визуал пуль и попаданий

Пули получили светящийся след, а при выстреле и уничтожении врага генерируются частицы. Это не просто декор: игрок лучше считывает траекторию и результат попадания в плотной сцене.

Поражение, рестарт и рост сложности

При столкновении игрока с врагом игра переходит в режим over. Повторный запуск выполняется клавишей R. Волна растёт по сумме счёта и времени выживания, поэтому давление увеличивается даже при аккуратной игре.

Как проверить сборку

Проверка Ожидаемый результат
Движение A/D/W/S и стрелками Игрок свободно перемещается во все 4 стороны и не выходит за края
Удержание ЛКМ Пули летят в сторону курсора с контролируемой частотой
Телефон: джойстик и «Огонь» Игрок двигается, пули летят в сторону джойстика, рестарт работает без клавиатуры
Игра 30+ секунд Враги продолжают идти с разных сторон, волна увеличивается
Серия попаданий Счёт и рекорд растут, заметны эффекты попадания
Столкновение с врагом и нажатие R Игра корректно завершается и запускается заново

FAQ

Почему стрельба на мыши, а не на пробеле?

Для выживача с движением по всей арене важнее быстрое прицеливание в любую сторону. Мышь даёт более точный контроль направления выстрела.

Можно ли добавить автоприцел?

Да. Можно искать ближайшего врага и автоматически вычислять вектор выстрела на него. Но сначала лучше освоить базовый ручной режим.

Как сделать разные типы врагов?

Добавьте поле типа и меняйте параметры: размер, скорость, здоровье, урон или рисунок. Текущая архитектура состояния это позволяет без переписывания цикла.

Подойдёт ли этот каркас для мобильной версии?

Да. В готовой игре уже есть экранный джойстик и кнопка стрельбы. Как их собрать — в бонусном блоке ниже.

Бонус: управление на телефоне

Клавиатура и мышь на телефоне недоступны. Поэтому под игровым полем появляются два сенсорных элемента: джойстик слева и кнопка стрельбы справа. Мышиный прицел оставляем компьютеру, а на сенсоре стреляем туда, куда направлен джойстик. Автоприцел в ближайшего врага ломает баланс: можно стоять на месте и зажать «Огонь».

HTML: джойстик и кнопка огня

Джойстик — круглая зона с ручкой внутри. Кнопка «Огонь» держит стрельбу, пока палец на ней. «Заново» ставим рядом со счётом, чтобы не задеть её вместо огня.

<section class="swarm__stats">
  <p data-swarm-score>Счёт: 0</p>
  <button class="swarm__restart" type="button" data-swarm-restart>Заново</button>
</section>

<div class="swarm__touch" data-swarm-touch>
  <div class="swarm__stick" data-swarm-stick aria-label="Джойстик движения">
    <div class="swarm__stick-knob" data-swarm-knob></div>
  </div>
  <button class="swarm__fire" type="button" data-swarm-fire aria-label="Стрелять">Огонь</button>
</div>

CSS: показываем только на сенсоре

На компьютере блок скрыт. На узком экране и на устройствах с грубым указателем (pointer: coarse) включаем flex-раскладку. touch-action: none не даёт странице уехать, пока палец едет по джойстику.

.swarm__touch {
  display: none;
  touch-action: none;
}

@media (max-width: 720px), (pointer: coarse) {
  .swarm__touch {
    display: flex;
  }
}

JavaScript: два пальца сразу

Каждому пальцу браузер даёт свой pointerId. Его нужно запомнить: левый палец крутит джойстик, правый в это же время может жать «Огонь». Смещение ручки делим на радиус базы — получается тот же вектор, что дают WASD.

stickRoot.addEventListener("pointerdown", function (event) {
  event.preventDefault();
  stickPointerId = event.pointerId;
  stickRoot.setPointerCapture(event.pointerId);
  applyStickPoint(event.clientX, event.clientY);
});

fireBtn.addEventListener("pointerdown", function (event) {
  event.preventDefault();
  firePointerId = event.pointerId;
  isFiring = true;
});

Пока джойстик сдвинут, прицел совпадает с вектором движения. Отпустил стик — направление выстрела остаётся последним. Мышиный прицел при этом не трогаем.

if (stick.x !== 0 || stick.y !== 0) {
  let stickDir = normalize(stick.x, stick.y);
  aim.x = stickDir.x;
  aim.y = stickDir.y;
  return;
}
if (stickPointerId !== null || firePointerId !== null) return;

Готовый вариант уже стоит в демо наверху: откройте статью с телефона и попробуйте удержать джойстик и «Огонь» двумя руками.

Исходники урока

Откройте финальные файлы этой версии. Это тот же код, который работает в демо наверху статьи и на отдельной странице раздела «Поиграть».

Обложка книги JavaScript с нуля: учимся программировать, создавая игры

Книга по JavaScript для начинающих

«JavaScript с нуля: учимся программировать, создавая игры»

Пошаговый формат с маленькими практическими задачами: от базового синтаксиса до первых игровых механик без перегруза теорией.

Читать книгу на ЛитРес →