diff --git a/src/editor/engine/MixamoAnimator.js b/src/editor/engine/MixamoAnimator.js index 1605d83..aaadd9b 100644 --- a/src/editor/engine/MixamoAnimator.js +++ b/src/editor/engine/MixamoAnimator.js @@ -51,6 +51,9 @@ const BASE_STATES = ["idle", "walk", "run", "jump", "fall"]; // Дополнительные движения (грузятся лениво при первом setState): 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", "jump_forward", "jump_backward", "jump_down", "crouch_enter", "crouch_idle", "crouch_walk", "crouch_to_stand", @@ -230,6 +233,18 @@ export class MixamoAnimator { const tnode = bone.getTransformNode ? bone.getTransformNode() : null; 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 для конкретного состояния. */ @@ -252,7 +267,53 @@ export class MixamoAnimator { // движений (walk/run/jump) фильтруем targetProperty=position // у кости с именем Hips — её двигает наш PlayerController. 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); attached++; @@ -273,6 +334,9 @@ export class MixamoAnimator { // эпизодические действия (hit, die, throw, pickup, gun_fire, gun_reload и т.д.) const ONE_SHOT = new Set([ "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", "hit_react", "die_forward", "die_back", "throw_action", "pickup", "push_button", "open_door", @@ -315,12 +379,37 @@ export class MixamoAnimator { setState(state) { if (this._currentEmote) 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()); // Anti-flicker debounce: не даём переключать состояние чаще чем раз в 120мс, // КРОМЕ переходов с/на воздушные состояния (jump/fall) — там важна скорость // и one-shot crouch_enter/crouch_to_stand (они короткие). - const isVitalSwitch = state === 'jump' || state === 'fall' - || this._currentState === 'jump' || this._currentState === 'fall' + const JUMP_VITAL = new Set([ + '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'; if (!isVitalSwitch && this._lastSwitchAt && (now - this._lastSwitchAt) < 120) { // Запомним последний запрошенный state — если он не изменится за @@ -361,12 +450,22 @@ export class MixamoAnimator { // eslint-disable-next-line no-console 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 // в start() иногда игнорится — дублируем через loopAnimation // (выставлен в _ensureGroup). try { next.reset(); - next.start(loop, 1.0, next.from, next.to, false); + next.start(loop, speedRatio, next.from, next.to, false); } catch (e) { try { next.play(loop); } catch (_) {} } diff --git a/src/editor/engine/PlayerController.js b/src/editor/engine/PlayerController.js index 04eb056..3cfa006 100644 --- a/src/editor/engine/PlayerController.js +++ b/src/editor/engine/PlayerController.js @@ -2853,17 +2853,42 @@ export class PlayerController { } else if (canJump && c.has('Space') && !inWater && !this._shipMode && !this._ufoMode) { if (!this._jumpHeld) { - // Robot — стартовый импульс полный (как куб) для тапа достаточный, - // boost-фаза 0.45с удлиняет подъём при удержании Space. - this._vy = this.JUMP_VELOCITY * (this._jumpPowerMul || 1) * gDir; - this._playJumpSound(); + // 3-фазная модель прыжка. + // _jumpKind определяется по нажатым клавишам в момент Space: + // in_place — нет WASD (анимация Mixamo Jumping) + // 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._coyoteLeft = 0; + this._jumpAnticipateUntil = Date.now() + antDuration; + this._jumpPendingImpulse = true; // Robot: запускаем boost-фазу на 0.45с if (this._robotMode) { 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')) { this._jumpHeld = true; } else if (this._ufoMode && c.has('Space') && !inWater) { @@ -3105,11 +3130,71 @@ export class PlayerController { const now = Date.now(); const inCrouchTransition = this._crouchTransitionUntil && now < this._crouchTransitionUntil; - if (!result.onGround) { - mState = (this._vy > 0.5) ? 'jump' : 'fall'; + // 3-фазная анимация прыжка. Выбираем семейство фаз по _jumpKind: + // 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._crouchExitPending = false; 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) { mState = 'crouch_enter'; } else if (this._crouchExitPending && inCrouchTransition && !isMoving) { @@ -3123,6 +3208,7 @@ export class PlayerController { } else if (isMoving) { this._crouchExitPending = false; this._crouchTransitionUntil = 0; + this._jumpLandUntil = 0; // прерываем jump_land если пошли mState = isSprinting ? 'run' : 'walk'; } else { this._crouchExitPending = false;