Работающую игру стало трудно менять: правило победы смешалось с обработкой клика, а одно вычисление повторяется. Вынесем правило в функцию, затем в отдельный JavaScript-модуль. Игрок должен получить тот же результат, что до изменений.

Продолжай собственную игру из прошлого практикума. Ниже разбирается отдельный пример «Собери ровно 10»: три разные карты из 1–6 должны дать сумму 10. Этот пример нужен для объяснения приёмов; его копирование не заменяет самостоятельный проект.

Открыть пример отдельно

Сначала зафиксируем, что игра должна делать

Нужны своя работающая версия, Git из урока 11 и HTTP-сервер из урока 19. В комплекте start — точная копия примера из own-game-prototype/solutions/reference-game. Если твоя игра уже написана иначе, не подгоняй её под этот файл: выбери похожий небольшой участок своего кода.

Рефакторинг меняет устройство кода, сохраняя правила. Новый тип карты, другая цель и исправление ошибки — отдельные задачи. Если изменить всё сразу, по результату трудно понять, какое изменение вызвало сбой.

Действия в примереОжидаемое поведение
1 → 3 → 610 третьей картой: победа
1 → 2 → 36 после трёх карт: проигрыш
6 → 511: немедленный проигрыш
4 → 610 после двух карт: партия ещё идёт
Нажать выбранную карту повторноСумма и число ходов не меняются
После результата нажать «Сначала»0 очков, три свободных хода, все карты доступны

Проверь эти случаи до первой правки. Для своей игры запиши минимум успешный путь, завершение без успеха и сброс. Сохрани наблюдение в worksheets/refactoring-plan.md: пустой шаблон входит в комплект.

В терминале своей папки посмотри, какие файлы изменены. Добавляй только файлы этой работы:

git status
git add index.html style.css game.js
git commit -m "Сохраняю рабочую игру перед рефакторингом"

Если Git сообщает, что нечего фиксировать, рабочая версия уже сохранена. Не создавай пустой коммит ради инструкции. Команды выполняются локально: отправлять код на сервер для этого урока не требуется.

Вынесем правило из обработчика клика

В game.js примера handleCard одновременно записывает выбранную карту, увеличивает сумму и определяет исход. Найди этот фрагмент:

if (state.total > target) {
    state.phase = "lost";
  } else if (state.moves === maxMoves) {
    if (state.total === target) state.phase = "won";
    else state.phase = "lost";
  }

Правило можно проверить без кнопок. В game.js перед handleCard добавь функцию:

function getOutcome(total, moves, target, maxMoves) {
  if (total > target) return "lost";
  if (moves === maxMoves) {
    if (total === target) return "won";
    return "lost";
  }
  return "playing";
}

Она получает четыре числа и возвращает одну из трёх строк. Это чистая функция: при одинаковых аргументах даёт одинаковый результат, не меняет чужие данные и не обращается к DOM. Передача target и maxMoves аргументами важна: все нужные настройки передаются ей явно.

Порядок проверок сохраняем: сначала перебор, затем окончание разрешённых ходов. Значение total = 10 само по себе не означает победу. В исходной игре 4 + 6 — только две карты, поэтому ответ ещё playing. Функция рассчитана на состояние нашего обработчика: неотрицательные числа и число ходов от 0 до maxMoves. Проверку произвольных пользовательских строк мы здесь не добавляем.

Теперь замени найденное условие в handleCard одной строкой:

state.phase = getOutcome(state.total, state.moves, target, maxMoves);

Остальные действия остаются на местах. Обработчик записывает данные, вызывает правило и render. Это step-01. Повтори все шесть сценариев из таблицы. Если игра стала вести себя иначе, не переходи дальше: посмотри аргументы и порядок условий.

Можно отдельно проверить функцию в Console, пока она находится в обычном game.js: getOutcome(10, 2, 10, 3) должно вернуть "playing". После перехода на модули область видимости изменится; на следующем уроке для таких вызовов сделаем отдельную страницу проверок.

Перенесём одну функцию в модуль

Модуль — JavaScript-файл, который явно сообщает, что отдаёт другим файлам и что получает от них. Нам хватит одного rules.js. Не нужны сборщик, npm, классы или папка для каждой функции.

Создай rules.js рядом с game.js. Перенеси туда getOutcome и добавь перед объявлением слово export:

export function getOutcome(total, moves, target, maxMoves) {
  if (total > target) return "lost";
  if (moves === maxMoves) {
    if (total === target) return "won";
    return "lost";
  }
  return "playing";
}

export делает эту функцию доступной для импорта. Удали её прежнее объявление из game.js: два экземпляра правила будут расходиться при последующих правках. В первую строку game.js запиши:

import { getOutcome } from "./rules.js";

import получает именованный экспорт. Имя внутри фигурных скобок должно совпадать с экспортированным именем; это специальный синтаксис импорта, а не создание объекта. ./ означает файл рядом с импортирующим game.js, расширение .js указываем явно. Не начинай путь с /: тогда он будет отсчитываться от корня сайта, а не от папки игры.

В index.html замени единственную строку подключения скрипта:

<!-- Было -->
<script src="game.js" defer></script>
<!-- Стало -->
<script type="module" src="game.js"></script>

type="module" включает правила модулей, в том числе import/export. Такой скрипт сам ожидает разбора HTML, поэтому defer здесь не требуется. rules.js отдельно через второй script не подключаем: его загрузит import. Импортированные имена принадлежат модулю и не становятся свойствами window; не полагайся на их доступность по короткому имени в Console.

Открой игру через HTTP. Двойной щелчок по index.html с адресом file:// может вызвать ошибку ограничений загрузки модулей. Как в уроке 19, запусти из папки выбранной версии py -3.14 -m http.server 8000 --bind 127.0.0.1 в Windows или python3 -m http.server 8000 --bind 127.0.0.1 с установленным Python на macOS/Linux.

Это step-02. В Network должны успешно загружаться game.js и rules.js. Если карточек нет, сначала прочитай Console: проверь type, имя экспорта, ./ и имя файла с учётом регистра. Исправь одну причину, обнови страницу, повтори сценарии.

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

В render дважды встречается maxMoves - state.moves: для счётчика и сообщения. Сейчас они одинаковы. Сохраним результат один раз в начале render:

function render() {
  const remainingMoves = maxMoves - state.moves;
  // Остальной код функции остаётся здесь.
}

Это фрагмент с местом вставки, а не замена всей функции. Далее замени только два использования:

movesLeftText.textContent = remainingMoves;
// В ветке сообщения для продолжающейся партии:
statusText.textContent = "Выбери ещё карт: " + remainingMoves + ". Нужная сумма: " + target + ".";

Получилась final. При каждом render переменная вычисляется из текущего состояния: мы не создаём вторую изменяемую копию счётчика. Данные задачи — target, maxMoves и cards — уже собраны в начале game.js. Не переносим их в пять новых файлов без причины. В Canvas-проекте похожим блоком будут карты levels; начни с отделения таких данных от условий, а не со строительства движка.

Повтори проверки, затем посмотри diff и сохрани именно проверенный результат:

git diff
git add index.html game.js rules.js
git commit -m "Выношу правило исхода в модуль без изменения игры"
git log --oneline -3

Diff должен показывать перенос правила и удаление повтора, а не случайные новые условия. Если последний собственный коммит оказался ошибочным, сначала сохрани отдельно незавершённые изменения и проверь git status. Команда git revert <хеш> создаёт новый коммит с отменой выбранного изменения, сохраняя историю. Подставляй хеш именно своего коммита, не пример из статьи. После отмены снова запускай игру.

Самостоятельно: вынеси ещё одну проверку в функцию

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

Для дополнительной тренировки на образце вынеси защиту phase !== "playing" || selectedIds.includes(card.id) в canChooseCard(phase, selectedIds, cardId). Функция должна возвращать boolean и не изменять массив selectedIds. Это упражнение на приём, а доказательством самостоятельного результата будет аналогичная работа в твоей игре.

Подсказка 1: что передать функции и что получить в ответ

Внутри функции не нужны state, button или render. Передай фазу, массив выбранных id и проверяемый id. true означает, что карту можно выбирать. В обработчике останется ранний return, когда функция вернула false.

Подсказка 2: функция не должна менять массив

includes читает массив, а push меняет его. В функции допустимости нужен только первый. Выбор новой карты должен по-прежнему записываться один раз в handleCard.

Разбор после попытки

В solutions/check-card rules.js экспортирует вторую небольшую функцию. Условие требует playing и отсутствия id в массиве. Проверь новую карту, уже выбранную карту и любую карту после результата, затем объясни, почему функция не добавляет карту сама.

Ответы на частые вопросы

Если код работает, нужно ли его трогать?

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

С чего начать рефакторинг в большом файле?

Возьми одну небольшую проверку, запиши её входы и результат. Вынеси её в функцию и повтори прежние сценарии. Разделять весь файл за один раз не нужно.

Что важнее: красиво или стабильно?

Сохрани рабочее поведение. Изменение полезно, когда после него легче найти и проверить нужное правило. Количество файлов и коротких функций само по себе ничего не доказывает.

Когда работа завершена

Ты можешь показать, где код повторялся или где правило было смешано с другими действиями, объяснить свою правку и подтвердить, что игра работает по-прежнему. Рабочая версия сохранена отдельным коммитом. Переименование всего проекта, подключение форматтера или увеличение числа файлов сами по себе этого результата не доказывают.

Сохрани полезную привычку: одно изменение — одна причина — одна проверка. Удаляй старый код после проверки всех его использований поиском по проекту. Автоматическое форматирование и линтер могут помогать, но их установка не является условием прохождения этого урока.

Справочно: модули JavaScript, export и import на MDN.