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 с кучей методов внутри. Ошибок нет — значит, холст найден и контекст получен.
Что здесь происходит по шагам:
document.getElementById('game')ищет на странице элемент сid="game"и возвращает ссылку на наш холст. Сохраняем в переменнуюcanvas.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.
Мини-практика: собери свой первый кадр
Возьми код с небом, травой и цыплёнком за основу и доделай сам:
- Сделай холст побольше —
640x360(это пропорции 16:9, как у видео на телефоне). Не забудь поменять атрибуты в HTML. - Перекрась небо в закатный цвет — например,
'#ff9966'. Поэкспериментируй с оттенками, пока не понравится. - Нарисуй солнце — ещё один
fillRectжёлтого цвета в правом верхнем углу, размером 50x50, с отступом 30 пикселей от верха и от правого края. Подсказка:xсолнца считается какcanvas.width - 50 - 30. - Поставь цыплёнка ровно на траву: его нижний край должен совпадать с верхним краем травы. Если трава начинается на
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.
Пока наш цыплёнок неподвижно стоит на траве. Но мы уже умеем рисовать кадр — а в игровом цикле у нас есть «сердцебиение», которое вызывает наш код шестьдесят раз в секунду. В следующем уроке мы соединим эти две вещи: будем стирать холст и перерисовывать цыплёнка в новой позиции каждый кадр — и он наконец задвигается. Готовь пальцы к клавишам со стрелками.