Игроки ненавидят проигрывать забег из-за какой-то технической мелочи. Платформа была ровной, тайминг — верным, но игра убила их не из-за ошибки, а потому что вкладка браузера потеряла фокус.

Я столкнулся с этим лично в Solstice Leap, аркадной игре на Three.js, которую я построил вокруг одной приносящей удовлетворение механики: удерживай кнопку, чтобы зарядить прыжок, затем отпусти её, чтобы совершить рывок через пропасть. Во время плейтестов я заметил раздражающую закономерность. Если кто-то нажимал Alt-Tab, чтобы ответить на сообщение, или кликал по другой вкладке во время накопления заряда, персонаж бросался в бездну в тот момент, когда окно возвращало фокус — или иногда сразу при его потере. Игра интерпретировала обычное прерывание операционной системы как намеренное отпускание кнопки. Забеги заканчивались несправедливо. Доверие к управлению подрывалось.

Первопричина: одно событие выполняет две задачи

Баг был тонким, но прямым. В исходном слое ввода код привязывал логику отпускания прыжка напрямую к событию blur окна:

window.addEventListener("blur", releaseCharge);

Это выглядит разумно, если присмотреться. Игрок удерживал клавишу или указатель; теперь что-то прекратилось. Но событие blur — это не событие ввода. Это сигнал управления окном. Оно срабатывает, когда вкладка браузера теряет фокус операционной системы, что может произойти, если игрок переключает вкладки, сворачивает окно, кликает по внешнему монитору или даже когда системное уведомление перехватывает фокус. Ни одно из этих действий не означает «я хочу запустить своего персонажа». Они означают «я взаимодействую с чем-то вне игры».

Направляя blur в releaseCharge, игра смешивала два совершенно разных понятия: намеренную остановку (игрок отпускает кнопку) и внешнее прерывание (браузер больше не является активным окном). Поскольку releaseCharge рассчитывал силу прыжка на основе текущего состояния заряда и немедленно применял скорость, любая потеря фокуса во время зарядки вызывала прыжок с той мощностью, которая успела накопиться. Игрок возвращался и видел, что его персонаж мертв или прогресс испорчен движением, которое он не санкционировал.

Реалии браузеров для разработчиков Three.js

Three.js дает вам мощный 3D-холст, но ввод по-прежнему проходит через DOM. Это разделение имеет значение. Браузер сам по себе не знает, что удержание пробела заряжает прыжок. Он знает только, что клавиша нажата. Когда фокус покидает документ, браузер не синтезирует автоматически событие keyup для каждой удерживаемой клавиши. Вместо этого он сообщает вам, что окно пропало. Если ваша игровая логика предполагает, что отсутствие фокуса равно отсутствию ввода, вы получите фантомные действия.

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

Разделение намерения и прерывания

Исправление требовало разделения пути выхода из состояния зарядки на два отдельных направления. Одно направление обрабатывает намеренный ввод. Другое обеспечивает «жизнеобеспечение» на случай вмешательства реального мира.

Намеренные отпусканияpointerup и keyup — по-прежнему выполняют прыжок. Это прямые сигналы игрока к действию.

События потери фокусаblur, pointercancel и visibilitychange (когда документ становится скрытым) — теперь вызывают отдельную функцию под названием cancelCharge.

cancelCharge — это не модифицированное отпускание. Это жесткий сброс. Она обнуляет накопленную силу заряда, возвращает визуальный масштаб игрока к стандартному состоянию покоя, обнуляет шкалу заряда на экране и возвращает игру в режим прицеливания. Что самое важное, она не затрагивает код траектории прыжка. Нет ни расчета скорости, ни импульса физики, ни самого прыжка. Заряд просто безопасно испаряется.

Обновленная схема концептуально выглядит так:

window.addEventListener("blur", cancelCharge);

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

Вам также следует отслеживать событие pointercancel. Браузер отправляет его, когда обнаруживает прерывание на уровне системы при использовании указывающего устройства — например, жест отсечения ладони на сенсорных экранах, вызов системного меню или потеря контакта пером при необычных условиях. Сочетание blur с pointercancel позволяет охватить как многозадачность на десктопе, так и прерывания на мобильных устройствах. Добавление visibilitychange позволяет отловить сценарий, когда пользователь переключает вкладки, не обязательно вызывая событие blur на самом объекте окна, что может происходить в некоторых комбинациях браузера и ОС.

Тестирование граничных условий

Исправление багов ввода требует тестирования за пределами стандартных сценариев. Никто не обнаружит такие проблемы, спокойно играя в игру в одной вкладке. Чтобы проверить новое поведение, я протестировал два конкретных сценария.

Во-первых, я начал зарядку прыжка, а затем принудительно вызвал событие blur, переключив вкладки браузера с помощью клавиатуры. Игра мгновенно вышла из режима зарядки и вернулась к прицеливанию. Прыжок не сработал. Скорость не применилась. Шкала зарядки обнулилась. Во-вторых, я выполнил обычную зарядку и намеренно отпустил кнопку. Прыжок выполнился точно так же, как и раньше, с той же дугой и масштабированием силы. Ощущение от игры осталось прежним; был исправлен только пограничный случай.

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

Паттерн для непрерывного ввода

Эта проблема выходит далеко за рамки платформеров. Любая игра на Three.js, полагающаяся на непрерывное нажатие, подвержена этому риску. Представьте себе крюк-кошку от первого лица, где удержание кнопки мыши наращивает натяжение, или гоночную игру, где зажатая клавиша заряжает ускорение. Если ваша логика сброса находится только в обработчике отпускания кнопки, и вы не учитываете переключение вкладок, уведомления ОС или блокировку экрана, вы позволяете операционной системе играть в вашу игру вместо вас.

Более широкий паттерн заключается в том, чтобы построить слой ввода с тремя явными состояниями: активный ввод, отпущенный ввод и отмененный ввод. Активный ввод накапливает заряд или инициирует действие. Отпущенный ввод подтверждает его. Отмененный ввод чисто прерывает его. Никогда не позволяйте событию blur маскироваться под отпускание кнопки. Браузер — это хост, а не игрок.

Учитывайте человеческий фактор

Люди переключают вкладки. Они отвечают на личные сообщения. Они смотрят гайды на втором мониторе. Им приходят уведомления в Slack. Это не пограничные случаи, а стандартное поведение пользователя в браузере. Браузерная игра, которая наказывает за обычную человеческую многозадачность, кажется хрупкой. Обращаясь с потерей фокуса как с отменой, а не как с командой, Solstice Leap теперь позволяет игрокам отвлечься на секунду, не жертвуя тщательно подготовленным прыжком.

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