Сохранение прогресса
Закрыл вкладку — и весь рекорд цыплёнка испарился. Сегодня научим игру помнить лучший счёт даже после перезагрузки браузера.
localStorage — маленькое хранилище прямо в браузере, куда твоя игра может записать пару строк (например, рекорд) и прочитать их обратно даже после того, как пользователь закрыл вкладку и вернулся через неделю.
В прошлом уроке мы разобрали загрузку ресурсов и прелоадер — научили игру дождаться, пока подгрузятся все спрайты, прежде чем стартовать. Теперь у нас есть аккуратный запуск. Но есть проблема, которую ты наверняка уже чувствовал, играя в собственную игру: набил красивый рекорд, обновил страницу — и счётчик снова на нуле, будто ничего и не было.
Этот урок закрывает именно эту дыру. Мы научим игру помнить — не картинки и не уровни, а самое личное для игрока: его результат. И сделаем это без сервера, без базы данных, без регистрации — всё хранится прямо у пользователя в браузере, парой строк кода. Это, кстати, ровно тот же приём, которым пользуются десятки реальных браузерных игр: сохранить рекорд и прогресс локально проще и быстрее, чем поднимать целый бэкенд.
Зачем игре память
Представь: ты залип в свою аркаду про цыплёнка, поймал поток и набил 4200 очков — личный максимум. Гордость. Тянешься показать другу, случайно жмёшь F5 — и вместо 4200 на экране ноль. Рекорда нет. Будто ты и не играл.
А теперь вспомни любую игру с телефона — хоть тот же Subway Surfers или 2048. Закрываешь приложение, через три дня открываешь — а там твой лучший счёт на месте, монетки никуда не делись, прокачка сохранилась. Игра помнит тебя. И именно это превращает «кликалку на пять минут» в то, к чему хочется возвращаться: всегда есть личный рекорд, который тянет побить.
Вся разница между этими двумя играми — буквально несколько строк кода. Браузер даёт нам крошечный сейф под названием localStorage, куда можно положить пару значений и забрать их обратно хоть через месяц. К концу урока твой цыплёнок будет запоминать лучший счёт: набил рекорд — игра его записала, закрыл вкладку, вернулся — а сверху уже горит «Рекорд: 4200». Поехали учить игру помнить.
И заметь: это первый кирпич настоящей «жизни» игры за пределами одной партии. Дальше из той же идеи вырастает всё, что удерживает игрока надолго — таблица личных рекордов, открытые скины для цыплёнка, пройденные уровни, ежедневные награды. Всё это сводится к одному вопросу: «что игра должна помнить о тебе, когда ты ушёл?» Сегодня мы отвечаем на него в самой простой форме — один рекорд, — но механика ровно та же, что и в больших проектах.
Главная идея: localStorage — это блокнот, который не выбрасывают
Поймай метафору, и всё встанет на место. Обычные переменные игры — chicken.x, score, массив частиц — живут в оперативной памяти вкладки. Это как заметки на руке маркером: пока вкладка открыта, всё видно, но стоит её закрыть (или нажать F5) — и руку «помыли», все пометки стёрлись. Игра каждый раз начинается с чистого листа.
А localStorage — это блокнот, который лежит в ящике стола браузера и который никто не выбрасывает. Ты можешь записать туда строчку, закрыть вкладку, выключить компьютер, прийти завтра — блокнот всё там же, и запись на месте. Каждый сайт получает свой собственный блокнот: твоя игра не видит записи других сайтов, и наоборот.
localStorage работает как объект с парами «ключ → значение»:
localStorage.setItem('ключ', 'значение')кладёт запись в блокнот,localStorage.getItem('ключ')достаёт её обратно. Ключ и значение — всегда строки.
Вот это «всегда строки» — самое важное и самое коварное правило. В блокнот можно записать только текст. Число 4200 превратится в строку "4200", а целый объект состояния игры просто так не запишешь — его сначала придётся перевести в текст. Но об этом чуть позже, сначала разберёмся с простым рекордом.
Где блокнот лежит на самом деле
Может казаться, что localStorage — это какая-то магия из облака. Нет: всё хранится прямо на компьютере игрока, в файлах самого браузера, и привязано к домену твоего сайта. Это значит три практичных вещи. Во-первых, читать и писать туда мгновенно — никакого интернета не нужно, всё работает даже офлайн. Во-вторых, объём ограничен (обычно около 5 мегабайт на сайт) — для рекорда и пары чисел это бездонная бочка, но гигантские сейвы туда не влезут. В-третьих, это не защищённое хранилище: продвинутый игрок может открыть DevTools и руками подправить свой рекорд. Для учебной игры это совершенно неважно, но держи в голове: серьёзные соревновательные таблицы рекордов всё-таки считают на сервере.
Пример 1. Знакомимся с блокнотом браузера
Прежде чем лезть в игру, потрогаем localStorage руками. Это редкий для нашего курса код, который реально запускается в обычной консоли браузера, — открой DevTools (F12), вкладку Console, и попробуй.
// кладём запись в блокнот
localStorage.setItem('chickenBestScore', '4200');
// достаём её обратно — хоть сейчас, хоть после перезагрузки
const saved = localStorage.getItem('chickenBestScore');
console.log(saved); // "4200"
console.log(typeof saved); // "string" — внимание, это строка!
// если ключа ещё нет, getItem вернёт null
console.log(localStorage.getItem('ничего-нет')); // nullРезультат: в консоли появится 4200, а затем string — обрати внимание, что число превратилось в строку. Если теперь нажать F5 и снова выполнить localStorage.getItem('chickenBestScore'), ты опять увидишь "4200": запись пережила перезагрузку. А запрос несуществующего ключа вернул null — это важный сигнал «здесь пусто», который мы будем ловить при первом запуске игры.
Запомни два момента отсюда. Первый: getItem для нового ключа возвращает null, а не ноль и не пустую строку. Второй: что положили строкой, то строкой и достанем. Если записать число, а потом попытаться сравнить "4200" > 999, можно нарваться на сюрпризы — поэтому при чтении мы будем превращать строку обратно в число.
Пример 2. Сохраняем и читаем лучший счёт
Теперь по-настоящему: научим игру помнить рекорд цыплёнка. Нам нужны две вещи — прочитать сохранённый рекорд при запуске и записать новый, когда игрок его побил. Заведём для этого пару маленьких функций.
const BEST_KEY = 'chickenBestScore'; // имя записи в блокноте
// читаем рекорд при старте игры
function loadBestScore() {
const raw = localStorage.getItem(BEST_KEY);
if (raw === null) {
return 0; // блокнот пуст — играем первый раз, рекорд = 0
}
return Number(raw); // строку "4200" превращаем обратно в число 4200
}
// сохраняем рекорд, но только если он реально побит
function saveBestScore(current, best) {
if (current > best) {
localStorage.setItem(BEST_KEY, String(current));
return current; // вернём новый рекорд
}
return best; // рекорд не побит — оставляем старый
}
// при запуске игры один раз достаём рекорд
let bestScore = loadBestScore();
let score = 0;Результат: при старте игры bestScore получает значение из блокнота — например, 4200 с прошлой сессии, или 0, если играем впервые. Пока на экране ничего не поменялось, но в переменной уже лежит честный рекорд, готовый показаться на экране.
Разберём по косточкам. В loadBestScore мы сначала ловим случай «ключа ещё нет»: getItem вернул null — значит, это первый запуск, и рекорд равен нулю. Если запись есть, прогоняем её через Number(raw): это превращает строку "4200" в настоящее число 4200, с которым можно нормально сравнивать. В saveBestScore мы записываем новое значение только если текущий счёт больше рекорда — иначе зачем трогать блокнот. И обратно строкой через String(current), ведь в блокнот ложится только текст.
Когда вызывать сохранение
Сохранять рекорд каждый кадр — расточительно и незачем. Лучший момент — когда партия закончилась, то есть в обработчике проигрыша. Помнишь, у нас была сцена game over из урока про игровые состояния? Прямо там:
function onGameOver() {
// обновляем рекорд, если текущий счёт его побил
bestScore = saveBestScore(score, bestScore);
scene = 'gameover'; // переключаемся на экран проигрыша
}Результат: цыплёнок врезался в препятствие, игра переходит на экран проигрыша — и в этот же момент, если набранный счёт оказался больше прежнего рекорда, блокнот тихо обновляется. Игрок этого не видит, но в следующий раз сверху будет гореть его новый максимум.
Пример 3. Показываем рекорд на экране
Сохранённое число пора показать игроку — иначе зачем мы его храним. Нарисуем текущий счёт и рекорд прямо на canvas, в углу экрана.
function drawHud() {
context.fillStyle = '#fff';
context.font = '20px sans-serif';
context.fillText('Счёт: ' + score, 16, 30);
context.fillText('Рекорд: ' + bestScore, 16, 56);
}
// в общем игровом цикле — рисуем HUD поверх всей сцены
function loop(dt) {
context.clearRect(0, 0, canvas.width, canvas.height);
updateChicken(dt);
drawLevel();
context.drawImage(chickenSprite, chicken.x, chicken.y, chicken.w, chicken.h);
drawHud(); // счёт и рекорд всегда сверху
}Результат: в левом верхнем углу появляются две строки — текущий счёт растёт, пока цыплёнок собирает монетки, а «Рекорд» показывает лучший результат из всех прошлых заходов. Открыл игру впервые — «Рекорд: 0». Набил 4200, проиграл, обновил страницу — а сверху уже честное «Рекорд: 4200». Вот ради этого момента всё и затевалось.
Пример 4. Сохраняем целое состояние через JSON
Рекорд — это одно число. А если захочется сохранить больше: текущий уровень, число собранных монеток, открытые скины? Записывать каждое значение отдельным ключом неудобно. Тут на помощь приходит сериализация — превращение целого объекта в одну текстовую строку.
JSON.stringify(объект) упаковывает объект в строку, которую можно положить в localStorage. Обратная операция — JSON.parse(строка) — разворачивает строку обратно в живой объект.
const SAVE_KEY = 'chickenProgress';
// собираем весь прогресс в один объект и пишем строкой
function saveProgress() {
const data = {
bestScore: bestScore,
level: currentLevel,
coins: totalCoins,
};
localStorage.setItem(SAVE_KEY, JSON.stringify(data));
}
// читаем прогресс при запуске и разворачиваем обратно в объект
function loadProgress() {
const raw = localStorage.getItem(SAVE_KEY);
if (raw === null) {
return { bestScore: 0, level: 1, coins: 0 }; // дефолт для новичка
}
return JSON.parse(raw); // строка снова стала объектом
}Результат: теперь весь прогресс цыплёнка живёт в одной записи блокнота. JSON.stringify превращает объект { bestScore: 4200, level: 3, coins: 57 } в строку '{"bestScore":4200,"level":3,"coins":57}', а JSON.parse при следующем запуске разворачивает её обратно в нормальный объект с числами. Цыплёнок продолжает с того же уровня и с теми же монетками, на которых ты бросил игру вчера.
Заметь красоту: внутри объекта числа остаются числами. JSON сам помнит, что 4200 — это число, а не строка, поэтому возиться с Number() здесь уже не нужно. Один stringify при сохранении, один parse при загрузке — и сколько угодно полей едут вместе одной строкой.
Слово JSON ты ещё не раз встретишь и за пределами игр: ровно в этом формате серверы шлют данные браузеру, в нём же хранят настройки приложения и обмениваются информацией почти все веб-сервисы. По сути это универсальный способ записать объект текстом так, чтобы его потом смог прочитать кто угодно — другая программа, другой язык, другой компьютер. Так что, разобравшись с сохранением прогресса цыплёнка, ты заодно потрогал один из самых ходовых инструментов всего веба. Главное, что нужно запомнить про связку с localStorage: туда едет только результат JSON.stringify, а на выходе JSON.parse возвращает обычный объект, с которым игра работает как с любым другим.
Частые ошибки и подводные камни
Сравнивают строки как числа. Достал рекорд через
getItemи сразу сравниваешь:if (score > saved). Ноsaved— строка, и сравнение может соврать (например,"9" > "100"в JS даётtrue, потому что строки сравниваются посимвольно). Всегда прогоняй прочитанное черезNumber(), прежде чем считать.Забывают про null при первом запуске. У нового игрока ключа в блокноте ещё нет, и
getItemвернётnull. Если сразу сделатьNumber(null), получишь0— повезло, а вотJSON.parse(null)уронит игру с ошибкой. Всегда проверяйif (raw === null)и подставляй значение по умолчанию.Кладут объект без JSON.stringify. Попытка записать объект напрямую —
localStorage.setItem(key, {score: 10})— сохранит бесполезную строку"[object Object]". localStorage умеет только строки, поэтому объект обязательно упаковывай черезJSON.stringify, а при чтении разворачивайJSON.parse.Сохраняют каждый кадр. Вызывать
setItemпо 60 раз в секунду внутри игрового цикла — лишняя работа, которая может подтормаживать игру. Сохраняй по событию: в конце партии, при сборе монетки, при переходе на новый уровень — но не на каждом кадре.Думают, что localStorage вечен и общий. Блокнот привязан к конкретному браузеру и сайту: открыл игру в другом браузере или в режиме инкогнито — рекорда там не будет. А если игрок чистит данные браузера, блокнот стирается. localStorage — это удобно, но это не замена настоящему серверу с аккаунтами.
Мини-проект: добей систему сохранений сам
База готова — игра уже помнит рекорд. Теперь три апгрейда, после каждого проверяй результат: набей счёт, обнови страницу, посмотри, что сохранилось.
Кнопка сброса рекорда. Иногда хочется начать с чистого листа. Сделай функцию
resetBest(), которая вызываетlocalStorage.removeItem(BEST_KEY)и обнуляетbestScore. Повесь её на нажатие клавиши R на экране проигрыша.Счётчик сыгранных партий. Заведи в объекте прогресса поле
gamesи увеличивай его на 1 вonGameOver, потом сохраняй черезsaveProgress. Покажи в HUD строку «Партий сыграно: N» — приятно видеть, сколько раз ты погонял цыплёнка.Защита от сломанной записи. Что будет, если в блокноте окажется мусор и
JSON.parseупадёт с ошибкой? Оберни разбор вtry / catch: если parse не удался, возвращай дефолтный объект новичка, чтобы игра не зависала на испорченном сохранении.
Если все три пункта заработали — у тебя полноценная система сохранений: рекорд, статистика и защита от сбоев. Ровно как в настоящих играх.
Итоги
Сегодня твоя игра научилась помнить игрока — то, что превращает разовую забаву в игру, к которой возвращаются ради нового рекорда. Главное на вынос:
localStorage — это блокнот браузера, который переживает закрытие вкладки:
setItem(ключ, значение)пишет,getItem(ключ)читает.Хранятся только строки. Число при чтении прогоняй через
Number(), а для нового ключаgetItemвозвращаетnull— всегда проверяй этот случай.Сохраняй по событию, а не каждый кадр: рекорд — в конце партии, прогресс — при важных изменениях.
Целое состояние едет одной строкой через JSON:
JSON.stringifyпри сохранении иJSON.parseпри загрузке — и сколько угодно полей сохраняются вместе.
В следующем уроке мы соберём всё, что прошли в этом разделе, в единый каркас игры: загрузка ресурсов, игровые состояния, сохранение прогресса — всё на своих местах. Твой цыплёнок уже умеет двигаться, прыгать, искрить, звучать и теперь ещё и помнить рекорды. Осталось аккуратно сложить детали в одну архитектуру — и до финальной аркады рукой подать. До встречи!