Locomotion
Движение персонажа как аспект. Игра каждый кадр заявляет намерение — куда идти, какой походкой,
куда смотреть — а движок делает остальное: пружинит симулированную скорость к намерению, предсказывает
путь впереди, выбирает, что показывает базовый слой Animator (idle, старт, цикл
походки, поворот, остановка, разворот на месте) и двигает тело. Набор клипов — имена из пака; движок
сам измеряет, что делает каждый клип (на сколько поворачивает, как быстро перемещается, куда
приземляются стопы). anim.warp — стадия Warp каждого Animator — подгоняет позу к реальному
движению тела (шаг и наклон), под Locomotion или сама по себе. Ось скорости, которой ты управляешь
сам, — это blend-цикл (anim.playLoop({ Idle: 0, Walk: 1.5, Run: 5 })), а не это.
Обзор
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()Опции
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, — список, и пустой список означает, что движок никогда не
показывает этот вид (пак без поворотов рулит через них):
{ 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).
Намерение
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, пока смотрит туда, через blendstrafes(состояние'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'.
Что из этого вышло
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
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 // мировая скорость тела (м/с), когда её не подаёт LocomotionWarpOptions |
По умолчанию | Значение |
|---|---|---|
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). Паку, чьи дубли и так читаются верно на скоростях, с которыми
играются, ничего из этого не нужно.
зависит от хоста. У headless-рендерера (lecodes render / lecodes test без --sim /
--desktop) нет движка локомоции: прикрепление предупреждает, тело никогда не двигается, а state
остаётся 'idle'. Проверяй движение с --sim или на устройстве.
Смотрите также
- Animator — клипы, blend'ы,
anim.clipInfo()(что измерил движок) - IK и стопы — фиксация стоп и IK по земле, которые включает Locomotion
- Физика и формы —
CharacterController - Аспекты —
updateBefore,static before/after