Compare commits

..

6 Commits

Author SHA1 Message Date
min
4913da49ff Merge pull request 'fix(skin): ��������� ����� � Play-������ ��������� (bacon > y-bot)' (#44) from fix/studio-skin-validation-2026-06-15 into main
All checks were successful
CI / Lint (push) Successful in 1m13s
CI / Build (push) Successful in 2m1s
CI / Secret scan (push) Successful in 24s
CI / PR size check (push) Has been skipped
CI / Deploy to S1 + S2 (push) Successful in 3m28s
2026-06-15 08:52:07 +00:00
min
e6cfcad2c0 fix(skin): валидация скина в Play-режиме редактора (legacy bacon → y-bot)
All checks were successful
CI / Lint (pull_request) Successful in 1m4s
CI / Build (pull_request) Successful in 1m57s
CI / Secret scan (pull_request) Successful in 23s
CI / PR size check (pull_request) Successful in 8s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
KubikonEditor подмешивал skin_folder из БД в hash без проверки.
Для ~22 legacy-юзеров БД отдаёт skin_bacon-hair (модель удалена) →
в Play-режиме студии играл бекон. Теперь невалидный скин (не в
MIXAMO_SKINS и не customskin:) подменяется на skin_y-bot, как в
плеере (KubikonPlayer/GameMenu) и кабинете. Дефолты bacon → y-bot.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 11:47:10 +03:00
min
ade30363c8 feat(studio): 3-������ ������ + ������������ �������� (#43)
All checks were successful
CI / Lint (push) Successful in 1m6s
CI / Build (push) Successful in 2m0s
CI / Secret scan (push) Successful in 23s
CI / PR size check (push) Has been skipped
CI / Deploy to S1 + S2 (push) Successful in 3m31s
2026-06-14 21:31:17 +00:00
min
1fb15eb87b feat(studio): вертикальная лестница ladder_vertical + лазание
All checks were successful
CI / Lint (pull_request) Successful in 1m5s
CI / Build (pull_request) Successful in 2m4s
CI / Secret scan (pull_request) Successful in 28s
CI / PR size check (pull_request) Successful in 8s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
- новый примитив ladder_vertical с параметром stepCount (высота в ступенях)
- при смене stepCount лестница перестраивается (не растягивается модель)
- ladder-mode в PlayerController: W/S вверх-вниз, gravity off, Space отпрыг
- анимации climb_up/climb_down (лазание) + climb_to_top (вылезание наверх 4с)
- предзагрузка climb-анимаций (нет дёрга на 180° при входе)
- заморозка анимации на месте без исчезания скина (play/pause по moving)
- toolbox kit «Лестница (лазание)», иконка prim-ladder, Inspector-слайдер
- толщина лестницы defaultScale.z 0.12

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-14 23:46:53 +03:00
min
897dc08d3e feat(studio): 3-фазные прыжки в редакторе (перенос из плеера)
- MixamoAnimator обновлён: jump_anticipate/air/land, jump_fwd_*, jump_run_*
- PlayerController: _jumpKind (in_place/forward/run), anticipate-фаза с
  отложенным импульсом, coyote-фильтр спуска по лестнице (microAir)
- студия теперь анимирует прыжки 1-в-1 как плеер

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-14 22:18:24 +03:00
min
396ef08c59 fix(skin): cache-bust character-assets URLs (#42)
All checks were successful
CI / Lint (push) Successful in 1m7s
CI / Build (push) Successful in 2m5s
CI / Secret scan (push) Successful in 26s
CI / PR size check (push) Has been skipped
CI / Deploy to S1 + S2 (push) Successful in 3m31s
2026-06-14 13:54:19 +00:00
9 changed files with 604 additions and 30 deletions

View File

@ -254,6 +254,8 @@ const GLYPHS = {
'prim-trigger': () => (<><path d="M12 3l8 4.5v9L12 21l-8-4.5v-9z" {...S} strokeDasharray="3 3"/><path d="M4 7.5l8 4.5 8-4.5M12 12v9" {...S} strokeDasharray="3 3"/></>), 'prim-trigger': () => (<><path d="M12 3l8 4.5v9L12 21l-8-4.5v-9z" {...S} strokeDasharray="3 3"/><path d="M4 7.5l8 4.5 8-4.5M12 12v9" {...S} strokeDasharray="3 3"/></>),
'prim-checkpoint': () => (<><path d="M6 21V4" {...S}/><path d="M6 4h6v3h6v3h-6V7H6" {...S}/><path d="M6 12h12v3h-6v-3" {...S}/></>), 'prim-checkpoint': () => (<><path d="M6 21V4" {...S}/><path d="M6 4h6v3h6v3h-6V7H6" {...S}/><path d="M6 12h12v3h-6v-3" {...S}/></>),
'prim-light': () => (<><path d="M8 14a6 6 0 1 1 8 0c-1 1-1.3 1.7-1.5 3h-5c-0.2-1.3-0.5-2-1.5-3z" {...S}/><path d="M9.5 20h5M10 22h4" {...S}/></>), 'prim-light': () => (<><path d="M8 14a6 6 0 1 1 8 0c-1 1-1.3 1.7-1.5 3h-5c-0.2-1.3-0.5-2-1.5-3z" {...S}/><path d="M9.5 20h5M10 22h4" {...S}/></>),
// Вертикальная лестница: две стойки + перекладины
'prim-ladder': () => (<><path d="M8 3v18M16 3v18" {...S}/><path d="M8 7h8M8 11h8M8 15h8M8 19h8" {...S}/></>),
'prim-emitter': () => (<><circle cx="12" cy="12" r="3" {...S}/><path d="M12 3v3M12 18v3M3 12h3M18 12h3M5.5 5.5l2 2M16.5 16.5l2 2M18.5 5.5l-2 2M5.5 18.5l2-2" {...S}/></>), 'prim-emitter': () => (<><circle cx="12" cy="12" r="3" {...S}/><path d="M12 3v3M12 18v3M3 12h3M18 12h3M5.5 5.5l2 2M16.5 16.5l2 2M18.5 5.5l-2 2M5.5 18.5l2-2" {...S}/></>),
// Табличка с GUI: прямоугольник с заголовком, иконкой-кружком и кнопкой // Табличка с GUI: прямоугольник с заголовком, иконкой-кружком и кнопкой
'prim-billboard': () => (<><rect x="3" y="5" width="18" height="14" rx="2" {...S}/><circle cx="7" cy="11" r="2" {...S}/><path d="M10.5 9h6M10.5 12h4" {...S}/><rect x="14" y="14.5" width="5" height="3" rx="1" {...S}/></>), 'prim-billboard': () => (<><rect x="3" y="5" width="18" height="14" rx="2" {...S}/><circle cx="7" cy="11" r="2" {...S}/><path d="M10.5 9h6M10.5 12h4" {...S}/><rect x="14" y="14.5" width="5" height="3" rx="1" {...S}/></>),

View File

@ -328,6 +328,7 @@ const InspectorPanel = ({
const [localTint, setLocalTint] = useState(''); const [localTint, setLocalTint] = useState('');
const [localBrightness, setLocalBrightness] = useState(1.5); const [localBrightness, setLocalBrightness] = useState(1.5);
const [localRange, setLocalRange] = useState(12); const [localRange, setLocalRange] = useState(12);
const [localStepCount, setLocalStepCount] = useState(8);
// Синхронизируем локальное состояние когда меняется selection // Синхронизируем локальное состояние когда меняется selection
useEffect(() => { useEffect(() => {
@ -374,6 +375,8 @@ const InspectorPanel = ({
// Параметры лампы // Параметры лампы
setLocalBrightness(selection.brightness ?? 1.5); setLocalBrightness(selection.brightness ?? 1.5);
setLocalRange(selection.range ?? 12); setLocalRange(selection.range ?? 12);
// Параметр лестницы число ступенек (высота).
setLocalStepCount(selection.stepCount ?? 8);
} }
}, [selection]); }, [selection]);
@ -2015,6 +2018,29 @@ const InspectorPanel = ({
</div> </div>
)} )}
{/* Лестница — число ступенек (высота). При изменении лестница перестраивается. */}
{primitiveType?.kind === 'ladder' && (
<div className={cl.section}>
<div className={cl.sectionTitle}><Icon name="arrow-up" size={12} /> Лестница</div>
<div style={{ padding: '4px 0' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, marginBottom: 4 }}>
<span>Высота (ступенек)</span>
<span style={{ opacity: 0.6 }}>{Math.round(localStepCount)}</span>
</div>
<input
type="range" min="2" max="30" step="1"
value={localStepCount}
onChange={(e) => {
const v = parseInt(e.target.value, 10);
setLocalStepCount(v);
onSetPrimitiveProps?.({ stepCount: v });
}}
style={{ width: '100%' }}
/>
</div>
</div>
)}
{/* Эмиттер частиц — выбор эффекта + цвет */} {/* Эмиттер частиц — выбор эффекта + цвет */}
{primitiveType?.kind === 'emitter' && ( {primitiveType?.kind === 'emitter' && (
<div className={cl.section}> <div className={cl.section}>

View File

@ -4,6 +4,7 @@ import { jwtDecode } from 'jwt-decode';
import { useAuth, redirectToLogin } from '../auth/AuthContext.jsx'; import { useAuth, redirectToLogin } from '../auth/AuthContext.jsx';
import { useSanctions } from '../auth/SanctionsContext.jsx'; import { useSanctions } from '../auth/SanctionsContext.jsx';
import { BabylonScene } from './engine/BabylonScene'; import { BabylonScene } from './engine/BabylonScene';
import { MIXAMO_SKINS } from './engine/PlayerController';
import { StudioCollab } from './engine/StudioCollab'; import { StudioCollab } from './engine/StudioCollab';
import { CollabOverlay } from './engine/CollabOverlay'; import { CollabOverlay } from './engine/CollabOverlay';
import { BLOCK_TYPES, BLOCK_CATEGORIES, blockPreview, registerCustomBlockType } from './engine/BlockTypes'; import { BLOCK_TYPES, BLOCK_CATEGORIES, blockPreview, registerCustomBlockType } from './engine/BlockTypes';
@ -850,8 +851,15 @@ const KubikonEditor = () => {
x: px + (p.x || 0), y: py + (p.y != null ? p.y : 1), z: pz + (p.z || 0), x: px + (p.x || 0), y: py + (p.y != null ? p.y : 1), z: pz + (p.z || 0),
sx: p.sx, sy: p.sy, sz: p.sz, sx: p.sx, sy: p.sy, sz: p.sz,
color: p.color, material: p.material, color: p.color, material: p.material,
canCollide: p.canCollide !== false, visible: p.visible !== false, anchored: true, // canCollide: явный false уважаем; для лестницы оставляем
// undefined addInstance применит свой дефолт (false, чтобы
// в неё можно было войти и лезть). Для прочих true.
canCollide: p.canCollide === false ? false
: (p.type === 'ladder_vertical' ? undefined : true),
visible: p.visible !== false, anchored: true,
name: p.name, name: p.name,
// stepCount высота лестницы (только для ladder_vertical).
...(p.stepCount != null ? { stepCount: p.stepCount } : {}),
}); });
if (newId != null) { if (newId != null) {
createdIds.push(newId); createdIds.push(newId);
@ -931,7 +939,7 @@ const KubikonEditor = () => {
// === Game settings inline в TopRibbon (вкладка Тест) === // === Game settings inline в TopRibbon (вкладка Тест) ===
// Дефолт R15-скин bacon-hair (классический Roblox-вид). // Дефолт R15-скин bacon-hair (классический Roblox-вид).
const [playerModelType, setPlayerModelTypeUI] = useState('skin_bacon-hair'); const [playerModelType, setPlayerModelTypeUI] = useState('skin_y-bot');
const [envPreset, setEnvPresetUI] = useState('day'); const [envPreset, setEnvPresetUI] = useState('day');
const [dayDurationMin, setDayDurationMinUI] = useState(5); const [dayDurationMin, setDayDurationMinUI] = useState(5);
const [nightDurationMin, setNightDurationMinUI] = useState(3); const [nightDurationMin, setNightDurationMinUI] = useState(3);
@ -957,7 +965,7 @@ const KubikonEditor = () => {
genre: 'other', genre: 'other',
thumbnail: '', thumbnail: '',
is_public: false, is_public: false,
player_model_type: 'skin_bacon-hair', player_model_type: 'skin_y-bot',
}); });
const projectNameRef = useRef(projectName); const projectNameRef = useRef(projectName);
useEffect(() => { projectNameRef.current = projectName; metaRef.current.title = projectName; }, [projectName]); useEffect(() => { projectNameRef.current = projectName; metaRef.current.title = projectName; }, [projectName]);
@ -1775,7 +1783,7 @@ const KubikonEditor = () => {
sceneRef.current.history?.initialize(); sceneRef.current.history?.initialize();
// Синхронизируем UI-state TopRibbon из загруженной сцены // Синхронизируем UI-state TopRibbon из загруженной сцены
try { try {
setPlayerModelTypeUI(sceneRef.current.getPlayerModelType?.() || 'skin_bacon-hair'); setPlayerModelTypeUI(sceneRef.current.getPlayerModelType?.() || 'skin_y-bot');
const env = sceneRef.current.getEnvironmentState?.(); const env = sceneRef.current.getEnvironmentState?.();
if (env?.preset) setEnvPresetUI(env.preset); if (env?.preset) setEnvPresetUI(env.preset);
if (env?.dayDurationMin) setDayDurationMinUI(env.dayDurationMin); if (env?.dayDurationMin) setDayDurationMinUI(env.dayDurationMin);
@ -2086,7 +2094,16 @@ const KubikonEditor = () => {
const uid = getCurrentUserId(); const uid = getCurrentUserId();
if (uid) { if (uid) {
const r = await Kubikon3DApi.getEquippedSkin(uid); const r = await Kubikon3DApi.getEquippedSkin(uid);
const sf = r?.data?.skin_folder; let sf = r?.data?.skin_folder;
// ВАЛИДАЦИЯ: legacy R15-скины (bacon-hair и пр.) больше
// не существуют. Если БД отдала невалидный подменяем
// на skin_y-bot (как в плеере и кабинете).
if (sf && typeof sf === 'string'
&& !MIXAMO_SKINS.has(sf)
&& !sf.startsWith('customskin:')) {
console.log('[KubikonEditor] skin', sf, 'не валиден → skin_y-bot');
sf = 'skin_y-bot';
}
if (sf && typeof sf === 'string') { if (sf && typeof sf === 'string') {
// Подмешиваем в hash так чтобы не сломать ticket=... // Подмешиваем в hash так чтобы не сломать ticket=...
const cur = window.location.hash || ''; const cur = window.location.hash || '';

View File

@ -35,6 +35,13 @@ export const GAMEPLAY_KITS = [
game.onKey('shift', () => game.player.setSpeed(1.8)); game.onKey('shift', () => game.player.setSpeed(1.8));
game.onKeyUp('shift', () => game.player.setSpeed(1.0));` }], game.onKeyUp('shift', () => game.player.setSpeed(1.0));` }],
}, },
{
id: 'ladder-climb',
name: 'Лестница (лазание)',
desc: 'Вертикальная лестница — подойди и жми W чтобы лезть вверх, S — вниз, Space — спрыгнуть. Высота настраивается параметром в свойствах.',
icon: 'arrow-up', category: 'movement',
prims: [{ type: 'ladder_vertical', x: 0, y: 2, z: 0, stepCount: 8, color: '#a8743a', name: 'Лестница' }],
},
{ {
id: 'double-jump', id: 'double-jump',
name: 'Двойной прыжок', name: 'Двойной прыжок',

View File

@ -51,10 +51,13 @@ 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",
"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", "hit_react", "die_forward", "die_back",
"punch_left", "kick_low", "kick_high", "punch_left", "kick_low", "kick_high",
"gun_fire", "gun_reload", "rifle_walk", "gun_fire", "gun_reload", "rifle_walk",
@ -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,8 +267,54 @@ 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") {
// 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; 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,7 +334,11 @@ 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",
"climb_to_top",
"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",
"gun_fire", "gun_reload", "sword_slash", "gun_fire", "gun_reload", "sword_slash",
@ -315,12 +380,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,18 +451,33 @@ 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 (_) {}
} }
// Кросс-фейд через weight (0→1 у новой, 1→0 у старой) за BLEND_MS. // Кросс-фейд через 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 (_) {} try { next.setWeightForAllAnimatables(0); } catch (_) {}
// Снимаем ВСЕ предыдущие blend-observers — rapid-switching // Снимаем ВСЕ предыдущие blend-observers — rapid-switching
// (Ctrl on/off с интервалом 50ms) оставлял несколько ticker'ов. // (Ctrl on/off с интервалом 50ms) оставлял несколько ticker'ов.
@ -461,6 +566,12 @@ export class MixamoAnimator {
group.onAnimationGroupEndObservable.addOnce(onEnd); group.onAnimationGroupEndObservable.addOnce(onEnd);
} }
/** Тихая предзагрузка анимации в кэш (БЕЗ проигрывания). Нужно чтобы
* при первом setState анимация уже была готова (нет дёрга от walk). */
preload(name) {
try { _ensureLoaded(this.scene, name); } catch (e) {}
}
/** Babylon сам тикает AnimationGroup, но оставим для интерфейса. */ /** Babylon сам тикает AnimationGroup, но оставим для интерфейса. */
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line no-unused-vars
update(dt) { /* noop */ } update(dt) { /* noop */ }

View File

@ -1192,4 +1192,24 @@ export class PhysicsAABB {
} }
return out; 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;
}
} }

View File

@ -32,7 +32,7 @@ import { MixamoAnimator, loadMixamoAnimations } from './MixamoAnimator';
// Список всех Mixamo-скинов. Должен совпадать со списком в плеере и // Список всех Mixamo-скинов. Должен совпадать со списком в плеере и
// каталоге сайта (rublox-site/src/data/skinsCatalog.js). // каталоге сайта (rublox-site/src/data/skinsCatalog.js).
const MIXAMO_SKINS = new Set([ export const MIXAMO_SKINS = new Set([
'skin_aj', 'skin_akai', 'skin_arissa', 'skin_big-vegas', 'skin_aj', 'skin_akai', 'skin_arissa', 'skin_big-vegas',
'skin_castle-guard-1', 'skin_castle-guard-2', 'skin_castle-guard-1', 'skin_castle-guard-2',
'skin_ch01', 'skin_ch02', 'skin_ch03', 'skin_ch04', 'skin_ch07', 'skin_ch08', 'skin_ch01', 'skin_ch02', 'skin_ch03', 'skin_ch04', 'skin_ch07', 'skin_ch08',
@ -86,6 +86,12 @@ export class PlayerController {
this._waveMode = false; // GD-гейммод Wave: всегда движение под ±45° (Space зажат — вверх, отпущен — вниз) this._waveMode = false; // GD-гейммод Wave: всегда движение под ±45° (Space зажат — вверх, отпущен — вниз)
this._robotMode = false; // GD-гейммод Robot: высота прыжка = длительность удержания Space (variable-jump) this._robotMode = false; // GD-гейммод Robot: высота прыжка = длительность удержания Space (variable-jump)
this._robotBoostLeft = 0; // оставшееся время boost-фазы (с) this._robotBoostLeft = 0; // оставшееся время boost-фазы (с)
// Лестница (ladder_vertical): когда игрок касается лестницы и жмёт W/S —
// входит в ladder-mode: гравитация отключена, W/S = вверх/вниз по лестнице,
// Space = отпрыг. Выход — наверху лестницы, при отходе или по Space.
this._ladderMode = false;
this._ladderData = null; // data текущей лестницы (для верх/низ/центр)
this.CLIMB_SPEED = 2.5; // скорость лазания вверх/вниз (м/с)
// Кубикон Dash: если > 0 — игрок САМ движется по +X со скоростью X м/с. // Кубикон Dash: если > 0 — игрок САМ движется по +X со скоростью X м/с.
// Ввод (WASD/тач-джойстик) игнорируется в _tick. Прыжок остаётся. // Ввод (WASD/тач-джойстик) игнорируется в _tick. Прыжок остаётся.
this._autoRunSpeed = 0; this._autoRunSpeed = 0;
@ -956,6 +962,14 @@ export class PlayerController {
animator.attach(this.scene, mixSk, root); animator.attach(this.scene, mixSk, root);
animator.setState('idle'); animator.setState('idle');
this._mixamoAnimator = animator; this._mixamoAnimator = animator;
// Предзагрузим climb-анимации заранее (тихо),
// чтобы при первом касании лестницы не было кадра
// walk с climb-поворотом (дёрг на 180°).
try {
animator.preload('climb_up');
animator.preload('climb_down');
animator.preload('climb_to_top');
} catch (e) {}
try { window.__mixamo = animator; } catch (e) {} try { window.__mixamo = animator; } catch (e) {}
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log('[PlayerController] MixamoAnimator готов, скелет=' + mixSk.bones.length + ' bones'); console.log('[PlayerController] MixamoAnimator готов, скелет=' + mixSk.bones.length + ' bones');
@ -2624,8 +2638,154 @@ export class PlayerController {
moveZ *= 0.5; 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 buoyancy = submerged ? 6 : 0;
const swimGravity = -3; const swimGravity = -3;
@ -2709,7 +2869,12 @@ export class PlayerController {
// PERF-METRICS: замер физики игрока // PERF-METRICS: замер физики игрока
const _pt0 = performance.now(); const _pt0 = performance.now();
const result = this.physics.moveAABB( // Во время 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, this._pos, this.HALF_W, this.HALF_H, this.HALF_D,
moveX, this._vy * dt, moveZ moveX, this._vy * dt, moveZ
); );
@ -2853,17 +3018,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) {
@ -2979,10 +3169,25 @@ export class PlayerController {
); );
// Поворот модели: // Поворот модели:
// - на лестнице: лицом К лестнице, yaw зафиксирован при входе.
// - на суше: направление РЕАЛЬНОГО движения (как было). // - на суше: направление РЕАЛЬНОГО движения (как было).
// - в воде: направление КАМЕРЫ (yaw игрока). Стрейф A/D просто // - в воде: направление КАМЕРЫ (yaw игрока).
// двигает тело вбок без вращения, как на суше при first-person. if (this._climbingTop) {
if (inWater) { // 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; const targetYaw = this._yaw;
let diff = targetYaw - this._modelYaw; let diff = targetYaw - this._modelYaw;
while (diff > Math.PI) diff -= Math.PI * 2; while (diff > Math.PI) diff -= Math.PI * 2;
@ -3103,13 +3308,105 @@ export class PlayerController {
if (this._mixamoAnimator) { if (this._mixamoAnimator) {
let mState; let mState;
const now = Date.now(); 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 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 +3420,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;

View File

@ -32,6 +32,11 @@ const STUDS_DIFFUSE_URL = '/kubikon-assets/materials/studs_v4_diffuse.png';
const STUDS_NORMAL_URL = '/kubikon-assets/materials/studs_v4_normal.png'; const STUDS_NORMAL_URL = '/kubikon-assets/materials/studs_v4_normal.png';
const STUD_UNIT = 1; // 1 круглый stud на 1 юнит размера const STUD_UNIT = 1; // 1 круглый stud на 1 юнит размера
const STUDS_GRID = 4; // текстура содержит сетку 4×4 круглых studs const STUDS_GRID = 4; // текстура содержит сетку 4×4 круглых studs
// Вертикальный шаг между ступеньками лестницы (юниты). Полная высота
// лестницы = stepCount * LADDER_STEP_SPACING. Экспортируется, чтобы
// PlayerController мог считать верх лестницы по data.stepCount.
export const LADDER_STEP_SPACING = 0.45;
// Кэш текстур на сцену: чтобы не грузить один и тот же PNG для каждого меша. // Кэш текстур на сцену: чтобы не грузить один и тот же PNG для каждого меша.
// Map<scene, { diffuse: Texture, normal: Texture }>. Каждый меш получает свою // Map<scene, { diffuse: Texture, normal: Texture }>. Каждый меш получает свою
// материал-копию (свой цвет/тайлинг), но текстуры шарятся. // материал-копию (свой цвет/тайлинг), но текстуры шарятся.
@ -146,8 +151,16 @@ export class PrimitiveManager {
id = this._nextId++; id = this._nextId++;
} }
const sx = opts.sx ?? typeDef.defaultScale.x; 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; const sz = opts.sz ?? typeDef.defaultScale.z;
// Лестница: высота ДЕРИВИРУЕТСЯ из stepCount (а не из sy). Это даёт
// корректный AABB для детекта касания (PlayerController) и совпадает
// с реальной геометрией меша.
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; const color = opts.color ?? typeDef.defaultColor;
// GD-сущности (порталы/шипы/финиш/монеты) — neon (светятся) и без коллизии физики. // GD-сущности (порталы/шипы/финиш/монеты) — neon (светятся) и без коллизии физики.
// Логику касания обрабатывает GdPortalManager/GdLevelManager по дистанции. // Логику касания обрабатывает GdPortalManager/GdLevelManager по дистанции.
@ -158,8 +171,10 @@ export class PrimitiveManager {
const material = opts.material ?? (isGlowingGd ? 'neon' : 'matte'); const material = opts.material ?? (isGlowingGd ? 'neon' : 'matte');
// studDensity — плотность кружков studs (1=стандарт, >1 мельче/чаще). // studDensity — плотность кружков studs (1=стандарт, >1 мельче/чаще).
const studDensity = Number.isFinite(opts.studDensity) ? opts.studDensity : 1; const studDensity = Number.isFinite(opts.studDensity) ? opts.studDensity : 1;
// canCollide: для всех GD-сущностей и шипов — false (игрок проходит сквозь, логика по дистанции) // canCollide: для всех GD-сущностей и шипов — false (игрок проходит сквозь, логика по дистанции).
const canCollide = opts.canCollide !== false && !isGdKind && !isGdSpike; // Лестница — тоже проходима (canCollide=false), чтобы игрок мог войти в её
// объём и лезть (ladder-mode в PlayerController по детекту касания).
const canCollide = opts.canCollide !== false && !isGdKind && !isGdSpike && !isLadder;
const visible = opts.visible !== false; const visible = opts.visible !== false;
const anchored = opts.anchored !== false; // по умолчанию заякорен const anchored = opts.anchored !== false; // по умолчанию заякорен
// Масса по умолчанию = объём (sx*sy*sz), округлённый до 2 знаков. // Масса по умолчанию = объём (sx*sy*sz), округлённый до 2 знаков.
@ -175,7 +190,11 @@ export class PrimitiveManager {
const rotationY = opts.rotationY ?? 0; const rotationY = opts.rotationY ?? 0;
const rotationZ = opts.rotationZ ?? 0; const rotationZ = opts.rotationZ ?? 0;
// Передаём stepCount в builder через временное поле (читается в
// _buildLadderMesh внутри _createMeshForType).
this._ladderStepCount = stepCount;
const mesh = this._createMeshForType(typeDef, id, sx, sy, sz, material, studDensity); const mesh = this._createMeshForType(typeDef, id, sx, sy, sz, material, studDensity);
this._ladderStepCount = undefined;
mesh.position = new Vector3(x, y, z); mesh.position = new Vector3(x, y, z);
mesh.rotation = new Vector3(rotationX, rotationY, rotationZ); mesh.rotation = new Vector3(rotationX, rotationY, rotationZ);
mesh.isPickable = true; mesh.isPickable = true;
@ -202,6 +221,8 @@ export class PrimitiveManager {
rotationX, rotationY, rotationZ, rotationX, rotationY, rotationZ,
color, material, canCollide, visible, anchored, mass, color, material, canCollide, visible, anchored, mass,
textureAsset, studDensity, textureAsset, studDensity,
// Лестница: число ступенек (высота лестницы). undefined для прочих.
...(isLadder ? { stepCount } : {}),
// Подпись над объектом (задача 10) — восстанавливается из project_data. // Подпись над объектом (задача 10) — восстанавливается из project_data.
label: opts.label || null, label: opts.label || null,
// locked — объект защищён от выделения/перемещения в редакторе // locked — объект защищён от выделения/перемещения в редакторе
@ -355,6 +376,11 @@ export class PrimitiveManager {
return this._buildWedgeMesh(name, sx, sy, sz); return this._buildWedgeMesh(name, sx, sy, sz);
case 'cornerwedge': case 'cornerwedge':
return this._buildCornerWedgeMesh(name, sx, sy, sz); return this._buildCornerWedgeMesh(name, sx, sy, sz);
case 'ladder_vertical':
// Лестница строится из stepCount ступенек — высота зависит от
// количества ступенек, а не от sy. stepCount передаётся через
// замыкание _ladderStepCount (см. _createMeshForType-вызов).
return this._buildLadderMesh(name, sx, sz, this._ladderStepCount || 8);
default: default:
return MeshBuilder.CreateBox(name, return MeshBuilder.CreateBox(name,
{ width: sx, height: sy, depth: sz }, this.scene); { width: sx, height: sy, depth: sz }, this.scene);
@ -502,6 +528,52 @@ export class PrimitiveManager {
return mesh; return mesh;
} }
/**
* Вертикальная лестница: 2 боковые стойки + N перекладин (ступенек).
* Строится из stepCount ступенек с шагом LADDER_STEP_SPACING по высоте.
* Полная высота = stepCount * LADDER_STEP_SPACING при изменении stepCount
* лестница ПЕРЕСТРАИВАЕТСЯ (добавляются/убираются ступеньки), а не тянется.
* Меш центрирован по (0,0,0) как CreateBox; все части мерджатся в один Mesh.
*
* 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); // толщина стойки по X
const railD = Math.max(0.06, sz); // глубина стойки/перекладины по Z
const rungH = Math.min(0.1, SPACING * 0.3); // высота перекладины по Y
const halfH = height / 2;
const railX = sx / 2 - railW / 2; // стойки у краёв по X
const parts = [];
// Две вертикальные стойки (тонкие высокие box).
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);
// Перекладины (ступеньки) — горизонтальные box между стойками.
// Первая на полшага от низа, далее с шагом SPACING.
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);
}
// Мерджим в один меш (true = удалить исходники, переиспользовать материал).
const merged = Mesh.MergeMeshes(parts, true, true, undefined, false, true);
if (merged) { merged.name = name; return merged; }
// Fallback: если merge не удался — вернуть простой box по габаритам.
return MeshBuilder.CreateBox(name, { width: sx, height, depth: sz }, this.scene);
}
/** Применить цвет и материал. */ /** Применить цвет и материал. */
_applyMaterial(mesh, typeDef, color, material, textureUrl) { _applyMaterial(mesh, typeDef, color, material, textureUrl) {
const matName = `${mesh.name}_mat`; const matName = `${mesh.name}_mat`;
@ -773,6 +845,14 @@ export class PrimitiveManager {
data.studDensity = Number.isFinite(patch.studDensity) ? patch.studDensity : 1; data.studDensity = Number.isFinite(patch.studDensity) ? patch.studDensity : 1;
scaleChanged = true; 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) { if (scaleChanged) {
// Поскольку MeshBuilder уже создал mesh с базовыми размерами, // Поскольку MeshBuilder уже создал mesh с базовыми размерами,
// изменения через scaling кажутся правильными. Простой способ — // изменения через scaling кажутся правильными. Простой способ —
@ -932,7 +1012,10 @@ export class PrimitiveManager {
const oldMat = oldMesh.material; const oldMat = oldMesh.material;
const typeDef = getPrimitiveType(data.type); 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); const newMesh = this._createMeshForType(typeDef, data.id, data.sx, data.sy, data.sz, data.material, data.studDensity);
this._ladderStepCount = undefined;
newMesh.position = oldPos; newMesh.position = oldPos;
if (oldRot) newMesh.rotation = oldRot; if (oldRot) newMesh.rotation = oldRot;
// studs — материал пересоздаём заново (свежий faceUV/тайлинг + текстура // studs — материал пересоздаём заново (свежий faceUV/тайлинг + текстура
@ -1018,6 +1101,8 @@ export class PrimitiveManager {
...(d.light ? { brightness: d.brightness, range: d.range } : {}), ...(d.light ? { brightness: d.brightness, range: d.range } : {}),
// Параметр эмиттера (только для type='emitter') // Параметр эмиттера (только для type='emitter')
...(d.effect !== undefined ? { effect: d.effect } : {}), ...(d.effect !== undefined ? { effect: d.effect } : {}),
// Число ступенек лестницы (только для type='ladder_vertical')
...(d.type === 'ladder_vertical' ? { stepCount: d.stepCount } : {}),
// Параметры билборда (только для type='billboard') // Параметры билборда (только для type='billboard')
...(d.billboard ? { ...(d.billboard ? {
template: d.billboard.template, template: d.billboard.template,

View File

@ -73,6 +73,14 @@ export const PRIMITIVE_TYPES = [
{ id: 'pointer', name: 'Стрелка-указатель', icon: 'arrow-right', kind: 'pointer', { id: 'pointer', name: 'Стрелка-указатель', icon: 'arrow-right', kind: 'pointer',
defaultScale: { x: 0.4, y: 0.4, z: 0.4 }, defaultColor: '#ff3a3a' }, defaultScale: { x: 0.4, y: 0.4, z: 0.4 }, defaultColor: '#ff3a3a' },
// === Вертикальная лестница — по ней можно лазить вверх/вниз ===
// Высота настраивается параметром stepCount (количество ступенек).
// При изменении stepCount лестница перестраивается (НЕ растягивается модель,
// а добавляются/убираются ступеньки). Касание → ladder-mode в PlayerController:
// W/S вверх-вниз, гравитация отключена, Space — отпрыг.
{ id: 'ladder_vertical', name: 'Лестница (вертикальная)', icon: 'prim-ladder', kind: 'ladder',
defaultScale: { x: 1, y: 4, z: 0.12 }, defaultColor: '#a8743a' },
// === GD-порталы (Geometry Dash 2.0) — переключают гейммод игрока === // === GD-порталы (Geometry Dash 2.0) — переключают гейммод игрока ===
// Все цилиндры-«столбы» с neon-материалом. Цвета и gdMode задают режим. // Все цилиндры-«столбы» с neon-материалом. Цвета и gdMode задают режим.
{ id: 'gd_cube', name: 'Портал: Куб', icon: 'prim-portal', kind: 'gd_portal', gdMode: 'cube', { id: 'gd_cube', name: 'Портал: Куб', icon: 'prim-portal', kind: 'gd_portal', gdMode: 'cube',
@ -103,7 +111,7 @@ export const PRIMITIVE_TYPES = [
/** Категории для группировки в палитре. */ /** Категории для группировки в палитре. */
export const PRIMITIVE_CATEGORIES = [ export const PRIMITIVE_CATEGORIES = [
{ id: 'geometry', name: 'Фигуры', ids: ['cube','sphere','cylinder','cone','plane','torus','wedge','cornerwedge'] }, { id: 'geometry', name: 'Фигуры', ids: ['cube','sphere','cylinder','cone','plane','torus','wedge','cornerwedge'] },
{ id: 'gameplay', name: 'Геймплей', ids: ['trigger', 'checkpoint', 'light', 'emitter', 'billboard', 'pointer'] }, { id: 'gameplay', name: 'Геймплей', ids: ['trigger', 'checkpoint', 'light', 'emitter', 'billboard', 'pointer', 'ladder_vertical'] },
{ id: 'gd-portals', name: 'GD-порталы', ids: ['gd_cube','gd_ship','gd_ball','gd_ufo','gd_wave','gd_robot'] }, { 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'] }, { id: 'gd-objects', name: 'GD-объекты', ids: ['gd_spike','gd_finish','gd_coin'] },
]; ];