Браузърната игра рисува кадър всеки път, когато екранът се опресни. На офисен монитор това става 60 пъти в секунда, на геймърски лаптоп 144 или 165, на нов телефон 120, а на лаптоп на батерия може да падне до 30. Ако играта мести нещата с „скорост по времето от последния кадър“, тя тихомълком става различна игра на всеки екран: скоковете стигат друга височина, враговете пристигат в друг момент, а сблъсъците се пропускат, когато някой кадър се забави. Това ръководство показва цикъла, който използваме в нашите 3D браузърни игри, защо е там всеки ред и автоматичния тест, който доказва, че правилата се играят еднакво на 60, 144 и 165 Hz.
#Защо променливата стъпка чупи играта
Браузърът извиква функцията, подадена на requestAnimationFrame, веднъж при всяко опресняване на екрана и ѝ дава текущото време. Очевидният цикъл мери времето от предишното извикване и мести всичко с толкова. Изглежда гладко, а на бърза машина дори по-добре. Проблемът е в правилата, не в картината.
- Грешката при пресмятането зависи от стъпката. При обичайното обновяване „добави скоростта, после гравитацията“ скок, пресметнат на шейсет стъпки в секунда, завършва на малко по-различна височина от същия скок на 144 стъпки. Малките разлики се натрупват за един рунд и ниво, което е честно на вашия монитор, става невъзможно или прекалено лесно на чужд.
- Дългите кадри прескачат сблъсъци. Когато кадър отнеме десета от секундата, бърз обект се премества десет пъти по-далеч наведнъж и може да мине през тънка стена или покрай проверката за ръба, която е трябвало да го спре.
- Повторенията, ботовете и тестовете спират да работят. Ако едни и същи действия не дават един и същ резултат, не можете да възпроизведете игра, да сравните две версии на баланса или да оставите бот да изиграе хиляда рунда и да вярвате на числата.
Решението е старо и добре известно в програмирането на игри: правилата се изпълняват на фиксирани стъпки, а рисуването става толкова често, колкото иска екранът.
#Цикълът с натрупване на време
В нашата арена игра Faultwarden правилата живеят в чиста симулация, която не знае нищо за екрана. Тя винаги напредва с точно една шейсета от секундата. Цикълът на кадрите събира реалното време в натрупвач и го изразходва на цели стъпки:
export const DT = 1 / 60;
let last = performance.now();
let acc = 0;
function frame(now) {
requestAnimationFrame(frame);
// не повече от 0,1 s на кадър: бавен кадър или спрян раздел
// не бива да се превърне в стотици наваксващи стъпки
const real = Math.min(0.1, (now - last) / 1000);
last = now;
acc += real * settings.speed;
const input = gatherInput();
let first = true;
while (acc >= DT) {
// еднократните натискания (удар, скок) важат само за първата стъпка
sim.step(first ? input : { ...input, strike: false, leap: false });
first = false;
acc -= DT;
}
const alpha = acc / DT; // докъде сме между две стъпки
render(sim, alpha);
}
requestAnimationFrame(frame);
На екран с 60 Hz цикълът обикновено прави по една стъпка на кадър. На екран със 144 Hz повечето кадри не правят нито една стъпка, а някои правят една; на екран с 30 Hz всеки кадър прави по две. Правилата виждат една и съща поредица от стъпки по една шейсета от секундата навсякъде.
Два детайла са по-важни, отколкото изглеждат. Първият е ограничението на времето на кадъра. Без него раздел, скрит за минута, се връща с шейсет секунди в натрупвача, изпълнява 3600 стъпки в един кадър и замръзва или, още по-лошо, изиграва минута от играта без играча. Вторият е отношението към еднократните действия. Натискането на клавиш е събитие, не състояние: ако един кадър направи две стъпки и двете видят „натиснат удар“, играчът удря два пъти. Задържаните действия, като движението, отиват във всяка стъпка, а натисканията само в първата.
#Гладка картина между стъпките
При фиксирани стъпки екран със 144 Hz би показал едно и също положение в два или три поредни кадъра и движението изглежда накъсано. Остатъкът в натрупвача казва колко е напреднало реалното време след последната стъпка, като дроб от 0 до 1. Изобразяването пази предишното и текущото положение на всеки движещ се обект и рисува смес от двете:
function lerp(a, b, t) {
return a + (b - a) * t;
}
// в render(sim, alpha), за всеки герой
mesh.position.set(
lerp(actor.prevX, actor.x, alpha),
lerp(actor.prevY, actor.y, alpha),
lerp(actor.prevZ, actor.z, alpha),
);
Картината изостава от правилата най-много с една стъпка, около 17 милисекунди, което никой не усеща. Камерата, частиците и всичко, което не влияе на правилата, може да продължи да се движи с реалното време на кадъра; фиксирани стъпки трябват само на симулацията.
#Пауза, когато играчът излезе
Браузърите забавят или спират requestAnimationFrame в скрит раздел, а игра, вградена в страница, губи фокуса всеки път, когато играчът щракне извън нея. И в двата случая играта трябва да спре, вместо да върви без надзор или да подскочи напред при връщането:
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
if (mode === 'play') pause();
audio.ctx?.suspend();
} else if (audio.ctx) {
audio.ctx.resume();
}
});
addEventListener('blur', () => {
if (mode === 'play') pause();
});
Когато играта е на пауза, нулирайте и натрупвача. Иначе малко останало време отпреди паузата се превръща в стъпка в мига, в който играта продължи.
#Правилата да са детерминирани
Фиксираните стъпки са половината от работата. Другата половина е нищо в правилата да не зависи от нещо различно от действията на играча и началното състояние.
- Използвайте генератор на случайни числа със зародиш.
Math.random()не може да получи зародиш. Нашите правила използват малък генератор mulberry32 със зародиш за всеки рунд, така че един и същ зародиш строи един и същ остров и пуска едни и същи врагове. Изобразяването може свободно да ползваMath.random()за искри и прах, защото правилата не го четат. - Никога не четете часовника в стъпка. Таймерите в правилата броят стъпки или добавят
DT; те никога не извикватperformance.now()илиDate.now(). - Пазете реда стабилен. Обхождайте масиви, а не обекти с ред на ключовете, който не сте контролирали, и не сортирайте със сравнение, което може да върне една и съща стойност за различни елементи, когато редът има значение.
#Докажете го с тест
Твърдение като „физиката не зависи от честотата на екрана“ лесно се чупи с една невинна промяна, затова го тестваме. Тестът кара симулацията така, както би го направил истински цикъл на 60, 144 и 165 Hz, с едно и също управление, и сравнява резултатите:
it('physics does not depend on the refresh rate', () => {
const run = (hz) => {
const sim = makeSim({ isle: 0, seed: 3 });
let acc = 0;
for (let f = 0; f < 20 * hz; f++) {
acc += 1 / hz;
while (acc >= DT - 1e-9) {
const t = f / hz;
sim.step({ ...NO_INPUT, mx: t % 3.3 < 1.65 ? 0.6 : -0.6 });
acc -= DT;
}
}
return sim;
};
const a = run(60), b = run(144), c = run(165);
for (const s of [b, c]) {
expect(Math.abs(s.time - a.time)).toBeLessThan(DT * 1.01);
expect(s.enemies.length).toBe(a.enemies.length);
expect(Math.abs(s.heartHp - a.heartHp)).toBeLessThanOrEqual(1);
}
});
Малкият допуск в условието на цикъла е там, защото събирането на 1/144 с плаваща запетая не попада точно на кратни на 1/60. Втори тест оставя нашите ботове за изпитване да изиграят цели рундове на 60 и 144 Hz и проверява, че изчистват един и същ брой вълни, с точност до една, защото самите ботове реагират по таймер и може да натиснат бутон една стъпка по-късно. Двата теста се пускат с останалите при всяка промяна, така че таймер, който брои кадри вместо стъпки, не може да се промъкне незабелязано.
#Настройка за скорост без допълнителна работа
Щом правилата вървят на фиксирани стъпки, по-бавната игра е едно умножение: цикълът добавя real * settings.speed към натрупвача. При 0,7 правилата вървят със 70 процента от скоростта, всеки времеви прозорец се удължава и нищо друго не се променя. Играчите, на които им трябва повече време за реакция, го получават без отделен лесен режим, а мястото на тази настройка е сред опциите за достъпност, до пренастройваемото управление. Тази част е описана в ръководството за управление с клавиатура и геймпад в браузърни игри.
#Кога не ви трябва
Пъзел на ходове, игра с карти или игра с менюта и бавно натрупване нямат физика, която да се отклонява, и обикновеният цикъл е напълно достатъчен. Фиксираните стъпки трябват, щом нещо се движи по правила: платформени игри, арени, състезания, всичко със снаряди и всичко, което искате да възпроизвеждате, да тествате с ботове или да предлагате като ежедневно предизвикателство с един и същ зародиш за всички. За по-широката картина на работата с Three.js вижте браузърни игри с Three.js и WebGL, а за това какво прави играта приятна в движение, как браузърната игра да се усеща добре. Публикуваните и предстоящите ни браузърни игри са в каталога с игри на PN Scripts.
#Често задавани въпроси
Стъпката да е 1/60 или 1/120 от секундата?
Шейсет стъпки в секунда стигат за повечето браузърни игри и пазят разхода нисък на слаби лаптопи и телефони. Използвайте 120 само когато бързи обекти имат нужда от по-фина проверка за сблъсък и сте измерили, че симулацията остава далеч под няколко милисекунди на стъпка.
Защо времето на кадъра се ограничава до 0,1 секунди?
След скрит раздел, спиране в дебъгера или дълго почистване на паметта измереното време може да е секунди или минути. Ограничението свежда наваксването до шест стъпки на кадър, така че играта се забавя за миг, вместо да замръзне или да прескочи напред.
Добавя ли интерполацията забавяне на управлението?
Тя показва света най-много с една стъпка след правилата, около 17 милисекунди при шейсет стъпки в секунда. Управлението се чете при всеки кадър и се прилага в следващата стъпка, така че забавянето е твърде малко, за да се усети.
Може ли да използвам Math.random() в симулацията?
Не, ако искате повторения, ежедневни зародиши или надеждни тестове с ботове. Използвайте малък генератор със зародиш за всичко, което решават правилата, и оставете Math.random() само за визуалните ефекти.
Коментари
Коментари
Напишете първия коментар.
Оставете коментар