From eef70084162431963382c7fa175b0132e3944a49 Mon Sep 17 00:00:00 2001 From: min Date: Sun, 14 Jun 2026 20:25:30 +0300 Subject: [PATCH 1/6] =?UTF-8?q?feat(anim):=203-=D1=84=D0=B0=D0=B7=D0=BD?= =?UTF-8?q?=D0=B0=D1=8F=20=D0=B0=D0=BD=D0=B8=D0=BC=D0=B0=D1=86=D0=B8=D1=8F?= =?UTF-8?q?=20=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; From 4db93592d2e5883f8d27eeb778f940d4a67391da Mon Sep 17 00:00:00 2001 From: min Date: Sun, 14 Jun 2026 20:34:56 +0300 Subject: [PATCH 2/6] =?UTF-8?q?feat(anim):=20=D0=BF=D1=80=D1=8B=D0=B6?= =?UTF-8?q?=D0=BE=D0=BA=20=D0=B2=D0=BF=D0=B5=D1=80=D1=91=D0=B4=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B8=20=D0=B4=D0=B2=D0=B8=D0=B6=D0=B5=D0=BD=D0=B8=D0=B8?= =?UTF-8?q?=20(jump=5Ffwd=203=20=D1=84=D0=B0=D0=B7=D1=8B)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - jump_fwd_anticipate/air/land из Mixamo Jump (прыжок с разбега) - _jumpKind=forward когда нажата WASD в момент Space - speedRatio=0.59 для jump_fwd_air (синхрон с физикой 0.73с, без велосипеда) - in_place вариант остаётся для прыжка на месте Co-Authored-By: Claude Opus 4.7 --- src/engine/MixamoAnimator.js | 31 ++++++++++++++++++------ src/engine/PlayerController.js | 44 ++++++++++++++++++++++------------ 2 files changed, 53 insertions(+), 22 deletions(-) diff --git a/src/engine/MixamoAnimator.js b/src/engine/MixamoAnimator.js index ec9cea0..2b537f3 100644 --- a/src/engine/MixamoAnimator.js +++ b/src/engine/MixamoAnimator.js @@ -52,6 +52,7 @@ 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", "walk_backward", "run_backward", "run_to_stop", "run_slide", "jump_forward", "jump_backward", "jump_down", "crouch_enter", "crouch_idle", "crouch_walk", "crouch_to_stand", @@ -274,7 +275,10 @@ export class MixamoAnimator { // (только что приземлились, ноги пружинят к bind), // середина = 0 (присед на полу), конец = выпрямление. // Для всех остальных — фильтруем (физика двигает _modelRoot). - const PHASES = new Set(['jump_anticipate', 'jump_land']); + const PHASES = new Set([ + 'jump_anticipate', 'jump_land', + 'jump_fwd_anticipate', 'jump_fwd_land', + ]); if (!PHASES.has(state)) { continue; } @@ -329,6 +333,7 @@ export class MixamoAnimator { 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", "crouch_enter", "crouch_to_stand", "hit_react", "die_forward", "die_back", "throw_action", "pickup", "push_button", "open_door", @@ -374,7 +379,10 @@ export class MixamoAnimator { // Сброс Hips.position в bind-pose при выходе из jump-фаз. // Иначе последний keyframe анимации остаётся на Hips и idle/walk // подхватывает смещённую позицию → персонаж проседает. - const JUMP_STATES = new Set(['jump_air', 'jump_land', 'jump_in_place']); + const JUMP_STATES = new Set([ + 'jump_air', 'jump_land', 'jump_in_place', 'jump_anticipate', + 'jump_fwd_anticipate', 'jump_fwd_air', 'jump_fwd_land', + ]); if (JUMP_STATES.has(this._currentState) && !JUMP_STATES.has(state) && this._restPositions) { const rest = this._restPositions.get('Hips'); @@ -391,10 +399,12 @@ export class MixamoAnimator { // 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' + const JUMP_VITAL = new Set([ + 'jump', 'fall', 'jump_air', 'jump_land', 'jump_anticipate', + 'jump_fwd_anticipate', 'jump_fwd_air', 'jump_fwd_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 — если он не изменится за @@ -435,12 +445,19 @@ 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 (замедлить чтобы клип не зациклился). + const SPEED_RATIO = { + jump_fwd_air: 0.59, + }; + 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/engine/PlayerController.js b/src/engine/PlayerController.js index 6a50de1..78d4cd1 100644 --- a/src/engine/PlayerController.js +++ b/src/engine/PlayerController.js @@ -3092,12 +3092,21 @@ export class PlayerController { } else if (canJump && c.has('Space') && !inWater && !this._shipMode && !this._ufoMode) { if (!this._jumpHeld) { - // 3-фазная модель: при первом нажатии Space запускаем - // jump_anticipate (присед перед прыжком) на 0.375с. - // Физика прыжка стартует ПОСЛЕ окончания anticipate-фазы. + // 3-фазная модель прыжка. + // _jumpKind определяется по нажатым клавишам в момент Space: + // in_place — нет WASD (анимация Mixamo Jumping) + // forward — есть WASD (анимация Mixamo 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')); + this._jumpKind = wasdHeld ? 'forward' : 'in_place'; + // anticipate-фаза разной длительности. + const antDuration = this._jumpKind === 'forward' ? 170 : 375; this._jumpHeld = true; this._coyoteLeft = 0; - this._jumpAnticipateUntil = Date.now() + 375; + this._jumpAnticipateUntil = Date.now() + antDuration; this._jumpPendingImpulse = true; // Robot: запускаем boost-фазу на 0.45с if (this._robotMode) { @@ -3341,30 +3350,35 @@ export class PlayerController { const now = Date.now(); const inCrouchTransition = this._crouchTransitionUntil && now < this._crouchTransitionUntil; - // jump_anticipate — присед перед прыжком (0.375с), физика заморожена. + // 3-фазная анимация прыжка. Выбираем семейство фаз по _jumpKind: + // in_place: jump_anticipate/jump_air/jump_land (Mixamo Jumping) + // forward: jump_fwd_anticipate/jump_fwd_air/jump_fwd_land (Mixamo Jump) + const isForward = this._jumpKind === 'forward'; + const stAnticipate = isForward ? 'jump_fwd_anticipate' : 'jump_anticipate'; + const stAir = isForward ? 'jump_fwd_air' : 'jump_air'; + const stLand = isForward ? 'jump_fwd_land' : 'jump_land'; + const landDuration = isForward ? 142 : 570; 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'; + mState = stAnticipate; } else if (!result.onGround) { - // В воздухе: jump_air (только в полёте, без приседа/landing). - mState = 'jump_air'; + mState = stAir; 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._jumpLandUntil = now + landDuration; this._wasAirborne = false; - mState = 'jump_land'; - } else if (inJumpLand && !isMoving) { - mState = 'jump_land'; + mState = stLand; + } else if (inJumpLand) { + // Для forward — доигрываем land даже при движении + // (там короткая фаза 142мс) + if (isForward || !isMoving) mState = stLand; } else if (this._crouchEnterPending && inCrouchTransition && !isMoving) { mState = 'crouch_enter'; } else if (this._crouchExitPending && inCrouchTransition && !isMoving) { From 6782a42ba3aec1c7b9942866acb7ce2777761f74 Mon Sep 17 00:00:00 2001 From: min Date: Sun, 14 Jun 2026 21:07:44 +0300 Subject: [PATCH 3/6] =?UTF-8?q?feat(anim):=20=D0=BF=D1=80=D1=8B=D0=B6?= =?UTF-8?q?=D0=BE=D0=BA=20=D0=B2=20=D0=B1=D0=B5=D0=B3=D0=B5=20(jump=5Frun?= =?UTF-8?q?=203=20=D1=84=D0=B0=D0=B7=D1=8B,=20Shift+=D0=B4=D0=B2=D0=B8?= =?UTF-8?q?=D0=B6=D0=B5=D0=BD=D0=B8=D0=B5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - jump_run_anticipate/air/land из Mixamo Running Jump - _jumpKind=run когда Shift+WASD в момент Space - speedRatio=0.71 для jump_run_air (синхрон 0.73с) - три типа: in_place / forward (шаг) / run (бег) Co-Authored-By: Claude Opus 4.7 --- src/engine/MixamoAnimator.js | 8 +++++++ src/engine/PlayerController.js | 42 ++++++++++++++++++++++++++-------- 2 files changed, 40 insertions(+), 10 deletions(-) diff --git a/src/engine/MixamoAnimator.js b/src/engine/MixamoAnimator.js index 2b537f3..aaadd9b 100644 --- a/src/engine/MixamoAnimator.js +++ b/src/engine/MixamoAnimator.js @@ -53,6 +53,7 @@ const BASE_STATES = ["idle", "walk", "run", "jump", "fall"]; 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", @@ -278,6 +279,7 @@ export class MixamoAnimator { 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; @@ -334,6 +336,7 @@ export class MixamoAnimator { "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", @@ -382,6 +385,7 @@ export class MixamoAnimator { 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) { @@ -402,6 +406,7 @@ export class MixamoAnimator { 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) @@ -448,8 +453,11 @@ export class MixamoAnimator { // 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 diff --git a/src/engine/PlayerController.js b/src/engine/PlayerController.js index 78d4cd1..783e75f 100644 --- a/src/engine/PlayerController.js +++ b/src/engine/PlayerController.js @@ -3101,9 +3101,16 @@ export class PlayerController { || cc.has('KeyA') || cc.has('KeyD') || cc.has('ArrowUp') || cc.has('ArrowDown') || cc.has('ArrowLeft') || cc.has('ArrowRight')); - this._jumpKind = wasdHeld ? 'forward' : 'in_place'; + // in_place — нет WASD + // forward — WASD без Shift (Mixamo Jump) + // run — WASD + Shift (Mixamo Running Jump) + 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 === 'forward' ? 170 : 375; + const antDuration = this._jumpKind === 'in_place' ? 375 + : this._jumpKind === 'run' ? 125 : 170; this._jumpHeld = true; this._coyoteLeft = 0; this._jumpAnticipateUntil = Date.now() + antDuration; @@ -3351,13 +3358,28 @@ export class PlayerController { const inCrouchTransition = this._crouchTransitionUntil && now < this._crouchTransitionUntil; // 3-фазная анимация прыжка. Выбираем семейство фаз по _jumpKind: - // in_place: jump_anticipate/jump_air/jump_land (Mixamo Jumping) - // forward: jump_fwd_anticipate/jump_fwd_air/jump_fwd_land (Mixamo Jump) - const isForward = this._jumpKind === 'forward'; - const stAnticipate = isForward ? 'jump_fwd_anticipate' : 'jump_anticipate'; - const stAir = isForward ? 'jump_fwd_air' : 'jump_air'; - const stLand = isForward ? 'jump_fwd_land' : 'jump_land'; - const landDuration = isForward ? 142 : 570; + // 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; @@ -3378,7 +3400,7 @@ export class PlayerController { } else if (inJumpLand) { // Для forward — доигрываем land даже при движении // (там короткая фаза 142мс) - if (isForward || !isMoving) mState = stLand; + if (isAirborneJump || !isMoving) mState = stLand; } else if (this._crouchEnterPending && inCrouchTransition && !isMoving) { mState = 'crouch_enter'; } else if (this._crouchExitPending && inCrouchTransition && !isMoving) { From 42b3c26382e3ee18fe869abd6cf78912037c988f Mon Sep 17 00:00:00 2001 From: min Date: Sun, 14 Jun 2026 21:49:37 +0300 Subject: [PATCH 4/6] =?UTF-8?q?feat(anim):=20=D0=BF=D0=B0=D0=B4=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D1=81=20=D0=BA=D1=80=D0=B0=D1=8F=20(fall?= =?UTF-8?q?=5Foff)=20+=20coyote-=D1=84=D0=B8=D0=BB=D1=8C=D1=82=D1=80=20?= =?UTF-8?q?=D1=81=D0=BF=D1=83=D1=81=D0=BA=D0=B0=20=D0=BF=D0=BE=20=D0=BB?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=BD=D0=B8=D1=86=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fall_off_air/fall_off_land при сходе с возвышенности без Space - coyote-фильтр по высоте падения (<1.3 блока → walk, не jump_air) убирает мигание анимаций при спуске по лестнице из блоков - jump_fwd_land / jump_run_land speedRatio 0.5 (присед виден) - land короче при движении (без скольжения), полный при остановке - компенсация Hips drop в land-фазах (ступни не уходят под пол) Все типы прыжка работают: in_place / forward / run / fall_off Co-Authored-By: Claude Opus 4.7 --- src/engine/PlayerController.js | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/engine/PlayerController.js b/src/engine/PlayerController.js index 783e75f..d3669b9 100644 --- a/src/engine/PlayerController.js +++ b/src/engine/PlayerController.js @@ -3384,8 +3384,29 @@ export class PlayerController { && 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; From 2294981597c10127384877e1e33621067b99da1d Mon Sep 17 00:00:00 2001 From: min Date: Sun, 14 Jun 2026 23:57:19 +0300 Subject: [PATCH 5/6] =?UTF-8?q?feat(player):=20=D0=B2=D0=B5=D1=80=D1=82?= =?UTF-8?q?=D0=B8=D0=BA=D0=B0=D0=BB=D1=8C=D0=BD=D0=B0=D1=8F=20=D0=BB=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D0=BD=D0=B8=D1=86=D0=B0=20ladder=5Fvertical=20+=20?= =?UTF-8?q?=D0=BB=D0=B0=D0=B7=D0=B0=D0=BD=D0=B8=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - перенос из студии: ladder-mode, climb_up/climb_down, climb_to_top - предзагрузка climb-анимаций (нет дёрга 180° при входе) - заморозка позы на месте без исчезания скина - гистерезис выхода, толщина лестницы 0.12 - climb_to_top вылезание на площадку 4с с заморозкой физики Co-Authored-By: Claude Opus 4.7 --- src/engine/MixamoAnimator.js | 16 ++- src/engine/PhysicsAABB.js | 20 +++ src/engine/PlayerController.js | 224 ++++++++++++++++++++++++++++++++- src/engine/PrimitiveManager.js | 80 +++++++++++- src/engine/PrimitiveTypes.js | 9 +- 5 files changed, 337 insertions(+), 12 deletions(-) diff --git a/src/engine/MixamoAnimator.js b/src/engine/MixamoAnimator.js index aaadd9b..a20fe56 100644 --- a/src/engine/MixamoAnimator.js +++ b/src/engine/MixamoAnimator.js @@ -57,7 +57,7 @@ const EXTRA_STATES = [ "walk_backward", "run_backward", "run_to_stop", "run_slide", "jump_forward", "jump_backward", "jump_down", "crouch_enter", "crouch_idle", "crouch_walk", "crouch_to_stand", - "climb_up", "climb_down", "sit_idle", "lie_idle", "sleeping", + "climb_up", "climb_down", "climb_to_top", "sit_idle", "lie_idle", "sleeping", "hit_react", "die_forward", "die_back", "punch_left", "kick_low", "kick_high", "gun_fire", "gun_reload", "rifle_walk", @@ -338,6 +338,7 @@ export class MixamoAnimator { "jump_fwd_anticipate", "jump_fwd_air", "jump_fwd_land", "jump_run_anticipate", "jump_run_air", "jump_run_land", "crouch_enter", "crouch_to_stand", + "climb_to_top", "hit_react", "die_forward", "die_back", "throw_action", "pickup", "push_button", "open_door", "gun_fire", "gun_reload", "sword_slash", @@ -471,7 +472,12 @@ export class MixamoAnimator { } // Кросс-фейд через weight (0→1 у новой, 1→0 у старой) за BLEND_MS. - const BLEND_MS = 150; + // Climb-состояния переключаем МГНОВЕННО (0мс) — при blend'е персонаж + // на доли секунды виден в промежуточном развороте (старая поза + новый + // _modelYaw), что выглядит как «дёрг разворота» при входе/выходе с лестницы. + const CLIMB_STATES = new Set(['climb_up', 'climb_down', 'climb_to_top']); + const BLEND_MS = (CLIMB_STATES.has(state) || CLIMB_STATES.has(this._currentState)) + ? 0 : 150; try { next.setWeightForAllAnimatables(0); } catch (_) {} // Снимаем ВСЕ предыдущие blend-observers — rapid-switching // (Ctrl on/off с интервалом 50ms) оставлял несколько ticker'ов. @@ -560,6 +566,12 @@ export class MixamoAnimator { group.onAnimationGroupEndObservable.addOnce(onEnd); } + /** Тихая предзагрузка анимации в кэш (БЕЗ проигрывания). Нужно чтобы + * при первом setState анимация уже была готова (нет дёрга от walk). */ + preload(name) { + try { _ensureLoaded(this.scene, name); } catch (e) {} + } + /** Babylon сам тикает AnimationGroup, но оставим для интерфейса. */ // eslint-disable-next-line no-unused-vars update(dt) { /* noop */ } diff --git a/src/engine/PhysicsAABB.js b/src/engine/PhysicsAABB.js index 4b21788..f7518ec 100644 --- a/src/engine/PhysicsAABB.js +++ b/src/engine/PhysicsAABB.js @@ -1192,4 +1192,24 @@ export class PhysicsAABB { } return out; } + + /** + * Найти лестницу (ladder_vertical), которой касается AABB игрока. + * Лестницы проходимы (canCollide=false) → НЕ попадают в spatial-grid, + * поэтому итерируем напрямую по инстансам (их на сцене единицы). + * Возвращает data ближайшей пересекающейся лестницы или null. + */ + getOverlappingLadder(cx, cy, cz, hw, hh, hd) { + if (!this.primitiveManager) return null; + let best = null, bestDist = Infinity; + for (const data of this.primitiveManager.instances.values()) { + if (data.type !== 'ladder_vertical') continue; + if (data.visible === false) continue; + if (!this._aabbIntersectsPrimitive(cx, cy, cz, hw, hh, hd, data)) continue; + const dx = data.x - cx, dz = data.z - cz; + const d = dx * dx + dz * dz; + if (d < bestDist) { bestDist = d; best = data; } + } + return best; + } } diff --git a/src/engine/PlayerController.js b/src/engine/PlayerController.js index d3669b9..9b11d78 100644 --- a/src/engine/PlayerController.js +++ b/src/engine/PlayerController.js @@ -107,6 +107,11 @@ export class PlayerController { this._waveMode = false; // GD-гейммод Wave: всегда движение под ±45° (Space зажат — вверх, отпущен — вниз) this._robotMode = false; // GD-гейммод Robot: высота прыжка = длительность удержания Space (variable-jump) this._robotBoostLeft = 0; // оставшееся время boost-фазы (с) + // Лестница (ladder_vertical): касание + W/S → ladder-mode (гравитация + // отключена, W/S = вверх/вниз, Space = отпрыг). + this._ladderMode = false; + this._ladderData = null; + this.CLIMB_SPEED = 2.5; // скорость лазания вверх/вниз (м/с) // Кубикон Dash: если > 0 — игрок САМ движется по +X со скоростью X м/с. // Ввод (WASD/тач-джойстик) игнорируется в _tick. Прыжок остаётся. this._autoRunSpeed = 0; @@ -1233,6 +1238,14 @@ export class PlayerController { animator.attach(this.scene, mixSk, root); animator.setState('idle'); this._mixamoAnimator = animator; + // Предзагрузим climb-анимации заранее (тихо), + // чтобы при первом касании лестницы не было кадра + // walk с climb-поворотом (дёрг на 180°). + try { + animator.preload('climb_up'); + animator.preload('climb_down'); + animator.preload('climb_to_top'); + } catch (e) {} // Глобально для отладки/скриптов: // window.__mixamo.playEmote('dance_hiphop') try { window.__mixamo = animator; } catch (e) {} @@ -2863,8 +2876,154 @@ export class PlayerController { moveZ *= 0.5; } + // === Лестница (ladder_vertical) === + // Детект касания лестницы. В воде/машине/GD-режиме лестница отключена. + let ladder = null; + if (!inWater && !inGdMode && this.physics?.getOverlappingLadder) { + ladder = this.physics.getOverlappingLadder( + this._pos.x, this._pos.y, this._pos.z, + this.HALF_W, this.HALF_H, this.HALF_D + ); + } + // Предзагрузка climb-анимаций при касании лестницы (ДО лазания), + // чтобы при входе в ladder-mode climb_up уже был в кэше. Без этого + // первый кадр играет walk с climb-поворотом → персонаж «дёргается» + // на 180° пока climb_up асинхронно подгружается. + if (ladder && this._mixamoAnimator && !this._climbPreloaded) { + this._climbPreloaded = true; + try { + this._mixamoAnimator.preload('climb_up'); + this._mixamoAnimator.preload('climb_down'); + this._mixamoAnimator.preload('climb_to_top'); + } catch (e) {} + } + const wantUp = c.has('KeyW') || c.has('ArrowUp'); + const wantDown = c.has('KeyS') || c.has('ArrowDown'); + // Фаза climb_to_top — вылезание на площадку (4с). Блокирует всё: + // управление, физику, обычный ladder-mode. Игрок плавно перемещается + // из _climbTopStart в _climbTopEnd (lerp), анимация climb_to_top играет. + if (this._climbingTop) { + const total = 4000; + const left = this._climbingTopUntil - Date.now(); + const t = Math.max(0, Math.min(1, 1 - left / total)); + const a = this._climbTopStart, b = this._climbTopEnd; + if (a && b) { + this._pos.x = a.x + (b.x - a.x) * t; + this._pos.y = a.y + (b.y - a.y) * t; + this._pos.z = a.z + (b.z - a.z) * t; + } + this._vy = 0; + if (left <= 0) { + // Завершили вылезание — выходим в обычный режим. + this._climbingTop = false; + this._ladderMode = false; + this._ladderData = null; + this._climbTopStart = null; + this._climbTopEnd = null; + } + // Пропускаем остальную ladder/движение логику в этом кадре. + // Но позволяем анимационной ветке проиграть climb_to_top. + } + + // Вход в ladder-mode: касаемся лестницы И жмём вверх/вниз. + if (!this._climbingTop && ladder && !this._ladderMode && (wantUp || wantDown)) { + this._ladderMode = true; + this._ladderData = ladder; + this._vy = 0; + // Прижать игрока к плоскости лестницы и повернуть лицом к ней. + // Лестница плоская: её фронт — вдоль локальной оси -Z, повёрнутой + // на rotationY. Нормаль фронта = (sin(rY), 0, cos(rY)). + const rY = (ladder.rotationY || 0) * Math.PI / 180; + const nx = Math.sin(rY); + const nz = Math.cos(rY); + // Игрок стоит ПЕРЕД лестницей: позиция = центр лестницы по XZ + // + нормаль * (полглубины лестницы + полширины игрока). + const standOff = (ladder.sz || 0.25) / 2 + this.HALF_D + 0.05; + this._pos.x = ladder.x + nx * standOff; + this._pos.z = ladder.z + nz * standOff; + // Повернуть лицом К лестнице (смотрит против нормали). + // climb_up-клип сам разворачивает Hips на 180°, поэтому модель + // доворачиваем на +π, чтобы персонаж смотрел на перекладины. + const faceYaw = Math.atan2(-nx, -nz); + this._yaw = faceYaw; // камера смотрит на лестницу + this._modelYaw = faceYaw + Math.PI; // +180° компенсация анимации + this._ladderMoving = null; // сброс — climb-анимация стартует заново + } + // Пока в ladder-mode: обновляем ссылку на лестницу если ещё касаемся. + // (НЕ во время climb_to_top — там своя логика перемещения.) + if (this._ladderMode && !this._climbingTop) { + if (ladder) this._ladderData = ladder; + const ld = this._ladderData; + // Верх лестницы (мировая координата). Поднялись выше — выходим наверх. + const ladderTop = ld ? (ld.y + (ld.sy || 0) / 2) : Infinity; + // Гистерезис выхода: НЕ выходим по мгновенному !ladder (детект + // нестабилен на грани AABB → мигание climb↔walk каждый кадр). + // Выходим только если игрок РЕАЛЬНО отошёл по XZ от сохранённой + // лестницы (> половины ширины + запас). + let farFromLadder = false; + if (ld) { + const dx = this._pos.x - ld.x; + const dz = this._pos.z - ld.z; + const distXZ = Math.hypot(dx, dz); + const exitDist = Math.max(ld.sx || 1, ld.sz || 0.25) / 2 + this.HALF_D + 0.6; + farFromLadder = distXZ > exitDist; + } else { + farFromLadder = true; + } + // Space → отпрыг назад + выход. + if (c.has('Space')) { + this._ladderMode = false; + this._ladderData = null; + this._vy = 5; + this._jumpHeld = true; + } else if (farFromLadder) { + // Реально отошли от лестницы — выходим (гравитация включится). + this._ladderMode = false; + this._ladderData = null; + } else { + // Лазание: гравитация отключена, A/D заблокированы. + // Вертикальное движение задаём через _vy (climb-скорость), + // чтобы moveAABB обработал коллизию корректно. Прямое + // _pos.y += не годилось: персонаж стоит на земле, и moveAABB + // снапил его обратно (онГраунд держал внизу). + moveX = 0; + moveZ = 0; + if (wantUp) this._vy = this.CLIMB_SPEED; + else if (wantDown) this._vy = -this.CLIMB_SPEED; + else this._vy = 0; + // Достигли верха лестницы И лезем вверх → запускаем переход + // climb_to_top (вылезание на площадку, 4с one-shot). Управление + // блокируется, физика замораживается, в конце игрок ставится + // на площадку над лестницей. + if (this._pos.y + this.HALF_H > ladderTop - 0.3 && wantUp + && !this._climbingTop) { + this._climbingTop = true; + this._climbingTopUntil = Date.now() + 4000; + this._vy = 0; + // Куда вылезти: вперёд (по нормали от лестницы, внутрь + // площадки) + на верх лестницы. + const ldd = this._ladderData; + const rY = (ldd?.rotationY || 0) * Math.PI / 180; + // Нормаль фронта (откуда лез) — игрок перед лестницей. + // Площадка — за лестницей (противоположная сторона). + const fnx = Math.sin(rY), fnz = Math.cos(rY); + const fwd = (ldd?.sz || 0.25) / 2 + this.HALF_D + 0.4; + this._climbTopStart = { x: this._pos.x, y: this._pos.y, z: this._pos.z }; + this._climbTopEnd = { + x: ldd.x - fnx * fwd, // на другую сторону лестницы + y: ladderTop + this.HALF_H, // на верх + z: ldd.z - fnz * fwd, + }; + } + } + } + // === Вертикальное === - if (inWater) { + if (this._ladderMode) { + // На лестнице гравитация НЕ применяется — _vy уже выставлен + // (=CLIMB_SPEED вверх / -CLIMB_SPEED вниз / 0 на месте) выше, + // moveAABB применит его с коллизией. + } else if (inWater) { // Плавание: лёгкая гравитация + плавучесть к поверхности const buoyancy = submerged ? 6 : 0; const swimGravity = -3; @@ -2948,10 +3107,15 @@ export class PlayerController { // PERF-METRICS: замер физики игрока const _pt0 = performance.now(); - const result = this.physics.moveAABB( - this._pos, this.HALF_W, this.HALF_H, this.HALF_D, - moveX, this._vy * dt, moveZ - ); + // Во время climb_to_top физику пропускаем — _pos двигается lerp'ом + // вручную (вылезание на площадку), коллизия не нужна. + const result = this._climbingTop + ? { x: this._pos.x, y: this._pos.y, z: this._pos.z, + onGround: false, hitY: false, surfaceFollowed: false } + : this.physics.moveAABB( + this._pos, this.HALF_W, this.HALF_H, this.HALF_D, + moveX, this._vy * dt, moveZ + ); const _bs = this._scene3d || this.scene3d; if (_bs && _bs._perfMetrics) { _bs._perfMetrics.physics_ms_sum += performance.now() - _pt0; @@ -3249,10 +3413,26 @@ export class PlayerController { ); // Поворот модели: + // - на лестнице: лицом К лестнице, yaw зафиксирован при входе. // - на суше: направление РЕАЛЬНОГО движения (как было). // - в воде: направление КАМЕРЫ (yaw игрока). Стрейф A/D просто // двигает тело вбок без вращения, как на суше при first-person. - if (inWater) { + if (this._climbingTop) { + // climb_to_top: модель смотрит В сторону площадки (куда вылазит). + // Эта анимация имеет другую ориентацию Hips чем climb_up, + // поэтому БЕЗ +π компенсации — иначе развёрнута на 180°. + if (this._climbTopStart && this._climbTopEnd) { + const dx = this._climbTopEnd.x - this._climbTopStart.x; + const dz = this._climbTopEnd.z - this._climbTopStart.z; + if (Math.abs(dx) > 0.001 || Math.abs(dz) > 0.001) { + this._modelYaw = Math.atan2(dx, dz); + } + } + } else if (this._ladderMode) { + // _modelYaw уже выставлен при входе в ladder-mode (лицом к лестнице). + // Анимация climb_up даёт ~180° поворот Hips → персонаж лицом к + // перекладинам. Ничего не доворачиваем. + } else if (inWater) { const targetYaw = this._yaw; let diff = targetYaw - this._modelYaw; while (diff > Math.PI) diff -= Math.PI * 2; @@ -3355,6 +3535,38 @@ export class PlayerController { if (this._mixamoAnimator) { let mState; const now = Date.now(); + // climb_to_top — вылезание на площадку (приоритет над всем). + if (this._climbingTop) { + this._mixamoAnimator.setState('climb_to_top'); + return; + } + // Лазание по лестнице имеет приоритет над всеми анимациями. + // climb_up — движется вверх (W), climb_down — вниз (S), + // на месте на лестнице — анимация продолжает играть циклично + // (НЕ паузим: g.pause() останавливал обновление скелета → + // bounding box не обновлялся → frustum culling прятал скин). + if (this._ladderMode) { + const climbUp = this._codes.has('KeyW') || this._codes.has('ArrowUp'); + const climbDown = this._codes.has('KeyS') || this._codes.has('ArrowDown'); + const moving = climbUp || climbDown; + // Меняем state ТОЛЬКО при реальном движении. На месте держим + // текущую анимацию (не дёргаем setState — это убирает мигание + // climb_up↔climb_down и исчезание скина). + if (climbUp) this._mixamoAnimator.setState('climb_up'); + else if (climbDown) this._mixamoAnimator.setState('climb_down'); + // play/pause трогаем ТОЛЬКО при смене режима движения (как в jump). + if (moving !== this._ladderMoving) { + this._ladderMoving = moving; + try { + const g = this._mixamoAnimator._currentGroup; + if (g) { + if (moving) g.play(true); // возобновить (снять паузу) + else g.pause(); // заморозить позу + } + } catch (e) {} + } + return; + } const inCrouchTransition = this._crouchTransitionUntil && now < this._crouchTransitionUntil; // 3-фазная анимация прыжка. Выбираем семейство фаз по _jumpKind: diff --git a/src/engine/PrimitiveManager.js b/src/engine/PrimitiveManager.js index 6c29f4d..fb1d4b1 100644 --- a/src/engine/PrimitiveManager.js +++ b/src/engine/PrimitiveManager.js @@ -38,6 +38,11 @@ const STUDS_DIFFUSE_URL = '/kubikon-assets/materials/studs_v4_diffuse.png'; const STUDS_NORMAL_URL = '/kubikon-assets/materials/studs_v4_normal.png'; const STUD_UNIT = 1; const STUDS_GRID = 4; + +// Вертикальный шаг между ступеньками лестницы (юниты). Полная высота +// лестницы = stepCount * LADDER_STEP_SPACING. +export const LADDER_STEP_SPACING = 0.45; + const _studsTexCache = new WeakMap(); function _getStudsTextures(scene) { let c = _studsTexCache.get(scene); @@ -114,8 +119,15 @@ export class PrimitiveManager { id = this._nextId++; } const sx = opts.sx ?? typeDef.defaultScale.x; - const sy = opts.sy ?? typeDef.defaultScale.y; + let sy = opts.sy ?? typeDef.defaultScale.y; const sz = opts.sz ?? typeDef.defaultScale.z; + // Лестница: высота ДЕРИВИРУЕТСЯ из stepCount (а не из sy) — даёт + // корректный AABB для детекта касания и совпадает с геометрией меша. + const isLadder = typeDef.id === 'ladder_vertical'; + const stepCount = isLadder + ? Math.max(2, Math.min(40, Math.round(opts.stepCount != null ? opts.stepCount : 8))) + : undefined; + if (isLadder) sy = stepCount * LADDER_STEP_SPACING; const color = opts.color ?? typeDef.defaultColor; // GD-сущности (порталы/шипы/финиш/монеты) — neon (светятся) и без коллизии физики. // Логику касания обрабатывает GdPortalManager/GdLevelManager по дистанции. @@ -126,8 +138,10 @@ export class PrimitiveManager { const material = opts.material ?? (isGlowingGd ? 'neon' : 'matte'); // studDensity — плотность кружков studs (1=стандарт, >1 мельче/чаще). const studDensity = Number.isFinite(opts.studDensity) ? opts.studDensity : 1; - // canCollide: для всех GD-сущностей и шипов — false (игрок проходит сквозь, логика по дистанции) - const canCollide = opts.canCollide !== false && !isGdKind && !isGdSpike; + // canCollide: для всех GD-сущностей и шипов — false (игрок проходит сквозь, логика по дистанции). + // Лестница — тоже проходима (canCollide=false), чтобы игрок мог войти в её + // объём и лезть (ladder-mode в PlayerController по детекту касания). + const canCollide = opts.canCollide !== false && !isGdKind && !isGdSpike && !isLadder; const visible = opts.visible !== false; const anchored = opts.anchored !== false; // по умолчанию заякорен // Масса по умолчанию = объём (sx*sy*sz), округлённый до 2 знаков. @@ -143,7 +157,11 @@ export class PrimitiveManager { const rotationY = opts.rotationY ?? 0; const rotationZ = opts.rotationZ ?? 0; + // Передаём stepCount в builder через временное поле (читается в + // _buildLadderMesh внутри _createMeshForType). + this._ladderStepCount = stepCount; const mesh = this._createMeshForType(typeDef, id, sx, sy, sz, material, studDensity); + this._ladderStepCount = undefined; mesh.position = new Vector3(x, y, z); mesh.rotation = new Vector3(rotationX, rotationY, rotationZ); mesh.isPickable = true; @@ -169,6 +187,8 @@ export class PrimitiveManager { rotationX, rotationY, rotationZ, color, material, canCollide, visible, anchored, mass, textureAsset, studDensity, + // Лестница: число ступенек (высота). undefined для прочих. + ...(isLadder ? { stepCount } : {}), // locked — объект защищён от выделения/перемещения в редакторе // (Фаза 5.11). На геймплей не влияет. locked: opts.locked === true, @@ -305,6 +325,10 @@ export class PrimitiveManager { return this._buildWedgeMesh(name, sx, sy, sz); case 'cornerwedge': return this._buildCornerWedgeMesh(name, sx, sy, sz); + case 'ladder_vertical': + // Лестница строится из stepCount ступенек — высота зависит от + // количества ступенек, а не от sy. + return this._buildLadderMesh(name, sx, sz, this._ladderStepCount || 8); default: return MeshBuilder.CreateBox(name, { width: sx, height: sy, depth: sz }, this.scene); @@ -452,6 +476,43 @@ export class PrimitiveManager { return mesh; } + /** + * Вертикальная лестница: 2 боковые стойки + N перекладин (ступенек). + * Полная высота = stepCount * LADDER_STEP_SPACING. При изменении stepCount + * лестница ПЕРЕСТРАИВАЕТСЯ. Меш центрирован по (0,0,0) как CreateBox. + * sx — ширина, sz — глубина стоек/перекладин. + */ + _buildLadderMesh(name, sx, sz, stepCount) { + const n = Math.max(2, Math.min(40, Math.round(stepCount || 8))); + const SPACING = LADDER_STEP_SPACING; + const height = n * SPACING; + const railW = Math.min(0.12, sx * 0.12); + const railD = Math.max(0.06, sz); + const rungH = Math.min(0.1, SPACING * 0.3); + const halfH = height / 2; + const railX = sx / 2 - railW / 2; + const parts = []; + const railL = MeshBuilder.CreateBox(name + '_railL', + { width: railW, height, depth: railD }, this.scene); + railL.position.x = -railX; + parts.push(railL); + const railR = MeshBuilder.CreateBox(name + '_railR', + { width: railW, height, depth: railD }, this.scene); + railR.position.x = railX; + parts.push(railR); + const rungWidth = sx - railW; + for (let i = 0; i < n; i++) { + const y = -halfH + SPACING * (i + 0.5); + const rung = MeshBuilder.CreateBox(name + '_rung' + i, + { width: rungWidth, height: rungH, depth: railD }, this.scene); + rung.position.y = y; + parts.push(rung); + } + const merged = Mesh.MergeMeshes(parts, true, true, undefined, false, true); + if (merged) { merged.name = name; return merged; } + return MeshBuilder.CreateBox(name, { width: sx, height, depth: sz }, this.scene); + } + /** Применить цвет и материал. */ _applyMaterial(mesh, typeDef, color, material, textureUrl) { const matName = `${mesh.name}_mat`; @@ -695,6 +756,14 @@ export class PrimitiveManager { data.studDensity = Number.isFinite(patch.studDensity) ? patch.studDensity : 1; scaleChanged = true; } + // Лестница: смена числа ступенек → пересборка меша. Высота (sy) + // деривируется из stepCount, поэтому AABB касания остаётся корректным. + if (patch.stepCount !== undefined && data.type === 'ladder_vertical') { + const sc = Math.max(2, Math.min(40, Math.round(patch.stepCount))); + data.stepCount = sc; + data.sy = sc * LADDER_STEP_SPACING; + scaleChanged = true; + } if (scaleChanged) { // Поскольку MeshBuilder уже создал mesh с базовыми размерами, // изменения через scaling кажутся правильными. Простой способ — @@ -828,7 +897,10 @@ export class PrimitiveManager { const oldMat = oldMesh.material; const typeDef = getPrimitiveType(data.type); + // Лестница: передаём актуальный stepCount в builder. + this._ladderStepCount = data.stepCount; const newMesh = this._createMeshForType(typeDef, data.id, data.sx, data.sy, data.sz, data.material, data.studDensity); + this._ladderStepCount = undefined; newMesh.position = oldPos; if (oldRot) newMesh.rotation = oldRot; // studs — материал пересоздаём заново (свежий faceUV/тайлинг). Иначе перенос. @@ -904,6 +976,8 @@ export class PrimitiveManager { ...(d.light ? { brightness: d.brightness, range: d.range } : {}), // Параметр эмиттера (только для type='emitter') ...(d.effect !== undefined ? { effect: d.effect } : {}), + // Число ступенек лестницы (только для type='ladder_vertical') + ...(d.type === 'ladder_vertical' ? { stepCount: d.stepCount } : {}), // Параметры билборда (только для type='billboard') ...(d.billboard ? { template: d.billboard.template, diff --git a/src/engine/PrimitiveTypes.js b/src/engine/PrimitiveTypes.js index cdbafe8..4b8cffb 100644 --- a/src/engine/PrimitiveTypes.js +++ b/src/engine/PrimitiveTypes.js @@ -66,6 +66,13 @@ export const PRIMITIVE_TYPES = [ { id: 'billboard', name: '3D-табличка', icon: 'prim-billboard', kind: 'billboard', defaultScale: { x: 2, y: 1.2, z: 0.05 }, defaultColor: '#1a1a2e' }, + // === Вертикальная лестница — по ней можно лазить вверх/вниз === + // Высота настраивается параметром stepCount (количество ступенек). + // При изменении stepCount лестница перестраивается (НЕ растягивается модель, + // а добавляются/убираются ступеньки). Касание → ladder-mode в PlayerController. + { id: 'ladder_vertical', name: 'Лестница (вертикальная)', icon: 'prim-ladder', kind: 'ladder', + defaultScale: { x: 1, y: 4, z: 0.12 }, defaultColor: '#a8743a' }, + // === GD-порталы (Geometry Dash 2.0) — переключают гейммод игрока === // Все цилиндры-«столбы» с neon-материалом. Цвета и gdMode задают режим. { id: 'gd_cube', name: 'Портал: Куб', icon: 'prim-portal', kind: 'gd_portal', gdMode: 'cube', @@ -96,7 +103,7 @@ export const PRIMITIVE_TYPES = [ /** Категории для группировки в палитре. */ export const PRIMITIVE_CATEGORIES = [ { id: 'geometry', name: 'Фигуры', ids: ['cube','sphere','cylinder','cone','plane','torus','wedge','cornerwedge'] }, - { id: 'gameplay', name: 'Геймплей', ids: ['trigger', 'checkpoint', 'light', 'emitter', 'billboard'] }, + { id: 'gameplay', name: 'Геймплей', ids: ['trigger', 'checkpoint', 'light', 'emitter', 'billboard', 'ladder_vertical'] }, { id: 'gd-portals', name: 'GD-порталы', ids: ['gd_cube','gd_ship','gd_ball','gd_ufo','gd_wave','gd_robot'] }, { id: 'gd-objects', name: 'GD-объекты', ids: ['gd_spike','gd_finish','gd_coin'] }, ]; From 71139def77430d8ecff3cc680476d568b07bbe9d Mon Sep 17 00:00:00 2001 From: min Date: Mon, 15 Jun 2026 00:19:36 +0300 Subject: [PATCH 6/6] =?UTF-8?q?fix(skin):=20=D0=B2=D0=B0=D0=BB=D0=B8=D0=B4?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F=20=D1=81=D0=BA=D0=B8=D0=BD=D0=B0=20?= =?UTF-8?q?=D0=B8=D0=B7=20=D0=91=D0=94=20=E2=80=94=20fallback=20=D0=BD?= =?UTF-8?q?=D0=B0=20y-bot=20=D0=B4=D0=BB=D1=8F=20legacy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Бэкенд отдаёт skin_bacon-hair как дефолт (22+ юзеров в БД с legacy R15), которого больше нет. Теперь если скин не в MIXAMO_SKINS (80 валидных) и не customskin: → fallback на skin_y-bot. Персонаж всегда загружается. Co-Authored-By: Claude Opus 4.7 --- src/KubikonPlayer/KubikonPlayer.jsx | 11 +++++++++++ src/engine/PlayerController.js | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/KubikonPlayer/KubikonPlayer.jsx b/src/KubikonPlayer/KubikonPlayer.jsx index 77c6647..c49fd70 100644 --- a/src/KubikonPlayer/KubikonPlayer.jsx +++ b/src/KubikonPlayer/KubikonPlayer.jsx @@ -4,6 +4,7 @@ import { jwtDecode } from 'jwt-decode'; import { Client } from 'colyseus.js'; import * as Kubikon3DApi from '../api/Kubikon3DService'; import { BabylonScene } from '../engine/BabylonScene'; +import { MIXAMO_SKINS } from '../engine/PlayerController'; import { attachConsoleHook, devlogReset } from '../engine/devlog'; import { MultiplayerSync } from '../engine/MultiplayerSync'; import { REALTIME_WS } from '../api/API'; @@ -669,6 +670,16 @@ const KubikonPlayer = () => { } } catch (e) {} } + // ВАЛИДАЦИЯ: если скин не из валидного набора Mixamo-скинов + // (legacy bacon-hair/sigma-labubu/cop и пр. — их больше нет, + // или бэкенд вернул дефолтный bacon) — fallback на skin_y-bot. + // Это защита: персонаж не должен пытаться загрузить несуществующий + // скин. См. БД rublox_equipped_skin (22+ юзеров с bacon-hair). + if (mySkin && !MIXAMO_SKINS.has(mySkin) + && !mySkin.startsWith('customskin:')) { + console.log('[KubikonPlayer] skin', mySkin, 'не валиден → skin_y-bot'); + mySkin = 'skin_y-bot'; + } skinFolderRef.current = mySkin; try { scene.setPlayerModelType?.(mySkin); } catch (e) {} diff --git a/src/engine/PlayerController.js b/src/engine/PlayerController.js index 9b11d78..038a565 100644 --- a/src/engine/PlayerController.js +++ b/src/engine/PlayerController.js @@ -38,7 +38,7 @@ import { AccessoryManager } from './AccessoryManager'; * 2026-06-11: эти 80 ID перенесены сюда из data/skinsCatalog.js фронта * чтобы плеер их распознавал и грузил по правильному пути. * Дефолтные: skin_x-bot (male), skin_y-bot (female/null). */ -const MIXAMO_SKINS = new Set([ +export const MIXAMO_SKINS = new Set([ 'skin_aj', 'skin_akai', 'skin_arissa', 'skin_big-vegas', 'skin_castle-guard-1', 'skin_castle-guard-2', 'skin_ch01', 'skin_ch02', 'skin_ch03', 'skin_ch04', 'skin_ch07', 'skin_ch08',