Партия «Памяти» заканчивается, но после перезагрузки её результат теряется. Сохраним лучший результат — минимальное число попыток — и выбранное оформление. При этом отсутствие записи, повреждённый JSON или запрет записи не должны мешать самой игре.

Открыть игру с рекордом и настройкой

Тот же проект, теперь по HTTP

start совпадает с финалом урока о локальном HTTP-сервере. Это уже работающая игра на три пары. Сделай отдельную копию и запусти её из папки с index.html способом предыдущего урока:

py -3.14 -m http.server 8000 --bind 127.0.0.1

Открывай http://127.0.0.1:8000/. Если сервер уже работает на этом порту, используй его правильную корневую папку или останови прежний запуск сочетанием Ctrl+C. Файлы по двойному клику с адресом file: здесь не используем: поведение хранилища для них не стоит принимать за правило.

localStorage — небольшое хранилище браузера для строк. Оно привязано к origin: сочетанию протокола, имени хоста и порта. http://127.0.0.1:8000 и http://localhost:8000 — разные origin; другой порт или HTTPS также дают другую область. Путь к файлу внутри одного origin сам по себе не отделяет сохранения, поэтому выбираем собственный ключ.

Это не аккаунт и не синхронизация между устройствами. Другой браузер не получит этот рекорд. Очистка данных сайта удаляет его, а приватная сессия не предназначена для постоянного хранения. Игра всё равно должна запускаться с начальными значениями.

Что будем сохранять

Текущие открытые карточки, выбранную пару и таймер сохранять не будем. Нам нужен лучший результат завершённой партии. Начальное значение null означает «ещё нет результата», а не ноль попыток.

В Console своей учебной страницы выполни небольшой опыт, который пока не пишет в хранилище:

const sampleProgress = { version: 1, bestMoves: 5 };
const sampleText = JSON.stringify(sampleProgress);
console.log(sampleText); // строка {"version":1,"bestMoves":5}
const restoredProgress = JSON.parse(sampleText);
console.log(restoredProgress.bestMoves); // число 5

JSON.stringify превращает данные в JSON-строку. JSON.parse читает строку обратно. Это не копирование всего приложения: здесь только выбранные данные. Неверный JSON вызывает ошибку; корректный JSON тоже может содержать неподходящее значение, например 7, массив или объект с неправильными свойствами. Поэтому разбор строки и проверка структуры — разные действия.

version: 1 — номер формата наших данных. Он не связан с версией браузера. Если позже структура данных изменится, номер версии поможет программе отличить старый формат от нового. Пока неизвестные версии считаем неподходящими и начинаем без сохранённого рекорда.

Загружаем сохранение и проверяем данные

В game.js, после createInitialCards и перед let cards = createInitialCards();, добавь постоянное имя ключа и сообщение:

const SAVE_KEY = "codeindie.memory.progress.v1";
let storageMessage = "";

Здесь же добавь две функции:

function createDefaultProgress() {
  return { version: 1, bestMoves: null };
}
function readProgress() {
  const fallback = createDefaultProgress();
  try {
    const text = localStorage.getItem(SAVE_KEY);
    if (text === null) {
      storageMessage = "Сохранения пока нет. Сыграй первую партию.";
      return fallback;
    }
    const saved = JSON.parse(text);
    if (saved === null || typeof saved !== "object" || Array.isArray(saved)) {
      storageMessage = "Неожиданный формат сохранения. Игра начнётся без рекорда.";
      return fallback;
    }
    if (saved.version !== 1) {
      storageMessage = "Версия сохранения не поддерживается. Игра начнётся без рекорда.";
      return fallback;
    }
    const minimumMoves = createInitialCards().length / 2;
    if (saved.bestMoves !== null &&
        (!Number.isInteger(saved.bestMoves) || saved.bestMoves < minimumMoves)) {
      storageMessage = "Некорректный рекорд. Игра начнётся без сохранённых данных.";
      return fallback;
    }
    storageMessage = "Сохранение прочитано из этого браузера.";
    return { version: 1, bestMoves: saved.bestMoves };
  } catch (error) {
    storageMessage = "Не удалось прочитать сохранение. Играть можно; рекорд пока только в этой вкладке.";
    return fallback;
  }
}

getItem возвращает строку или null, если записи нет. Отсутствие записи — обычный первый запуск. В этом случае возвращаем новый объект со стандартным значением. Запись читается один раз при загрузке страницы, а не при каждом отображении карточек.

try содержит действия, при которых может возникнуть ошибка. Если это произошло, выполнение переходит в catch. В нашем примере ошибка чтения или разбора превращается в понятное сообщение и запасной объект. Программа не прекращает запуск игры. Даже само обращение к localStorage находится внутри try, потому что доступ может быть запрещён.

typeof проверяет тип значения. Для null он исторически возвращает "object", поэтому null проверяется отдельно. Массив тоже объект, но нам нужен объект сохранения, поэтому исключаем его через Array.isArray. Далее проверяем номер формата и рекорд: Number.isInteger требует целое число, а минимум попыток равен количеству пар. Рекорд из строки "5", число -1 и результат меньше трёх попыток этой игре не подходят.

После этих функций, перед созданием массива карточек, добавь:

let progress = readProgress();

Мы возвращаем только известные поля, а не используем всё содержимое прочитанной строки как состояние игры. Повреждённую запись автоматически не удаляем: текущая вкладка работает со стандартными данными, новая успешная запись позже сможет заменить старую.

Записываем только законченный результат

После readProgress добавь функцию записи:

function writeProgress() {
  try {
    localStorage.setItem(SAVE_KEY, JSON.stringify(progress));
    storageMessage = "Сохранено в этом браузере.";
  } catch (error) {
    storageMessage = "Не удалось записать сохранение. Изменения остаются только в этой вкладке.";
  }
}

setItem создаёт или заменяет строку по нашему ключу. При отказе записи локальная переменная progress остаётся рабочей, но игрок увидит сообщение, что после перезагрузки изменения могут потеряться. Сама партия не прерывается.

Замени finishGame:

function finishGame() {
  gameState = "finished";
  if (progress.bestMoves === null || moves < progress.bestMoves) {
    progress.bestMoves = moves;
    writeProgress();
  }
}

Меньше попыток — лучше. Поэтому запись нужна при первом завершении или новом лучшем результате. На первый клик, каждое открытие карточки, сброс партии и вызов render сохранение не выполняется. Незаконченная партия не становится рекордом.

Показываем рекорд и состояние сохранения

В index.html, перед #card-field, добавь:

<p>Лучший результат: <strong id="best-moves">ещё нет</strong>.
  Меньше попыток — лучше.</p>
<p id="storage-status" aria-live="polite"></p>

В game.js рядом с переменными элементов добавь:

const bestElement = document.querySelector("#best-moves");
const storageElement = document.querySelector("#storage-status");

Рядом с функциями чтения и записи добавь отображение:

function renderProgress() {
  if (progress.bestMoves === null) {
    bestElement.textContent = "ещё нет";
  } else {
    bestElement.textContent = progress.bestMoves;
  }
  storageElement.textContent = storageMessage;
}

Первой строкой внутри существующей render вызови renderProgress();. Остальную отрисовку, правила и сброс не меняй. Сохрани файлы, обнови страницу, заверши игру и ещё раз перезагрузи. Рекорд должен остаться, а новая партия — начаться с закрытых карточек. Это step-01.

Сохраняем выбор оформления

Добавим настройку, которую можно проверить без новой победы. В index.html перед #storage-status вставь:

<label class="setting">
  <input id="contrast-toggle" type="checkbox"> Контрастное оформление
</label>

Checkbox — переключатель. Его свойство checked содержит логическое значение, а событие change происходит при изменении выбора. В game.js рядом с DOM-переменными добавь const contrastToggle = document.querySelector("#contrast-toggle");. В createDefaultProgress замени возвращаемую строку:

return { version: 1, bestMoves: null, highContrast: false };

В readProgress перед сообщением «Сохранение прочитано…» добавь проверку:

if (saved.highContrast !== undefined && typeof saved.highContrast !== "boolean") {
  storageMessage = "Некорректная настройка. Игра начнётся со стандартными данными.";
  return fallback;
}

В записи предыдущего этапа этого свойства ещё нет. Отсутствие допускаем и выбираем стандартное false; если свойство есть, оно должно быть логическим. Так мы добавим настройку и сохраним рекорд из предыдущей версии. После сообщения об успешном чтении замени возвращаемый объект:

return { version: 1, bestMoves: saved.bestMoves, highContrast: saved.highContrast === true };

В начало renderProgress добавь:

contrastToggle.checked = progress.highContrast;
document.body.classList.toggle("high-contrast", progress.highContrast);

classList.toggle со вторым аргументом true добавляет CSS-класс, а с false убирает. Это применение уже загруженной настройки. В конце файла, перед подключением кнопки новой партии, добавь обработчик и одну подписку:

function handleContrastChange() {
  progress.highContrast = contrastToggle.checked;
  writeProgress();
  render();
}
contrastToggle.addEventListener("change", handleContrastChange);

В конец style.css добавь оформление переключателя и контрастного режима:

.setting {
  display: flex;
  align-items: center;
  gap: 10px;
  min-height: 48px;
  margin: 12px 0;
  cursor: pointer;
}

.setting input {
  width: 22px;
  height: 22px;
  flex: 0 0 22px;
}

body.high-contrast {
  background: #111;
  color: #111;
}

body.high-contrast main {
  border: 2px solid #111;
}

body.high-contrast button {
  background: #111;
  border: 2px solid #111;
  color: white;
}

body.high-contrast button:disabled {
  background: white;
  color: #111;
  border: 2px dashed #111;
}

body.high-contrast button:focus-visible, .setting input:focus-visible {
  outline: 3px solid #005fcc;
  outline-offset: 4px;
}

Теперь включи оформление и перезагрузи страницу: переключатель и стиль должны восстановиться. При начале новой партии выбранное оформление сохраняется. Попробуй выключить и снова перезагрузить. Это step-02.

Удаляем только данные этой игры

Сброс партии и удаление сохранения — разные действия. В index.html после кнопки новой партии добавь:

<p><button id="clear-save-button" type="button">
  Удалить рекорд и настройку
</button></p>

В game.js рядом с DOM-переменными добавь const clearSaveButton = document.querySelector("#clear-save-button");, ниже функций сохранения — действие:

function clearProgress() {
  progress = createDefaultProgress();
  try {
    localStorage.removeItem(SAVE_KEY);
    storageMessage = "Рекорд и настройка удалены. Текущая партия продолжается.";
  } catch (error) {
    storageMessage = "Не удалось удалить запись. Вкладка сброшена, но прежнее сохранение может вернуться после перезагрузки.";
  }
  render();
}

Внизу файла подключи его один раз:

clearSaveButton.addEventListener("click", clearProgress);

removeItem(SAVE_KEY) удаляет только нашу запись. localStorage.clear() здесь не подходит: на том же origin могут находиться данные других учебных проектов. Удаление сбрасывает рекорд и настройку оформления в текущей вкладке; игровая партия продолжается. Если браузер не позволил удалить запись, после перезагрузки может вернуться старое сохранение — об этом говорит сообщение.

Удали данные, перезагрузи и проверь начальное оформление и «ещё нет». Сыграй новую партию: сохранение снова создаётся. Это final.

Проверяем ошибки чтения и записи

Опыты выполняй на своей учебной странице, с нашим ключом, после сохранения нужной рабочей версии. В Console временно запиши повреждённый JSON, затем перезагрузи страницу:

localStorage.setItem("codeindie.memory.progress.v1", "{не JSON");

Игра должна запуститься без рекорда и с сообщением. Затем проверь корректную строку с неправильными данными:

localStorage.setItem("codeindie.memory.progress.v1",
  JSON.stringify({ version: 1, bestMoves: "пять", highContrast: true }));

Это допустимый JSON, но неподходящий рекорд. Он не должен попасть в сравнение результатов. После опыта нажми удаление сохранения. Если сам доступ к хранилищу запрещён, прямой вызов в Console тоже может вернуть ошибку; наша игровая функция должна поймать такую ошибку и продолжить работу.

СценарийЧто проверить
Ключа нетНет рекорда, можно сыграть и сохранить первый
Реальная перезагрузка после победыРекорд вернулся; открытые карточки и текущая попытка не восстановились
Результат хуже сохранённогоЛучший результат не изменился
Переключить оформление и перезагрузитьВыбор восстановился; выключение тоже сохраняется
Пустая строка или повреждённый JSONЗапасные значения, сообщение, работающая игра
JSON-массив, null, чужая версия, строка вместо числа, строка вместо booleanНеожиданные данные не применяются
Чтение или запись недоступныИгра работает в памяти; сообщение не обещает сохранения
Удалить сохранениеСвой ключ исчез, посторонние ключи не удалены
Сменить порт или имя хостаДругая область хранения; сохранённый рекорд остался по прежнему адресу

Для локальной проверки ветки catch можно временно вставить throw new Error("Учебный отказ"); первой строкой внутри try функции записи. throw намеренно вызывает ошибку, а new Error создаёт её с сообщением. После победы игра продолжит работу и сообщит об отказе. Убери эту строку после проверки. Это симуляция ошибки, а не фактический запрет браузера.

Самостоятельно: добавь настройку показа правил

Добавь переключатель «Показывать правила». Он скрывает и возвращает только вводный абзац с правилами, а не поле или статус партии. Сохраняй эту настройку вместе с рекордом. По умолчанию правила видны; запись без нового поля должна открываться со стандартным значением. После удаления сохранения правила снова видны.

Для этого уже знакомы checkbox, checked, change, JSON и проверка типа. Ещё одно свойство элемента: hidden = true скрывает его, hidden = false возвращает. Задай абзацу свой HTML-id и изменяй это свойство при отображении. Сначала перечисли места, которые придётся изменить, затем реализуй.

Подсказка 1: что нужно добавить

Нужны стандартное значение, проверка прочитанного поля, возврат поля из чтения, checkbox, обработчик и применение настройки. Одной записи в localStorage мало, если загрузка её игнорирует.

Подсказка 2: отсутствие не равно неверному типу

Свойство showHelp может отсутствовать в старой записи: тогда используй true. Присутствующая строка "false" не является логическим false. Проверяй тип до применения.

Решение после попытки

Вариант с переключателем правил находится в solutions/show-help. Проверь сохранение выключенного значения, перезагрузку и удаление. Разбор есть в просмотрщике исходников.

Проверь себя: добавь рекорд в другую игру

В exercise/catch-square лежит прежняя законченная игра «Поймай квадрат» без сохранений. Сделай для неё локальный рекорд. Здесь больше очков — лучше; сброс и отменённая при скрытии вкладки попытка не должны записывать результат. Сохраняй только в finishRound, когда время действительно закончилось.

Выбери отдельный ключ, например codeindie.catch-square.best.v1, и формат { version: 1, bestScore: 7 }. Для нового запуска рекорд отсутствует; завершённая партия с нулём очков — допустимый результат. Покажи рекорд на странице и обработай ошибки чтения и записи, как в этом уроке. Затем перезагрузи страницу и проверь, что результат сохранился. Не копируй в эту игру DOM-переменные карточек или правило «меньше — лучше».

Подсказка к переносу

Изучи finishRound, handleReset и handleVisibilityChange. Запись нужна только в первой функции. Для обновления рекорда используется score > bestScore; число должно быть целым и неотрицательным. Функцию чтения можно повторить по принципу, но состав данных другой.

Пример решения для другой игры

Поймай квадрат с рекордом лежит в solutions/catch-square. Сравни места чтения и записи, а не переписывай весь проект. Проверь, что рекорд «Памяти» остался отдельным.

Проверь сохранение в обеих играх

  • Ты отличаешь объект в памяти, JSON-строку и запись по ключу.
  • Свежий запуск, плохая строка, неподходящая структура и отказ доступа не ломают игру.
  • Рекорд сохраняется только после завершения, настройка — при выборе; чтение и обновление экрана не вызывают лишнюю запись.
  • Удаление касается только своего ключа, а новая партия не сбрасывает настройки.
  • Рекорд перенесён в другую игру с её собственным правилом сравнения и проверен перезагрузкой.

Если меняешь число пар или правила так, что результаты больше нельзя сравнивать, используй другой ключ либо явно сбрось прежний рекорд. Этот учебный формат хранит один лучший результат для одного набора правил.

Материалы урока

Справочно: localStorage и область хранения, JSON.parse, удаление одной записи на MDN.