From eef70084162431963382c7fa175b0132e3944a49 Mon Sep 17 00:00:00 2001 From: min Date: Sun, 14 Jun 2026 20:25:30 +0300 Subject: [PATCH] =?UTF-8?q?feat(anim):=203-=D1=84=D0=B0=D0=B7=D0=BD=D0=B0?= =?UTF-8?q?=D1=8F=20=D0=B0=D0=BD=D0=B8=D0=BC=D0=B0=D1=86=D0=B8=D1=8F=20?= =?UTF-8?q?=D0=BF=D1=80=D1=8B=D0=B6=D0=BA=D0=B0=20=D0=BD=D0=B0=20=D0=BC?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=B5=20(anticipate=20+=20air=20+=20land)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/KubikonPlayer/KubikonPlayer.jsx | 12 ++--- src/engine/BabylonScene.js | 5 +- src/engine/MixamoAnimator.js | 76 ++++++++++++++++++++++++++++- src/engine/PlayerController.js | 52 ++++++++++++++------ 4 files changed, 119 insertions(+), 26 deletions(-) diff --git a/src/KubikonPlayer/KubikonPlayer.jsx b/src/KubikonPlayer/KubikonPlayer.jsx index db0f6dd..77c6647 100644 --- a/src/KubikonPlayer/KubikonPlayer.jsx +++ b/src/KubikonPlayer/KubikonPlayer.jsx @@ -1233,14 +1233,10 @@ const KubikonPlayer = () => { outline: 'none', }} /> - {/* Задача 05: красивый экран загрузки игры (Ken Burns + название места + автор). */} - {loading && ( - - )} + {/* GameLoadingScreen НЕ показывается при загрузке плейса. + * Появляется ТОЛЬКО когда автор вызовет его из скрипта игры + * (через game.showLoadingScreen или аналог). По дефолту — игра + * открывается сразу, как в Roblox. */} {/* 2026-06-14: стартовый оверлей. Один клик → fullscreen → * Chrome блокирует Ctrl+W/Ctrl+T/Ctrl+R и др. Без него diff --git a/src/engine/BabylonScene.js b/src/engine/BabylonScene.js index 52c9e58..e770cc3 100644 --- a/src/engine/BabylonScene.js +++ b/src/engine/BabylonScene.js @@ -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 для всех проектов). diff --git a/src/engine/MixamoAnimator.js b/src/engine/MixamoAnimator.js index 1605d83..ec9cea0 100644 --- a/src/engine/MixamoAnimator.js +++ b/src/engine/MixamoAnimator.js @@ -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,7 +265,49 @@ 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']); + 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 — если он не изменится за diff --git a/src/engine/PlayerController.js b/src/engine/PlayerController.js index e3b10d2..6a50de1 100644 --- a/src/engine/PlayerController.js +++ b/src/engine/PlayerController.js @@ -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;