Canvas и контекст 2D

В прошлый раз ты запустил пустое сердцебиение игры — а сегодня твой цыплёнок впервые появится на экране.
Canvas — это HTML-элемент-холст, на котором JavaScript рисует игровую графику пиксель за пикселем. А контекст 2D — объект, полученный через canvas.getContext('2d'), через который мы вызываем все команды рисования.

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

Зачем вообще нужен холст

Представь, что ты хочешь нарисовать своего цыплёнка на странице. Первая мысль обычного веб-разработчика — взять картинку, поставить её в <img> и двигать через CSS. И для лендинга это нормально. Но в игре у тебя на экране одновременно: цыплёнок, десяток монеток, три врага, фон, частицы от прыжка, счёт очков. И всё это меняется шестьдесят раз в секунду. Делать сотни DOM-элементов и гонять их через CSS — это как пытаться снять мультик, переклеивая стикеры на холодильнике. Тормозить начнёт мгновенно.

Игры рисуют иначе. Берётся один прямоугольный холст — и на нём JavaScript каждый кадр заново перерисовывает всю картинку с нуля: стёр всё, нарисовал фон, поверх нарисовал цыплёнка в новой позиции, поверх — монетки, поверх — счёт. Готово, показали кадр. Через 16 миллисекунд — снова стёрли и нарисовали. Это и есть тот самый флипбук: блокнот, на каждой странице которого чуть-чуть сдвинутый рисунок, а когда быстро листаешь — кажется, что он двигается.

Тут важно поймать разницу в мышлении. Когда ты двигаешь блок через CSS, ты говоришь браузеру: «вот элемент, держи его вон там, а дальше сам следи, чтобы он там и оставался». Браузер хранит этот элемент в памяти как отдельную вещь со своими стилями и положением. А холст ничего не помнит. Нарисовал жёлтый квадрат — и всё, для холста это просто россыпь жёлтых пикселей, никакого «квадрата» как объекта внутри него нет. Захотел сдвинуть — стирай весь холст и рисуй заново на новом месте. Поначалу это кажется неудобным, но именно эта «беспамятность» делает canvas быстрым: браузеру не нужно следить за тысячей объектов, он просто закрашивает пиксели по твоей команде.

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

Кладём холст на страницу

Canvas — обычный HTML-тег. Самый минимальный документ, в котором мы будем жить весь курс, выглядит так:

<!DOCTYPE html>
<html lang="ru">
<head>
  <meta charset="UTF-8">
  <title>Цыплёнок на холсте</title>
</head>
<body>
  <canvas id="game" width="480" height="320"></canvas>
  <script src="game.js"></script>
</body>
</html>

Результат: на белой странице появляется прямоугольная область 480 на 320 пикселей. Пока она прозрачная и сливается с фоном — её даже не видно. Но она там есть и уже готова принимать команды рисования.

Разберём по косточкам:

  • id="game" — имя, по которому мы потом найдём холст из JavaScript. Можешь назвать как угодно, но game удобно и коротко.
  • width="480" и height="320" — размер холста в пикселях рисования. Это важный момент, к нему вернёмся в ошибках.
  • <script src="game.js"> стоит после canvas, в самом низу <body>. Так к моменту запуска скрипта холст уже существует в документе, и мы сможем его найти.

Достаём холст и контекст из JavaScript

Сам по себе тег <canvas> — это просто пустая рамка. Чтобы на ней рисовать, нужно получить две вещи: ссылку на сам элемент и его контекст 2D — пульт управления кистью. Открываем game.js и пишем:

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

console.log(canvas.width, canvas.height); // 480 320
console.log(ctx); // CanvasRenderingContext2D { ... }

Результат: в консоли браузера появляются числа 480 320 и большой объект CanvasRenderingContext2D с кучей методов внутри. Ошибок нет — значит, холст найден и контекст получен.

Что здесь происходит по шагам:

  1. document.getElementById('game') ищет на странице элемент с id="game" и возвращает ссылку на наш холст. Сохраняем в переменную canvas.
  2. canvas.getContext('2d') просит у холста контекст для плоского, двумерного рисования. Строка '2d' здесь обязательна — есть ещё 'webgl' для трёхмерной графики, но он нам не нужен. Сохраняем результат в ctx — это сокращение от «context», и так его называют почти во всех туториалах мира.

Запомни эту пару строк наизусть. Любая игра на canvas начинается ровно с них. Это как «привет» при встрече: сначала getElementById, потом getContext('2d'), а дальше уже всё интересное.

Что вообще умеет контекст

Объект ctx — это твой набор инструментов художника. Внутри у него десятки методов и свойств, но в играх 90% времени ты будешь пользоваться буквально горсткой. Вот те, что встретятся уже в ближайших уроках:

КомандаЧто делает
ctx.fillStyle = '...'выбирает цвет заливки (фломастер)
ctx.fillRect(x, y, w, h)рисует залитый прямоугольник
ctx.clearRect(x, y, w, h)стирает прямоугольную область до прозрачности
ctx.drawImage(img, x, y)рисует картинку-спрайт — им мы оживим цыплёнка
ctx.fillText('Очки: 0', x, y)пишет текст — для счёта и меню

Не надо учить весь список. Сегодня нам хватит fillStyle и fillRect — с них и начнём. Остальное будем добавлять по мере того, как цыплёнку понадобятся новые способности: спрайт, текст счёта, стирание кадра. Хорошая новость: контекст у всех команд один и тот же ctx, так что новые инструменты будут просто новыми строчками, а каркас останется прежним.

Система координат: ноль наверху слева

Прежде чем рисовать, нужно понять, где рисовать. И тут новичков ждёт первая засада. На уроках математики тебя учили, что начало координат — внизу слева, и чем выше точка, тем больше y. На холсте всё наоборот.

На canvas точка (0, 0) находится в левом верхнем углу. Ось x растёт вправо — как обычно. А ось y растёт вниз: чем больше y, тем ниже на экране.

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

УголКоординаты (x, y)
Левый верхний(0, 0)
Правый верхний(480, 0)
Левый нижний(0, 320)
Правый нижний(480, 320)

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

Есть ещё одна деталь, о которую спотыкаются почти все. Когда ты рисуешь прямоугольник через fillRect(x, y, ширина, высота), точка (x, y) — это левый верхний угол прямоугольника, а не его центр. То есть fillRect(100, 50, 40, 40) поставит верхний-левый угол квадрата в точку (100, 50), а сам квадрат растянется вправо и вниз от неё. Это значит, что «позиция» нашего цыплёнка chicken.x, chicken.y — это его левый верхний угол. Запомни это: когда дойдём до столкновений и центрирования, разница между «угол» и «центр» будет решать всё. Если хочешь поставить квадрат строго по центру холста, его x считается как canvas.width / 2 - size / 2, а не просто canvas.width / 2 — иначе он съедет вправо на половину своей ширины.

Заливаем фон цветом

Теперь самое приятное — наконец что-то нарисуем. Самая первая команда рисования в любой игре — залить весь холст цветом фона. Делается это так:

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

ctx.fillStyle = '#7ec0ee';            // цвет кисти — небесно-голубой
ctx.fillRect(0, 0, canvas.width, canvas.height); // прямоугольник во весь холст

Результат: весь холст 480 на 320 заливается приятным голубым цветом неба. Раньше прозрачная рамка теперь стала видимым голубым прямоугольником — это первый кадр будущей игры.

Две команды, разберём обе:

  • ctx.fillStyle = '#7ec0ee' — задаёт цвет заливки. Это как выбрать фломастер перед рисованием: пока не сменишь, всё будет рисоваться этим цветом. Цвет можно писать как в CSS: 'red', '#ff0000', 'rgb(126, 192, 238)'.
  • ctx.fillRect(x, y, ширина, высота) — рисует залитый прямоугольник. Первые два числа — координаты левого верхнего угла, вторые два — размеры. Мы поставили угол в (0, 0) и взяли размеры во весь холст, поэтому залилось всё.

Добавим «землю» и самого цыплёнка — пока жёлтым квадратом, но с теми же именами переменных, которые поедут с нами через весь курс:

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

// состояние нашего цыплёнка — поедет из урока в урок
const chicken = { x: 220, y: 220, size: 40 };

// 1. небо
ctx.fillStyle = '#7ec0ee';
ctx.fillRect(0, 0, canvas.width, canvas.height);

// 2. трава внизу (высотой 60 пикселей)
ctx.fillStyle = '#5fb55f';
ctx.fillRect(0, canvas.height - 60, canvas.width, 60);

// 3. цыплёнок — жёлтый квадрат
ctx.fillStyle = '#ffd633';
ctx.fillRect(chicken.x, chicken.y, chicken.size, chicken.size);

Результат: голубое небо сверху, полоса зелёной травы внизу высотой 60 пикселей, и жёлтый квадрат-цыплёнок 40 на 40 стоит примерно по центру, опираясь на траву. Это уже похоже на самый первый кадр настоящей игры.

Обрати внимание на строку canvas.height - 60 для травы: высота холста 320, минус 60 — получаем y = 260, то есть трава начинается за 60 пикселей до низа и тянется до самого края. Это прямое применение правила «вниз — больше y».

И обрати внимание на chicken — это наше состояние игрока: набор данных об объекте (координаты x и y и другие свойства), которые будут меняться каждый кадр. Сейчас он стоит на месте, но именно chicken.x и chicken.y мы начнём менять в следующих уроках, чтобы он задвигался.

Почему мы сразу завели объект chicken, а не написали голые числа прямо в fillRect? Потому что данные героя и рисование героя — это две разные вещи, и их полезно разделить с самого начала. В одном месте лежат факты о цыплёнке (где он, какого размера), в другом месте идёт рисование по этим фактам. Когда в следующем уроке мы захотим его сдвинуть, нам нужно будет поменять только chicken.x — а строка рисования ctx.fillRect(chicken.x, chicken.y, chicken.size, chicken.size) останется нетронутой и сама нарисует цыплёнка на новом месте. Это та же привычка «держим одни имена переменных», о которой мы будем помнить весь курс: chicken и chickenSprite поедут с тобой от Понга до финальной аркады.

Заметь и порядок рисования: сначала небо, потом трава, потом цыплёнок. Холст рисует слоями, как аппликация: то, что нарисовано позже, ложится поверх того, что раньше. Нарисуй цыплёнка первым — и небо потом закрасит его целиком, останется чистый голубой прямоугольник. Поэтому правило простое: дальние и фоновые штуки рисуем раньше, героя и важные объекты — позже, чтобы они оказались сверху.

Частые ошибки и подводные камни

1. Скрипт запускается раньше, чем появился холст

Если поставить <script> в <head>, он выполнится до того, как браузер дойдёт до <canvas>. Тогда document.getElementById('game') вернёт null, а следующая строка упадёт с ошибкой Cannot read properties of null (reading 'getContext'). Лекарство: держи <script> в самом низу <body> (как у нас) или оберни код в обработчик window.addEventListener('load', ...).

2. Размер задают через CSS, а не через атрибуты

Самая коварная ошибка. Атрибуты width и height на теге — это разрешение рисования, реальное число пикселей. А если задать размер через CSS (canvas { width: 800px }), браузер не добавит пикселей — он растянет картинку 480x320 до 800 пикселей, как растянутую маленькую фотографию. Всё станет мыльным. Правило: размер холста задавай атрибутами в HTML или через canvas.width = ... в JS, а не стилями.

3. Забыли поставить fillStyle перед рисованием

Если вызвать fillRect, не задав fillStyle, прямоугольник нарисуется чёрным — это цвет по умолчанию. А ещё fillStyle «прилипает»: задал один раз — и всё рисуется этим цветом, пока не сменишь. Частый баг новичка: нарисовал цыплёнка жёлтым, а следующий объект забыл перекрасить — и он тоже вышел жёлтым. Меняй fillStyle перед каждым объектом другого цвета.

4. Перепутали порядок аргументов fillRect

Сигнатура — fillRect(x, y, width, height), именно в таком порядке. Очень легко написать ширину раньше координаты или перепутать y с высотой. Тогда квадрат окажется не там или не того размера. Если объект «уехал» в неожиданное место — первым делом проверь порядок чисел.

5. Рисуют за границей холста и удивляются пустоте

Если поставить fillRect(500, 0, 40, 40) на холсте шириной 480 — прямоугольник нарисуется, но за видимой областью, и ты его не увидишь. Браузер ошибку не выдаст: рисовать «в никуда» разрешено. Если объект пропал — проверь, не вылез ли он за 0..width по x и 0..height по y.

Мини-практика: собери свой первый кадр

Возьми код с небом, травой и цыплёнком за основу и доделай сам:

  1. Сделай холст побольше — 640x360 (это пропорции 16:9, как у видео на телефоне). Не забудь поменять атрибуты в HTML.
  2. Перекрась небо в закатный цвет — например, '#ff9966'. Поэкспериментируй с оттенками, пока не понравится.
  3. Нарисуй солнце — ещё один fillRect жёлтого цвета в правом верхнем углу, размером 50x50, с отступом 30 пикселей от верха и от правого края. Подсказка: x солнца считается как canvas.width - 50 - 30.
  4. Поставь цыплёнка ровно на траву: его нижний край должен совпадать с верхним краем травы. Если трава начинается на canvas.height - 60, а цыплёнок высотой 40, то его chicken.y = canvas.height - 60 - 40.

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

Итоги

Сегодня ты сделал первый видимый шаг в геймдеве:

  • добавил canvas на страницу и задал ему размер атрибутами width/height;
  • получил контекст 2D парой строк getElementById + getContext('2d') — теперь это твой рефлекс;
  • разобрался с системой координат, где (0, 0) наверху слева, а y растёт вниз;
  • залил фон и нарисовал первые прямоугольники через fillStyle и fillRect, включая состояние chicken.

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

Проверьте себя
1. Что возвращает вызов canvas.getContext('2d')?
AОбъект контекста рисования, через который вызываются все команды отрисовки
BСаму картинку холста в виде файла PNG
CШирину и высоту холста в виде массива
DСсылку на HTML-элемент &lt;canvas&gt; на странице
2. Где на холсте находится точка с координатами (0, 0)?
AВ центре холста
BВ левом нижнем углу, как на уроках математики
CВ левом верхнем углу
DВ правом верхнем углу
3. Почему задавать размер холста через CSS (width: 800px) — плохая идея?
ACSS вообще не умеет менять размер canvas
BБраузер растянет картинку рисования до новых размеров, и она станет мыльной
CХолст станет прозрачным и исчезнет
DКонтекст 2D после этого перестанет работать
4. Какой каркас холста на 320 пикселей в высоту нарисует полосу травы высотой 60 пикселей у самого низа?
Actx.fillRect(0, 0, canvas.width, 60)
Bctx.fillRect(0, 60, canvas.width, canvas.height)
Cctx.fillRect(0, canvas.height - 60, canvas.width, 60)
Dctx.fillRect(0, canvas.height, canvas.width, 60)
5. Скрипт упал с ошибкой «Cannot read properties of null (reading 'getContext')». В чём вероятная причина?
AСкрипт выполнился раньше, чем браузер создал элемент canvas
BВ строке getContext опечатка в слове '2d'
CХолсту не задали fillStyle
DРазмеры холста слишком большие для браузера
6. Что делает строка ctx.fillStyle = '#ffd633' перед вызовом fillRect?
AСразу рисует жёлтый прямоугольник на холсте
BЗадаёт цвет заливки, которым будут рисоваться следующие прямоугольники
CОчищает холст и заливает его жёлтым
DМеняет цвет фона HTML-страницы