Финальный проект: своя аркада
Сегодня мы не учим новое — мы собираем всё, что ты уже умеешь, в одну законченную игру, в которую реально хочется играть и которой не стыдно похвастаться.
Финальный проект — это не «ещё один пример», а сборка: ты берёшь готовые куски из прошлых уроков (движение цыплёнка, прыжок, коллизии, спрайты, состояния, сохранение) и соединяешь их в единую аркаду с меню, игрой, счётом и экраном проигрыша.
Помнишь самый первый урок, где наш цыплёнок просто стоял неподвижным квадратиком на canvas? С тех пор ты научил его бегать, прыгать, сталкиваться с врагами, переключать игровые состояния и помнить рекорд через сохранение прогресса. Сегодня все эти кусочки наконец-то щёлкнут вместе. На выходе у тебя будет файл, который можно открыть в браузере, дать другу и сказать: «Смотри, это я сделал».
И вот что важно понять с самого начала: финальный проект пугает новичков сильнее, чем любой отдельный урок. Кажется, что «собрать всю игру» — это какая-то отдельная сложная магия, недоступная тебе. Но это иллюзия. Ни одной новой формулы сегодня не будет. Будет ровно то, что ты уже сто раз писал: цикл, переменные с координатами, проверка столкновений, рисование на холсте. Просто раньше каждый кусок жил в своём файле-песочнице, а теперь они впервые встретятся в одной комнате и начнут разговаривать друг с другом. Вся хитрость сборки — в том, чтобы аккуратно их познакомить и проследить, чтобы они не путались, кто сейчас главный на экране.
Что мы собираем
Представь нашу игру как готовое блюдо. Все продукты уже куплены и нарезаны в прошлых уроках — осталось правильно сложить их в кастрюлю и не забыть посолить. Вот меню нашей аркады про цыплёнка:
- Меню — экран с названием и подсказкой «Нажми пробел». Это лицо игры.
- Игра — цыплёнок бежит, прыгает через врагов, собирает монетки, копит счёт.
- Проигрыш — экран с финальным счётом, рекордом и предложением начать заново.
- Связки — счёт, звук удара и сохранённый в браузере рекорд, который не теряется после перезагрузки.
К концу урока всё это будет работать как единое целое: запустил, поиграл, проиграл, увидел рекорд, нажал пробел — играешь снова. Это и есть кор-луп — главная петля, ради которой в игру возвращаются.
Картина целиком: один большой цикл
Вся игра по-прежнему держится на одном игровом цикле — нашем «сердцебиении». Разница лишь в том, что теперь внутри одного удара сердца игра спрашивает: «А в каком я сейчас режиме?» — и ведёт себя по-разному в меню, в бою и на экране смерти. Метафора простая: один и тот же телевизор показывает разные каналы, но кнопка питания и провод в розетке — одни на всех.
Зачем так заморачиваться, почему не сделать три отдельные программы — отдельно меню, отдельно игру, отдельно проигрыш? Потому что им нужно передавать данные друг другу. Меню должно запустить игру с чистого листа. Игра должна на проигрыше передать набранный счёт. Экран проигрыша — показать его рядом с рекордом и при нажатии пробела снова запустить игру. Если бы это были три не связанных куска, они бы не знали о счёте и рекорде друг друга. А единый цикл с переменной scene держит все общие данные (score, best, нашего chicken) в одном месте, и любой экран может в них заглянуть. Это и есть главная мысль урока: не три игры, а одна игра в трёх настроениях.
Шаг 1. Скелет состояний
Начнём с каркаса. Мы уже разбирали игровые состояния, поэтому просто собираем их в чистую конструкцию. Заводим переменную scene, которая хранит текущий режим, и в цикле смотрим на неё.
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
// Сквозной герой курса. Те же имена, что и во всех прошлых уроках.
const chicken = { x: 60, y: 0, vx: 0, vy: 0, w: 48, h: 48, onGround: false };
const chickenSprite = new Image();
chickenSprite.src = '/sprites/chicken.png';
let scene = 'menu'; // 'menu' | 'play' | 'gameover'
let score = 0;
let best = 0;
function loop(time) {
update(time);
draw();
requestAnimationFrame(loop); // ритм игрового цикла
}
requestAnimationFrame(loop);Результат: на экране пока ничего осмысленного не происходит — мы только завели холст, контекст, нашего цыплёнка с привычными именами chicken и chickenSprite и запустили вечный цикл через requestAnimationFrame. Игра «дышит», но дышит вхолостую: update и draw мы напишем дальше.
Главное тут — переменная scene. Это тот самый рубильник каналов. Пока в ней лежит строка 'menu', игра показывает меню; переключим на 'play' — начнётся бой. Числа score и best будут жить дольше одного забега, поэтому они снаружи всех функций.
Шаг 2. update — мозг игры
Функция update — это мозг. Каждый кадр она решает, что должно измениться, и делает это по-разному в зависимости от scene. Обрати внимание: в меню и на экране проигрыша цыплёнку двигаться не нужно — там мы просто ждём нажатия пробела.
const keys = {};
document.addEventListener('keydown', (e) => { keys[e.code] = true; });
document.addEventListener('keyup', (e) => { keys[e.code] = false; });
function update() {
if (scene === 'menu') {
if (keys['Space']) startGame(); // пробел — поехали
return;
}
if (scene === 'gameover') {
if (keys['Space']) startGame(); // пробел — заново
return;
}
// дальше — только когда scene === 'play'
updateChicken();
updateEnemies();
checkCollisions();
}
function startGame() {
scene = 'play';
score = 0;
chicken.x = 60;
chicken.y = 0;
chicken.vy = 0;
enemies.length = 0; // очищаем врагов с прошлого забега
}Результат: теперь игра реагирует на пробел. На стартовом экране нажатие пробела вызывает startGame, и режим переключается на 'play' — цыплёнок встаёт на старт, счёт обнуляется, старые враги выметаются. На экране проигрыша тот же пробел запускает новый забег. А в режиме play каждый кадр обновляются цыплёнок, враги и проверяются столкновения.
Заметь приём с return: если мы в меню или на проигрыше, функция честно делает своё маленькое дело и сразу выходит. Так физика игры (движение, гравитация, коллизии) крутится только в боевом режиме и не мешает спокойным экранам. Это и есть аккуратное переключение каналов.
Шаг 3. Цыплёнок, враги и счёт
Теперь — само ядро игры. Это почти дословно тот код, что ты писал в уроках про прыжок и коллизии. Мы переиспользуем гравитацию, вектор скорости и AABB-проверку столкновений — ничего не переписываем заново.
const GRAVITY = 0.6;
const JUMP = -11;
const groundY = 300;
let enemies = [];
let spawnTimer = 0;
function updateChicken() {
// прыжок только с земли
if (keys['Space'] && chicken.onGround) {
chicken.vy = JUMP;
chicken.onGround = false;
}
chicken.vy += GRAVITY; // гравитация тянет вниз каждый кадр
chicken.y += chicken.vy; // двигаем по вектору скорости
if (chicken.y >= groundY) { // приземление
chicken.y = groundY;
chicken.vy = 0;
chicken.onGround = true;
}
}
function updateEnemies() {
spawnTimer--;
if (spawnTimer <= 0) {
enemies.push({ x: canvas.width, y: groundY, w: 40, h: 40 });
spawnTimer = 90; // новый враг примерно раз в 1.5 секунды
}
for (const e of enemies) e.x -= 4; // ползут влево на цыплёнка
// улетел за край и не зацепил нас — это +1 к счёту
for (let i = enemies.length - 1; i >= 0; i--) {
if (enemies[i].x + enemies[i].w < 0) {
enemies.splice(i, 1);
score++;
}
}
}
function hit(a, b) { // AABB: пересекаются ли два прямоугольника
return a.x < b.x + b.w && a.x + a.w > b.x &&
a.y < b.y + b.h && a.y + a.h > b.y;
}
function checkCollisions() {
for (const e of enemies) {
if (hit(chicken, e)) gameOver();
}
}Результат: цыплёнок бежит на месте, а навстречу ему слева ползут квадратные враги. Пробел подбрасывает цыплёнка вверх, гравитация возвращает на землю. Если враг улетает за левый край — счёт растёт на единицу. Если цыплёнок врезается во врага — игра вызывает gameOver и забег заканчивается.
Разберём по частям. updateChicken — это знакомая связка «прыжок + гравитация + приземление» из урока про платформер. updateEnemies по таймеру спавнит врагов, двигает их влево и начисляет очко за каждого пережитого. hit — наша старая добрая AABB-проверка: четыре сравнения координат, и мы знаем, пересеклись ли прямоугольники. Всё это ты уже видел — сегодня оно просто работает вместе.
Один момент стоит подсветить отдельно — почему мы бежим по массиву врагов с конца (от enemies.length - 1 к нулю), когда удаляем улетевших. Если идти с начала и при этом удалять элементы через splice, массив на ходу сдвигается, и цикл перепрыгивает через соседний элемент — один враг останется неучтённым. А идя с конца, мы удаляем элементы, которые цикл уже прошёл, и ничего не ломается. Это классическая ловушка, на которой спотыкаются даже взрослые программисты, так что запомни приём: удаляешь из массива в цикле — иди с конца.
Шаг 4. Проигрыш, рекорд и звук
Когда цыплёнок врезался, нужно красиво закончить забег: переключить сцену, сравнить счёт с рекордом и сохранить рекорд в браузере, чтобы он пережил перезагрузку страницы. Тут пригодится наш урок про сохранение прогресса.
const hitSound = new Audio('/sounds/hit.wav');
// рекорд читаем из localStorage один раз при загрузке
best = Number(localStorage.getItem('chicken_best')) || 0;
function gameOver() {
hitSound.currentTime = 0;
hitSound.play(); // звук удара — обратная связь игроку
if (score > best) {
best = score;
localStorage.setItem('chicken_best', String(best)); // запоминаем навсегда
}
scene = 'gameover';
}Результат: в момент столкновения раздаётся короткий звук удара, игра переключается на экран проигрыша. Если текущий счёт побил прошлый рекорд, новый рекорд тут же записывается в localStorage — и даже если игрок закроет вкладку и вернётся завтра, его лучший результат будет на месте.
Две важные мелочи. Первая: localStorage хранит только строки, поэтому при чтении мы оборачиваем результат в Number(...), а при записи — в String(...). Вторая: hitSound.currentTime = 0 перематывает звук на начало — без этой строчки при частых ударах звук бы «не успевал» проигрываться заново.
Шаг 5. draw — лицо игры
Осталось всё нарисовать. Функция draw тоже смотрит на scene и рисует разные картинки для меню, боя и проигрыша. Это тот самый флипбук: каждый кадр мы стираем холст и рисуем новую страницу.
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height); // стираем прошлый кадр
if (scene === 'menu') {
ctx.fillStyle = '#222';
ctx.font = '32px sans-serif';
ctx.fillText('ЦЫПЛЁНОК-РАННЕР', 80, 140);
ctx.font = '18px sans-serif';
ctx.fillText('Нажми ПРОБЕЛ, чтобы начать', 90, 190);
return;
}
// общий фон боя и проигрыша
ctx.drawImage(chickenSprite, chicken.x, chicken.y, chicken.w, chicken.h);
ctx.fillStyle = '#c0392b';
for (const e of enemies) ctx.fillRect(e.x, e.y, e.w, e.h);
ctx.fillStyle = '#222';
ctx.font = '20px sans-serif';
ctx.fillText('Счёт: ' + score, 20, 30);
ctx.fillText('Рекорд: ' + best, 20, 56);
if (scene === 'gameover') {
ctx.font = '28px sans-serif';
ctx.fillText('Игра окончена!', 120, 150);
ctx.font = '16px sans-serif';
ctx.fillText('Пробел — сыграть ещё раз', 110, 185);
}
}Результат: в меню по центру светится название игры и подсказка про пробел. В бою на экране — наш спрайт цыплёнка, красные квадраты-враги и две строки в углу: текущий счёт и рекорд. Когда забег проигран, поверх этой же картинки появляется крупное «Игра окончена!» и подсказка сыграть снова. Один draw обслуживает все три экрана — просто по-разному в зависимости от scene.
Обрати внимание на clearRect в самом начале: без него кадры наслаивались бы друг на друга, и цыплёнок оставлял бы за собой шлейф призраков. Стереть — и нарисовать заново: так работает каждая страница флипбука.
Частые ошибки и подводные камни
Сборка — это место, где всплывают баги, которых не было в отдельных кусочках. Вот на чём спотыкаются почти все, кто впервые соединяет игру воедино.
- Забыл сбросить состояние при рестарте. Игрок проиграл, нажал пробел — а цыплёнок появляется уже мёртвым или счёт продолжается со старого. Лечится тем самым
startGame: обнуляй счёт, верни цыплёнка на старт, очисти массив врагов (enemies.length = 0). Если этого не сделать, второй забег унаследует мусор от первого. - Физика крутится в меню. Если не поставить
returnв веткахmenuиgameover, цыплёнок будет падать сквозь невидимый пол и копить скорость прямо на стартовом экране. К моменту старта он улетит в космос. Физика — только вscene === 'play'. - Рекорд читается как строка.
localStorageотдаёт строки, и безNumber(...)сравнениеscore > bestсработает как сравнение текста:'9'окажется «больше»'10'. Всегда приводи прочитанное к числу. - Один пробел делает два дела. Если на экране проигрыша по пробелу сразу начинается игра, а ты держишь пробел дольше кадра, цыплёнок мгновенно подпрыгнет на старте. Это не критично, но если мешает — заводи флаг «пробел уже обработан» и сбрасывай его на
keyup. - Звук не играет с первого раза. Браузеры блокируют автозвук, пока игрок не кликнул или не нажал клавишу на странице. Поскольку наша игра всё равно стартует с нажатия пробела — проблема обычно решается сама. Но если тестируешь звук до первого нажатия, не пугайся тишины.
Мини-проект: доведи аркаду до своей
Базовая игра готова и играбельна. Теперь сделай её своей — выбери минимум три улучшения из списка и доведи проект до состояния, которым не стыдно поделиться.
- Монетки. Добавь жёлтые кружки, которые цыплёнок собирает в прыжке — той же AABB-проверкой
hit. За монетку давай +5 к счёту. Это обогатит кор-луп: теперь рискованный прыжок не только опасен, но и выгоден. - Пауза. Добавь четвёртое состояние
'paused'и переключай его по клавише'Escape'. В паузеupdateничего не двигает, аdrawрисует затемнение и слово «Пауза». - Дельта-время. Сейчас скорости заданы «на кадр». Передай в
updateиdrawаргументdtи умножай движение на него — тогда игра будет одинаково быстрой и на слабом, и на мощном устройстве. - Рост сложности. Сделай так, чтобы с ростом счёта враги ползли быстрее, а
spawnTimerсокращался. Не забудь поставить потолок, чтобы игра не стала нечестной. - Своё лицо. Поменяй название в меню, цвета, добавь второй спрайт врага. Это твоя игра — пусть она выглядит как твоя.
Когда доделаешь — открой файл в браузере, сыграй сам, выбей рекорд, перезагрузи страницу и убедись, что рекорд на месте. Потом дай поиграть другу и посмотри, на каком счёте он сдаётся. Это и есть момент, ради которого был весь курс.
И ещё один совет, которым пользуются все, кто доводит игры до конца, а не бросает на полпути: добавляй улучшения по одному и проверяй после каждого. Соблазнительно навалить сразу монетки, паузу, дельта-время и рост сложности, а потом запустить и увидеть чёрный экран с непонятной ошибкой в консоли — и не знать, какое из четырёх изменений всё сломало. Если же ты вносишь одно улучшение, запускаешь, убеждаешься, что игра по-прежнему играется, и только потом берёшься за следующее — любой баг сразу понятно, откуда взялся. Это та же логика «меняем — смотрим что вышло», которой мы держались весь курс, только теперь на масштабе целой игры.
Итоги
Ты только что собрал законченную игру из деталей, которые делал на протяжении всего курса. Ничего принципиально нового сегодня не было — и это главный урок: большая игра — это не магия, а аккуратная сборка знакомых кусочков. Что стоит унести:
- Вся игра живёт в одном игровом цикле, а переменная
sceneпереключает его между меню, боем и проигрышем — как каналы на одном телевизоре. - Функции
updateиdrawсмотрят наsceneи ведут себя по-разному; физика крутится только в боевом режиме. - При рестарте обязательно сбрасывай состояние: счёт, позицию цыплёнка и список врагов.
- Счёт, звук и сохранённый в
localStorageрекорд превращают набор механик в настоящую аркаду, в которую хочется возвращаться.
Поздравляю — у тебя на руках живая игра про цыплёнка, прошедшего путь от неподвижного квадрата до героя собственной аркады. В следующем уроке мы поговорим о том, как показать эту игру миру: куда её выложить, как сделать ссылку, которой можно поделиться, и что написать, чтобы в неё захотели сыграть. Игра готова — пора выпускать её в свет.