Первую игру хочется сделать, а не провести вечер с установщиками. Поэтому сегодня обойдёмся браузером: откроем онлайн-редактор, нарисуем квадрат и превратим его в игру на реакцию. Без игрового движка, Node.js и настройки проекта из десятка файлов.

Игра называется «Попади в зону». Сначала квадрат просто ездит по полю. Потом добавим кнопку остановки, проверку попадания и новую попытку. В конце ты изменишь несколько правил и получишь свою версию, а не только копию чужого кода.

Что получится: останови квадрат в нужном месте

Нажми «Начать», дождись подходящего момента и нажми «Стоп». Квадрат должен оказаться целиком внутри отмеченной зоны. Даже небольшой выступ за границу — проигрыш. Совпадение краёв засчитывается. После результата кнопка «Ещё раз» запускает новую попытку.

Открыть игру отдельно Открыть код игры Скачать исходники

Да, это маленькая игра. Но у неё уже есть то, чего нет у обычной анимации: действие игрока, правило и результат. Именно эту разницу мы и разберём. Таблицы рекордов и двадцать уровней пока оставим в покое.

Что понадобится для первой игры

Браузер и онлайн-редактор

Для основного маршрута используем JSFiddle. Код будем писать прямо на странице редактора и там же смотреть результат. Графика у нашей игры состоит из прямоугольников, поэтому картинки, библиотеки и другие внешние файлы не нужны.

Работать удобнее на компьютере с клавиатурой. Готовая игра рассчитана и на узкий экран, но набирать и исправлять код в нескольких панелях с телефона заметно менее удобно. Это два разных вопроса: где разрабатывать и где потом играть.

За что отвечают HTML, CSS и JavaScript

HTML задаёт элементы страницы: поле, кнопку и строку результата. CSS отвечает за внешний вид. JavaScript хранит положение квадрата, двигает его и проверяет, выиграл ли игрок. Пока достаточно этого разделения: разбираться со всеми возможностями трёх языков перед первым запуском не нужно.

Без установки — не значит без кода

Мы не будем собирать игру только мышкой в конструкторе. Здесь обычный JavaScript. Незнакомые строки будут, но двигаться станем от конкретной задачи: сначала показать квадрат, потом сдвинуть его, затем остановить. Не требуется заранее запомнить весь листинг.

Открываем редактор и запускаем первый пример

Куда вставлять HTML, CSS и JavaScript

Открой новый пустой проект в JSFiddle. Выбери обычные HTML, CSS и JavaScript — без React, TypeScript, препроцессоров и подключённых библиотек. Названия переключателей могут меняться, нам нужен самый простой режим.

ПанельЧто туда вставлять
HTMLРазметку игрового поля и кнопки. Без <html>, <head> и <body>.
CSSСтили. Без тегов <style>.
JavaScriptКод поведения. Без тегов <script>.
ResultСюда ничего не вставляем: здесь появится работающая страница.

В документации JSFiddle описан именно такой формат: три панели кода и панель результата. Кнопка Run запускает пример. Для JavaScript выбери выполнение после готовности HTML — обычно это режим On DOM ready. Тогда код найдёт поле и кнопку, а не обратится к ним раньше времени.

Как проходить шаги и не запутаться в коде

Сначала вставим HTML и CSS: дальше они останутся прежними. JavaScript будем менять по шагам. Под каждым шагом есть раскрывающийся полный листинг — им нужно заменять всю панель JavaScript, а не дописывать его в конец. Короткие фрагменты в объяснении показывают главное, но не являются ещё одной версией программы.

После замены нажимай Run и проверяй результат. Так в редакторе не появятся две функции с одинаковым именем или несколько анимаций одновременно. Полный финальный код тоже есть ниже: можно сначала запустить готовую игру, а затем вернуться к разбору.

Шаг 1. Собираем игровое поле и кнопку

Добавляем Canvas

Вставь в панель HTML следующую разметку. Элемент canvas — область, внутри которой мы будем рисовать. Размер 640 × 220 задаёт нашу рабочую систему координат. Через CSS поле можно уменьшить вместе с экраном, сохранив пропорции; сами координаты при этом останутся прежними. Это различие между размерами холста и его отображением объясняется в базовом руководстве Canvas.

<section class="zone-game" aria-labelledby="zone-title">
  <h2 id="zone-title">Попади в зону</h2>
  <p id="zone-rules">Нажми «Начать», затем останови квадрат
    целиком внутри отмеченной зоны.</p>

  <canvas id="game" width="640" height="220"
    aria-describedby="zone-rules game-message">
    На поле квадрат движется слева направо и обратно.
    Его нужно остановить внутри отмеченной зоны.
  </canvas>

  <button id="action" type="button">Начать</button>
  <p id="game-message" role="status" aria-live="polite">
    Готов? Первая попытка — без ограничения времени.
  </p>
</section>

Показываем правила и результат партии

Кнопку и сообщения мы оставили обычными HTML-элементами, а не нарисовали внутри Canvas. Нативная кнопка уже поддерживает фокус и активацию с клавиатуры. Играть можно мышью, касанием или, после выбора кнопки клавишей Tab, клавишами Enter и пробел.

Атрибут id нужен, чтобы JavaScript нашёл конкретный элемент. Поэтому значения game, action и game-message пока не переименовывай. role="status" помогает вспомогательным технологиям распознавать обновление сообщения. Сама игра при этом остаётся визуальной задачей: одна эта разметка не делает её полностью доступной незрячему игроку.

Теперь вставь CSS в одноимённую панель. Стили здесь только оформляют поле и большую кнопку; правила игры от них не зависят.

CSS для игры — вставь целиком в панель CSS
.zone-game,
.zone-game * {
  box-sizing: border-box;
}

.zone-game {
  width: min(100%, 720px);
  margin: 0 auto;
  padding: 20px;
  border-radius: 16px;
  background: #24283b;
  color: #c0caf5;
  font: 17px/1.5 system-ui, sans-serif;
}

.zone-game h2 {
  margin: 0 0 8px;
  font-size: 26px;
}

.zone-game p {
  margin: 0 0 16px;
}

.zone-game canvas {
  display: block;
  width: 100%;
  height: auto;
  margin-bottom: 16px;
  border: 1px solid #565f89;
  border-radius: 10px;
  background: #1a1b26;
}

.zone-game button {
  width: 100%;
  min-height: 48px;
  padding: 10px 18px;
  border: 0;
  border-radius: 10px;
  background: #7aa2f7;
  color: #15161e;
  font: inherit;
  font-weight: 700;
  cursor: pointer;
  touch-action: manipulation;
}

.zone-game button:focus-visible {
  outline: 3px solid #e0af68;
  outline-offset: 4px;
}

.zone-game button:disabled {
  opacity: 0.65;
  cursor: default;
}

.zone-game #game-message {
  min-height: 3em;
  margin: 14px 0 0;
}

Все селекторы начинаются с .zone-game, чтобы оформление относилось к нашей игре. Поле занимает доступную ширину, а высота меняется пропорционально. Тёмный фон можно заменить своим — на логику это не повлияет.

Проверь себя: после Run видны заголовок, правила, пустое поле и кнопка. Она ещё ничего не запускает. На этом шаге так и задумано.

Шаг 2. Рисуем квадрат и зону попадания

Задаём положение и размер

В JavaScript найдём Canvas и получим контекст рисования 2d. Затем зададим размер квадрата, его положение и границы зоны. Команда fillRect(x, y, width, height) рисует прямоугольник: первые два числа — левый верхний угол, вторые два — ширина и высота. Координата X растёт вправо, Y — вниз; подробнее это разобрано в руководстве по фигурам Canvas.

const playerSize = 34;
const zoneX = 390;
const zoneWidth = 130;
let playerX = 20;

Квадрат начинается в точке 20 по горизонтали. Зона — в точке 390 и заканчивается на 520: это 390 + 130. Положение по вертикали сделаем одинаковым для всей партии, чтобы задача оставалась одномерной.

Выносим настройки, которые будем менять

const используем для значений, которые не переназначаем во время партии. let playerX — для координаты, которую будем изменять. Это не значит, что число 130 запрещено редактировать: поменяешь его в исходнике, снова нажмёшь Run — получишь зону другого размера.

В полном коде рисованием занимается функция draw(). Она сначала закрашивает всё поле фоном, затем рисует зону, квадрат и подпись. Это наша инструкция «как выглядит сцена сейчас». Для изменения картинки достаточно вызвать её ещё раз с новым значением playerX.

JavaScript шага 2 целиком: замени им содержимое панели
const canvas = document.querySelector('#game');
const actionButton = document.querySelector('#action');
const message = document.querySelector('#game-message');

if (!canvas || !actionButton || !message) {
  throw new Error('Проверь HTML: нужны game, action и game-message.');
}

const ctx = canvas.getContext('2d');

if (!ctx) {
  actionButton.disabled = true;
  message.textContent = 'Не удалось получить контекст Canvas 2D.';
  throw new Error('Canvas 2D недоступен.');
}

const playerSize = 34;
const zoneX = 390;
const zoneWidth = 130;

let playerX = 20;

function draw() {
  // Сплошной фон перекрывает предыдущий кадр.
  ctx.fillStyle = '#1a1b26';
  ctx.fillRect(0, 0, canvas.width, canvas.height);

  // Зона, в которую нужно попасть.
  ctx.fillStyle = '#244a48';
  ctx.fillRect(zoneX, 64, zoneWidth, 92);
  ctx.strokeStyle = '#73daca';
  ctx.lineWidth = 2;
  ctx.strokeRect(zoneX, 64, zoneWidth, 92);

  ctx.font = '16px system-ui';
  ctx.textAlign = 'center';
  ctx.fillStyle = '#c0caf5';
  ctx.fillText('ЗОНА', zoneX + zoneWidth / 2, 43);

  // Игрок. До проверки результата квадрат светлый.
  ctx.fillStyle = '#c0caf5';
  ctx.fillRect(playerX, (canvas.height - playerSize) / 2,
    playerSize, playerSize);

  ctx.font = '14px system-ui';
  ctx.fillStyle = '#a9b1d6';
  ctx.fillText('Квадрат должен поместиться целиком',
    canvas.width / 2, 194);
}

draw();

Проверь себя: появился неподвижный квадрат слева и отмеченная зона справа. Поставь playerX = 420 и нажми Run: квадрат окажется внутри зоны. Затем верни 20.

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

Шаг 3. Заставляем квадрат двигаться

Обновляем положение и рисуем новый кадр

Добавим скорость 220 пикселей в секунду и направление 1. Чтобы сдвинуть квадрат, изменяем его координату. Вот главное действие внутри функции update():

playerX += speed * direction * deltaTime;

Здесь += означает «прибавь к текущему значению». Если между кадрами прошло 0,02 секунды, квадрат сдвинется на 220 × 1 × 0,02 = 4,4 пикселя. Направление -1 меняет знак: движение становится левым.

Не используем playerX += 2 на каждом кадре. У экранов разная частота обновления, поэтому одинаковое смещение за кадр может дать разную скорость. requestAnimationFrame() передаёт время кадра; по разнице времён рассчитываем deltaTime в секундах.

const deltaTime = previousTime === null
  ? 0
  : Math.min((timestamp - previousTime) / 1000, 0.05);
previousTime = timestamp;

update(deltaTime);
draw();

Запись с вопросительным знаком — сокращённый выбор одного из двух значений. Когда предыдущего времени ещё нет, берём 0. Иначе вычисляем разницу. Ограничение 0,05 не даёт единичной задержке превратиться в огромный скачок. При сильных торможениях игра из-за этого замедлится: для учебного примера это лучше, чем незаметно пролететь всю зону.

Разворачиваем квадрат у края поля

Правая граница для левого края квадрата — не 640, а 640 - 34 = 606. При большей координате часть фигуры уже окажется за полем. Когда квадрат доходит до края, меняем направление. Если за один кадр он немного вышел наружу, отражаем этот небольшой остаток обратно.

function update(deltaTime) {
  playerX += speed * direction * deltaTime;
  const rightEdge = canvas.width - playerSize;

  // Отражаем небольшой выход за край, сохраняя остаток движения.
  if (playerX > rightEdge) {
    playerX = rightEdge - (playerX - rightEdge);
    direction = -1;
  } else if (playerX < 0) {
    playerX = -playerX;
    direction = 1;
  }
}

В примере это работает для заданной скорости и небольших изменений. В заданиях попробуй значения 100–400. Мы не строим здесь универсальный физический движок для произвольно больших скоростей.

Функция frame() обновляет координату, рисует сцену и заказывает следующий кадр. Ещё в коде есть обработчик смены видимости вкладки: он отменяет ожидание кадра и сбрасывает прошлое время. После возвращения игра продолжит движение без попытки «догнать» всё время отсутствия. Для этого используется Page Visibility API.

JavaScript шага 3 целиком: замени им содержимое панели
const canvas = document.querySelector('#game');
const actionButton = document.querySelector('#action');
const message = document.querySelector('#game-message');

if (!canvas || !actionButton || !message) {
  throw new Error('Проверь HTML: нужны game, action и game-message.');
}

const ctx = canvas.getContext('2d');

if (!ctx) {
  actionButton.disabled = true;
  message.textContent = 'Не удалось получить контекст Canvas 2D.';
  throw new Error('Canvas 2D недоступен.');
}

const playerSize = 34;
const zoneX = 390;
const zoneWidth = 130;

const speed = 220; // Пикселей в секунду.

let playerX = 20;
let direction = 1;
let state = 'running';
let previousTime = null;
let frameId = null;

function draw() {
  // Сплошной фон перекрывает предыдущий кадр.
  ctx.fillStyle = '#1a1b26';
  ctx.fillRect(0, 0, canvas.width, canvas.height);

  // Зона, в которую нужно попасть.
  ctx.fillStyle = '#244a48';
  ctx.fillRect(zoneX, 64, zoneWidth, 92);
  ctx.strokeStyle = '#73daca';
  ctx.lineWidth = 2;
  ctx.strokeRect(zoneX, 64, zoneWidth, 92);

  ctx.font = '16px system-ui';
  ctx.textAlign = 'center';
  ctx.fillStyle = '#c0caf5';
  ctx.fillText('ЗОНА', zoneX + zoneWidth / 2, 43);

  // Игрок. До проверки результата квадрат светлый.
  ctx.fillStyle = '#c0caf5';
  ctx.fillRect(playerX, (canvas.height - playerSize) / 2,
    playerSize, playerSize);

  ctx.font = '14px system-ui';
  ctx.fillStyle = '#a9b1d6';
  ctx.fillText('Квадрат должен поместиться целиком',
    canvas.width / 2, 194);
}

function update(deltaTime) {
  playerX += speed * direction * deltaTime;
  const rightEdge = canvas.width - playerSize;

  // Отражаем небольшой выход за край, сохраняя остаток движения.
  if (playerX > rightEdge) {
    playerX = rightEdge - (playerX - rightEdge);
    direction = -1;
  } else if (playerX < 0) {
    playerX = -playerX;
    direction = 1;
  }
}

function frame(timestamp) {
  frameId = null;

  if (state !== 'running' || document.hidden) {
    previousTime = null;
    return;
  }

  const deltaTime = previousTime === null
    ? 0
    : Math.min((timestamp - previousTime) / 1000, 0.05);
  previousTime = timestamp;

  update(deltaTime);
  draw();
  frameId = requestAnimationFrame(frame);
}

document.addEventListener('visibilitychange', () => {
  previousTime = null;

  if (document.hidden) {
    if (frameId !== null) cancelAnimationFrame(frameId);
    frameId = null;
  } else if (state === 'running' && frameId === null) {
    frameId = requestAnimationFrame(frame);
  }
});

actionButton.disabled = true;
message.textContent = 'Квадрат движется. Управление добавим дальше.';
draw();
frameId = requestAnimationFrame(frame);

Проверь себя: квадрат ездит в обе стороны и не исчезает за границами. Кнопка пока отключена — управление добавим следующим шагом.

Порядок каждого кадра уже напоминает игру: изменили данные → нарисовали сцену → повторили. Более подробный разбор есть в статье про игровой цикл на JavaScript.

Шаг 4. Добавляем кнопку «Стоп»

Обрабатываем действие игрока

Теперь дадим игроку влиять на происходящее. Когда он активирует кнопку, вызовем функцию stopGame(). Связь выглядит так:

actionButton.addEventListener('click', stopGame);

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

Останавливаем движение

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

function stopGame() {
  state = 'stopped';
  if (frameId !== null) cancelAnimationFrame(frameId);
  frameId = null;
  previousTime = null;
  actionButton.disabled = true;
  message.textContent = 'Квадрат остановился. Проверку добавим дальше.';
}

frameId хранит номер запланированного кадра. Когда кадра нет, записываем null. Нам не нужно угадывать этот номер или считать его вручную: он возвращается при вызове requestAnimationFrame().

JavaScript шага 4 целиком: замени им содержимое панели
const canvas = document.querySelector('#game');
const actionButton = document.querySelector('#action');
const message = document.querySelector('#game-message');

if (!canvas || !actionButton || !message) {
  throw new Error('Проверь HTML: нужны game, action и game-message.');
}

const ctx = canvas.getContext('2d');

if (!ctx) {
  actionButton.disabled = true;
  message.textContent = 'Не удалось получить контекст Canvas 2D.';
  throw new Error('Canvas 2D недоступен.');
}

const playerSize = 34;
const zoneX = 390;
const zoneWidth = 130;

const speed = 220; // Пикселей в секунду.

let playerX = 20;
let direction = 1;
let state = 'running';
let previousTime = null;
let frameId = null;

function draw() {
  // Сплошной фон перекрывает предыдущий кадр.
  ctx.fillStyle = '#1a1b26';
  ctx.fillRect(0, 0, canvas.width, canvas.height);

  // Зона, в которую нужно попасть.
  ctx.fillStyle = '#244a48';
  ctx.fillRect(zoneX, 64, zoneWidth, 92);
  ctx.strokeStyle = '#73daca';
  ctx.lineWidth = 2;
  ctx.strokeRect(zoneX, 64, zoneWidth, 92);

  ctx.font = '16px system-ui';
  ctx.textAlign = 'center';
  ctx.fillStyle = '#c0caf5';
  ctx.fillText('ЗОНА', zoneX + zoneWidth / 2, 43);

  // Игрок. До проверки результата квадрат светлый.
  ctx.fillStyle = '#c0caf5';
  ctx.fillRect(playerX, (canvas.height - playerSize) / 2,
    playerSize, playerSize);

  ctx.font = '14px system-ui';
  ctx.fillStyle = '#a9b1d6';
  ctx.fillText('Квадрат должен поместиться целиком',
    canvas.width / 2, 194);
}

function update(deltaTime) {
  playerX += speed * direction * deltaTime;
  const rightEdge = canvas.width - playerSize;

  // Отражаем небольшой выход за край, сохраняя остаток движения.
  if (playerX > rightEdge) {
    playerX = rightEdge - (playerX - rightEdge);
    direction = -1;
  } else if (playerX < 0) {
    playerX = -playerX;
    direction = 1;
  }
}

function frame(timestamp) {
  frameId = null;

  if (state !== 'running' || document.hidden) {
    previousTime = null;
    return;
  }

  const deltaTime = previousTime === null
    ? 0
    : Math.min((timestamp - previousTime) / 1000, 0.05);
  previousTime = timestamp;

  update(deltaTime);
  draw();
  frameId = requestAnimationFrame(frame);
}

function stopGame() {
  state = 'stopped';
  if (frameId !== null) cancelAnimationFrame(frameId);
  frameId = null;
  previousTime = null;
  actionButton.disabled = true;
  message.textContent = 'Квадрат остановился. Проверку добавим дальше.';
}

actionButton.addEventListener('click', stopGame);

document.addEventListener('visibilitychange', () => {
  previousTime = null;

  if (document.hidden) {
    if (frameId !== null) cancelAnimationFrame(frameId);
    frameId = null;
  } else if (state === 'running' && frameId === null) {
    frameId = requestAnimationFrame(frame);
  }
});

actionButton.textContent = 'Стоп';
message.textContent = 'Нажми кнопку, чтобы остановить квадрат.';
draw();
frameId = requestAnimationFrame(frame);

Проверь себя: квадрат движется до нажатия «Стоп» и замирает после него. Результата «попал» пока нет. Для повторной проверки этого промежуточного шага нажми Run в редакторе.

Шаг 5. Проверяем попадание

Когда считать попытку успешной

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

const hit = playerX >= zoneX &&
  playerX + playerSize <= zoneX + zoneWidth;

&& означает «и». Условия должны быть верны вместе. Недостаточно, чтобы внутрь попал только левый край или только центр квадрата.

Положение квадратаГраницы квадратаРезультат
playerX = 420420…454 внутри зоны 390…520Победа
playerX = 486486…520, правые края совпалиТоже победа
playerX = 500500…534, часть фигуры снаружиПроигрыш
playerX = 380380…414, левый край раньше зоныПроигрыш
Три положения квадрата шириной 34 пикселя на одном масштабе. Зона от 390 до 520. При playerX 420 квадрат целиком внутри — победа. При playerX 486 правые края совпали — тоже победа. При playerX 500 часть квадрата снаружи — проигрыш.
Одинаковый масштаб: зона 390…520 и квадрат 34 px. Первые два положения — победа, третье — проигрыш.

При наших настройках удачное положение левого края лежит от 390 до 486 включительно. Уже видно, почему уменьшение зоны усложнит задачу: допустимых положений станет меньше. Если сделать зону уже самого квадрата, выиграть по этому правилу не получится.

Показываем победу или проигрыш

Переменная hit содержит true или false. По ней выбираем состояние и текст. В функцию рисования добавляем два условия, чтобы победный квадрат стал зелёным, а проигрышный — розовым. Сообщение дублирует результат словами: полагаться только на цвет не будем.

state = hit ? 'won' : 'lost';
message.textContent = hit
  ? 'Попал! Квадрат целиком внутри зоны.'
  : 'Мимо. Попробуй остановить квадрат внутри зоны.';
JavaScript шага 5 целиком: замени им содержимое панели
const canvas = document.querySelector('#game');
const actionButton = document.querySelector('#action');
const message = document.querySelector('#game-message');

if (!canvas || !actionButton || !message) {
  throw new Error('Проверь HTML: нужны game, action и game-message.');
}

const ctx = canvas.getContext('2d');

if (!ctx) {
  actionButton.disabled = true;
  message.textContent = 'Не удалось получить контекст Canvas 2D.';
  throw new Error('Canvas 2D недоступен.');
}

const playerSize = 34;
const zoneX = 390;
const zoneWidth = 130;

const speed = 220; // Пикселей в секунду.

let playerX = 20;
let direction = 1;
let state = 'running';
let previousTime = null;
let frameId = null;

function draw() {
  // Сплошной фон перекрывает предыдущий кадр.
  ctx.fillStyle = '#1a1b26';
  ctx.fillRect(0, 0, canvas.width, canvas.height);

  // Зона, в которую нужно попасть.
  ctx.fillStyle = '#244a48';
  ctx.fillRect(zoneX, 64, zoneWidth, 92);
  ctx.strokeStyle = '#73daca';
  ctx.lineWidth = 2;
  ctx.strokeRect(zoneX, 64, zoneWidth, 92);

  ctx.font = '16px system-ui';
  ctx.textAlign = 'center';
  ctx.fillStyle = '#c0caf5';
  ctx.fillText('ЗОНА', zoneX + zoneWidth / 2, 43);

  // Игрок. До проверки результата квадрат светлый.
  ctx.fillStyle = '#c0caf5';
  if (state === 'won') ctx.fillStyle = '#9ece6a';
  if (state === 'lost') ctx.fillStyle = '#f7768e';
  ctx.fillRect(playerX, (canvas.height - playerSize) / 2,
    playerSize, playerSize);

  ctx.font = '14px system-ui';
  ctx.fillStyle = '#a9b1d6';
  ctx.fillText('Квадрат должен поместиться целиком',
    canvas.width / 2, 194);
}

function update(deltaTime) {
  playerX += speed * direction * deltaTime;
  const rightEdge = canvas.width - playerSize;

  // Отражаем небольшой выход за край, сохраняя остаток движения.
  if (playerX > rightEdge) {
    playerX = rightEdge - (playerX - rightEdge);
    direction = -1;
  } else if (playerX < 0) {
    playerX = -playerX;
    direction = 1;
  }
}

function frame(timestamp) {
  frameId = null;

  if (state !== 'running' || document.hidden) {
    previousTime = null;
    return;
  }

  const deltaTime = previousTime === null
    ? 0
    : Math.min((timestamp - previousTime) / 1000, 0.05);
  previousTime = timestamp;

  update(deltaTime);
  draw();
  frameId = requestAnimationFrame(frame);
}

function stopGame() {
  if (frameId !== null) cancelAnimationFrame(frameId);
  frameId = null;
  previousTime = null;

  const hit = playerX >= zoneX &&
    playerX + playerSize <= zoneX + zoneWidth;

  state = hit ? 'won' : 'lost';
  message.textContent = hit
    ? 'Попал! Квадрат целиком внутри зоны.'
    : 'Мимо. Попробуй остановить квадрат внутри зоны.';
  actionButton.textContent = 'Попытка закончена';
  actionButton.disabled = true;
  draw();
}

actionButton.addEventListener('click', stopGame);

document.addEventListener('visibilitychange', () => {
  previousTime = null;

  if (document.hidden) {
    if (frameId !== null) cancelAnimationFrame(frameId);
    frameId = null;
  } else if (state === 'running' && frameId === null) {
    frameId = requestAnimationFrame(frame);
  }
});

actionButton.textContent = 'Стоп';
message.textContent = 'Останови квадрат целиком внутри зоны.';
draw();
frameId = requestAnimationFrame(frame);

Проверь себя: остановись внутри зоны, затем запусти пример заново и остановись снаружи. В первом случае должно быть попадание, во втором — промах. Если квадрат только задел зону, победу не засчитываем.

Шаг 6. Начинаем новую попытку

Возвращаем квадрат на старт

Обновлять весь редактор ради новой попытки неудобно. Добавим startGame(): она возвращает квадрат в начальную точку, задаёт направление вправо и сбрасывает время. Старый результат тоже не должен переезжать в новую партию.

function startGame() {
  if (frameId !== null) cancelAnimationFrame(frameId);

  playerX = 20;
  direction = 1;
  state = 'running';
  previousTime = null;
  actionButton.disabled = false;
  actionButton.textContent = 'Стоп';
  message.textContent = 'Останови квадрат целиком внутри зоны.';

  draw();
  frameId = requestAnimationFrame(frame);
}

Сброс направления важен не меньше координаты. Иначе после проигрыша на движении влево следующая попытка может начаться с движения к левому краю. Все значения, которые определяют новую партию, лучше устанавливать явно.

Одна кнопка: начать, остановить и попробовать снова

В начале состояние будет idle — игра ждёт человека и не двигается сама. Если партия идёт, нажатие останавливает её. Во всех остальных состояниях нажатие запускает новую. Кнопка одна, а её подпись меняется вслед за происходящим.

actionButton.addEventListener('click', () => {
  if (state === 'running') {
    stopGame();
  } else {
    startGame();
  }
});

Это заменяет старый обработчик из предыдущего шага, а не добавляется рядом с ним. Чтобы ничего не пропустить, вставь финальную версию JavaScript из следующего раздела. HTML и CSS менять не нужно.

Проверь себя: сначала «Начать», затем «Стоп», после результата — «Ещё раз». Сделай десять попыток подряд. Движение не должно ускоряться, сообщение прошлой партии должно исчезать при старте.

Полный код игры: проверь свою версию

Это три согласованных блока финальной игры. Вставь каждый в свою панель JSFiddle и нажми Run. Не вставляй новые блоки под предыдущими: замени содержимое соответствующей панели.

HTML — поле, кнопка и сообщения

Показать полный HTML
<section class="zone-game" aria-labelledby="zone-title">
  <h2 id="zone-title">Попади в зону</h2>
  <p id="zone-rules">Нажми «Начать», затем останови квадрат
    целиком внутри отмеченной зоны.</p>

  <canvas id="game" width="640" height="220"
    aria-describedby="zone-rules game-message">
    На поле квадрат движется слева направо и обратно.
    Его нужно остановить внутри отмеченной зоны.
  </canvas>

  <button id="action" type="button">Начать</button>
  <p id="game-message" role="status" aria-live="polite">
    Готов? Первая попытка — без ограничения времени.
  </p>
</section>

CSS — оформление

Показать полный CSS
.zone-game,
.zone-game * {
  box-sizing: border-box;
}

.zone-game {
  width: min(100%, 720px);
  margin: 0 auto;
  padding: 20px;
  border-radius: 16px;
  background: #24283b;
  color: #c0caf5;
  font: 17px/1.5 system-ui, sans-serif;
}

.zone-game h2 {
  margin: 0 0 8px;
  font-size: 26px;
}

.zone-game p {
  margin: 0 0 16px;
}

.zone-game canvas {
  display: block;
  width: 100%;
  height: auto;
  margin-bottom: 16px;
  border: 1px solid #565f89;
  border-radius: 10px;
  background: #1a1b26;
}

.zone-game button {
  width: 100%;
  min-height: 48px;
  padding: 10px 18px;
  border: 0;
  border-radius: 10px;
  background: #7aa2f7;
  color: #15161e;
  font: inherit;
  font-weight: 700;
  cursor: pointer;
  touch-action: manipulation;
}

.zone-game button:focus-visible {
  outline: 3px solid #e0af68;
  outline-offset: 4px;
}

.zone-game button:disabled {
  opacity: 0.65;
  cursor: default;
}

.zone-game #game-message {
  min-height: 3em;
  margin: 14px 0 0;
}

JavaScript — движение и правила

Показать полный JavaScript
const canvas = document.querySelector('#game');
const actionButton = document.querySelector('#action');
const message = document.querySelector('#game-message');

if (!canvas || !actionButton || !message) {
  throw new Error('Проверь HTML: нужны game, action и game-message.');
}

const ctx = canvas.getContext('2d');

if (!ctx) {
  actionButton.disabled = true;
  message.textContent = 'Не удалось получить контекст Canvas 2D.';
  throw new Error('Canvas 2D недоступен.');
}

const playerSize = 34;
const zoneX = 390;
const zoneWidth = 130;

const speed = 220; // Пикселей в секунду.

let playerX = 20;
let direction = 1;
let state = 'idle';
let previousTime = null;
let frameId = null;

function draw() {
  // Сплошной фон перекрывает предыдущий кадр.
  ctx.fillStyle = '#1a1b26';
  ctx.fillRect(0, 0, canvas.width, canvas.height);

  // Зона, в которую нужно попасть.
  ctx.fillStyle = '#244a48';
  ctx.fillRect(zoneX, 64, zoneWidth, 92);
  ctx.strokeStyle = '#73daca';
  ctx.lineWidth = 2;
  ctx.strokeRect(zoneX, 64, zoneWidth, 92);

  ctx.font = '16px system-ui';
  ctx.textAlign = 'center';
  ctx.fillStyle = '#c0caf5';
  ctx.fillText('ЗОНА', zoneX + zoneWidth / 2, 43);

  // Игрок. До проверки результата квадрат светлый.
  ctx.fillStyle = '#c0caf5';
  if (state === 'won') ctx.fillStyle = '#9ece6a';
  if (state === 'lost') ctx.fillStyle = '#f7768e';
  ctx.fillRect(playerX, (canvas.height - playerSize) / 2,
    playerSize, playerSize);

  ctx.font = '14px system-ui';
  ctx.fillStyle = '#a9b1d6';
  ctx.fillText('Квадрат должен поместиться целиком',
    canvas.width / 2, 194);
}

function update(deltaTime) {
  playerX += speed * direction * deltaTime;
  const rightEdge = canvas.width - playerSize;

  // Отражаем небольшой выход за край, сохраняя остаток движения.
  if (playerX > rightEdge) {
    playerX = rightEdge - (playerX - rightEdge);
    direction = -1;
  } else if (playerX < 0) {
    playerX = -playerX;
    direction = 1;
  }
}

function frame(timestamp) {
  frameId = null;

  if (state !== 'running' || document.hidden) {
    previousTime = null;
    return;
  }

  const deltaTime = previousTime === null
    ? 0
    : Math.min((timestamp - previousTime) / 1000, 0.05);
  previousTime = timestamp;

  update(deltaTime);
  draw();
  frameId = requestAnimationFrame(frame);
}

function startGame() {
  if (frameId !== null) cancelAnimationFrame(frameId);

  playerX = 20;
  direction = 1;
  state = 'running';
  previousTime = null;
  actionButton.disabled = false;
  actionButton.textContent = 'Стоп';
  message.textContent = 'Останови квадрат целиком внутри зоны.';

  draw();
  frameId = requestAnimationFrame(frame);
}

function stopGame() {
  if (frameId !== null) cancelAnimationFrame(frameId);
  frameId = null;
  previousTime = null;

  const hit = playerX >= zoneX &&
    playerX + playerSize <= zoneX + zoneWidth;

  state = hit ? 'won' : 'lost';
  message.textContent = hit
    ? 'Попал! Квадрат целиком внутри зоны.'
    : 'Мимо. Попробуй остановить квадрат внутри зоны.';
  actionButton.textContent = 'Ещё раз';
  draw();
}

actionButton.addEventListener('click', () => {
  if (state === 'running') {
    stopGame();
  } else {
    startGame();
  }
});

document.addEventListener('visibilitychange', () => {
  previousTime = null;

  if (document.hidden) {
    if (frameId !== null) cancelAnimationFrame(frameId);
    frameId = null;
  } else if (state === 'running' && frameId === null) {
    frameId = requestAnimationFrame(frame);
  }
});

draw();

Все файлы лежат в архиве исходников. Внутри есть готовый index.html и отдельные блоки для онлайн-редактора. Они делают одну и ту же игру.

Сделай свою версию игры

Копирование закончилось. Теперь важная часть: поменяй код сам и заранее предположи, что увидишь. Не нужно сразу добавлять десять функций. Одного изменения, которое ты можешь объяснить, достаточно для следующего шага.

Измени скорость квадрата

Поставь speed = 120, сыграй пару раз, затем попробуй 320. Что стало сложнее: дождаться зоны или вовремя нажать кнопку? Оставь такую скорость, при которой иногда выигрываешь, а иногда промахиваешься.

Сделай зону уже или шире

Поменяй zoneWidth со 130 на 80. Теперь допустимая координата левого края — от 390 до 436: всего 46 пикселей вместо 96. Не ставь ширину меньше playerSize, пока используешь правило полного попадания. И проверь, что zoneX + zoneWidth не выходит за ширину поля.

Придумай другое условие победы

Усложним задачу не для игрока, а для автора. Сделай так, чтобы засчитывалось попадание центра квадрата, даже если его края выступают за зону. Найди одну проверку внутри stopGame() и замени именно её. Остальную игру переписывать не нужно.

Подсказка и пример решения

Центр находится на половину ширины правее левого края. Проверь, попадает ли эта точка в зону:

const centerX = playerX + playerSize / 2;
const hit = centerX >= zoneX &&
  centerX <= zoneX + zoneWidth;

Поменяй также правила и сообщения: теперь неправильно писать «квадрат целиком внутри». Текст интерфейса должен соответствовать новой механике.

В этом и начинается самостоятельная разработка: ты меняешь условие, понимаешь последствие и исправляешь связанные с ним части. Не просто двигаешь цветной блок, а определяешь правила маленькой системы.

Как сохранить игру и отправить ссылку другу

Сохраняем свою копию проекта

Кнопка Run запускает код, но сама по себе не означает, что твои изменения сохранены по публичной ссылке. После проверки используй сохранение проекта — Save или Update. Если редактор попросит войти в аккаунт, это понадобится для сохранения, а не для запуска скачанного HTML-файла. Порядок сохранения и создание отдельной копии через Fork описаны в руководстве JSFiddle.

Когда работаешь с чужим готовым примером, сохраняй собственную копию. После следующей правки сохраняй её снова: открытая вкладка с изменениями и сохранённая версия по ссылке — не одно и то же.

Проверяем, что ссылка открывается у другого человека

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

У ссылки на редактор может быть виден исходный код — для учебного проекта это нормально. Если нужен отдельный адрес только с игрой, следующий шаг — публикация браузерной игры по ссылке. Сохранение проекта в редакторе и размещение своей игры на хостинге — разные сценарии.

Не вставляй в публичную песочницу пароли, токены и другие секреты. В этой игре их нет: все настройки находятся прямо в коде, и скрывать здесь нечего.

Как запустить ту же игру без онлайн-редактора

Собираем один файл index.html

Самый простой вариант — скачать архив, распаковать его и взять index.html. В нём HTML, CSS и JavaScript уже соединены. Открывай именно распакованный файл, а не пытайся играть внутри просмотрщика архива.

Для своей изменённой версии используй обычный текстовый редактор. В полном HTML-документе CSS помещается в <style> внутри <head>, а JavaScript — в <script> после разметки поля и кнопки. Готовый index.html из архива показывает, где находятся эти части. Замени соответствующие блоки своей версией и сохрани файл.

Открываем его в браузере

Дважды щёлкни по index.html или перетащи файл в окно браузера. В нашей версии нет импортов, запросов к серверу и загрузки картинок, поэтому ей не нужен локальный сервер. После скачивания игра работает без интернета. Если файл открылся как текст, проверь расширение: оно должно быть .html, а не .html.txt.

Адрес вида file:///... ведёт к файлу на твоём компьютере. Отправить другу только такой адрес недостаточно: у него этого файла нет. Отправь сам HTML-файл или опубликуй игру и поделись сетевой ссылкой.

Что делать, если игра не работает

Вместо игры пустое поле

Сначала проверь, что в HTML есть id="game", а в JavaScript — селектор #game. Затем проверь режим запуска после готовности HTML. В локальном файле скрипт расположен после разметки; в онлайн-редакторе за порядок отвечает настройка выполнения.

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

Кнопка не реагирует

У кнопки должен остаться id="action". Проверь, что вставлен весь финальный JavaScript, включая addEventListener. В первых трёх контрольных шагах кнопка специально не управляет игрой; в пятом она отключается после результата. Полноценный повторный запуск появляется в шестом.

После перезапуска квадрат движется быстрее

Обычно это повод искать несколько запущенных циклов. В финальной версии startGame() сначала отменяет запланированный кадр, а обработчик кнопки объявлен один раз. Не вызывай дополнительно frame() из обработчика и не вставляй несколько полных версий JavaScript друг под другом.

Изменения не появились в результате

Нажми Run после редактирования. Убедись, что меняешь активный проект, а не соседнюю вкладку или просмотр старой версии. Для локального файла сначала сохрани изменения в редакторе, затем обнови страницу браузера.

Ссылка открывается у тебя, но не у друга

Проверь, что проект сохранён, доступен по этой ссылке без твоей авторизации и содержит последнюю версию. Не отправляй пустую стартовую страницу редактора или локальный адрес file:///. Проверка в приватном окне обычно быстро показывает, что именно получил бы другой человек.

Частые вопросы

Можно ли создать игру в браузере бесплатно?

Для кода этого урока не нужны платный движок, графика или библиотеки. Используй базовый сценарий браузерного редактора либо скачанный HTML-файл. Дополнительные функции внешнего сервиса — например, особые варианты публикации — могут иметь свои условия; они не нужны для самой игры.

Можно ли обойтись без знания JavaScript?

Запустить готовый пример можно, не понимая каждую строку. Чтобы менять правила, придётся постепенно разобраться с переменными, функциями, условиями и событиями. Этот урок как раз даёт несколько конкретных примеров, на которых видно, зачем они нужны.

Можно ли делать игру с телефона?

В нашей игре большая кнопка и поле, которое уменьшается под экран. Играть можно касанием. Для написания кода я всё же советую компьютер: на телефоне приходится постоянно переключаться между текстом урока, кодом и результатом.

Нужен ли интернет для готовой игры?

Для сайта редактора и открытия опубликованной ссылки нужен доступ к ним. Автономный index.html из этого урока хранит все необходимые части внутри себя и не делает сетевых запросов, поэтому после скачивания игра может работать офлайн.

Можно ли потом опубликовать её на игровой площадке?

Развивать этот код дальше можно, но учебный пример не является готовой к модерации платформенной сборкой. Перед отправкой понадобятся отдельная проверка требований выбранной площадки, подготовка ресурсов и нужная интеграция. Следующий материал — как выложить игру в Яндекс Игры; он не заменяет актуальные требования платформы.

Первая игра работает. Что делать дальше?

Мы начали с пустого поля, а закончили игрой с управлением, победой, проигрышем и повторной попыткой. Положение квадрата хранится в переменной. Движение выполняет функция. Нажатие запускает обработчик. Победу определяет условие. Теперь эти слова связаны с тем, что ты уже видел и менял.

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

В бесплатном курсе Инди Код разберём основы по порядку и применим их в следующих проектах: от HTML и первых действий на JavaScript до Canvas и публикации своей игры. Продолжай не с самого сложного урока, а с места, где тебе ещё нужна опора.

Перейти в бесплатный курс по созданию игр →

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

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

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

Подробнее о книге