Управление толпой

Забавная штука - разработка игр. Самые базовые механики в ней могут вызвать (и, несомненно, вызовут) затруднения у меня, как у frontend-разработчика, привыкшего думать другими категориями и архитектурными шаблонами.

К примеру, как передвигать модель персонажа и при этом синхронизировать его перемещение с анимацией ходьбы, чтобы опорная нога не скользила по поверхности, а оставалась на месте до следующего шага?

 Вроде есть путь, по которому его нужно провести, сама модель и ландшафт.  Но только как ориентировать его в нужную сторону? Если бы он направлялся по прямой,  то можно было бы просто ориентировать его в сторону конечной точки. Но ведь путь - это ломаная кривая, так что по пути от одной точки до другой в таком случае он ходил бы боком.

Можно предположить, что, когда он достигнет одной опорной точки, достаточно повернуть модель в сторону следующей опорной точки. Но и это не даст нужного эффекта: персонаж будет разворачиваться слишком резко. После долгих экспериментов и поиска решений я остановился на следующем (How to move objects along a spline): от начальной до конечной точки можно нарисовать кривую Безье, заставить персонажа двигаться по ней и смотреть себе под ноги, то есть быть направленным в следующую точку кривой. Так получится плавный проход по всем звеньям маршрута (с багами в некоторых случаях, но не будем об этом).

Победа! Но есть одно "но". Анимации в Three.js не проигрываются сами по себе, даже будучи запущенными внутри игрового цикла. Их нужно проводить самому, мол, любезная анимация, давай сделаем вид, что ты уже в точке 15%. И для этого обычно используется вспомогательный объект Clock. "Но" заключается в том, что он приостанавливает стрелки, если ресурсов не хватает. Так что вертеть камерой в момент передвижения не получится. Это несомненно полезно для анимации каких-то второстепенных моделей, частотой обновления которых можно пожертвовать для производительности, но не для модели игрока. Для того, чтобы сделать задачу продвижения анимации супер-приоритетной, пришлось вынести эту логику внутрь web worker'а.  Анимация перемещения по кривой

Перемещение без worker
const ctx: Worker = self as any;
 
 let lastTime: number;
 let scheduleNextCall = true;
 
 function tick() {
   const currentTime = performance.now();
   ctx.postMessage({
     useFrame: {
       delta: (currentTime - lastTime) / 1000, // in seconds
     },
   });
   lastTime = currentTime;
   if (scheduleNextCall) {
     requestAnimationFrame(tick);
   }
 }
 
 // Respond to message from parent thread
 ctx.addEventListener('message', (e) => {
   if (!e.data.useFrame) {
     return;
   }
   if (e.data.useFrame.enabled) {
     scheduleNextCall = true;
     lastTime = performance.now();
     tick();
   } else {
     scheduleNextCall = false;
   }
 });
Перемещение с worker

Как говорилось в одном хорошем фильме, Трудно с тремя, а потом число не имеет значения! Управлять одним персонажем мне показалось занятием скучным, поэтому я решил добавить еще несколько.

Архитектура react диктует императивный подход к управлению: когда компонент, представляющий игрока, получает новый путь, то он, как истинный самурай, реагирует запуском анимаций и передвижением по нему. Это свойство передает ему родительский компонент, так что, если в момент передвижения одной фигурки задать маршрут для другой, то все сразу сломается. Думаю, в нормальных движках с ООП-подходом такие вещи разрешаются легко и просто, но в проекте, где используется react-three-fiber, нужно исходить из принятых шаблонов. И самым логичным было бы выделение общего менеджера, который бы управлял NPC, как кукловод. На роль такого менеджера неплохо подошел Redux.

const [activePlayer, setActivePlayer] = useState('oldman');
                 
 const [playersState, dispatch] = useReducer(playersReducer, {
   movement: playerIds.reduce((acc, id) => ({ ...acc, [id]: null }), {}),
   position: playerIds.reduce((acc, id, index) => ({ ...acc, [id]: [25 + index, 0, 25 + index] }), {}),
 });
 
 function handleFloorClick(e) {
   e.stopPropagation();
   if (isWorkerBusy) {
     return;
   }
   dispatch(movePlayer(activePlayer, path));
 }
 
 <Context.Provider value={{ dispatch, playersState }}> 
   {playerIds.map((id) => <Character 
     id={id} 
     position={playersState.position[id]} 
     model="player/Manequin" 
     isSelected={activePlayer === id} 
   />)} 
 </Context.Provider>                
Так контроллер игры будет искать путь и сообщать его выбранному юниту, а тот, в свою очередь, уведомлять контроллер о том, что он достиг точки назначения. Чтобы, например, обновить информацию о том, какие клетки поверхности проходимы, а какие - нет, чтобы юниты не проходили сквозь друг друга.

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

В демо-сцене можно попробовать, как это все работает. Выбрать нескольких персонажей можно зажав левый shift (либо рамкой, либо по отдельности).

21 февраля 2021
Передвижение по отдельностиПередвижение группойВыделение группы