Гравці терпіти не можуть програвати за технічних причин. Платформа була рівною, таймінг — правильним, а потім гра вбивала їх не через помилку, а через те, що вкладка браузера втратила фокус.
Я спостерігав це на власні очі в Solstice Leap — аркадній грі на Three.js, яку я побудував навколо однієї приємної механіки: утримувати кнопку для зарядки стрибка, а потім відпустити її, щоб перелетіти через прірву. Під час плейтестів я помітив подразнювальну закономірність. Якщо хтось натискав Alt-Tab, щоб відповісти на повідомлення, або перемикався на іншу вкладку під час зарядки, персонаж кидався у безодню в той самий момент, коли вікно знову ставало активним — або іноді одразу після втрати фокусу. Гра інтерпретувала звичайне переривання операційної системи як навмисне відпускання кнопки. Проходження закінчувалися несправедливо. Довіра до керування зникала.
Першопричина: одна подія виконує дві роботи
Баг був ледь помітним, але прямим. В оригінальному шарі введення код прив'язував логіку відпускання стрибка безпосередньо до події blur вікна:
window.addEventListener("blur", releaseCharge);
Якщо придивитися, це виглядає логічно. Гравець утримував клавішу або вказівник; тепер щось зупинилося. Але подія blur — це не подія введення. Це сигнал керування вікном. Вона спрацьовує, коли вкладка браузера втрачає фокус операційної системи, що може статися, коли гравець перемикає вкладки, згортає вікно, клікає на зовнішній монітор або навіть коли системне сповіщення перехоплює фокус. Жодна з цих дій не означає «я хочу запустити свого персонажа». Вони означають «я взаємодію з чимось поза грою».
Направляючи blur у releaseCharge, гра змішувала два абсолютно різні поняття: навмисну зупинку (гравець відпускає кнопку) та зовнішнє переривання (браузер більше не є активним вікном). Оскільки releaseCharge розраховував силу стрибка на основі поточного стану зарядки та негайно застосовував швидкість, будь-яка втрата фокусу під час зарядки спричиняла стрибок із тією силою, яка встигла накопичитися. Повернувшись, гравець бачив свого персонажа мертвим або свій прогрес зруйнованим через рух, який він ніколи не дозволяв.
Реалії браузерів для розробників Three.js
Three.js надає потужний 3D-canvas, але введення все одно проходить через DOM. Цей поділ має значення. Браузер сам по собі не знає, що утримання пробілу заряджає стрибок. Він знає лише те, що клавіша натиснута. Коли фокус залишає документ, браузер не синтезує автоматично подію keyup для кожної утримуваної клавіші. Замість цього він повідомляє, що вікно зникло. Якщо логіка вашої гри припускає, що відсутність фокусу дорівнює відсутності введення, ви отримаєте фантомні дії.
Ця відмінність особливо важлива для механік зарядки, які зустрічаються всюди: натягування лука, розгін автомобіля, читання зарядженого заклинання або спринт із накопиченням витривалості. Будь-яка тривала дія, що накопичує стан протягом певного часу, вразлива до такої ж помилкової інтерпретації. Нативні додатки часто ставлять усю симуляцію на паузу при втраті фокусу. Браузерні ігри можуть робити так само, але навіть якщо гра продовжує працювати, ви повинні відокремити системні переривання від команд гравця.
Відокремлення наміру від переривання
Виправлення вимагало розділення шляху виходу зі стану зарядки на дві окремі гілки. Одна гілка обробляє навмисне введення. Інша — забезпечує стабільність, коли втручається реальний світ.
Навмисні відпускання — pointerup та keyup — все ще виконують стрибок. Це прямі сигнали гравця до дії.
Події втрати фокусу — blur, pointercancel та visibilitychange, коли документ стає прихованим — тепер викликають окрему функцію під назвою cancelCharge.
cancelCharge — це не змінене відпускання. Це жорстке скидання. Вона зводить накопичену силу зарядки до нуля, повертає візуальний масштаб гравця до стандартного стану спокою, обнуляє екранний індикатор зарядки та повертає гру в режим прицілювання. Що найважливіше, вона не чіпає код траєкторії запуску. Немає жодних розрахунків швидкості, фізичного імпульсу чи стрибка. Зарядка просто безпечно випаровується.
Оновлена логіка концептуально виглядає так:
window.addEventListener("blur", cancelCharge);
Але справжня архітектурна зміна полягає у визнанні того, що зарядка тепер є станом із двома можливими виходами. При правильному відпусканні машина станів оцінює відсоток зарядки, обчислює швидкість стрибка та переходить до анімації стрибка. При перериванні машина станів перериває процес і повертається до стану спокою. Розділення цих шляхів запобігає побічним ефектам.
Вам також слід відстежувати pointercancel. Браузер надсилає цю подію, коли виявляє переривання на рівні системи на пристрої вказування — наприклад, жест відхилення долоні на сенсорних екранах, виклик системного меню або втрату контакту пером за незвичних умов. Поєднання blur із pointercancel дозволяє врахувати як багатозадачність на десктопах, так і переривання на мобільних пристроях. Додавання visibilitychange дозволяє відловити сценарій, коли користувач перемикає вкладки, не обов'язково викликаючи blur на самому об'єкті window, що може траплятися в деяких комбінаціях браузера та ОС.
Тестування граничних умов
Виправлення багів введення вимагає тестування поза межами «щасливого шляху» (happy path). Ніхто не знайде ці проблеми, спокійно граючи в гру в одній вкладці. Щоб перевірити нову поведінку, я провів два конкретні сценарії.
По-перше, я почав заряджати стрибок, а потім примусово викликав подію blur, перемикаючи вкладки браузера за допомогою клавіатури. Гра миттєво вийшла з режиму заряджання і повернулася до прицілювання. Стрибок не відбувся. Швидкість не була застосована. Шкала заряджання очистилася. По-друге, я виконав звичайне заряджання і навмисно відпустив кнопку. Стрибок відбувся саме так, як і раніше, з тією ж дугою та масштабуванням сили. Відчуття від гри залишилося незмінним; було виправлено лише граничний випадок.
Обидва шляхи мали залишатися незалежними. Виправлення, яке запобігає випадковим стрибкам, але робить неможливими легітимні, — це не виправлення, а інший баг. Метою було зберегти чіткість оригінальної механіки, одночасно захистивши її від браузерного хаосу.
Патерн для безперервного введення
Ця проблема виходить далеко за межі платформерів. Будь-яка гра на Three.js, що покладається на безперервне натискання, є вразливою. Розглянемо гак-кошу у першій особі, де утримання кнопки миші створює натяг, або гоночну гру, де утримання клавіші заряджає прискорення. Якщо ваша логіка завершення дії (teardown logic) міститься лише в обробнику відпускання кнопки, і ви не враховуєте перемикання вкладок, сповіщення ОС або блокування екрана, ви дозволяєте операційній системі грати у вашу гру замість вас.
Ширший патерн полягає в тому, щоб побудувати шар введення з трьома чіткими станами: active input, released input та cancelled input. Active input накопичує заряд або ініціює дію. Released input підтверджує її. Cancelled input чисто її перериває. Ніколи не дозволяйте blur вікна видавати себе за відпускання кнопки. Браузер — це хост, а не гравець.
Пам'ятайте про людську поведінку
Люди перемикають вкладки. Вони відповідають на особисті повідомлення. Вони шукають підказки на другому моніторі. Вони отримують робочі сповіщення у Slack. Це не граничні випадки, це стандартна поведінка в браузері. Браузерна гра, яка карає за звичайну людську багатозадачність, здається крихкою. Розглядаючи втрату фокусу як скасування, а не як команду, Solstice Leap тепер дозволяє гравцям відійти на секунду, не втрачаючи ретельно підготовленого стрибка.
Подія blur — це не подія відпускання кнопки. Це просто браузер каже, що він вийшов з кімнати. Пишіть код відповідно, і ваші гравці будуть достатньо довіряти керуванню, щоб здійснити стрибок саме тоді, коли вони цього справді хочуть.
