feat(studio): 3-������ ������ + ������������ �������� #43
@ -51,6 +51,9 @@ const BASE_STATES = ["idle", "walk", "run", "jump", "fall"];
|
|||||||
|
|
||||||
// Дополнительные движения (грузятся лениво при первом setState):
|
// Дополнительные движения (грузятся лениво при первом setState):
|
||||||
const EXTRA_STATES = [
|
const EXTRA_STATES = [
|
||||||
|
"jump_anticipate", "jump_air", "jump_land",
|
||||||
|
"jump_fwd_anticipate", "jump_fwd_air", "jump_fwd_land",
|
||||||
|
"jump_run_anticipate", "jump_run_air", "jump_run_land",
|
||||||
"walk_backward", "run_backward", "run_to_stop", "run_slide",
|
"walk_backward", "run_backward", "run_to_stop", "run_slide",
|
||||||
"jump_forward", "jump_backward", "jump_down",
|
"jump_forward", "jump_backward", "jump_down",
|
||||||
"crouch_enter", "crouch_idle", "crouch_walk", "crouch_to_stand",
|
"crouch_enter", "crouch_idle", "crouch_walk", "crouch_to_stand",
|
||||||
@ -230,6 +233,18 @@ export class MixamoAnimator {
|
|||||||
const tnode = bone.getTransformNode ? bone.getTransformNode() : null;
|
const tnode = bone.getTransformNode ? bone.getTransformNode() : null;
|
||||||
this._cleanToTarget.set(name, tnode || bone);
|
this._cleanToTarget.set(name, tnode || bone);
|
||||||
}
|
}
|
||||||
|
// Запомним bind-pose позиции (особенно Hips) — нужны для нормализации
|
||||||
|
// Hips.position в jump_air/jump_land и для сброса после анимаций.
|
||||||
|
this._restPositions = new Map();
|
||||||
|
for (const [name, target] of this._cleanToTarget) {
|
||||||
|
if (target && target.position) {
|
||||||
|
this._restPositions.set(name, {
|
||||||
|
x: target.position.x,
|
||||||
|
y: target.position.y,
|
||||||
|
z: target.position.z,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Создать (или достать из кэша) AnimationGroup для конкретного состояния. */
|
/** Создать (или достать из кэша) AnimationGroup для конкретного состояния. */
|
||||||
@ -252,7 +267,53 @@ export class MixamoAnimator {
|
|||||||
// движений (walk/run/jump) фильтруем targetProperty=position
|
// движений (walk/run/jump) фильтруем targetProperty=position
|
||||||
// у кости с именем Hips — её двигает наш PlayerController.
|
// у кости с именем Hips — её двигает наш PlayerController.
|
||||||
if (t.boneName === "Hips" && cloned.targetProperty === "position") {
|
if (t.boneName === "Hips" && cloned.targetProperty === "position") {
|
||||||
continue;
|
// 3-фазная модель прыжка:
|
||||||
|
// jump_anticipate — присед перед прыжком. baseY = первый кадр
|
||||||
|
// (стоячая поза → опускается ниже).
|
||||||
|
// jump_air — физика поднимает _modelRoot, Hips.Y не используем.
|
||||||
|
// jump_land — приземление с амортизацией. baseY = МИНИМУМ
|
||||||
|
// (самая низкая точка приседа), так первый кадр будет Y > 0
|
||||||
|
// (только что приземлились, ноги пружинят к bind),
|
||||||
|
// середина = 0 (присед на полу), конец = выпрямление.
|
||||||
|
// Для всех остальных — фильтруем (физика двигает _modelRoot).
|
||||||
|
const PHASES = new Set([
|
||||||
|
'jump_anticipate', 'jump_land',
|
||||||
|
'jump_fwd_anticipate', 'jump_fwd_land',
|
||||||
|
'jump_run_anticipate', 'jump_run_land',
|
||||||
|
]);
|
||||||
|
if (!PHASES.has(state)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const rest = this._restPositions?.get('Hips');
|
||||||
|
try {
|
||||||
|
const keys = cloned.getKeys();
|
||||||
|
if (keys && keys.length > 0 && keys[0].value) {
|
||||||
|
// baseY = МАКСИМУМ Y по клипу. Тогда delta = k.Y - max
|
||||||
|
// всегда ≤ 0 → Hips только опускается ниже bind.
|
||||||
|
// jump_land: персонаж приземлился (ноги на полу = bind),
|
||||||
|
// потом корпус опускается = присед амортизации,
|
||||||
|
// потом возвращается обратно к bind (выпрямление).
|
||||||
|
// jump_anticipate: то же — корпус опускается из стоячей.
|
||||||
|
let maxY = -Infinity;
|
||||||
|
for (const k of keys) {
|
||||||
|
const y = k.value.y || 0;
|
||||||
|
if (y > maxY) maxY = y;
|
||||||
|
}
|
||||||
|
const baseY = Number.isFinite(maxY) ? maxY : (keys[0].value.y || 0);
|
||||||
|
const newKeys = keys.map(k => ({
|
||||||
|
frame: k.frame,
|
||||||
|
value: new (k.value.constructor)(
|
||||||
|
rest ? rest.x : 0,
|
||||||
|
(rest ? rest.y : 0) + ((k.value.y || 0) - baseY),
|
||||||
|
rest ? rest.z : 0,
|
||||||
|
),
|
||||||
|
inTangent: k.inTangent,
|
||||||
|
outTangent: k.outTangent,
|
||||||
|
interpolation: k.interpolation,
|
||||||
|
}));
|
||||||
|
cloned.setKeys(newKeys);
|
||||||
|
}
|
||||||
|
} catch (e) { continue; }
|
||||||
}
|
}
|
||||||
group.addTargetedAnimation(cloned, target);
|
group.addTargetedAnimation(cloned, target);
|
||||||
attached++;
|
attached++;
|
||||||
@ -273,6 +334,9 @@ export class MixamoAnimator {
|
|||||||
// эпизодические действия (hit, die, throw, pickup, gun_fire, gun_reload и т.д.)
|
// эпизодические действия (hit, die, throw, pickup, gun_fire, gun_reload и т.д.)
|
||||||
const ONE_SHOT = new Set([
|
const ONE_SHOT = new Set([
|
||||||
"jump", "jump_forward", "jump_backward", "jump_down",
|
"jump", "jump_forward", "jump_backward", "jump_down",
|
||||||
|
"jump_anticipate", "jump_land",
|
||||||
|
"jump_fwd_anticipate", "jump_fwd_air", "jump_fwd_land",
|
||||||
|
"jump_run_anticipate", "jump_run_air", "jump_run_land",
|
||||||
"crouch_enter", "crouch_to_stand",
|
"crouch_enter", "crouch_to_stand",
|
||||||
"hit_react", "die_forward", "die_back",
|
"hit_react", "die_forward", "die_back",
|
||||||
"throw_action", "pickup", "push_button", "open_door",
|
"throw_action", "pickup", "push_button", "open_door",
|
||||||
@ -315,12 +379,37 @@ export class MixamoAnimator {
|
|||||||
setState(state) {
|
setState(state) {
|
||||||
if (this._currentEmote) return; // эмоция блокирует смену состояния
|
if (this._currentEmote) return; // эмоция блокирует смену состояния
|
||||||
if (state === this._currentState) return;
|
if (state === this._currentState) return;
|
||||||
|
// Сброс Hips.position в bind-pose при выходе из jump-фаз.
|
||||||
|
// Иначе последний keyframe анимации остаётся на Hips и idle/walk
|
||||||
|
// подхватывает смещённую позицию → персонаж проседает.
|
||||||
|
const JUMP_STATES = new Set([
|
||||||
|
'jump_air', 'jump_land', 'jump_in_place', 'jump_anticipate',
|
||||||
|
'jump_fwd_anticipate', 'jump_fwd_air', 'jump_fwd_land',
|
||||||
|
'jump_run_anticipate', 'jump_run_air', 'jump_run_land',
|
||||||
|
]);
|
||||||
|
if (JUMP_STATES.has(this._currentState) && !JUMP_STATES.has(state)
|
||||||
|
&& this._restPositions) {
|
||||||
|
const rest = this._restPositions.get('Hips');
|
||||||
|
const hips = this._cleanToTarget?.get('Hips');
|
||||||
|
if (rest && hips && hips.position) {
|
||||||
|
try {
|
||||||
|
hips.position.x = rest.x;
|
||||||
|
hips.position.y = rest.y;
|
||||||
|
hips.position.z = rest.z;
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
const now = (typeof performance !== 'undefined' ? performance.now() : Date.now());
|
const now = (typeof performance !== 'undefined' ? performance.now() : Date.now());
|
||||||
// Anti-flicker debounce: не даём переключать состояние чаще чем раз в 120мс,
|
// Anti-flicker debounce: не даём переключать состояние чаще чем раз в 120мс,
|
||||||
// КРОМЕ переходов с/на воздушные состояния (jump/fall) — там важна скорость
|
// КРОМЕ переходов с/на воздушные состояния (jump/fall) — там важна скорость
|
||||||
// и one-shot crouch_enter/crouch_to_stand (они короткие).
|
// и one-shot crouch_enter/crouch_to_stand (они короткие).
|
||||||
const isVitalSwitch = state === 'jump' || state === 'fall'
|
const JUMP_VITAL = new Set([
|
||||||
|| this._currentState === 'jump' || this._currentState === 'fall'
|
'jump', 'fall', 'jump_air', 'jump_land', 'jump_anticipate',
|
||||||
|
'jump_fwd_anticipate', 'jump_fwd_air', 'jump_fwd_land',
|
||||||
|
'jump_run_anticipate', 'jump_run_air', 'jump_run_land',
|
||||||
|
]);
|
||||||
|
const isVitalSwitch = JUMP_VITAL.has(state)
|
||||||
|
|| JUMP_VITAL.has(this._currentState)
|
||||||
|| state === 'crouch_enter' || state === 'crouch_to_stand';
|
|| state === 'crouch_enter' || state === 'crouch_to_stand';
|
||||||
if (!isVitalSwitch && this._lastSwitchAt && (now - this._lastSwitchAt) < 120) {
|
if (!isVitalSwitch && this._lastSwitchAt && (now - this._lastSwitchAt) < 120) {
|
||||||
// Запомним последний запрошенный state — если он не изменится за
|
// Запомним последний запрошенный state — если он не изменится за
|
||||||
@ -361,12 +450,22 @@ export class MixamoAnimator {
|
|||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.log(`[MixamoAnimator] setState: ${this._currentState || 'none'} → ${state} (loop=${loop})`);
|
console.log(`[MixamoAnimator] setState: ${this._currentState || 'none'} → ${state} (loop=${loop})`);
|
||||||
|
|
||||||
|
// Per-state speedRatio: подгоняем длительность под физику.
|
||||||
|
// jump_fwd_air: Mixamo Jump полёт = 0.43с, физика = 0.73с
|
||||||
|
// → speedRatio = 0.59 (замедлить чтобы клип не зациклился).
|
||||||
|
// jump_fwd_air: Mixamo Jump полёт 0.43с, физика 0.73с → 0.59
|
||||||
|
// jump_run_air: Mixamo Running Jump полёт 0.52с, физика 0.73с → 0.71
|
||||||
|
const SPEED_RATIO = {
|
||||||
|
jump_fwd_air: 0.59,
|
||||||
|
jump_run_air: 0.71,
|
||||||
|
};
|
||||||
|
const speedRatio = SPEED_RATIO[state] || 1.0;
|
||||||
// Запустить новую анимацию. Babylon 7 ВНИМАНИЕ: параметр loop
|
// Запустить новую анимацию. Babylon 7 ВНИМАНИЕ: параметр loop
|
||||||
// в start() иногда игнорится — дублируем через loopAnimation
|
// в start() иногда игнорится — дублируем через loopAnimation
|
||||||
// (выставлен в _ensureGroup).
|
// (выставлен в _ensureGroup).
|
||||||
try {
|
try {
|
||||||
next.reset();
|
next.reset();
|
||||||
next.start(loop, 1.0, next.from, next.to, false);
|
next.start(loop, speedRatio, next.from, next.to, false);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
try { next.play(loop); } catch (_) {}
|
try { next.play(loop); } catch (_) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2853,17 +2853,42 @@ export class PlayerController {
|
|||||||
} else
|
} else
|
||||||
if (canJump && c.has('Space') && !inWater && !this._shipMode && !this._ufoMode) {
|
if (canJump && c.has('Space') && !inWater && !this._shipMode && !this._ufoMode) {
|
||||||
if (!this._jumpHeld) {
|
if (!this._jumpHeld) {
|
||||||
// Robot — стартовый импульс полный (как куб) для тапа достаточный,
|
// 3-фазная модель прыжка.
|
||||||
// boost-фаза 0.45с удлиняет подъём при удержании Space.
|
// _jumpKind определяется по нажатым клавишам в момент Space:
|
||||||
this._vy = this.JUMP_VELOCITY * (this._jumpPowerMul || 1) * gDir;
|
// in_place — нет WASD (анимация Mixamo Jumping)
|
||||||
this._playJumpSound();
|
// forward — WASD без Shift (Mixamo Jump)
|
||||||
|
// run — WASD + Shift (Mixamo Running Jump)
|
||||||
|
const cc = this._codes;
|
||||||
|
const wasdHeld = cc && (cc.has('KeyW') || cc.has('KeyS')
|
||||||
|
|| cc.has('KeyA') || cc.has('KeyD')
|
||||||
|
|| cc.has('ArrowUp') || cc.has('ArrowDown')
|
||||||
|
|| cc.has('ArrowLeft') || cc.has('ArrowRight'));
|
||||||
|
const sprinting = this._shift && !this._crouching;
|
||||||
|
if (!wasdHeld) this._jumpKind = 'in_place';
|
||||||
|
else if (sprinting) this._jumpKind = 'run';
|
||||||
|
else this._jumpKind = 'forward';
|
||||||
|
// anticipate-фаза разной длительности.
|
||||||
|
const antDuration = this._jumpKind === 'in_place' ? 375
|
||||||
|
: this._jumpKind === 'run' ? 125 : 170;
|
||||||
this._jumpHeld = true;
|
this._jumpHeld = true;
|
||||||
this._coyoteLeft = 0;
|
this._coyoteLeft = 0;
|
||||||
|
this._jumpAnticipateUntil = Date.now() + antDuration;
|
||||||
|
this._jumpPendingImpulse = true;
|
||||||
// Robot: запускаем boost-фазу на 0.45с
|
// Robot: запускаем boost-фазу на 0.45с
|
||||||
if (this._robotMode) {
|
if (this._robotMode) {
|
||||||
this._robotBoostLeft = 0.45;
|
this._robotBoostLeft = 0.45;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
// Запускаем физический прыжок ровно в конце anticipate-фазы.
|
||||||
|
if (this._jumpPendingImpulse
|
||||||
|
&& this._jumpAnticipateUntil
|
||||||
|
&& Date.now() >= this._jumpAnticipateUntil
|
||||||
|
&& !inWater && !this._shipMode && !this._ufoMode) {
|
||||||
|
this._vy = this.JUMP_VELOCITY * (this._jumpPowerMul || 1) * gDir;
|
||||||
|
this._playJumpSound();
|
||||||
|
this._jumpPendingImpulse = false;
|
||||||
|
// _jumpAnticipateUntil оставляем для анимационной ветки
|
||||||
} else if (this._shipMode && c.has('Space')) {
|
} else if (this._shipMode && c.has('Space')) {
|
||||||
this._jumpHeld = true;
|
this._jumpHeld = true;
|
||||||
} else if (this._ufoMode && c.has('Space') && !inWater) {
|
} else if (this._ufoMode && c.has('Space') && !inWater) {
|
||||||
@ -3105,11 +3130,71 @@ export class PlayerController {
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const inCrouchTransition = this._crouchTransitionUntil
|
const inCrouchTransition = this._crouchTransitionUntil
|
||||||
&& now < this._crouchTransitionUntil;
|
&& now < this._crouchTransitionUntil;
|
||||||
if (!result.onGround) {
|
// 3-фазная анимация прыжка. Выбираем семейство фаз по _jumpKind:
|
||||||
mState = (this._vy > 0.5) ? 'jump' : 'fall';
|
// in_place: jump_* (Mixamo Jumping)
|
||||||
|
// forward: jump_fwd_* (Mixamo Jump, прыжок с шага)
|
||||||
|
// run: jump_run_* (Mixamo Running Jump, прыжок с бега)
|
||||||
|
const jk = this._jumpKind;
|
||||||
|
const isAirborneJump = jk === 'forward' || jk === 'run';
|
||||||
|
let stAnticipate, stAir, stLand, landDuration;
|
||||||
|
if (jk === 'run') {
|
||||||
|
stAnticipate = 'jump_run_anticipate';
|
||||||
|
stAir = 'jump_run_air';
|
||||||
|
stLand = 'jump_run_land';
|
||||||
|
landDuration = 175;
|
||||||
|
} else if (jk === 'forward') {
|
||||||
|
stAnticipate = 'jump_fwd_anticipate';
|
||||||
|
stAir = 'jump_fwd_air';
|
||||||
|
stLand = 'jump_fwd_land';
|
||||||
|
landDuration = 142;
|
||||||
|
} else {
|
||||||
|
stAnticipate = 'jump_anticipate';
|
||||||
|
stAir = 'jump_air';
|
||||||
|
stLand = 'jump_land';
|
||||||
|
landDuration = 570;
|
||||||
|
}
|
||||||
|
const inAnticipate = this._jumpAnticipateUntil
|
||||||
|
&& now < this._jumpAnticipateUntil
|
||||||
|
&& this._jumpPendingImpulse;
|
||||||
|
const inJumpLand = this._jumpLandUntil && now < this._jumpLandUntil;
|
||||||
|
// Coyote-фильтр для микро-полётов на ступеньках. При спуске по
|
||||||
|
// лестнице из блоков персонаж 30-700мс физически в воздухе, и
|
||||||
|
// jump_air мигает между шагами walk. Критерий — ВЫСОТА падения
|
||||||
|
// от последней наземной позиции (а не время — полёт может быть
|
||||||
|
// длинным при спуске лицом к камере). Опустился <1.3 блока И не
|
||||||
|
// прыгал → ступенька, играем walk/run.
|
||||||
|
if (result.onGround) {
|
||||||
|
this._lastGroundY = this._pos.y;
|
||||||
|
}
|
||||||
|
const dropFromGround = (this._lastGroundY != null)
|
||||||
|
? (this._lastGroundY - this._pos.y) : Infinity;
|
||||||
|
const microAir = !result.onGround
|
||||||
|
&& !this._jumpHeld // не прыжок со Space
|
||||||
|
&& !this._wasAirborne // не продолжение реального прыжка
|
||||||
|
&& dropFromGround < 1.3 // опустился меньше 1.3 блока
|
||||||
|
&& this._vy < 4; // не подлетает вверх (степ-ап импульс)
|
||||||
|
if (inAnticipate) {
|
||||||
|
mState = stAnticipate;
|
||||||
|
} else if (microAir) {
|
||||||
|
// Микро-полёт между ступеньками — наземная анимация.
|
||||||
|
mState = this._crouching
|
||||||
|
? (isMoving ? 'crouch_walk' : 'crouch_idle')
|
||||||
|
: (isMoving ? (isSprinting ? 'run' : 'walk') : 'idle');
|
||||||
|
} else if (!result.onGround) {
|
||||||
|
mState = stAir;
|
||||||
|
this._wasAirborne = true;
|
||||||
this._crouchEnterPending = false;
|
this._crouchEnterPending = false;
|
||||||
this._crouchExitPending = false;
|
this._crouchExitPending = false;
|
||||||
this._crouchTransitionUntil = 0;
|
this._crouchTransitionUntil = 0;
|
||||||
|
this._jumpAnticipateUntil = 0;
|
||||||
|
} else if (this._wasAirborne) {
|
||||||
|
this._jumpLandUntil = now + landDuration;
|
||||||
|
this._wasAirborne = false;
|
||||||
|
mState = stLand;
|
||||||
|
} else if (inJumpLand) {
|
||||||
|
// Для forward — доигрываем land даже при движении
|
||||||
|
// (там короткая фаза 142мс)
|
||||||
|
if (isAirborneJump || !isMoving) mState = stLand;
|
||||||
} else if (this._crouchEnterPending && inCrouchTransition && !isMoving) {
|
} else if (this._crouchEnterPending && inCrouchTransition && !isMoving) {
|
||||||
mState = 'crouch_enter';
|
mState = 'crouch_enter';
|
||||||
} else if (this._crouchExitPending && inCrouchTransition && !isMoving) {
|
} else if (this._crouchExitPending && inCrouchTransition && !isMoving) {
|
||||||
@ -3123,6 +3208,7 @@ export class PlayerController {
|
|||||||
} else if (isMoving) {
|
} else if (isMoving) {
|
||||||
this._crouchExitPending = false;
|
this._crouchExitPending = false;
|
||||||
this._crouchTransitionUntil = 0;
|
this._crouchTransitionUntil = 0;
|
||||||
|
this._jumpLandUntil = 0; // прерываем jump_land если пошли
|
||||||
mState = isSprinting ? 'run' : 'walk';
|
mState = isSprinting ? 'run' : 'walk';
|
||||||
} else {
|
} else {
|
||||||
this._crouchExitPending = false;
|
this._crouchExitPending = false;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user