feat(anim): 3-фазная анимация прыжка на месте (anticipate + air + land)

- jump_anticipate (0.375с): присед перед прыжком, физика заблокирована,
  Hips опускается визуально
- jump_air (0.975с): полёт без Hips.Y подъёма (физика управляет _modelRoot)
- jump_land (0.56с): амортизация при приземлении, Hips опускается
  относительно maxY (никогда не выше bind — иначе ноги повиснут в воздухе)
- Mixamo Jumping разрезан на 3 GLB через scripts/split_clip.js
- Blender pipeline для FBX→GLB через scripts/fbx2glb_blender.py + strip_anim_channels.js
- GameLoadingScreen убран при старте плеера (по умолчанию игра открывается сразу)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
min 2026-06-14 20:25:30 +03:00
parent 308b183db1
commit eef7008416
4 changed files with 119 additions and 26 deletions

View File

@ -1233,14 +1233,10 @@ const KubikonPlayer = () => {
outline: 'none',
}}
/>
{/* Задача 05: красивый экран загрузки игры (Ken Burns + название места + автор). */}
{loading && (
<GameLoadingScreen
meta={meta}
loadingScreen={loadingScreenCfg}
progress={loadProgress}
/>
)}
{/* GameLoadingScreen НЕ показывается при загрузке плейса.
* Появляется ТОЛЬКО когда автор вызовет его из скрипта игры
* (через game.showLoadingScreen или аналог). По дефолту игра
* открывается сразу, как в Roblox. */}
{/* 2026-06-14: стартовый оверлей. Один клик fullscreen
* Chrome блокирует Ctrl+W/Ctrl+T/Ctrl+R и др. Без него

View File

@ -5638,8 +5638,9 @@ export class BabylonScene {
// поэтому скрипты стартуем в следующем кадре.
this.gameRuntime = new GameRuntime(this);
try { this.modalManager?.attachRuntime?.(this.gameRuntime); } catch (e) {}
// Задача 05: стартовый экран загрузки (Ken-Burns + название места).
try { this.showStartupLoadingScreen(); } catch (e) {}
// НЕ показывать стартовый экран загрузки автоматически.
// По дефолту игра открывается мгновенно (как в Roblox). Экран загрузки
// только если автор явно вызовет showLoadingScreen() из скрипта.
// Аудио-менеджер (GD-музыка/SFX) — создаётся вместе с runtime.
// ВАЖНО: поле называется gameAudioManager чтобы не путать со штатным
// this.audioManager (AudioManager — ambient/music для всех проектов).

View File

@ -51,6 +51,7 @@ const BASE_STATES = ["idle", "walk", "run", "jump", "fall"];
// Дополнительные движения (грузятся лениво при первом setState):
const EXTRA_STATES = [
"jump_anticipate", "jump_air", "jump_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 +231,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,8 +265,50 @@ export class MixamoAnimator {
// движений (walk/run/jump) фильтруем targetProperty=position
// у кости с именем Hips — её двигает наш PlayerController.
if (t.boneName === "Hips" && cloned.targetProperty === "position") {
// 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']);
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 +328,7 @@ 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",
"crouch_enter", "crouch_to_stand",
"hit_react", "die_forward", "die_back",
"throw_action", "pickup", "push_button", "open_door",
@ -315,12 +371,30 @@ 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']);
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'
|| state === 'jump_air' || state === 'jump_land'
|| this._currentState === 'jump' || this._currentState === 'fall'
|| this._currentState === 'jump_air' || this._currentState === 'jump_land'
|| state === 'crouch_enter' || state === 'crouch_to_stand';
if (!isVitalSwitch && this._lastSwitchAt && (now - this._lastSwitchAt) < 120) {
// Запомним последний запрошенный state — если он не изменится за

View File

@ -3092,17 +3092,28 @@ 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-фазная модель: при первом нажатии Space запускаем
// jump_anticipate (присед перед прыжком) на 0.375с.
// Физика прыжка стартует ПОСЛЕ окончания anticipate-фазы.
this._jumpHeld = true;
this._coyoteLeft = 0;
this._jumpAnticipateUntil = Date.now() + 375;
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) {
@ -3330,22 +3341,33 @@ export class PlayerController {
const now = Date.now();
const inCrouchTransition = this._crouchTransitionUntil
&& now < this._crouchTransitionUntil;
if (!result.onGround) {
mState = (this._vy > 0.5) ? 'jump' : 'fall';
// Воздух — отменяем pending crouch-переход
// jump_anticipate — присед перед прыжком (0.375с), физика заморожена.
const inAnticipate = this._jumpAnticipateUntil
&& now < this._jumpAnticipateUntil
&& this._jumpPendingImpulse;
// jump_land — постприземление (присед→встать) 0.57с.
const inJumpLand = this._jumpLandUntil && now < this._jumpLandUntil;
if (inAnticipate) {
mState = 'jump_anticipate';
} else if (!result.onGround) {
// В воздухе: jump_air (только в полёте, без приседа/landing).
mState = 'jump_air';
this._wasAirborne = true;
this._crouchEnterPending = false;
this._crouchExitPending = false;
this._crouchTransitionUntil = 0;
// Сбрасываем anticipate-окно — мы уже в воздухе
this._jumpAnticipateUntil = 0;
} else if (this._wasAirborne) {
// Только что приземлились — запустим jump_land
this._jumpLandUntil = now + 570;
this._wasAirborne = false;
mState = 'jump_land';
} else if (inJumpLand && !isMoving) {
mState = 'jump_land';
} else if (this._crouchEnterPending && inCrouchTransition && !isMoving) {
// ВХОД в присед: одноразовая анимация Standing→Crouch.
// Если игрок начал двигаться сразу — пропускаем переход
// и идём в crouch_walk.
mState = 'crouch_enter';
} else if (this._crouchExitPending && inCrouchTransition && !isMoving) {
// ВЫХОД из приседа: одноразовая анимация Crouched→Standing.
// Если игрок сразу пошёл/побежал — пропускаем переход и
// идём прямо в walk/run. Иначе персонаж скользит вдоль
// пола в позе вставания.
mState = 'crouch_to_stand';
} else if (this._crouching) {
this._crouchEnterPending = false;
@ -3354,9 +3376,9 @@ export class PlayerController {
} else if (inWater) {
mState = isMoving ? 'walk' : 'idle';
} else if (isMoving) {
// Сбросим pending crouch_to_stand — игрок уже бежит
this._crouchExitPending = false;
this._crouchTransitionUntil = 0;
this._jumpLandUntil = 0; // прерываем jump_land если пошли
mState = isSprinting ? 'run' : 'walk';
} else {
this._crouchExitPending = false;