Locomotion

Движение персонажа как аспект. Игра каждый кадр заявляет намерение — куда идти, какой походкой, куда смотреть — а движок делает остальное: пружинит симулированную скорость к намерению, предсказывает путь впереди, выбирает, что показывает базовый слой Animator (idle, старт, цикл походки, поворот, остановка, разворот на месте) и двигает тело. Набор клипов — имена из пака; движок сам измеряет, что делает каждый клип (на сколько поворачивает, как быстро перемещается, куда приземляются стопы). anim.warp — стадия Warp каждого Animator — подгоняет позу к реальному движению тела (шаг и наклон), под Locomotion или сама по себе. Ось скорости, которой ты управляешь сам, — это blend-цикл (anim.playLoop({ Idle: 0, Walk: 1.5, Run: 5 })), а не это.

Обзор

TypeScript
class PlayerInput extends Aspect<'playerInput', Node> {
  static before = [Locomotion]                  // заявить намерение до того, как Locomotion его прочитает
  updateBefore(): void {
    const x = (Input.key('KeyA') ? 1 : 0) - (Input.key('KeyD') ? 1 : 0)
    const z = (Input.key('KeyW') ? 1 : 0) - (Input.key('KeyS') ? 1 : 0)
    const loco = this.node.get(Locomotion)!
    if (x === 0 && z === 0) loco.move(null)     // стоп: тело сохраняет направление, о котором просили
    else loco.move([x, 0, z], { gait: Input.key('ShiftLeft') ? 'run' : 'walk' })
  }
}

const main = async () => {
  const scene = new Scene()
  scene.add(Mesh.box({ size: [40, 1, 40], position: [0, -0.5, 0] })
    .aspect(Shape, { box: [20, 0.5, 20] }).aspect(Physics, { motion: 'static' }))
  const model = await Model.load(asset('./hero.glb'))    // клипы: Idle, Walk, Run — записаны на месте
  model.position = [0, -0.9, 0]                           // низ капсулы
  const player = new Node()
    .aspect(Shape, { capsule: { halfHeight: 0.6, radius: 0.3 } })
    .aspect(CharacterController)
    .aspect(Locomotion, {
      animator: model.anim,
      set: { idle: 'Idle', gaits: ['Walk', 'Run'] },
      displacement: 'code',                               // клипы на месте: тело двигает симуляция
      tuning: { speeds: { walk: 1.4, run: 4 } },          // м/с
    })
    .aspect(PlayerInput)
  player.add(model)
  player.position = [0, 1, 0]
  scene.add(player)
  player.loco.on('state', (state) => console.log(`${state} at ${player.loco.speed.toFixed(2)} m/s`))
  scene.open()
}
main()

Опции

TypeScript
node.aspect(Locomotion, { animator?, set, displacement?, tuning?, warp?, feet? })
node.loco.rebuild()         // перечитать set + tuning (заново измеряет клипы) — после изменения любого из них в рантайме
node.loco.retune()          // заново отправить только tuning (отладочный слайдер, персонаж, который потяжелел)
Опция По умолчанию Значение
animator первый Animator на узле или под ним Чьи клипы показываются. Он должен существовать при прикреплении — сначала добавь модель или передай его
set {} Клипы, которые селектор может показывать (ниже)
displacement 'hybrid' Кто двигает тело (ниже)
tuning {} Ощущение (ниже)
warp выкл Задаёт anim.warp: true или WarpOptions
feet { lock: true } Задаёт anim.feet: true = фиксация + IK по земле, false = ни то ни другое, или опции

Набор (LocomotionSet) — имена клипов аниматора или { clip, gait?, angle?, speed? } там, где измерению движка нужна помощь: gait говорит, к какой походке относится старт / остановка / поворот (опущен = любой), angle переопределяет поворот, который делает клип (градусы, + = влево), speed — м/с клипа походки. Всё, кроме idle, — список, и пустой список означает, что движок никогда не показывает этот вид (пак без поворотов рулит через них):

TypeScript
{ idle, gaits: [walk, run, sprint],   // члены цикла походки, от самого медленного — смешиваются по симулированной скорости
  starts, stops, turns,               // стоя → движение, движение → стоя, повороты в движении
  spins,                              // развороты на месте
  strafes }                           // одна походка, записанная во все стороны — blend move(dir, { face, strafe: true })

Перемещение — кто двигает тело:

  • 'hybrid' (по умолчанию) — его двигает записанное движение клипов; пока показывается цикл походки, оно подгоняется к тому, чего хочет симуляция, не больше чем на tuning.adjust м/с, а старт / поворот / остановка / разворот двигают его в одиночку. Для паков, созданных с root motion.
  • 'code' — его двигает симуляция. Для паков, записанных на месте (их клипы никуда не перемещаются, так что задай tuning.speeds).
  • 'data' — его двигает root motion клипов и ничто его не подгоняет; симуляция только предсказывает.

Тело — узел, на котором аспект: с CharacterController на нём или на предке оно получает скорость через controller.move() (так что сталкивается), иначе его позиция интегрируется. Yaw узла записывается каждый кадр (под 'data' его поворачивает root motion).

Намерение

TypeScript
loco.move(direction: Vec3Like | null, { gait?, face?, strafe? })   // повторяй каждый кадр; null = нет движения
loco.face(direction: Vec3Like | number)                             // направление само по себе (курс в градусах)
loco.act(clip, { direction?, angle?, turn?, at?, exit?, fade?, out?, rate?, travel?, dash? }): boolean
loco.gait                          // 'walk' | 'run' | 'sprint' — запрошенная походка (можно задавать)
loco.moving / loco.direction / loco.heading / loco.intentSpeed   // намерение как есть (курс °, скорость м/с)
  • direction — МИРОВОЕ направление по земле (его y игнорируется); его длина — отклонение стика, ограниченное 1, а ниже tuning.deadZone считается отпущенным. Курсы в градусах, 0 = +Z, + = к +X.
  • null останавливает тело, не его направление: намерение смотреть переживает направление движения, так что стоящее тело всё ещё поворачивается к последнему запрошенному курсу (на месте, через spins, когда оно должно больше tuning.spinMin).
  • Заявляй его из updateBefore() аспекта, упорядоченного до Locomotion (static before = [Locomotion]), который читает его в своём собственном updateBefore().
  • face даёт собственное направление взгляда (прицеливание, шутер относительно камеры); со strafe: true тело движется вдоль direction, пока смотрит туда, через blend strafes (состояние 'strafe').
  • act(clip, …) играет ДЕЙСТВИЕ — перекат, шаг назад, выпад — которое владеет телом так же, как старт или остановка, а потом возвращает его обычным правилам; false = нет такого клипа. direction (мировое: Vec3 или курс в градусах; опущено = куда смотрит тело) — куда должно идти его перемещение: тело поворачивается на разницу между этим и тем, куда перемещается клип (angle, градусы, + = влево; измеряется по корню клипа, когда опущено) за turn секунд (по умолчанию 0.12). at входит в него на столько секунд; exit — время клипа, с которого намерение движения может перехватить (окно отмены); fade / out — переходы на входе и выходе (по умолчанию tuning.blend); rate — темп его проигрывания; travel — доля его записанного перемещения, которую взять (или { share, at, after }); dash: { distance, time? } бросает тело на distance метров вдоль направления действия за time секунд (по умолчанию 0.12). state тем временем читается как 'action'.

Что из этого вышло

TypeScript
loco.state            // 'idle' | 'start' | 'move' | 'turn' | 'stop' | 'spin' | 'action' | 'strafe'
loco.on('state', (state) => …)     // то, что показывает селектор, изменилось
loco.speed            // м/с, с которой перемещается тело — симуляции
loco.velocity         // Vec3, мировые м/с
loco.facing           // градусы, yaw узла (0 = +Z, + = к +X)
loco.phase            // 0–1 по циклу походки (-1 = нет)
loco.trajectory       // Vec3[] — где будет тело через 0.2 / 0.4 / 0.7 с, если намерение сохранится
loco.yawLeft / loco.clipTime / loco.exitAt                                          // показываемый переход (отладка)
loco.stopFootOffset / loco.stopStepsLeft / loco.stopStepWait / loco.stopReport()   // почему остановка играет так, как играет

Симулированная скорость приближается к желаемой как пружина: tuning.responseWalk (по умолчанию 0.27 с) — полупериод разницы: половина её уходит за 0.27 с. Первый кадр излучает состояние, в котором он начинается ('idle').

Настройка

LocomotionTuning — время в секундах, скорости в м/с, углы в градусах; каждый ключ необязателен.

Ключ По умолчанию Значение
speeds собственные клипов Максимальная скорость на походку, { walk, run, sprint }. Опущено — каждая походка движется со скоростью, с которой записан её клип — это удерживает стопы от скольжения
responseWalk, responseRun 0.27, 0.2 Полупериод разрыва между симулированной и желаемой скоростью
responseStop 0.15 Как резко тело тормозит, когда ничего не запрошено
responseFacing 0.15 То же для направления взгляда
turnRate без ограничения Предел поворота РУЛЕНИЕМ, °/с. Клип старта / поворота / разворота владеет направлением, пока поворачивает
turnMin 60 Ошибка курса, при которой играет клип поворота вместо руления через неё
spinMin 30 Сколько направления должно задолжать стоящее тело, прежде чем развернётся на месте
blend, stopBlend 0.2, 0.3 Секунды перехода между клипами / в idle после остановки
yawWarp 0.3 Насколько поворот клипа старта / поворота / разворота может быть масштабирован, чтобы выдать запрошенный курс (0.3 = ×0.7 … ×1.3; 0 = запись — закон)
exitTolerance 0.15 Старт или поворот передаёт цикл походки на первом касании стопы, где его поза в пределах стольких метров от позы цикла (0 = на своём конце)
exitSteps 0 …а потом сначала играет ещё столько касаний стопы
resumeSpeed 0.5 Остановка, которую попросили снова двигаться, возобновляет цикл, пока у тела ещё есть эта доля скорости походки
stopEarly, stopSteps, stopFit, stopPose 0, 0, 0.25, 1 Как выбирается вариант остановки: пространство торможения в обмен на более длинный вход / лишний шаг, допустимое расстояние позы до первого касания стопы дубля, метр позы против метра пространства
strideShare, strideBand 0.5, [0.85, 1.15] Походка, запрошенная на скорости не её клипа: шаг берёт ratio^share (в пределах полосы), каденс — остальное
warpStride, warpCadence автоматически Закрепить коэффициент шага / каденса (стенд настройки)
deadZone 0.2 Величина стика, ниже которой он считается отпущенным
adjust 0.5 'hybrid': насколько, м/с, тело может быть сдвинуто с записи к симуляции, пока показывается цикл

Варп — anim.warp

TypeScript
anim.warp.set(true)                                          // шаг + ориентация, по умолчанию
anim.warp.set({ stride: [0.7, 1.4], orientation: 45 })       // ЗАМЕНЯЕТ: что не запрошено — выключено
anim.warp.orientation = 12                                   // свойства меняют одну настройку
anim.warp.options                                            // что включено
anim.warp.step = { slope: 15 }                               // регуляторы варпа шага; null = выкл
anim.motion = controller.velocity                            // мировая скорость тела (м/с), когда её не подаёт Locomotion
WarpOptions По умолчанию Значение
stride выкл Подогнать шаг к скорости, с которой тело реально перемещается; [min, max] ограничивает масштаб (true = 0.85 … 1.2)
orientation выкл Повернуть низ тела туда, куда тело реально перемещается, позвоночник поворачивается в обратную сторону; число ограничивает в градусах (true = 20). Применяется, пока показывается цикл походки
minSpeed 0.2 Ниже этой скорости (тела или клипа), м/с, оба варпа выключены
pelvis 0.25 Насколько, м, таз может опуститься, чтобы вытянутая нога не выпрямилась в замок
strideTime 0.15 Секунды сглаживания масштаба шага

StepWarpOptions (anim.warp.step): stride масштабирует шаг каждой стопы, lift добавляет метры к высоте маховой стопы (отрицательное = шарканье; половина поднимает таз), pitch поворачивает каждую стопу (градусы, + = носки вверх), slope (градусы, + = подъём) ставит стопы на уклон и наклоняет их с ним — поднимай персонажа на tan(slope) × перемещение клипа, чтобы каждая поставленная стопа осталась на своей ступени.

Обоим варпам нужны два движения: тела (Locomotion подаёт его каждый кадр; веди походку вручную и вместо этого задавай anim.motion каждый кадр) и клипа (его root motion, или для цикла на месте — темп, который его пак объявил в GLB). Паку, чьи дубли и так читаются верно на скоростях, с которыми играются, ничего из этого не нужно.

Note

зависит от хоста. У headless-рендерера (lecodes render / lecodes test без --sim / --desktop) нет движка локомоции: прикрепление предупреждает, тело никогда не двигается, а state остаётся 'idle'. Проверяй движение с --sim или на устройстве.

Смотрите также