Превратим испытание на время в игру на точность: теперь нужно попадать по квадрату, который появляется в новом месте. Научимся располагать цель внутри поля, вычислять допустимые координаты и не давать квадрату выходить за край при изменении ширины окна.
Нажми «Начать». После секунды подготовки у тебя десять секунд: каждое попадание даёт одно очко и переносит квадрат. После конца счёт сохраняется, а новая попытка начинается с нуля.
Продолжаем игру из урока о таймерах
Потребуется финал урока «Таймеры в игре: задержка, отсчёт и остановка». Случайное число и округление вниз знакомы по выбору хода компьютера. Ты умеешь изменять счёт, проверять состояние партии и находить элементы страницы.
Папка start/ содержит те же файлы, что и финал урока о таймерах. Открой её index.html и проверь одну партию. Здесь уже есть handleStart, handleAction, handleReset, finishRound и отмена обоих таймеров. Сохраним готовый таймер: повторно писать тот же отсчёт не нужно. В конце проверим, что он корректно работает вместе с новой целью.
start/ — знакомое испытание; step-01/ — неподвижная цель в поле; step-02/ — два положения; step-03/ — случайные координаты; final/ — та же завершённая версия. Инструкция по запуску и версиям есть в просмотрщике и архиве. Решение самостоятельного задания находится в solutions/.
Распакуй весь 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: теперь у тебя есть небольшая игра с собственным изменением поведения.