Сохранение прогресса

Закрыл вкладку — и весь рекорд цыплёнка испарился. Сегодня научим игру помнить лучший счёт даже после перезагрузки браузера.

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 — это удобно, но это не замена настоящему серверу с аккаунтами.

Мини-проект: добей систему сохранений сам

База готова — игра уже помнит рекорд. Теперь три апгрейда, после каждого проверяй результат: набей счёт, обнови страницу, посмотри, что сохранилось.

  1. Кнопка сброса рекорда. Иногда хочется начать с чистого листа. Сделай функцию resetBest(), которая вызывает localStorage.removeItem(BEST_KEY) и обнуляет bestScore. Повесь её на нажатие клавиши R на экране проигрыша.

  2. Счётчик сыгранных партий. Заведи в объекте прогресса поле games и увеличивай его на 1 в onGameOver, потом сохраняй через saveProgress. Покажи в HUD строку «Партий сыграно: N» — приятно видеть, сколько раз ты погонял цыплёнка.

  3. Защита от сломанной записи. Что будет, если в блокноте окажется мусор и JSON.parse упадёт с ошибкой? Оберни разбор в try / catch: если parse не удался, возвращай дефолтный объект новичка, чтобы игра не зависала на испорченном сохранении.

Если все три пункта заработали — у тебя полноценная система сохранений: рекорд, статистика и защита от сбоев. Ровно как в настоящих играх.

Итоги

Сегодня твоя игра научилась помнить игрока — то, что превращает разовую забаву в игру, к которой возвращаются ради нового рекорда. Главное на вынос:

  • localStorage — это блокнот браузера, который переживает закрытие вкладки: setItem(ключ, значение) пишет, getItem(ключ) читает.

  • Хранятся только строки. Число при чтении прогоняй через Number(), а для нового ключа getItem возвращает null — всегда проверяй этот случай.

  • Сохраняй по событию, а не каждый кадр: рекорд — в конце партии, прогресс — при важных изменениях.

  • Целое состояние едет одной строкой через JSON: JSON.stringify при сохранении и JSON.parse при загрузке — и сколько угодно полей сохраняются вместе.

В следующем уроке мы соберём всё, что прошли в этом разделе, в единый каркас игры: загрузка ресурсов, игровые состояния, сохранение прогресса — всё на своих местах. Твой цыплёнок уже умеет двигаться, прыгать, искрить, звучать и теперь ещё и помнить рекорды. Осталось аккуратно сложить детали в одну архитектуру — и до финальной аркады рукой подать. До встречи!

Проверьте себя
1. Что произойдёт с записями в localStorage после того, как игрок закроет вкладку и вернётся через неделю?
AВсе записи сотрутся сразу при закрытии вкладки
BЗаписи сохранятся — localStorage переживает закрытие вкладки и перезагрузку
ClocalStorage очистится через минуту бездействия
DЗаписи останутся только если игра была на паузе
2. Какого типа значение вернёт localStorage.getItem('chickenBestScore'), если туда раньше записали число 4200?
AЧисло 4200
BСтроку "4200" — localStorage хранит только строки
CОбъект { score: 4200 }
DЛогическое значение true
3. Что вернёт getItem, если ключа в localStorage ещё нет (первый запуск игры)?
AНоль (0)
BПустую строку ""
Cnull
Dundefined и ошибку
4. Как правильно сохранить в localStorage целый объект прогресса { level: 3, coins: 57 }?
AПередать объект напрямую: localStorage.setItem(key, data)
BСначала упаковать в строку через JSON.stringify(data), затем setItem
CСохранить каждое поле отдельным вызовом без преобразования
DОбъекты в localStorage сохранять нельзя вообще
5. Почему лучше сохранять рекорд в обработчике проигрыша, а не каждый кадр в игровом цикле?
AКаждый кадр localStorage запрещает запись
BЗапись 60 раз в секунду — лишняя работа, которая может подтормаживать игру
CВ игровом цикле переменная score недоступна
DИначе рекорд запишется в чужой блокнот
6. Зачем прочитанную из localStorage строку рекорда прогоняют через Number()?
AЧтобы ускорить чтение из блокнота
BЧтобы строка "4200" стала числом и сравнения вроде score > best работали правильно
CNumber() обязателен для любого getItem, иначе будет ошибка
DЧтобы записать значение обратно в localStorage