До этого мы меняли кнопки, надписи и карточки на странице. Теперь нарисуем сцену внутри одного элемента Canvas: небо, круглое солнце, землю и заготовку героя. Разберём, откуда берутся координаты и почему изменение данных само по себе не передвигает рисунок.

Результат урока

На холсте появится сцена 480×320. Ты сможешь перенести прямоугольник, изменив две координаты, стереть картинку и восстановить её из данных. Движения по времени и управления здесь пока нет.

Открыть результат отдельно

Что понадобится

Пригодятся функции с параметрами, обращение к свойствам объекта и поиск HTML-элемента. Если обращение к свойству player.x пока непонятно, вернись к уроку о состоянии игры. Сохранение из предыдущего проекта здесь не требуется: мы начинаем отдельную сцену.

Распакуй архив и открой start/index.html. Рядом должны остаться style.css и game.js. В этом проекте можно открыть HTML прямо в браузере или воспользоваться знакомым локальным сервером. После правки сохраняй файл и обновляй страницу. Пока будет виден белый пустой холст — это ожидаемый старт.

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

В start/ лежит пустой холст; step-01/ — прямоугольник; step-02/ — сцена с кругом; step-03/ и final/ — перерисовка из данных. solutions/ содержит ответ на самостоятельную задачу. Инструкция по запуску доступна в просмотрщике и ZIP.

Скачать ZIP

Холст и его два размера

Canvas хранит итоговую картинку — сетку пикселей, или bitmap. Нарисованный прямоугольник не становится отдельным DOM-элементом, как кнопка или карточка. Браузер не запоминает, что это «герой»: данные героя будем хранить сами, а Canvas получит команды рисования.

В index.html уже есть такой элемент. Атрибуты width и height задают внутреннее поле координат. Здесь по горизонтали 480 единиц, по вертикали 320.

<canvas
  id="game"
  width="480"
  height="320"
  aria-label="Учебная сцена: небо, солнце, земля и герой"
>
  Твоему браузеру нужна поддержка Canvas, чтобы показать рисунок.
</canvas>

Текст внутри тега нужен как запасное содержимое для браузера без Canvas. aria-label кратко описывает статичный рисунок для вспомогательных технологий. Это ещё не делает будущую игру полностью доступной: интерактивным правилам и результату позже понадобятся свои понятные элементы интерфейса.

CSS отвечает за размер картинки на странице. В готовом style.css у родителя main стоит max-width: 480px, а у холста — width: 100%; height: auto. На узком экране он уменьшается целиком и сохраняет пропорции. Внутренние координаты остаются прежними. Если дать CSS ширину 480px и высоту 100px, круг станет сплющенным, хотя команда рисования не изменится. Эту разницу описывает введение MDN в Canvas.

Не меняй canvas.width при каждом рисовании: присваивание внутреннего размера очищает bitmap и сбрасывает настройки контекста. В этом проекте размеры постоянны; адаптивность делает CSS.

Шаг 1. Прямоугольник по координатам

Замени комментарий в game.js этим кодом. Скрипт подключён с defer, поэтому к его запуску браузер уже прочитает HTML и найдёт холст по id="game".

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

ctx.fillStyle = '#6c42d8';
ctx.fillRect(80, 180, 32, 40);

getContext('2d') возвращает объект с командами двумерного рисования. Мы сохраняем его в ctx; имя можно было выбрать другим. fillStyle задаёт цвет для последующей заливки. Уже нарисованные пиксели от присваивания цвета не меняются.

Верхний левый угол холста — точка (0, 0). X растёт вправо, Y — вниз. У fillRect(x, y, width, height) первые два аргумента задают верхний левый угол прямоугольника, следующие два — ширину и высоту. В нашем примере правая граница получается 80 + 32 = 112, нижняя — 180 + 40 = 220. Граница — край фигуры, а не дополнительный закрашенный столбец.

Проверь: виден небольшой фиолетовый прямоугольник. Поменяй только 80 на 160: он должен уйти вправо, сохранив размер. Верни 80. Если менять третье число, меняется ширина, а не положение. Результат этого этапа лежит в step-01/.

Шаг 2. Цвет, круг и порядок слоёв

Построим фон в отдельной функции drawScene(). Так его можно будет вызывать повторно. Замени содержимое game.js целиком: здесь сохранены поиск холста и прямоугольник, а между ними появилась сцена.

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

function drawScene() {
  ctx.fillStyle = '#e8e2ff';
  ctx.fillRect(0, 0, canvas.width, canvas.height);

  ctx.fillStyle = '#ffc857';
  ctx.beginPath();
  ctx.arc(380, 70, 24, 0, Math.PI * 2);
  ctx.fill();

  ctx.fillStyle = '#655a86';
  ctx.fillRect(0, 220, canvas.width, 100);
}

drawScene();
ctx.fillStyle = '#6c42d8';
ctx.fillRect(80, 180, 32, 40);

Небо заполняет весь холст. Земля начинается на Y=220 и занимает оставшиеся 100 единиц. Солнце устроено иначе, чем прямоугольник: мы сначала задаём путь, то есть описание контура, затем заливаем его.

  • beginPath() начинает новый путь. Это не очистка картинки.
  • arc(380, 70, 24, 0, Math.PI * 2) описывает окружность: центр (380,70), радиус 24. Последние два числа — начальный и конечный углы.
  • Math.PI — число π, примерно 3,14. Canvas измеряет углы в радианах: Math.PI соответствует половине оборота, Math.PI * 2 — целому. Здесь нужен полный круг.
  • fill() закрашивает текущий контур цветом fillStyle.

В отличие от fillRect(), метод arc() сам не закрашивает пиксели. И его X/Y задают центр, а не угол. У нашего солнца левый край находится на 356, правый — на 404. Параметры arc в MDN пригодятся, если захочешь нарисовать дугу.

Команды выполняются сверху вниз. Если вызвать заливку неба в самом конце, она закроет и солнце, и героя. Поэтому порядок такой: небо → солнце → земля → герой. Никакого CSS z-index у отдельных фигур здесь нет.

Проверь: прямоугольник стоит нижней гранью на земле, а солнце остаётся круглым при сужении окна. На минуту перенеси drawScene(); после ctx.fillRect(80, 180, 32, 40);. Герой исчезнет под фоном. Верни вызов на место. Это законченная версия step-02/.

Шаг 3. Данные, очистка и повторное рисование

Сейчас координаты спрятаны внутри команды. Вынесем их в знакомый объект player, а рисование целой картинки назовём render(). Эта функция будет читать данные, а не менять их. Полностью замени game.js следующей версией:

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

const player = { x: 80, y: 180, width: 32, height: 40 };

function drawScene() {
  ctx.fillStyle = '#e8e2ff';
  ctx.fillRect(0, 0, canvas.width, canvas.height);

  ctx.fillStyle = '#ffc857';
  ctx.beginPath();
  ctx.arc(380, 70, 24, 0, Math.PI * 2);
  ctx.fill();

  ctx.fillStyle = '#655a86';
  ctx.fillRect(0, 220, canvas.width, 100);
}

function render() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  drawScene();
  ctx.fillStyle = '#6c42d8';
  ctx.fillRect(player.x, player.y, player.width, player.height);
}

render();

clearRect(0, 0, canvas.width, canvas.height) делает всю область холста прозрачной. Белый цвет после очистки — это фон из CSS, а не белая заливка Canvas. Сама очистка не меняет ни player.x, ни fillStyle, ни текущий путь. Для нового круга поэтому по-прежнему нужен beginPath(). Разница между стиранием пикселей и новым путём показана в справочнике clearRect.

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

Проверь в консоли разработчика у отдельно открытой страницы final/index.html: выполни команды по очереди. После первой команды координата изменится, но изображение останется прежним. Только вызов render() перестроит картинку.

player.x = 160;
render();

Теперь выполни очистку отдельно:

ctx.clearRect(0, 0, canvas.width, canvas.height);
console.log(player.x);

Рисунок исчез, но в консоли осталось 160. Вызови render(); — сцена вернётся с героем в новой позиции. Если повторить render(), герой не поедет ещё дальше: функция рисует уже имеющееся состояние. Обновление страницы восстановит начальное x: 80 из файла.

Самостоятельно: поставь героя в проход между стойками

На базе final/ построй ворота. Координаты заданы намеренно: задача — прочитать их и определить место для героя, а не подобрать красивую картинку на глаз. Автоматическое движение не требуется.

ЧастьXYШиринаВысота
Левая стойка1801601260
Правая стойка2321601260
Перекладина1801486412

Добавь их рисование после фона. Размер героя оставь 32×40, Y=180. Выбери X так, чтобы он целиком поместился между стойками и не касался их. Затем перенеси героя, вызови render() и убедись, что старой копии нет. Объясни, почему X=228 не подходит, даже если левый край героя ещё находится в проходе.

Подсказка: какие границы сравнивать

Проход начинается после ширины левой стойки, а заканчивается у левого края правой. У героя нужно проверить обе стороны: player.x и player.x + player.width.

Более конкретная подсказка

Проход занимает X от 192 до 232. Его ширина 40, у героя — 32. Останется 8 единиц свободного места; можно распределить их поровну по краям.

Разбор после своей попытки

X=196 оставляет по четыре единицы с каждой стороны: правая граница героя будет 228. При X=228 правая граница станет 260 и зайдёт за стойку. Один рабочий вариант есть в solutions/ общего комплекта. В нём ворота рисуются после drawScene(), затем герой. Эти проверки пока делаешь ты; столкновений в программе ещё нет.

Если результат не совпал

Что видноЧто проверить
Ошибка про null или getContextОдинаковый ли id у холста и в getElementById? Есть ли defer у подключения game.js?
Белый холст, ошибок нетВызвана ли render()? Не нарисован ли фон после героя? Не осталась ли отдельная clearRect в конце файла?
Круг пропал или соединён с чужой фигуройПеред arc нужен beginPath, после — fill. Проверь положительный радиус.
Цвет не изменилсяfillStyle должен стоять до нужной заливки. После правки данных надо перерисовать картинку.
Круг вытянулсяВнутренний размер 480×320 и пропорции CSS должны совпадать. Для адаптивной высоты оставь auto.
После переноса видны две копииНе рисуй только героя поверх прежних пикселей: используй полную render с очисткой и фоном.

Что должно получиться

Ты можешь назвать верхний левый угол и размер прямоугольника, отличить их от центра и радиуса круга, предсказать перекрытие фигур по порядку команд. Очистка стирает изображение, но сохраняет данные; render() восстанавливает сцену. В самостоятельной версии герой помещается в проход целиком, а при сужении страницы координаты и пропорции сохраняются.

Попробуй без подсматривания восстановить две строки, которые задают цвет и рисуют прямоугольник из player. Затем объясни, почему присваивание player.x = 160 не равнозначно перемещению DOM-элемента. Если эти проверки получились, обязательная часть урока закончена.

Справочное продолжение: другие команды Canvas

Можно вернуться позже

Здесь можно попробовать другие команды Canvas. К этим примерам удобно вернуться позже. Выполняй их по отдельности: не вставляй все подряд в рабочую render(). Для них нужны уже созданные canvas и ctx.

Контур прямоугольника, линии и треугольник

strokeStyle задаёт цвет обводки, lineWidth — её толщину. strokeRect() рисует только контур прямоугольника. Линия проходит по границе фигуры и выступает на половину своей толщины в обе стороны.

ctx.strokeStyle = '#6c42d8';
ctx.lineWidth = 3;
ctx.strokeRect(40, 40, 100, 60);

У произвольного контура сначала задают путь. moveTo() переводит начало в точку без соединительной линии; lineTo() добавляет отрезок. stroke() обводит созданный путь. Новый moveTo() внутри пути позволяет начать отдельный отрезок, например второй ус кота.

ctx.beginPath();
ctx.moveTo(40, 140);
ctx.lineTo(100, 100);
ctx.lineTo(160, 140);
ctx.stroke();

Чтобы замкнуть путь до начальной точки, вызови closePath(). Для заполненного треугольника используй fill(); при необходимости после заливки можно обвести тот же путь. Закрытие пути не стирает его и не рисует само по себе.

ctx.fillStyle = '#ffc857';
ctx.beginPath();
ctx.moveTo(200, 140);
ctx.lineTo(240, 80);
ctx.lineTo(280, 140);
ctx.closePath();
ctx.fill();
ctx.stroke();

Полукруг и текст

Дуга от 0 до Math.PI идёт по нижней половине окружности: в Canvas Y растёт вниз. Здесь stroke() оставит только дугу, а fill() даст заполненный полукруг.

ctx.beginPath();
ctx.arc(350, 100, 30, 0, Math.PI);
ctx.stroke();

font задаёт размер и семейство шрифта, fillText(text, x, y) выводит строку. Y по умолчанию обозначает базовую линию текста, а не его верхний край. textAlign = 'center' ставит горизонтальный центр надписи на выбранный X. Текст становится пикселями: изменение строки в переменной потребует перерисовки.

ctx.fillStyle = '#211939';
ctx.font = '24px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('Сцена готова', canvas.width / 2, 60);

Изображение: сначала загрузка, затем drawImage

Фигуры можно заменить файлом изображения. new Image() создаёт объект изображения, src задаёт путь. Положи свою картинку player.png рядом с HTML. В отличие от прямоугольника, она не доступна мгновенно: рисуем после события загрузки. Пример показывает только один ресурс и не заменяет систему загрузки целой игры.

const playerImage = new Image();

playerImage.onload = function () {
  ctx.drawImage(playerImage, 100, 100, 64, 64);
};

playerImage.onerror = function () {
  console.log('Не удалось загрузить player.png');
  ctx.fillStyle = '#6c42d8';
  ctx.fillRect(100, 100, 64, 64);
};

playerImage.src = 'player.png';

onload и onerror получают функции, которые браузер вызовет при успехе или ошибке. Обработчики стоят перед присваиванием пути. У drawImage(image, x, y, width, height) последние два аргумента задают размер рисунка на холсте. Можно вызвать вариант с тремя аргументами и использовать исходный размер файла. Неверный путь проверь намеренно: в этом опыте должен появиться запасной прямоугольник. Подробно ожидание нескольких ресурсов рассматривается отдельно, после того как появится сама игра.

Как работает анимация

Чтобы получать меняющуюся картинку, нужно обновлять данные и повторять рисование. requestAnimationFrame(callback) просит браузер вызвать функцию перед очередной отрисовкой страницы. Это запрос одного кадра; для продолжения нужен следующий запрос. Он не обещает ровно 60 кадров в секунду.

Что выбирать: Canvas, HTML или SVG

HTML подходит для кнопок, форм и текстового интерфейса: элементы остаются доступны через DOM. SVG описывает векторные фигуры отдельными элементами. Canvas хранит итоговый bitmap и удобен там, где ты сам регулярно перестраиваешь сцену из данных. Эти подходы можно сочетать: Canvas для мира, HTML для управления и результата. 2d — двумерный контекст; трёхмерные сцены требуют других средств, например WebGL. Учить все методы до первой игры не нужно.

Справочник для дальнейших опытов: фигуры и пути в MDN, текст, изображения.