Превратим испытание на время в игру на точность: теперь нужно попадать по квадрату, который появляется в новом месте. Научимся располагать цель внутри поля, вычислять допустимые координаты и не давать квадрату выходить за край при изменении ширины окна.

Игра «Поймай квадрат»

Нажми «Начать». После секунды подготовки у тебя десять секунд: каждое попадание даёт одно очко и переносит квадрат. После конца счёт сохраняется, а новая попытка начинается с нуля.

Открыть эту версию отдельно

Продолжаем игру из урока о таймерах

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

Папка start/ содержит те же файлы, что и финал урока о таймерах. Открой её index.html и проверь одну партию. Здесь уже есть handleStart, handleAction, handleReset, finishRound и отмена обоих таймеров. Сохраним готовый таймер: повторно писать тот же отсчёт не нужно. В конце проверим, что он корректно работает вместе с новой целью.

Исходники урока

start/ — знакомое испытание; step-01/ — неподвижная цель в поле; step-02/ — два положения; step-03/ — случайные координаты; final/ — та же завершённая версия. Инструкция по запуску и версиям есть в просмотрщике и архиве. Решение самостоятельного задания находится в solutions/.

Скачать ZIP

Распакуй весь ZIP. В каждой версии index.html открывается прямо в браузере, а style.css и game.js должны оставаться рядом. Если уже используешь локальный сервер, запуск через него тоже подходит. Меняй свою рабочую копию; папки этапов нужны для проверки конкретного места, а не для замены всего проекта при первой ошибке.

Этап 1. Добавляем поле и квадрат

Сейчас кнопка стоит рядом со стартом и сбросом. Чтобы перемещать её, нужен контейнер с определёнными размерами. В index.html удали только прежнюю кнопку action-button из div class="actions". После закрывающего тега этого блока, перед концом main, добавь:

<div id="field" class="field">
  <button id="action-button" class="target" type="button"
          disabled aria-label="Поймать квадрат">Лови</button>
</div>

id="action-button" остаётся прежним: JavaScript найдёт перенесённую кнопку. В документе она должна быть одна. Класс target даст ей отдельное оформление. aria-label задаёт понятное имя для программы чтения с экрана, а disabled запрещает действие до начала партии. Заголовок h1 и title поменяй на «Поймай квадрат», пояснение — на правила новой игры.

В конце style.css добавь:

.field {
  position: relative;
  width: 100%;
  height: 280px;
  padding: 0;
  background: #f2eeff;
  outline: 1px solid #d5cafa;
}

.target {
  position: absolute;
  left: 0;
  top: 0;
  width: 64px;
  height: 64px;
  max-width: 100%;
  max-height: 100%;
  min-height: 0;
  padding: 0;
  border-radius: 8px;
  font-size: 16px;
  user-select: none;
}

Обычные элементы занимают место в потоке страницы. position: relative оставляет поле в потоке, но делает его ориентиром для вложенной цели с position: absolute. Цель теперь располагается по left и top, а не как следующая строка текста. Обе координаты 0 помещают её в левый верхний угол. Объяснение поведения — MDN: position.

Высота поля задана явно: абсолютная цель сама не растянет контейнер. width: 100% занимает доступную ширину, а max-width и max-height не дают кнопке стать больше поля. min-height: 0 снимает общее минимальное ограничение кнопок, padding: 0 убирает внутренние отступы цели. Общий box-sizing: border-box уже есть в файле: заданные 64 пикселя включают её рамку. У поля нет рамки и отступов; outline рисует внешний контур, не меняя размеры. Это упростит вычисления.

user-select: none убирает выделение надписи при игре. Готовое общее правило кнопок touch-action: manipulation подходит для обычных тапов; прокрутку всей страницы мы не блокируем. Квадрат остаётся настоящей кнопкой: клики мышью, тап и клавиатурная активация обрабатываются знакомым событием click. Это упражнение на точность указателя, поэтому результаты мыши и клавиатуры нельзя считать одинаковым испытанием.

JavaScript на этом этапе не меняется. Запусти step-01/ или свою копию: после подготовки попадание по неподвижному квадрату прибавляет одно очко. Клик по пустому полю ничего не делает. Если очки не растут, проверь, что сохранил именно action-button и не оставил вторую кнопку с этим же идентификатором.

Этап 2. Двигаем квадрат от края к краю

Сначала зададим два известных положения: после попадания цель чередует левый и правый верхние углы. В начале game.js, рядом с остальными ссылками на элементы, добавь поле. Рядом с состоянием объяви координаты:

const field = document.getElementById('field');
let targetX = 0;
let targetY = 0;

targetX — расстояние от левого края, targetY — от верхнего. Это числа в CSS-пикселях, которые сохраняем как данные. Чтобы положить квадрат у правого края, недостаточно взять ширину поля: его собственная ширина тоже должна поместиться.

ПолеЦельДопустимый левый край цели
300 пикселей64 пикселяОт 0 до 236 включительно
200 пикселей64 пикселяОт 0 до 136 включительно
Высота 280 пикселейВысота 64 пикселяВерхний край: от 0 до 216

field.clientWidth и clientHeight читают внутренние размеры поля. actionButton.offsetWidth и offsetHeight — фактические размеры кнопки вместе с рамкой. Мы специально оставили поле без padding и border, а кнопку — с нулевыми внешними отступами, поэтому эти размеры согласованы с координатами. См. MDN: clientWidth и MDN: offsetWidth.

Перед render добавь функцию установки положения:

function placeTarget(x, y) {
  const maxX = Math.max(0, field.clientWidth - actionButton.offsetWidth);
  const maxY = Math.max(0, field.clientHeight - actionButton.offsetHeight);
  targetX = Math.min(maxX, Math.max(0, x));
  targetY = Math.min(maxY, Math.max(0, y));
  actionButton.style.left = targetX + 'px';
  actionButton.style.top = targetY + 'px';
}

Math.max знаком по отсчёту: максимум между нулём и разностью не даёт получить отрицательную границу. Math.min выбирает меньшее число. Выражение Math.min(maxX, Math.max(0, x)) сначала не пускает x ниже нуля, затем выше maxX. Например, при границе 236 запрос 260 превратится в 236, а −5 — в 0. Та же проверка нужна по вертикали.

element.style.left меняет CSS-свойство конкретного элемента. Оно получает строку с единицей измерения: число 30 превращается через 30 + 'px' в '30px'. Переменные targetX и targetY при этом остаются числами. Для любого перемещения вызывай placeTarget: так проверка границ останется в одном месте.

Сразу после неё добавь две функции:

function moveTarget() {
  const maxX = Math.max(0, field.clientWidth - actionButton.offsetWidth);
  if (targetX === 0) {
    placeTarget(maxX, 0);
  } else {
    placeTarget(0, 0);
  }
}

function keepTargetInside() {
  placeTarget(targetX, targetY);
}

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

window.addEventListener('resize', keepTargetInside);

window — объект окна браузера. Его событие resize приходит при изменении размера окна. Например, поле было широким, квадрат стоял справа, а окно сузилось. Старая координата больше не подходит: мы ограничим её новой границей. При расширении цель останется на прежнем допустимом месте. Обработчик регистрируем один раз, не при каждом старте.

В handleAction вставь moveTarget(); после прибавления очка и перед render();. Проверки окончания, стоящие выше, оставь. В обеих функциях handleStart и handleReset после score = 0; добавь placeTarget(0, 0);. Таким образом новая подготовка и сброс возвращают цель в начальный угол.

Проверка step-02/: начни, попади дважды — квадрат должен перейти вправо, затем влево, а счёт стать 2. Поставь его справа и сузь окно: цель целиком видна. Сброс возвращает левый верхний угол и нулевой счёт. Если перемещения нет, проверь подключение game.js и строковые единицы 'px'. Если цель скачет относительно всей страницы, проверь position: relative у поля.

Этап 3. Выбираем случайное место для квадрата

Теперь меняется только выбор координат. Замени функцию moveTarget целиком:

function moveTarget() {
  const maxX = Math.max(0, field.clientWidth - actionButton.offsetWidth);
  const maxY = Math.max(0, field.clientHeight - actionButton.offsetHeight);
  const x = Math.floor(Math.random() * (maxX + 1));
  const y = Math.floor(Math.random() * (maxY + 1));
  placeTarget(x, y);
}

Math.random() возвращает число от 0 включительно до 1, не включая 1. Math.floor округляет вниз. При maxX = 236 умножение на 237 и округление дают целое от 0 до 236 включительно. Поэтому здесь maxX + 1: допустимых целых координат на одну больше, чем число 236. В массиве мы умножали на количество элементов — принцип тот же. Для y вызываем случайный выбор отдельно.

В beginRound, сразу после playing = true;, добавь moveTarget();. Первая цель тоже появится в случайном месте, и только затем будет задан момент окончания десятисекундной партии. Остальные вызовы переноса уже находятся в обработчике попадания.

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

Проверка step-03/: сыграй несколько партий и наблюдай горизонтальные и вертикальные положения. У края весь квадрат должен оставаться внутри поля. Измени размер окна во время подготовки и во время игры. Положение корректируется по фактическим размерам, а партия не перезапускается. final/ совпадает с этим этапом.

Проверяем таймер, попадания и новую партию

Таймер остался из прошлого урока. Проверим, что попадания и перемещение квадрата работают вместе с ним. handleAction сначала проверяет playing, затем вызывает updateClock и ещё раз проверяет playing. Только после этого прибавляет очко и двигает квадрат. Если срок истёк между обновлениями надписи, ни очко, ни перенос не произойдут.

handleStart отменяет старые ожидания и начинает новую подготовку; handleReset отменяет их и оставляет игру остановленной. finishRound сохраняет итоговый счёт, показывает 0 секунд и блокирует цель. Скрытие вкладки по-прежнему отменяет незавершённую попытку. Повторная регистрация событий или второй интервал для движения не нужны: перенос происходит только после попадания.

Что сделатьЧто должно получиться
Тапнуть по цели до старта и во время подготовкиСчёт 0, цель недоступна
Попасть один раз в активной партииРовно одно очко и новый выбор позиции
Нажать на пустое место поляОчки и положение не меняются
Дождаться конца, снова нажать на цельИтоговый счёт сохраняется
Сбросить партию после нескольких попаданийОчки 0, время 10, цель в левом верхнем углу и недоступна
Начать пять раз подрядОдна подготовка от последнего старта; одна партия на десять секунд
Сузить окно с целью у правой границыЦель остаётся целиком в поле, страница не прокручивается горизонтально
Уйти на другую вкладку, затем вернутьсяНезавершённая попытка отменена; нужен явный новый старт

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

Самостоятельно: сложность меняется во время партии

Сделай копию финальной версии и попробуй усложнить игру. Первые пять попаданий игрок ловит квадрат 64 × 64 пикселя. Сразу после пятого попадания цель становится 44 × 44 и остаётся такой до конца. Награда за попадание и длительность партии прежние. Сброс и новая попытка возвращают исходный размер. Квадрат всегда помещается в поле.

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

Критерии: при счёте 4 размер 64; после пятого попадания — 44; следующее попадание даёт ровно одно очко; после конца счёт замер; сброс вернул 64, 0 очков и 10 секунд. Отдельно проверь начало новой попытки без сброса. После уменьшения и расширения цели её положение должно оставаться допустимым.

Подсказка 1: выбери размер по счёту

Выдели функцию, которая смотрит на score и выбирает один из двух размеров. Ширина и высота кнопки меняются через style.width и style.height — так же, как координаты через style.left. Не меняй размер поля.

Подсказка 2: важен порядок

После попадания сначала меняется счёт, затем размер, затем вызывается moveTarget. Так offsetWidth прочитает новый габарит. При старте и сбросе сначала обнули счёт, восстанови размер и только потом вызови placeTarget(0, 0).

Сверить после своей попытки

В ZIP открой solutions/game.js и solutions/README.md. Проверочный вариант поможет проверить результат. После сравнения закрой код и восстанови правило с другим порогом — например, восемь попаданий. Объясни, какие функции пришлось изменить и почему сброс затрагивает размер.

Проверь свою версию игры

Покажи завершённую партию и свою вариацию сложности. Объясни, почему из ширины поля вычитается ширина цели, зачем хранить числовые координаты отдельно от CSS-строк и почему перенос стоит после проверки срока. Затем без финального образца восстанови выражение допустимого случайного x и проверь его при поле шириной 200 и цели шириной 64. Сохрани проверенную версию в Git: теперь у тебя есть небольшая игра с собственным изменением поведения.