Compare commits

..

19 Commits

Author SHA1 Message Date
min
542949eaff Merge pull request 'fix(vehicle): ������ ���� ������ (LOD-freeze)' (#47) from fix/vehicle-lod-freeze-2026-06-17 into main
Some checks failed
CI / Lint (push) Successful in 1m10s
CI / Build (push) Successful in 2m0s
CI / Secret scan (push) Failing after 5m8s
CI / PR size check (push) Has been skipped
CI / Deploy to S1 + S2 (push) Has been skipped
2026-06-17 07:22:55 +00:00
min
b638842948 ci: rerun checks (secret-scan флапнул, не код)
Some checks failed
CI / Lint (pull_request) Successful in 1m8s
CI / Build (pull_request) Successful in 1m57s
CI / Secret scan (pull_request) Failing after 5m10s
CI / PR size check (pull_request) Successful in 11s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
2026-06-17 10:11:15 +03:00
min
22026f2f1d fix(vehicle): машина едет видимо — исключить кузов из LOD-freeze
Some checks failed
CI / Lint (pull_request) Successful in 1m21s
CI / Build (pull_request) Successful in 2m7s
CI / Secret scan (pull_request) Failing after 5m11s
CI / PR size check (pull_request) Successful in 8s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
Регрессия: LOD-оптимизация (_updateModelLOD) замораживала worldMatrix
далёких моделей. Кузов машины (modelManager-инстанс) под неё попадал, а
после парентинга к chassisNode его root.position стала локальной (≈0,0,0)
→ LOD мерял дистанцию неверно и замораживал машину → меш переставал
следовать за коллайдером (едешь на невидимой машине, видимая стоит).
Фикс: помечаем кузов _spawnedAtRuntime=true (+_isVehicleBody) при спавне —
LOD его не трогает (как зомби/динамику).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 09:57:57 +03:00
min
89bdeff657 Merge pull request 'feat(studio): ���������� UI + fullscreen + desktop-guard' (#46) from feat/studio-ui-compact-fullscreen-2026-06-15 into main
All checks were successful
CI / Lint (push) Successful in 1m10s
CI / Build (push) Successful in 2m8s
CI / Secret scan (push) Successful in 23s
CI / PR size check (push) Has been skipped
CI / Deploy to S1 + S2 (push) Successful in 3m29s
2026-06-15 17:45:34 +00:00
min
b457a29a95 Merge branch 'main' into feat/studio-ui-compact-fullscreen-2026-06-15
All checks were successful
CI / Lint (pull_request) Successful in 1m15s
CI / Build (pull_request) Successful in 1m58s
CI / Secret scan (pull_request) Successful in 23s
CI / PR size check (pull_request) Successful in 7s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
2026-06-15 17:37:45 +00:00
min
143e529d11 Merge pull request 'feat(studio): ����� ��������� (marquee) + hover-���������' (#45) from feat/studio-marquee-hover-2026-06-15 into main
Some checks failed
CI / Lint (push) Successful in 1m7s
CI / Build (push) Successful in 1m59s
CI / Secret scan (push) Successful in 28s
CI / PR size check (push) Has been skipped
CI / Deploy to S1 + S2 (push) Has been cancelled
2026-06-15 17:37:33 +00:00
min
2e1d915922 Merge branch 'main' into feat/studio-marquee-hover-2026-06-15
All checks were successful
CI / Lint (pull_request) Successful in 1m10s
CI / Build (pull_request) Successful in 2m5s
CI / Secret scan (pull_request) Successful in 31s
CI / PR size check (pull_request) Successful in 14s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
2026-06-15 17:27:57 +00:00
min
a9a9668071 feat(studio): компактный UI + fullscreen + desktop-guard
All checks were successful
CI / Lint (pull_request) Successful in 1m10s
CI / Build (pull_request) Successful in 2m7s
CI / Secret scan (pull_request) Successful in 24s
CI / PR size check (pull_request) Successful in 7s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
UI (ближе к Roblox Studio):
- интерфейс уменьшен ~10% (шрифты/паддинги/topbar 56→50);
- правая панель шире (280→320), компактные строки в «Объектах сцены»;
- перетаскиваемая граница между списком объектов и свойствами (доля в
  localStorage).

Fullscreen редактора:
- кнопка «На весь экран» в шапке + автовход при первом клике (браузер
  требует user gesture);
- иконки fullscreen/fullscreen-exit.

Desktop-guard (window.__RUBLOX_DESKTOP__):
- в Electron-приложении авто-fullscreen отключён (окно и так на весь
  экран): не дёргаем FS при автовходе, при «Запустить», кнопка FS скрыта.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 20:17:58 +03:00
min
04c593ef5a feat(studio): рамка выделения (marquee) + hover-подсветка
All checks were successful
CI / Lint (pull_request) Successful in 1m7s
CI / Build (pull_request) Successful in 2m8s
CI / Secret scan (pull_request) Successful in 26s
CI / PR size check (pull_request) Successful in 7s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
Рамка выделения (rubber-band):
- ЛКМ-drag по пустому месту при tool=select → зелёный прямоугольник;
  объекты, чей центр (экранная проекция) попал в рамку, выделяются
  группой (multi). Пол/террейн/сетка не выделяются.
- Ctrl+рамка добавляет к текущему выделению.
- Групповой гизмо (_attachMultiGizmo) двигает всю группу; перетаскивание
  за объект группы тоже двигает группу; Ctrl+D дублирует всю группу.
- SelectionManager: setMultiSelection/moveMultiBy/getMultiCenter.

Hover-подсветка (как Roblox Studio):
- Наведение мышью на объект → белый контур (HighlightLayer).
- Объект в папке → подсвечивается вся папка целиком.
- Pick троттлится через requestAnimationFrame; контур снимается при
  вращении камеры / рамке / уводе курсора.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 19:45:26 +03:00
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
min
8f0266f8c2 fix(skin): cache-bust query ?v=20260614 for character-assets URLs
All checks were successful
CI / Lint (pull_request) Successful in 1m7s
CI / Build (pull_request) Successful in 2m2s
CI / Secret scan (pull_request) Successful in 24s
CI / PR size check (pull_request) Successful in 8s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
After backend CORS rollout users had stale CORS-failure cached for
Mixamo GLB. Adding a query suffix forces browsers to re-fetch the URL
instead of replaying the cached failure.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-14 16:43:34 +03:00
min
a697438661 Merge pull request 'feat(studio): Mixamo skins + crouch + fullscreen + skin from DB' (#41) from feat/mixamo-skins-fullscreen-2026-06-14 into main
All checks were successful
CI / Lint (push) Successful in 1m5s
CI / Build (push) Successful in 1m58s
CI / Secret scan (push) Successful in 24s
CI / PR size check (push) Has been skipped
CI / Deploy to S1 + S2 (push) Successful in 3m31s
2026-06-14 12:56:46 +00:00
min
1229bdad3f feat(studio): Mixamo skins + crouch + fullscreen + skin from DB
All checks were successful
CI / Lint (pull_request) Successful in 1m7s
CI / Build (pull_request) Successful in 2m0s
CI / Secret scan (pull_request) Successful in 23s
CI / PR size check (pull_request) Successful in 7s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
Синхронизация с rublox-player (Этап 1 ANIMATIONS_PLAN.md):

MIXAMO:
- Добавлен MixamoAnimator.js (полная копия из плеера)
- В PlayerController список MIXAMO_SKINS (78 шт)
- _resolveModelSource ветка для skin_* — грузит GLB с /character-assets/skins/
- _loadPlayerModel создаёт MixamoAnimator для Mixamo-скинов

CROUCH (Ctrl):
- AABB → 0.45, скорость 0.45×walk, приоритет над sprint
- Анимации: crouch_enter → crouch_idle / crouch_walk → crouch_to_stand
- Y-drop по 0.20-0.45м для разных Mixamo-клипов
- Анти-флик debounce + детект one-shot vs loopable

PREVIEW PLAYER:
- Skin приоритет: URL #skin= → БД → localStorage → дефолт
- Дефолт skin_bacon-hair → skin_y-bot

ENTERPLAYMODE:
- BabylonScene при Play читает hash/localStorage и подставляет user skin
  поверх projectModelType (тест-режим = играешь СВОИМ скином)

KUBIKONEDITOR:
- handlePlay async: предзагрузка скина из БД (если нет в URL) и инжект в hash
- При Play: requestFullscreen() — блокирует Ctrl+W/D/T/R/S/A/P/F и др.
- Во время Play: window keydown listener блокирует системные хоткеи,
  WASD проходят к PlayerController (для crouch+движение)
2026-06-14 15:50:19 +03:00
min
7d8a41d4d8 Merge pull request '������� �������/�������� (�������) + ��������� + ������� ����������' (#40) from restore/all-tasks into main
All checks were successful
CI / Lint (push) Successful in 1m7s
CI / Build (push) Successful in 2m3s
CI / Secret scan (push) Successful in 23s
CI / PR size check (push) Has been skipped
CI / Deploy to S1 + S2 (push) Successful in 3m25s
2026-06-09 22:49:19 +00:00
15 changed files with 2343 additions and 75 deletions

View File

@ -1,8 +1,10 @@
/* === Hierarchy Panel === */ /* === Hierarchy Panel === */
/* Компактные строки (как Roblox Explorer): меньше вертикальных отступов
больше объектов влезает без скролла. */
.hierarchy { .hierarchy {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
padding: 6px 0; padding: 4px 0;
font-size: 12px; font-size: 12px;
user-select: none; user-select: none;
position: relative; position: relative;
@ -13,13 +15,13 @@
} }
.rootLine { .rootLine {
padding: 6px 8px; padding: 3px 8px;
color: var(--text); color: var(--text);
font-size: 13px; font-size: 12px;
} }
.systemItem { .systemItem {
padding: 4px 8px 4px 28px; padding: 2px 8px 2px 26px;
color: var(--text-dim); color: var(--text-dim);
font-size: 12px; font-size: 12px;
} }
@ -28,11 +30,11 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 4px; gap: 4px;
padding: 6px 8px; padding: 3px 8px;
color: var(--text); color: var(--text);
cursor: pointer; cursor: pointer;
border-radius: 4px; border-radius: 4px;
margin-top: 6px; margin-top: 3px;
font-weight: 600; font-weight: 600;
} }
@ -72,8 +74,8 @@
.item { .item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 7px;
padding: 4px 8px; padding: 2px 8px;
cursor: pointer; cursor: pointer;
border-radius: 4px; border-radius: 4px;
color: var(--text); color: var(--text);

View File

@ -73,6 +73,9 @@ const GLYPHS = {
'arrow-down': () => (<><path d="M12 5v14" {...S}/><path d="M6 13l6 6 6-6" {...S}/></>), 'arrow-down': () => (<><path d="M12 5v14" {...S}/><path d="M6 13l6 6 6-6" {...S}/></>),
'arrow-left': () => (<><path d="M19 12H5" {...S}/><path d="M11 6l-6 6 6 6" {...S}/></>), 'arrow-left': () => (<><path d="M19 12H5" {...S}/><path d="M11 6l-6 6 6 6" {...S}/></>),
'arrow-right': () => (<><path d="M5 12h14" {...S}/><path d="M13 6l6 6-6 6" {...S}/></>), 'arrow-right': () => (<><path d="M5 12h14" {...S}/><path d="M13 6l6 6-6 6" {...S}/></>),
// Полноэкранный режим: 4 уголка наружу / внутрь.
'fullscreen': () => (<><path d="M4 9V4h5" {...S}/><path d="M20 9V4h-5" {...S}/><path d="M4 15v5h5" {...S}/><path d="M20 15v5h-5" {...S}/></>),
'fullscreen-exit': () => (<><path d="M9 4v5H4" {...S}/><path d="M15 4v5h5" {...S}/><path d="M9 20v-5H4" {...S}/><path d="M15 20v-5h5" {...S}/></>),
refresh: () => (<><path d="M20 11a8 8 0 0 0-14-4.5L4 9" {...S}/><path d="M4 4v5h5" {...S}/><path d="M4 13a8 8 0 0 0 14 4.5L20 15" {...S}/><path d="M20 20v-5h-5" {...S}/></>), refresh: () => (<><path d="M20 11a8 8 0 0 0-14-4.5L4 9" {...S}/><path d="M4 4v5h5" {...S}/><path d="M4 13a8 8 0 0 0 14 4.5L20 15" {...S}/><path d="M20 20v-5h-5" {...S}/></>),
cycle: () => (<><path d="M20 11a8 8 0 0 0-14-4.5L4 9" {...S}/><path d="M4 4v5h5" {...S}/><path d="M4 13a8 8 0 0 0 14 4.5L20 15" {...S}/><path d="M20 20v-5h-5" {...S}/></>), cycle: () => (<><path d="M20 11a8 8 0 0 0-14-4.5L4 9" {...S}/><path d="M4 4v5h5" {...S}/><path d="M4 13a8 8 0 0 0 14 4.5L20 15" {...S}/><path d="M20 20v-5h-5" {...S}/></>),
flag: () => (<><path d="M6 21V4" {...S}/><path d="M6 4h11l-2.5 4L17 12H6" {...S}/></>), flag: () => (<><path d="M6 21V4" {...S}/><path d="M6 4h11l-2.5 4L17 12H6" {...S}/></>),
@ -254,6 +257,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';
@ -46,6 +47,11 @@ import cl from './KubikonEditor.module.css';
import Icon from './Icon'; import Icon from './Icon';
import ConfirmModal from './ConfirmModal'; import ConfirmModal from './ConfirmModal';
// В десктоп-приложении (Electron-обёртка, см. rublox-desktop) окно и так на
// весь экран без браузерной панели fullscreen НЕ нужен. preload выставляет
// window.__RUBLOX_DESKTOP__. Глушим авто-fullscreen, чтобы не дёргать окно.
const IS_DESKTOP_APP = typeof window !== 'undefined' && !!window.__RUBLOX_DESKTOP__;
const AUTOSAVE_DEBOUNCE_MS = 10000; // 10 секунд тишины авто-сохранение const AUTOSAVE_DEBOUNCE_MS = 10000; // 10 секунд тишины авто-сохранение
// Шаблон глобального скрипта. // Шаблон глобального скрипта.
@ -469,6 +475,98 @@ const KubikonEditor = () => {
const canvasRef = useRef(null); const canvasRef = useRef(null);
const sceneRef = useRef(null); const sceneRef = useRef(null);
// === Fullscreen редактора ===
// Верхняя панель браузера съедает ~20% экрана. Браузер НЕ даёт включить
// fullscreen автоматически при загрузке (нужен user gesture), поэтому:
// 1) кнопка в шапке;
// 2) автоматический вход при ПЕРВОМ клике пользователя по редактору.
const [isFullscreen, setIsFullscreen] = useState(false);
const fsAutoTriedRef = useRef(false); // авто-вход пробуем только 1 раз
const requestEditorFullscreen = React.useCallback(() => {
try {
const root = document.documentElement;
const req = root.requestFullscreen
|| root.webkitRequestFullscreen
|| root.mozRequestFullScreen
|| root.msRequestFullscreen;
if (req && !document.fullscreenElement) req.call(root).catch(() => {});
} catch (e) { /* юзер запретил — работаем в окне */ }
}, []);
const exitEditorFullscreen = React.useCallback(() => {
try {
const ex = document.exitFullscreen || document.webkitExitFullscreen
|| document.mozCancelFullScreen || document.msExitFullscreen;
if (ex && document.fullscreenElement) ex.call(document).catch?.(() => {});
} catch (e) { /* ignore */ }
}, []);
const toggleEditorFullscreen = React.useCallback(() => {
if (document.fullscreenElement) exitEditorFullscreen();
else requestEditorFullscreen();
}, [requestEditorFullscreen, exitEditorFullscreen]);
// Следим за состоянием fullscreen (кнопка показывает актуальную иконку).
useEffect(() => {
const onFsChange = () => setIsFullscreen(!!document.fullscreenElement);
document.addEventListener('fullscreenchange', onFsChange);
document.addEventListener('webkitfullscreenchange', onFsChange);
return () => {
document.removeEventListener('fullscreenchange', onFsChange);
document.removeEventListener('webkitfullscreenchange', onFsChange);
};
}, []);
// Автовход в fullscreen при ПЕРВОМ клике/нажатии по редактору. Один раз.
useEffect(() => {
// В десктоп-приложении окно и так на весь экран авто-FS не нужен.
if (IS_DESKTOP_APP) return;
const tryAuto = () => {
if (fsAutoTriedRef.current) return;
fsAutoTriedRef.current = true;
if (!document.fullscreenElement) requestEditorFullscreen();
};
window.addEventListener('pointerdown', tryAuto, { once: true, capture: true });
window.addEventListener('keydown', tryAuto, { once: true, capture: true });
return () => {
window.removeEventListener('pointerdown', tryAuto, { capture: true });
window.removeEventListener('keydown', tryAuto, { capture: true });
};
}, [requestEditorFullscreen]);
// При уходе со страницы редактора выходим из fullscreen.
useEffect(() => () => { exitEditorFullscreen(); }, [exitEditorFullscreen]);
// === Регулируемая граница между «Объекты сцены» и «Свойства» ===
// Доля высоты под список объектов (0.2..0.85). Сохраняем в localStorage.
const [hierFraction, setHierFraction] = useState(() => {
const v = parseFloat(localStorage.getItem('rbxStudioHierFraction'));
return Number.isFinite(v) && v >= 0.2 && v <= 0.85 ? v : 0.5;
});
const rightPanelRef = useRef(null);
const splitDragRef = useRef(false);
useEffect(() => {
const onMove = (e) => {
if (!splitDragRef.current || !rightPanelRef.current) return;
const rect = rightPanelRef.current.getBoundingClientRect();
// Доля от верха панели до курсора (за вычетом верхнего заголовка ~24px).
let f = (e.clientY - rect.top - 24) / Math.max(1, rect.height - 24);
f = Math.max(0.2, Math.min(0.85, f));
setHierFraction(f);
};
const onUp = () => {
if (!splitDragRef.current) return;
splitDragRef.current = false;
document.body.style.cursor = '';
try { localStorage.setItem('rbxStudioHierFraction', String(hierFraction)); } catch (_) {}
};
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
return () => {
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
};
}, [hierFraction]);
const startSplitDrag = React.useCallback((e) => {
e.preventDefault();
splitDragRef.current = true;
document.body.style.cursor = 'row-resize';
}, []);
// Team Create клиент совместного редактирования + presence-overlay. // Team Create клиент совместного редактирования + presence-overlay.
const collabRef = useRef(null); const collabRef = useRef(null);
const collabOverlayRef = useRef(null); const collabOverlayRef = useRef(null);
@ -640,6 +738,37 @@ const KubikonEditor = () => {
return () => clearInterval(t); return () => clearInterval(t);
}, [isPlaying]); }, [isPlaying]);
// 2026-06-14: блокировка системных Ctrl-хоткеев во время Play.
// F-клавиши и Ctrl+W/D/T/R/S/A/P/F/U/J/H/L/O/G + Ctrl+1..9 + Ctrl+Tab.
// В fullscreen Chrome даёт preventDefault'иться. WASD-хоткеи
// (Ctrl+W/A/S/D) НЕ stopPropagation PlayerController должен их видеть
// (одновременный crouch+движение).
useEffect(() => {
if (!isPlaying) return;
const onKey = (e) => {
if (e.code === 'F5' || e.code === 'F3' || e.code === 'F6' || e.code === 'F7') {
e.preventDefault(); e.stopPropagation(); return;
}
if (e.ctrlKey || e.metaKey) {
const wasd = ['KeyW', 'KeyA', 'KeyS', 'KeyD'];
if (wasd.includes(e.code)) {
e.preventDefault();
return;
}
const blocked = ['KeyR','KeyT','KeyN','KeyP','KeyU','KeyJ','KeyH',
'KeyF','KeyG','KeyL','KeyO','Tab',
'Digit1','Digit2','Digit3','Digit4','Digit5',
'Digit6','Digit7','Digit8','Digit9'];
if (blocked.includes(e.code)) {
e.preventDefault();
e.stopPropagation();
}
}
};
window.addEventListener('keydown', onKey, { capture: true });
return () => window.removeEventListener('keydown', onKey, { capture: true });
}, [isPlaying]);
// При выходе из Play сбросим HP к полному (для следующего захода) // При выходе из Play сбросим HP к полному (для следующего захода)
useEffect(() => { useEffect(() => {
if (!isPlaying) { if (!isPlaying) {
@ -819,8 +948,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);
@ -900,7 +1036,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);
@ -926,7 +1062,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]);
@ -1744,7 +1880,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);
@ -2032,7 +2168,7 @@ const KubikonEditor = () => {
} }
}; };
const handlePlay = () => { const handlePlay = async () => {
const scene = sceneRef.current; const scene = sceneRef.current;
if (!scene) return; if (!scene) return;
if (scene.isPlaying()) { if (scene.isPlaying()) {
@ -2043,6 +2179,48 @@ const KubikonEditor = () => {
// дёргается только на Esc-выход, кнопка Стоп нет. // дёргается только на Esc-выход, кнопка Стоп нет.
hudRef.current?.reset?.(); hudRef.current?.reset?.();
} else { } else {
// 2026-06-14: Перед входом в Play подтягиваем СКИН ЮЗЕРА из БД
// (если ещё не передан в URL #skin=). Источник:
// 1) URL hash #skin=<id> (если уже есть не трогаем)
// 2) БД (rublox_equipped_skin) через /equipped-skin GET
// BabylonScene.enterPlayMode сам прочитает hash, поэтому
// записываем туда найденный скин.
try {
const hasHashSkin = /[#&]skin=/.test(window.location.hash || '');
if (!hasHashSkin) {
const uid = getCurrentUserId();
if (uid) {
const r = await Kubikon3DApi.getEquippedSkin(uid);
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') {
// Подмешиваем в hash так чтобы не сломать ticket=...
const cur = window.location.hash || '';
const sep = cur && !cur.endsWith('&') ? '&' : '';
const newHash = cur
? `${cur}${sep}skin=${encodeURIComponent(sf)}`
: `#skin=${encodeURIComponent(sf)}`;
// history.replaceState чтобы не сломать react-router
window.history.replaceState(
null, '',
window.location.pathname + window.location.search + newHash,
);
console.log('[KubikonEditor] play skin from DB:', sf);
}
}
}
} catch (e) {
console.warn('[KubikonEditor] equipped-skin fetch failed:', e?.message || e);
}
// Флаш ScriptEditor иначе при печати сразу Play игра пойдёт // Флаш ScriptEditor иначе при печати сразу Play игра пойдёт
// со старым кодом (debounce 600мс ещё не сработал). // со старым кодом (debounce 600мс ещё не сработал).
try { scriptEditorFlushRef.current?.(); } catch (_) {} try { scriptEditorFlushRef.current?.(); } catch (_) {}
@ -2061,6 +2239,22 @@ const KubikonEditor = () => {
scene.setSpawnPoint(sp.x, spawnY, sp.z); scene.setSpawnPoint(sp.x, spawnY, sp.z);
scene.enterPlayMode(); scene.enterPlayMode();
setIsPlaying(true); setIsPlaying(true);
// 2026-06-14: при входе в Play автоматически запрашиваем
// fullscreen иначе Ctrl+W/Ctrl+D случайно закрывают вкладку
// в режиме игры. Это user gesture (клик по кнопке Play),
// поэтому requestFullscreen() разрешён.
// В десктоп-приложении этого риска нет (нет вкладок браузера)
// окно и так на весь экран, FS не нужен.
if (!IS_DESKTOP_APP) try {
const root = document.documentElement;
const req = root.requestFullscreen
|| root.webkitRequestFullscreen
|| root.mozRequestFullScreen
|| root.msRequestFullscreen;
if (req && !document.fullscreenElement) {
req.call(root).catch(() => {});
}
} catch (e) { /* юзер запретил — играем без FS */ }
// Если активен таб скрипта авто-переключение на «Сцена», // Если активен таб скрипта авто-переключение на «Сцена»,
// чтобы пользователь сразу видел игру. // чтобы пользователь сразу видел игру.
setActiveTabId('scene'); setActiveTabId('scene');
@ -2214,6 +2408,18 @@ const KubikonEditor = () => {
</button> </button>
</> </>
)} )}
{/* Полноэкранный режим съедает верхнюю панель браузера.
В десктоп-приложении не нужен (окно и так на весь экран). */}
{!IS_DESKTOP_APP && (
<button
onClick={toggleEditorFullscreen}
title={isFullscreen ? 'Выйти из полноэкранного режима (F11)' : 'На весь экран (F11)'}
className={cl.toolbarBtn}
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 38, height: 38, padding: 0, flexShrink: 0 }}
>
<Icon name={isFullscreen ? 'fullscreen-exit' : 'fullscreen'} size={16} />
</button>
)}
{/* Кнопка баг-репорта в шапке — кликает по скрытой плавающей. */} {/* Кнопка баг-репорта в шапке — кликает по скрытой плавающей. */}
<button <button
onClick={() => document.querySelector('[data-kubikon-bug-btn]')?.click()} onClick={() => document.querySelector('[data-kubikon-bug-btn]')?.click()}
@ -3425,7 +3631,8 @@ const KubikonEditor = () => {
</div> </div>
{/* Правая панель — Hierarchy + Inspector */} {/* Правая панель — Hierarchy + Inspector */}
<aside className={cl.rightPanel}> <aside className={cl.rightPanel} ref={rightPanelRef}>
<div className={cl.rightSection} style={{ flexGrow: hierFraction, flexBasis: 0 }}>
<div className={cl.panelTitle}>Объекты сцены</div> <div className={cl.panelTitle}>Объекты сцены</div>
<HierarchyPanel <HierarchyPanel
blocks={blocksList} blocks={blocksList}
@ -3650,7 +3857,16 @@ const KubikonEditor = () => {
onAssignToFolder={(kind, ref, folderId) => onAssignToFolder={(kind, ref, folderId) =>
sceneRef.current?.assignToFolder(kind, ref, folderId)} sceneRef.current?.assignToFolder(kind, ref, folderId)}
/> />
</div>
{/* Перетаскиваемая граница между списком объектов и свойствами. */}
<div
className={cl.rightSplitter}
onPointerDown={startSplitDrag}
title="Потяните, чтобы изменить высоту"
/>
<div className={cl.rightSection} style={{ flexGrow: (1 - hierFraction), flexBasis: 0 }}>
<div className={cl.panelTitle}>Свойства</div> <div className={cl.panelTitle}>Свойства</div>
<InspectorPanel <InspectorPanel
selection={selection} selection={selection}
@ -3787,6 +4003,7 @@ const KubikonEditor = () => {
markDirty(); markDirty();
}} }}
/> />
</div>
</aside> </aside>
</div> </div>

View File

@ -31,7 +31,8 @@
font-family: "Roboto Condensed", system-ui, -apple-system, sans-serif; font-family: "Roboto Condensed", system-ui, -apple-system, sans-serif;
display: grid; display: grid;
grid-template-rows: 56px auto 1fr 28px; /* UI уменьшен на ~10% (ближе к Roblox Studio): topbar 56→50, status 28→26. */
grid-template-rows: 50px auto 1fr 26px;
height: 100vh; height: 100vh;
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
@ -39,7 +40,7 @@
color: var(--text); color: var(--text);
overflow: hidden; overflow: hidden;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 13px;
} }
.editor *, .editor *,
@ -52,8 +53,8 @@
.topBar { .topBar {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 16px; gap: 12px;
padding: 0 16px; padding: 0 14px;
background: linear-gradient(180deg, var(--bg-dark) 0%, var(--bg-darkest) 100%); background: linear-gradient(180deg, var(--bg-dark) 0%, var(--bg-darkest) 100%);
border-bottom: 2px solid var(--border); border-bottom: 2px solid var(--border);
z-index: 10; z-index: 10;
@ -145,12 +146,12 @@
} }
.toolbarBtn { .toolbarBtn {
padding: 8px 16px; padding: 7px 13px;
background: var(--bg-mid); background: var(--bg-mid);
border: 2px solid var(--border); border: 2px solid var(--border);
border-radius: 6px; border-radius: 6px;
color: var(--text); color: var(--text);
font-size: 13px; font-size: 12px;
cursor: pointer; cursor: pointer;
transition: all 0.15s; transition: all 0.15s;
font-weight: 600; font-weight: 600;
@ -210,7 +211,9 @@
/* === WORKSPACE === */ /* === WORKSPACE === */
.workspace { .workspace {
display: grid; display: grid;
grid-template-columns: 240px minmax(0, 1fr) 280px; /* Правая панель шире (280320) длинные имена объектов не обрезаются,
больше места под список и свойства. Левая чуть уже (240224). */
grid-template-columns: 224px minmax(0, 1fr) 320px;
overflow: hidden; overflow: hidden;
min-height: 0; min-height: 0;
} }
@ -233,18 +236,61 @@
.rightPanel { .rightPanel {
border-left: 2px solid var(--border); border-left: 2px solid var(--border);
overflow: hidden; /* секции скроллятся внутри себя, панель — нет */
}
/* Секция правой панели (список объектов / свойства) занимает свою долю
высоты, скроллится независимо. Доля регулируется сплиттером. */
.rightSection {
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
/* Перетаскиваемая граница между списком объектов и свойствами. */
.rightSplitter {
height: 6px;
flex-shrink: 0;
cursor: row-resize;
background: var(--bg-mid);
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
position: relative;
transition: background 0.12s;
}
.rightSplitter::before {
/* визуальная «ручка» — три точки по центру */
content: '';
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 28px;
height: 2px;
border-radius: 2px;
background: var(--text-dim);
opacity: 0.5;
}
.rightSplitter:hover {
background: var(--accent);
}
.rightSplitter:hover::before {
background: #fff;
opacity: 0.9;
} }
.panelTitle { .panelTitle {
padding: 12px 16px; padding: 7px 14px;
font-size: 11px; font-size: 10px;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 1.5px; letter-spacing: 1.3px;
color: var(--text-dim); color: var(--text-dim);
background: var(--bg-mid); background: var(--bg-mid);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
font-weight: 800; font-weight: 800;
flex-shrink: 0;
} }
.panelTitle:first-child { .panelTitle:first-child {

View File

@ -35,6 +35,8 @@ import {
ParticleSystem, ParticleSystem,
Texture, Texture,
Ray, Ray,
Matrix,
HighlightLayer,
PointerEventTypes, PointerEventTypes,
Tools as BabylonTools, Tools as BabylonTools,
ColorCurves, ColorCurves,
@ -198,6 +200,18 @@ export class BabylonScene {
this._freeDragCandidate = null; // {mesh} — потенциальный объект для перетаскивания this._freeDragCandidate = null; // {mesh} — потенциальный объект для перетаскивания
this._freeDragActive = false; // идёт ли перетаскивание this._freeDragActive = false; // идёт ли перетаскивание
this._freeDragHalf = null; // {x,y,z} полу-габариты объекта (для коллизий) this._freeDragHalf = null; // {x,y,z} полу-габариты объекта (для коллизий)
// Рамка выделения (rubber-band / marquee): ЛКМ-drag по пустому месту.
this._marqueeCandidate = null; // {startX,startY,curX,curY,additive}
this._marqueeActive = false; // появилась ли рамка (после сдвига)
this._marqueeEl = null; // DOM-оверлей прямоугольника
// Групповой пивот multi-выделения (для гизмо).
this._multiPivot = null;
this._multiPivotLast = null;
// Hover-подсветка (белый контур при наведении, как в Roblox Studio).
this._hoverLayer = null; // HighlightLayer
this._hoverMeshes = []; // подсвеченные сейчас меши
this._hoverKey = null; // ключ текущего hover-объекта (для throttle)
this._hoverRaf = 0; // requestAnimationFrame id (throttle pick)
this._isDragPlacing = false; // флаг drag-постановки/удаления блоков this._isDragPlacing = false; // флаг drag-постановки/удаления блоков
this._isTerrainBrushing = false; // флаг drag-кисти террейна this._isTerrainBrushing = false; // флаг drag-кисти террейна
this._terrainDragLockY = null; // Y-фикс для скульпт/выровнять this._terrainDragLockY = null; // Y-фикс для скульпт/выровнять
@ -214,7 +228,7 @@ export class BabylonScene {
// Дефолт — R15-скин bacon-hair (классический Roblox-вид). // Дефолт — R15-скин bacon-hair (классический Roblox-вид).
// 'skin_*' грузится из characters/<id>/body.glb (R15-скелет), // 'skin_*' грузится из characters/<id>/body.glb (R15-скелет),
// 'character-*' — старые Kenney-модели. // 'character-*' — старые Kenney-модели.
this._playerModelType = 'skin_bacon-hair'; this._playerModelType = 'skin_y-bot';
// Размер пола: пол идёт от -worldHalf до +worldHalf по X и Z. // Размер пола: пол идёт от -worldHalf до +worldHalf по X и Z.
// По умолчанию 80×80 (worldHalf = 40). Можно менять через setWorldSize(). // По умолчанию 80×80 (worldHalf = 40). Можно менять через setWorldSize().
this._worldHalf = 40; this._worldHalf = 40;
@ -347,7 +361,13 @@ export class BabylonScene {
this.blockManager = new BlockManager(this.scene); this.blockManager = new BlockManager(this.scene);
// При создании нового proto-меша блока — сразу регистрируем его // При создании нового proto-меша блока — сразу регистрируем его
// как shadow caster (если генератор уже создан). // как shadow caster (если генератор уже создан).
// ОПТИМИЗАЦИЯ: на БОЛЬШИХ block-картах (стриминг включён — лабиринты,
// 200к+ блоков) НЕ кастуем тени от блоков. Shadow-map иначе рендерит
// всю видимую геометрию повторно — это и есть причина «idle 220мс/кадр»
// при крошечном render_ms. Точно как с terrain (см. ниже). Блоки всё
// равно ПРИНИМАЮТ тени (receiveShadows на proto), но сами не кастуют.
this.blockManager.setOnProtoCreated((proto) => { this.blockManager.setOnProtoCreated((proto) => {
if (this._blockStreamingEnabled) return;
this.addShadowCaster(proto); this.addShadowCaster(proto);
}); });
@ -1369,6 +1389,18 @@ export class BabylonScene {
// когда родительская scene control включён (мы убрали detachControl). // когда родительская scene control включён (мы убрали detachControl).
this._gizmoLayer = new UtilityLayerRenderer(this.scene); this._gizmoLayer = new UtilityLayerRenderer(this.scene);
// Hover-подсветка: белый контур по краю объекта при наведении мышью
// (как в Roblox Studio). HighlightLayer рисует мягкий outline.
try {
this._hoverLayer = new HighlightLayer('hoverLayer', this.scene, {
blurHorizontalSize: 1.0,
blurVerticalSize: 1.0,
});
// Тонкая, не «неоновая» обводка — ближе к Roblox.
this._hoverLayer.innerGlow = false;
this._hoverLayer.outerGlow = true;
} catch (e) { console.warn('[hover] HighlightLayer init failed', e); }
this._gizmo = new GizmoController(this._gizmoLayer, this.scene); this._gizmo = new GizmoController(this._gizmoLayer, this.scene);
this._gizmo.setMode('select'); // по умолчанию — без манипулятора this._gizmo.setMode('select'); // по умолчанию — без манипулятора
this._gizmo.setSnap(1.0); // снэп для блоков this._gizmo.setSnap(1.0); // снэп для блоков
@ -1382,6 +1414,8 @@ export class BabylonScene {
// Групповая папка — применяем дельту в реальном времени (видно движение). // Групповая папка — применяем дельту в реальном времени (видно движение).
const sel = this.selection?.getSelection?.(); const sel = this.selection?.getSelection?.();
if (sel && sel.type === 'folder') this._onFolderGizmoDrag(mode); if (sel && sel.type === 'folder') this._onFolderGizmoDrag(mode);
// Multi-выделение (рамка) — двигаем всю группу по дельте пивота.
if (sel && sel.type === 'multi') this._onMultiGizmoDrag(mode);
}); });
// Привязка гизмо к выделенному // Привязка гизмо к выделенному
@ -1552,6 +1586,38 @@ export class BabylonScene {
const decoRadius = Math.max(18, radius * 0.35); const decoRadius = Math.max(18, radius * 0.35);
this.decoManager.updateStreaming(cx, cz, decoRadius); this.decoManager.updateStreaming(cx, cz, decoRadius);
} }
// Чанковый стриминг БЛОКОВ (большие block-карты:
// лабиринты). Радиус больше террейна — высокие стены
// нужно видеть дальше. Регионы вне радиуса скрыты.
if (this._blockStreamingEnabled && this.blockManager?.updateStreaming) {
const blockRadius = Math.max(90, radius * 1.6);
this.blockManager.updateStreaming(cx, cz, blockRadius);
}
}
}
}
// Block-стриминг работает и когда terrain-стриминг ВЫКЛЮЧЕН
// (block-карта без воксельного террейна — как лабиринт).
else if (this._blockStreamingEnabled && this.blockManager?.updateStreaming) {
const nowMs3 = performance.now();
if (nowMs3 - (this._blockStreamingLastUpdate || 0) > 200) {
this._blockStreamingLastUpdate = nowMs3;
let bx, bz;
if (this._isPlaying && this.player && this.player._pos) {
bx = this.player._pos.x; bz = this.player._pos.z;
} else if (this.camera && this.camera.position) {
bx = this.camera.position.x; bz = this.camera.position.z;
}
if (bx !== undefined) {
const px = this._blockStreamingPrevX, pz = this._blockStreamingPrevZ;
const moved = (px === undefined) ||
((bx - px) * (bx - px) + (bz - pz) * (bz - pz) >= 9);
if (moved) {
this._blockStreamingPrevX = bx; this._blockStreamingPrevZ = bz;
const camY = (this.camera && this.camera.position && this.camera.position.y) || 0;
const blockRadius = 90 + Math.max(0, Math.min(40, camY * 0.4));
this.blockManager.updateStreaming(bx, bz, blockRadius);
}
} }
} }
} }
@ -2066,8 +2132,13 @@ export class BabylonScene {
refreshAllShadows() { refreshAllShadows() {
if (!this._shadowGenerator) return; if (!this._shadowGenerator) return;
if (this.blockManager) { if (this.blockManager) {
// Для thin-instance proto-мешей: один addShadowCaster на тип = все инстансы // ОПТИМИЗАЦИЯ: на БОЛЬШИХ block-картах (стриминг — лабиринты, 200к+
if (this.blockManager._protoMeshes) { // блоков) НЕ кастуем тени от блоков. Иначе shadow-map рендерит всю
// видимую геометрию ВТОРОЙ раз → idle ~350мс/кадр при render_ms 1.5.
// Блоки всё равно ПРИНИМАЮТ тени (receiveShadows на proto). Точно
// как terrain (он вообще исключён из shadow casters).
if (!this._blockStreamingEnabled && this.blockManager._protoMeshes) {
// Для thin-instance proto-мешей: один addShadowCaster на тип = все инстансы
for (const proto of this.blockManager._protoMeshes.values()) { for (const proto of this.blockManager._protoMeshes.values()) {
this.addShadowCaster(proto); this.addShadowCaster(proto);
} }
@ -2407,9 +2478,15 @@ export class BabylonScene {
// Free-drag: ЛКМ на объекте при tool=select (и без gizmo-перетаскивания). // Free-drag: ЛКМ на объекте при tool=select (и без gizmo-перетаскивания).
// Запоминаем объект как кандидата — реальное перетаскивание начнётся // Запоминаем объект как кандидата — реальное перетаскивание начнётся
// в mousemove, если курсор сдвинется (иначе это просто клик-выбор). // в mousemove, если курсор сдвинется (иначе это просто клик-выбор).
// Если ЛКМ попала в ПУСТОЕ место (не объект) — запускаем рамку
// выделения (rubber-band / marquee).
if (e.button === 0 && !e.shiftKey && this._activeTool === 'select' && !this._isPlaying) { if (e.button === 0 && !e.shiftKey && this._activeTool === 'select' && !this._isPlaying) {
if (this._beginFreeDragCandidate()) { if (this._beginFreeDragCandidate()) {
e.preventDefault(); e.preventDefault();
} else {
// Пусто под курсором → кандидат на рамку выделения.
// Реальная рамка появится в mousemove после сдвига.
this._beginMarqueeCandidate(e);
} }
} }
@ -2444,6 +2521,7 @@ export class BabylonScene {
if (e.button === 2) { if (e.button === 2) {
this._isRotating = true; this._isRotating = true;
this._clearHover(); // прячем hover пока крутим камеру
this._lastMouseX = e.clientX; this._lastMouseX = e.clientX;
this._lastMouseY = e.clientY; this._lastMouseY = e.clientY;
canvas.style.cursor = 'grabbing'; canvas.style.cursor = 'grabbing';
@ -2478,6 +2556,24 @@ export class BabylonScene {
return; return;
} }
// Рамка выделения (marquee): тянем прямоугольник. Активируем после
// небольшого сдвига, чтобы обычный клик по пустому месту (= снять
// выделение) не превращался в рамку.
if (this._marqueeCandidate) {
if (!this._marqueeActive) {
const ddx = Math.abs(e.clientX - this._marqueeCandidate.startClientX);
const ddy = Math.abs(e.clientY - this._marqueeCandidate.startClientY);
if (ddx > 4 || ddy > 4) {
this._marqueeActive = true;
this._showMarqueeBox();
}
}
if (this._marqueeActive) {
this._updateMarqueeBox(e);
return;
}
}
// Free-drag: тянем объект ЛКМ. Активируем после небольшого сдвига, // Free-drag: тянем объект ЛКМ. Активируем после небольшого сдвига,
// чтобы обычный клик-выбор не превращался в перетаскивание. // чтобы обычный клик-выбор не превращался в перетаскивание.
if (this._freeDragCandidate) { if (this._freeDragCandidate) {
@ -2515,6 +2611,13 @@ export class BabylonScene {
this._updateTerrainBrushPosition(); this._updateTerrainBrushPosition();
} }
// Hover-подсветка (белый контур при наведении). Только инструмент
// «Выделение», не в play, не во время вращения/панорамы камеры.
if (!this._isPlaying && this._activeTool === 'select'
&& !this._isRotating && !this._isPanning && !this._marqueeActive) {
this._scheduleHoverUpdate();
}
if (!this._isRotating && !this._isPanning) return; if (!this._isRotating && !this._isPanning) return;
const dx = e.clientX - this._lastMouseX; const dx = e.clientX - this._lastMouseX;
const dy = e.clientY - this._lastMouseY; const dy = e.clientY - this._lastMouseY;
@ -2536,6 +2639,18 @@ export class BabylonScene {
}; };
const onMouseUp = (e) => { const onMouseUp = (e) => {
// Рамка выделения: завершаем. Если рамку реально тянули — отбираем
// объекты внутри и НЕ обрабатываем как клик (иначе сбросит выбор).
if (this._marqueeCandidate) {
const wasActive = this._marqueeActive;
this._endMarquee(e);
if (wasActive) {
this._mouseDownButton = -1;
return;
}
// Не тянули (просто клик по пустому) — продолжаем обычную
// обработку клика ниже (она снимет выделение).
}
// Free-drag: завершаем перетаскивание. Если объект реально тащили — // Free-drag: завершаем перетаскивание. Если объект реально тащили —
// фиксируем историю и НЕ обрабатываем как клик (иначе сбросит выбор). // фиксируем историю и НЕ обрабатываем как клик (иначе сбросит выбор).
if (this._freeDragCandidate) { if (this._freeDragCandidate) {
@ -2788,12 +2903,15 @@ export class BabylonScene {
canvas.addEventListener('mousedown', onMouseDown, true); canvas.addEventListener('mousedown', onMouseDown, true);
canvas.addEventListener('wheel', onWheel, { passive: false, capture: true }); canvas.addEventListener('wheel', onWheel, { passive: false, capture: true });
canvas.addEventListener('contextmenu', onContextMenu, true); canvas.addEventListener('contextmenu', onContextMenu, true);
// Курсор ушёл с canvas → снять hover-подсветку.
const onCanvasLeave = () => this._clearHover();
// mousemove/mouseup на window — для drag за пределами canvas. // mousemove/mouseup на window — для drag за пределами canvas.
window.addEventListener('mousemove', onMouseMove); window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp); window.addEventListener('mouseup', onMouseUp);
window.addEventListener('keydown', onKeyDown); window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp); window.addEventListener('keyup', onKeyUp);
window.addEventListener('blur', onBlur); window.addEventListener('blur', onBlur);
canvas.addEventListener('mouseleave', onCanvasLeave);
this._listeners = [ this._listeners = [
{ target: canvas, type: 'mousedown', fn: onMouseDown, opts: true }, { target: canvas, type: 'mousedown', fn: onMouseDown, opts: true },
@ -2804,6 +2922,7 @@ export class BabylonScene {
{ target: window, type: 'keydown', fn: onKeyDown }, { target: window, type: 'keydown', fn: onKeyDown },
{ target: window, type: 'keyup', fn: onKeyUp }, { target: window, type: 'keyup', fn: onKeyUp },
{ target: window, type: 'blur', fn: onBlur }, { target: window, type: 'blur', fn: onBlur },
{ target: canvas, type: 'mouseleave', fn: onCanvasLeave },
]; ];
} }
@ -4004,6 +4123,58 @@ export class BabylonScene {
if (this._onSceneChange) this._onSceneChange(); if (this._onSceneChange) this._onSceneChange();
} }
// ── Групповой гизмо для multi-выделения (рамка) ─────────────────────────
// По аналогии с папкой: пивот в центре группы, drag двигает/вращает/
// масштабирует пивот, дельта применяется ко всем объектам через
// selection.moveMultiBy. Сейчас поддержан move (перемещение группы) —
// самая нужная операция; rotate/scale для произвольного multi сложнее
// (блоки на сетке) и пока сводятся к move.
/** Создать пивот-узел в центре multi-группы и привязать к нему gizmo. */
_attachMultiGizmo(center) {
try {
if (this._multiPivot) { this._multiPivot.dispose(); this._multiPivot = null; }
const pivot = new TransformNode('multiPivot', this.scene);
pivot.position = new Vector3(center.x, center.y, center.z);
pivot.rotation = new Vector3(0, 0, 0);
pivot.scaling = new Vector3(1, 1, 1);
this._multiPivot = pivot;
this._multiPivotLast = { x: center.x, y: center.y, z: center.z };
if (this._gizmo) {
this._gizmo.attachTo(pivot);
this._gizmo.refreshMode();
}
} catch (e) { console.warn('[multiGizmo] attach failed', e); }
}
/** Инкрементально применить движение пивота к объектам группы во время drag. */
_onMultiGizmoDrag(mode) {
const pivot = this._multiPivot;
const last = this._multiPivotLast;
if (!pivot || !last || !this.selection) return;
if (mode === 'move') {
const dx = pivot.position.x - last.x;
const dy = pivot.position.y - last.y;
const dz = pivot.position.z - last.z;
// Блоки двигаются по сетке (целые клетки) — копим дробный остаток,
// чтобы при медленном drag блоки тоже сдвигались на целые числа.
if (dx || dy || dz) {
this.selection.moveMultiBy(dx, dy, dz);
last.x = pivot.position.x; last.y = pivot.position.y; last.z = pivot.position.z;
}
}
// rotate/scale для произвольного multi не применяем (см. комментарий выше).
}
/** dragEnd: добираем остаток дельты и пересоздаём пивот в новом центре. */
_applyMultiGizmo(mode) {
if (!this.selection) return;
this._onMultiGizmoDrag(mode);
const c = this.selection.getMultiCenter();
if (c) this._attachMultiGizmo(c);
if (this._onSceneChange) this._onSceneChange();
}
/** /**
* Обновить гизмо под текущее выделение. * Обновить гизмо под текущее выделение.
*/ */
@ -4014,6 +4185,11 @@ export class BabylonScene {
try { this._folderPivot.dispose(); } catch (e) {} try { this._folderPivot.dispose(); } catch (e) {}
this._folderPivot = null; this._folderPivotId = null; this._folderPivot = null; this._folderPivotId = null;
} }
// Сменилось выделение и это НЕ multi → убрать пивот multi.
if ((!sel || sel.type !== 'multi') && this._multiPivot) {
try { this._multiPivot.dispose(); } catch (e) {}
this._multiPivot = null; this._multiPivotLast = null;
}
if (!sel) { if (!sel) {
this._gizmo.attachTo(null); this._gizmo.attachTo(null);
return; return;
@ -4028,6 +4204,14 @@ export class BabylonScene {
} else if (sel.type === 'folder') { } else if (sel.type === 'folder') {
// Групповой gizmo — привязан к пивоту папки (создан в _attachFolderGizmo). // Групповой gizmo — привязан к пивоту папки (создан в _attachFolderGizmo).
if (this._folderPivot) this._gizmo.attachTo(this._folderPivot); if (this._folderPivot) this._gizmo.attachTo(this._folderPivot);
} else if (sel.type === 'multi') {
// Multi (рамка) — привязан к пивоту группы. Если пивота ещё нет
// (multi выставлен не из рамки, напр. Ctrl+клик) — создаём его.
if (!this._multiPivot) {
const c = this.selection.getMultiCenter?.();
if (c) { this._attachMultiGizmo(c); return; }
}
if (this._multiPivot) this._gizmo.attachTo(this._multiPivot);
} }
// Переустанавливаем режим чтобы sub-gizmo (move/rotate/scale) // Переустанавливаем режим чтобы sub-gizmo (move/rotate/scale)
// гарантированно пересоздалась поверх нового attached-mesh. // гарантированно пересоздалась поверх нового attached-mesh.
@ -4069,6 +4253,10 @@ export class BabylonScene {
this._applyFolderGizmo(mode); this._applyFolderGizmo(mode);
return; return;
} }
if (sel.type === 'multi') {
this._applyMultiGizmo(mode);
return;
}
if (sel.type === 'block') { if (sel.type === 'block') {
if (mode === 'move') { if (mode === 'move') {
// Babylon-mesh.position — центр блока (gridX, gridY+0.5, gridZ) // Babylon-mesh.position — центр блока (gridX, gridY+0.5, gridZ)
@ -5500,9 +5688,90 @@ export class BabylonScene {
* Блок: создаёт копию в соседней клетке (по +X, или -X если +X занят, или +Y). * Блок: создаёт копию в соседней клетке (по +X, или -X если +X занят, или +Y).
* Модель: создаёт копию со смещением +1 по X. * Модель: создаёт копию со смещением +1 по X.
*/ */
/**
* Дублировать всё multi-выделение (Ctrl+D над рамкой). Модели/примитивы/
* user-модели копируются РОВНО на месте оригиналов (как в Roblox Studio,
* дубль сразу можно тащить). Блоки в свободную клетку рядом (нельзя
* наложить два блока в одну клетку). По завершении дубли становятся новым
* multi-выделением.
*/
async _duplicateMulti() {
const items = this.selection?.getMultiSelection?.() || [];
if (!items.length) return;
const newSel = [];
for (const it of items) {
try {
if (it.kind === 'block') {
const { x, y, z } = it.ref;
const typeId = this.blockManager?.blocks.get(`${x},${y},${z}`)?.metadata?.blockTypeId;
if (typeId == null) continue;
const cands = [[1, 0, 0], [-1, 0, 0], [0, 0, 1], [0, 0, -1], [0, 1, 0]];
for (const [dx, dy, dz] of cands) {
const nx = x + dx, ny = y + dy, nz = z + dz;
if (ny < 0) continue;
if (!this.blockManager.hasBlock(nx, ny, nz)) {
this.blockManager.addBlock(nx, ny, nz, typeId);
this._copyScriptsToNewObject('block', { x, y, z }, { x: nx, y: ny, z: nz });
newSel.push({ kind: 'block', ref: { x: nx, y: ny, z: nz } });
break;
}
}
} else if (it.kind === 'primitive') {
const d = this.primitiveManager?.instances.get(it.ref);
if (!d) continue;
const newId = this.primitiveManager.addInstance(d.type, {
x: d.x, y: d.y, z: d.z, sx: d.sx, sy: d.sy, sz: d.sz,
rotationX: d.rotationX || 0, rotationY: d.rotationY || 0, rotationZ: d.rotationZ || 0,
color: d.color, material: d.material,
canCollide: d.canCollide, visible: d.visible, anchored: d.anchored,
textureAsset: d.textureAsset || null,
brightness: d.brightness, range: d.range, effect: d.effect,
});
if (newId != null) {
this._copyScriptsToNewObject('primitive', it.ref, newId);
newSel.push({ kind: 'primitive', ref: newId });
}
} else if (it.kind === 'model') {
const d = this.modelManager?.instances.get(it.ref);
if (!d) continue;
const newId = await this.modelManager.addInstance(d.modelTypeId, d.x, d.y, d.z, d.rotationY || 0);
if (newId != null) {
this._copyScriptsToNewObject('model', it.ref, newId);
newSel.push({ kind: 'model', ref: newId });
}
} else if (it.kind === 'userModel') {
const d = this.userModelManager?.instances.get(it.ref);
if (!d) continue;
const newId = await this.userModelManager.addInstance(
d.userModelTypeId, d.x, d.y, d.z, d.rotationY || 0,
{ currentUserId: this._currentUserId || null });
if (newId != null) {
this._copyScriptsToNewObject('userModel', it.ref, newId);
newSel.push({ kind: 'userModel', ref: newId });
}
}
} catch (err) {
// eslint-disable-next-line no-console
console.error('[BabylonScene] duplicate multi item error:', err);
}
}
// Выделяем дубли как новую группу.
if (newSel.length && this.selection) {
this.selection.setMultiSelection(newSel, false);
const c = this.selection.getMultiCenter?.();
if (c) this._attachMultiGizmo(c);
}
this.history?.markChange();
if (this._onSceneChange) this._onSceneChange();
}
duplicateSelected() { duplicateSelected() {
const sel = this.selection?.getSelection(); const sel = this.selection?.getSelection();
if (!sel) return; if (!sel) return;
if (sel.type === 'multi') {
this._duplicateMulti();
return;
}
if (sel.type === 'block') { if (sel.type === 'block') {
// Ищем свободную клетку рядом // Ищем свободную клетку рядом
const candidates = [ const candidates = [
@ -5858,6 +6127,18 @@ export class BabylonScene {
const m = pick.mesh; const m = pick.mesh;
if (m.name && (m.name.startsWith('gridLine') || m.name === 'ground')) return false; if (m.name && (m.name.startsWith('gridLine') || m.name === 'ground')) return false;
if (m.metadata?._isBlockProto) return false; // блоки тащим только гизмо if (m.metadata?._isBlockProto) return false; // блоки тащим только гизмо
// Если есть multi-выделение и кликнули по объекту ВНУТРИ него —
// тащим всю группу (а не пере-выбираем один объект).
const curSel = this.selection?.getSelection?.();
if (curSel?.type === 'multi' && this._meshInMultiSelection(m)) {
const c = this.selection.getMultiCenter();
this._freeDragCandidate = { multi: true, last: { ...(c || { x: 0, y: 0, z: 0 }) } };
this._freeDragHalf = { x: 0.5, y: 0.5, z: 0.5 };
this._freeDragActive = false;
return true;
}
// Выбираем объект (резолв mesh→тип внутри selection). // Выбираем объект (резолв mesh→тип внутри selection).
this.selection?.selectByMesh(m); this.selection?.selectByMesh(m);
const sel = this.selection?.getSelection(); const sel = this.selection?.getSelection();
@ -5902,6 +6183,23 @@ export class BabylonScene {
return; return;
} }
// Multi (рамка): тащим всю группу по дельте центра в горизонтальной
// плоскости (как папку). Гизмо-пивот пересоздадим в _endFreeDrag.
if (cand.multi) {
const ray = this.scene.createPickingRay(this.scene.pointerX, this.scene.pointerY, null, this.scene.activeCamera);
if (Math.abs(ray.direction.y) < 1e-4) return;
const t = (cand.last.y - ray.origin.y) / ray.direction.y;
if (t < 0) return;
const px = ray.origin.x + ray.direction.x * t;
const pz = ray.origin.z + ray.direction.z * t;
const dx = px - cand.last.x, dz = pz - cand.last.z;
if (dx || dz) {
this.selection?.moveMultiBy(dx, 0, dz);
cand.last.x = px; cand.last.z = pz;
}
return;
}
const root = cand.root; const root = cand.root;
const half = this._freeDragHalf || { x: 0.5, y: 0.5, z: 0.5 }; const half = this._freeDragHalf || { x: 0.5, y: 0.5, z: 0.5 };
@ -5956,16 +6254,306 @@ export class BabylonScene {
/** Завершить free-drag, зафиксировать изменение в истории. */ /** Завершить free-drag, зафиксировать изменение в истории. */
_endFreeDrag() { _endFreeDrag() {
const wasActive = this._freeDragActive; const wasActive = this._freeDragActive;
const wasMulti = this._freeDragCandidate?.multi;
this._freeDragCandidate = null; this._freeDragCandidate = null;
this._freeDragActive = false; this._freeDragActive = false;
this._freeDragHalf = null; this._freeDragHalf = null;
if (wasActive) { if (wasActive) {
// После перетаскивания multi-группы — пересоздать пивот гизмо в новом центре.
if (wasMulti && this.selection) {
const c = this.selection.getMultiCenter();
if (c) this._attachMultiGizmo(c);
}
this.history?.markChange(); this.history?.markChange();
if (this._onSceneChange) this._onSceneChange(); if (this._onSceneChange) this._onSceneChange();
} }
return wasActive; return wasActive;
} }
/** Проверить, принадлежит ли mesh одному из объектов в multi-выделении. */
_meshInMultiSelection(mesh) {
if (!this.selection) return false;
const multi = this.selection.getMultiSelection?.() || [];
if (!multi.length) return false;
const md = mesh.metadata || {};
let kind = null, ref = null;
if (md.isBlock) { kind = 'block'; ref = { x: md.gridX, y: md.gridY, z: md.gridZ }; }
else if (md.isModel) { kind = 'model'; ref = md.instanceId; }
else if (md.isPrimitive) { kind = 'primitive'; ref = md.primitiveId; }
else if (md.isUserModel) { kind = 'userModel'; ref = md.instanceId; }
else return false;
return multi.some(it => {
if (it.kind !== kind) return false;
if (kind === 'block') return it.ref.x === ref.x && it.ref.y === ref.y && it.ref.z === ref.z;
return it.ref === ref;
});
}
// ── Рамка выделения (rubber-band / marquee) ─────────────────────────────
// ЛКМ зажата на ПУСТОМ месте (не на объекте) при tool=select → тянем
// прямоугольник. Все объекты, чей ЦЕНТР (экранная проекция позиции)
// попадает в прямоугольник — выделяются (multi-select). Пол не выделяется.
/** Запомнить старт рамки. Реальная рамка появится после сдвига курсора. */
_beginMarqueeCandidate(e) {
this._clearHover(); // не держим белый контур во время рамки
const r = this.canvas.getBoundingClientRect();
this._marqueeCandidate = {
startClientX: e.clientX,
startClientY: e.clientY,
// Координаты относительно canvas (для проекции и оверлея).
startX: e.clientX - r.left,
startY: e.clientY - r.top,
curX: e.clientX - r.left,
curY: e.clientY - r.top,
additive: e.ctrlKey || e.metaKey, // Ctrl — добавить к текущему выделению
};
this._marqueeActive = false;
}
/** Создать DOM-оверлей прямоугольника поверх канваса. */
_showMarqueeBox() {
if (!this._marqueeEl) {
const el = document.createElement('div');
el.style.cssText = [
'position:absolute', 'pointer-events:none', 'z-index:50',
'border:1px solid #38d957',
'background:rgba(56,217,87,0.15)',
'box-shadow:0 0 0 1px rgba(0,0,0,0.25) inset',
'left:0', 'top:0', 'width:0', 'height:0',
].join(';');
// Вставляем в родителя канваса (он position:relative в редакторе).
const parent = this.canvas.parentElement || document.body;
parent.appendChild(el);
this._marqueeEl = el;
}
this._marqueeEl.style.display = 'block';
}
/** Обновить размеры оверлея под текущее положение курсора. */
_updateMarqueeBox(e) {
const cand = this._marqueeCandidate;
if (!cand || !this._marqueeEl) return;
const r = this.canvas.getBoundingClientRect();
cand.curX = e.clientX - r.left;
cand.curY = e.clientY - r.top;
const x0 = Math.min(cand.startX, cand.curX);
const y0 = Math.min(cand.startY, cand.curY);
const w = Math.abs(cand.curX - cand.startX);
const h = Math.abs(cand.curY - cand.startY);
const el = this._marqueeEl;
// Оверлей позиционируется относительно canvas.parentElement, поэтому
// добавляем offset канваса внутри родителя.
el.style.left = (this.canvas.offsetLeft + x0) + 'px';
el.style.top = (this.canvas.offsetTop + y0) + 'px';
el.style.width = w + 'px';
el.style.height = h + 'px';
}
/** Спроецировать мировую точку в экранные координаты канваса (или null если за камерой). */
_projectToScreen(x, y, z) {
const engine = this.engine;
const w = engine.getRenderWidth();
const h = engine.getRenderHeight();
const p = Vector3.Project(
new Vector3(x, y, z),
Matrix.Identity(),
this.scene.getTransformMatrix(),
{ x: 0, y: 0, width: w, height: h }
);
// p.z вне [0,1] → точка за ближней/дальней плоскостью (за спиной камеры).
if (p.z < 0 || p.z > 1) return null;
return { x: p.x, y: p.y };
}
/** Собрать все выделяемые объекты сцены с их центрами. */
_collectSelectableObjects() {
const out = [];
if (this.blockManager) {
for (const mesh of this.blockManager.blocks.values()) {
const md = mesh.metadata;
if (!md?.isBlock) continue;
if (md.locked) continue;
out.push({ kind: 'block', ref: { x: md.gridX, y: md.gridY, z: md.gridZ },
cx: md.gridX, cy: md.gridY + 0.5, cz: md.gridZ });
}
}
if (this.modelManager) {
for (const [id, d] of this.modelManager.instances) {
if (d.locked) continue;
out.push({ kind: 'model', ref: id, cx: d.x || 0, cy: d.y || 0, cz: d.z || 0 });
}
}
if (this.primitiveManager) {
for (const [id, d] of this.primitiveManager.instances) {
if (d.locked) continue;
out.push({ kind: 'primitive', ref: id, cx: d.x || 0, cy: d.y || 0, cz: d.z || 0 });
}
}
if (this.userModelManager) {
for (const [id, d] of this.userModelManager.instances) {
if (d.locked) continue;
out.push({ kind: 'userModel', ref: id, cx: d.x || 0, cy: d.y || 0, cz: d.z || 0 });
}
}
return out;
}
/** Завершить рамку: отобрать объекты внутри и выставить multi-select. */
_endMarquee(e) {
const cand = this._marqueeCandidate;
const wasActive = this._marqueeActive;
this._marqueeCandidate = null;
this._marqueeActive = false;
if (this._marqueeEl) this._marqueeEl.style.display = 'none';
if (!wasActive || !cand) return;
const minX = Math.min(cand.startX, cand.curX);
const maxX = Math.max(cand.startX, cand.curX);
const minY = Math.min(cand.startY, cand.curY);
const maxY = Math.max(cand.startY, cand.curY);
const objs = this._collectSelectableObjects();
const picked = [];
for (const o of objs) {
const s = this._projectToScreen(o.cx, o.cy, o.cz);
if (!s) continue;
if (s.x >= minX && s.x <= maxX && s.y >= minY && s.y <= maxY) {
picked.push({ kind: o.kind, ref: o.ref });
}
}
if (!this.selection) return;
// Ctrl при старте рамки → добавляем к уже выделенному, иначе заменяем.
this.selection.setMultiSelection(picked, cand.additive);
// Привязать групповой гизмо если выбрано >1.
const sel = this.selection.getSelection();
if (sel?.type === 'multi') {
const c = this.selection.getMultiCenter();
if (c) this._attachMultiGizmo(c);
}
this.history?.markChange();
}
// ── Hover-подсветка (белый контур при наведении, как Roblox Studio) ──────
// Наводим мышь на объект → подсвечиваем его белым контуром. Если объект
// в папке — подсвечиваем ВСЮ папку (все её меши), как в Roblox Studio.
/** Запланировать обновление hover на следующий кадр (throttle дорогого pick). */
_scheduleHoverUpdate() {
if (this._hoverRaf) return;
this._hoverRaf = requestAnimationFrame(() => {
this._hoverRaf = 0;
this._updateHover();
});
}
/** Определить набор мешей под курсором для подсветки + уникальный ключ. */
_resolveHoverTarget() {
const pick = this._pickFromMouse();
if (!pick || !pick.mesh) return null;
const m = pick.mesh;
// Пол / сетка / ghost / террейн — не подсвечиваем.
if (m === this._ghostMesh) return null;
if (m.name && (m.name.startsWith('gridLine') || m.name === 'ground')) return null;
const md = m.metadata || {};
if (md._isTerrainProto || md._isRegionMesh || md._isRobloxTerrain) return null;
// Определяем тип объекта + его folderId (как в SelectionManager.selectByMesh).
let kind = null, id = null, folderId = null;
if (md.isBlock) {
kind = 'block'; id = `${md.gridX},${md.gridY},${md.gridZ}`;
folderId = md.folderId ?? null;
} else if (md.isModel) {
kind = 'model'; id = md.instanceId;
folderId = this.modelManager?.instances.get(id)?.folderId ?? null;
} else if (md.isUserModel) {
kind = 'userModel'; id = md.instanceId;
folderId = this.userModelManager?.instances.get(id)?.folderId ?? null;
} else if (md.isPrimitive) {
kind = 'primitive'; id = md.primitiveId;
folderId = this.primitiveManager?.instances.get(id)?.folderId ?? null;
} else {
return null;
}
// Объект в папке → подсвечиваем всю папку.
if (folderId != null && this.folderManager) {
const g = this.folderManager.getFolderObjects(folderId);
const meshes = [];
for (const mesh of g.meshes) this._collectMeshTree(mesh, meshes);
// Блоки папки (у getFolderObjects блоки в g.blocks).
for (const bm of (g.blocks || [])) this._collectMeshTree(bm, meshes);
return { key: `folder:${folderId}`, meshes };
}
// Одиночный объект → собираем его меши.
const meshes = [];
if (kind === 'block') {
this._collectMeshTree(m, meshes);
} else if (kind === 'model') {
const d = this.modelManager?.instances.get(id);
for (const cm of (d?.clonedMeshes || [])) this._collectMeshTree(cm, meshes);
} else if (kind === 'userModel') {
const d = this.userModelManager?.instances.get(id);
for (const um of (d?.meshes || [])) this._collectMeshTree(um, meshes);
} else if (kind === 'primitive') {
const d = this.primitiveManager?.instances.get(id);
if (d?.mesh) this._collectMeshTree(d.mesh, meshes);
}
return { key: `${kind}:${id}`, meshes };
}
/** Добавить mesh и его дочерние меши (только реальные Mesh с геометрией). */
_collectMeshTree(node, out) {
if (!node) return;
// HighlightLayer.addMesh работает только с настоящими Mesh (не TransformNode).
if (typeof node.getClassName === 'function'
&& (node.getClassName() === 'Mesh' || node.getClassName() === 'InstancedMesh')) {
if (node.getTotalVertices?.() > 0) out.push(node);
}
const kids = node.getChildMeshes?.(false) || [];
for (const k of kids) {
if (k.getTotalVertices?.() > 0) out.push(k);
}
}
/** Обновить hover-подсветку под текущим положением курсора. */
_updateHover() {
if (!this._hoverLayer || this._isPlaying || this._activeTool !== 'select') {
this._clearHover();
return;
}
const target = this._resolveHoverTarget();
if (!target || !target.meshes.length) {
this._clearHover();
return;
}
// Тот же объект — ничего не меняем (throttle лишних addMesh).
if (target.key === this._hoverKey) return;
this._clearHover();
const WHITE = new Color3(1, 1, 1);
const seen = new Set();
for (const mesh of target.meshes) {
if (seen.has(mesh)) continue;
seen.add(mesh);
try { this._hoverLayer.addMesh(mesh, WHITE); this._hoverMeshes.push(mesh); }
catch (e) { /* некоторые меши нельзя добавить — игнор */ }
}
this._hoverKey = target.key;
}
/** Снять hover-подсветку. */
_clearHover() {
if (this._hoverLayer && this._hoverMeshes.length) {
for (const mesh of this._hoverMeshes) {
try { this._hoverLayer.removeMesh(mesh); } catch (e) { /* ignore */ }
}
}
this._hoverMeshes = [];
this._hoverKey = null;
}
// ── Небо (задача 16) — обёртки для game-API и UI редактора ────────────── // ── Небо (задача 16) — обёртки для game-API и UI редактора ──────────────
setSkybox(opts) { this.skybox?.setSkybox(opts); if (this._onSceneChange) this._onSceneChange(); } setSkybox(opts) { this.skybox?.setSkybox(opts); if (this._onSceneChange) this._onSceneChange(); }
setClouds(opts) { this.skybox?.setClouds(opts); if (this._onSceneChange) this._onSceneChange(); } setClouds(opts) { this.skybox?.setClouds(opts); if (this._onSceneChange) this._onSceneChange(); }
@ -6189,9 +6777,30 @@ export class BabylonScene {
// Запускаем фоновую музыку и амбиент // Запускаем фоновую музыку и амбиент
this.audioManager?.start(); this.audioManager?.start();
// Создаём PlayerController и стартуем // Создаём PlayerController и стартуем.
// 2026-06-14: В тест-режиме студии (Play) персонаж = СКИН ЮЗЕРА,
// а не из настроек проекта. Источник:
// 1) hash #skin=<id> (передаёт сайт при openStudio)
// 2) localStorage 'rublox_selected_skin' (если открыли студию напрямую)
// 3) _playerModelType из настроек проекта (фолбэк)
let userSkin = null;
try {
const m = (typeof window !== 'undefined' ? window.location.hash : '')
.match(/[#&]skin=([\w-]+)/);
if (m && m[1]) userSkin = m[1];
else if (typeof localStorage !== 'undefined') {
const ls = localStorage.getItem('rublox_selected_skin');
if (ls && typeof ls === 'string') userSkin = ls;
}
} catch (e) {}
const finalSkin = userSkin || this._playerModelType;
// eslint-disable-next-line no-console
console.log('[BabylonScene] play skin:',
'project=' + this._playerModelType,
'user=' + (userSkin || 'none'),
'→ final=' + finalSkin);
this.player = new PlayerController(this.scene, this.canvas, this.physics, this); this.player = new PlayerController(this.scene, this.canvas, this.physics, this);
this.player.setModelType(this._playerModelType); this.player.setModelType(finalSkin);
// Задача 04: модал нуждается в player и audio для блока ввода/freeze камеры/duck // Задача 04: модал нуждается в player и audio для блока ввода/freeze камеры/duck
try { try {
this.modalManager?.attachPlayer?.(this.player); this.modalManager?.attachPlayer?.(this.player);
@ -7731,12 +8340,29 @@ export class BabylonScene {
serialize() { serialize() {
// Принадлежность объектов папкам — серилизуется в их собственных // Принадлежность объектов папкам — серилизуется в их собственных
// данных (folderId), а сами папки в отдельном массиве. // данных (folderId), а сами папки в отдельном массиве.
const blocksWithFolders = this.blockManager ? this.blockManager.serialize() : []; // БЛОКИ: для БОЛЬШИХ карт (лабиринты, 200к+ блоков) — RLE-формат
// BlockManager.serialize не знает про folderId — добавляем его поверх. // (×20-30 меньше, async-загрузка по чанкам без фриза). RLE не хранит
if (this.blockManager) { // folderId на блоках (для процедурных карт он не нужен — все null);
for (const item of blocksWithFolders) { // если на блоках есть реальные folderId — остаёмся на плоском массиве.
const mesh = this.blockManager.blocks.get(`${item.x},${item.y},${item.z}`); let blocksWithFolders;
item.folderId = mesh?.metadata?.folderId ?? null; const blockCount = this.blockManager ? this.blockManager.count() : 0;
let blocksHaveFolders = false;
if (this.blockManager && blockCount > 5000 && typeof this.blockManager.serializeRLE === 'function') {
for (const mesh of this.blockManager.blocks.values()) {
if (mesh?.metadata?.folderId != null) { blocksHaveFolders = true; break; }
}
}
if (this.blockManager && blockCount > 5000 && !blocksHaveFolders
&& typeof this.blockManager.serializeRLE === 'function') {
blocksWithFolders = this.blockManager.serializeRLE(); // {format:'blocks-rle-v1',...}
} else {
blocksWithFolders = this.blockManager ? this.blockManager.serialize() : [];
// BlockManager.serialize не знает про folderId — добавляем его поверх.
if (this.blockManager) {
for (const item of blocksWithFolders) {
const mesh = this.blockManager.blocks.get(`${item.x},${item.y},${item.z}`);
item.folderId = mesh?.metadata?.folderId ?? null;
}
} }
} }
const modelsWithFolders = this.modelManager ? this.modelManager.serialize() : []; const modelsWithFolders = this.modelManager ? this.modelManager.serialize() : [];
@ -7945,9 +8571,29 @@ export class BabylonScene {
this.setShadowQuality(state.scene.shadowQuality); this.setShadowQuality(state.scene.shadowQuality);
} }
// Блоки — синхронно // Блоки — синхронно. Для БОЛЬШИХ block-карт (лабиринты и т.п.) включаем
if (this.blockManager && Array.isArray(state.scene.blocks)) { // чанковый стриминг: блоки бьются на регионы 48×48, дальние скрываются
this.blockManager.loadFromArray(state.scene.blocks); // по радиусу вокруг камеры/игрока (см. blockManager.updateStreaming в
// onBeforeRender). Иначе 200к+ блоков рендерятся все сразу → FPS висит.
// 48 (а не 32) — баланс: меньше proto-мешей/draw-call, скрытие ~75%.
// Поддерживаем 2 формата блоков (как террейн):
// 1. Legacy: blocks = [{x,y,z,type}, ...] — малые карты
// 2. RLE: blocks = {format:'blocks-rle-v1', palette, chunks, props}
// — большие карты (лабиринты), ×20-30 меньше, async без фриза
const bs = state.scene.blocks;
if (this.blockManager && bs && bs.format === 'blocks-rle-v1') {
// RLE-карта всегда большая → стриминг + тени-OFF
if (this.blockManager.enableStreaming) {
this.blockManager.enableStreaming(48);
this._blockStreamingEnabled = true;
}
await this.blockManager.loadFromRLE(bs);
} else if (this.blockManager && Array.isArray(bs)) {
if (bs.length >= 5000 && this.blockManager.enableStreaming) {
this.blockManager.enableStreaming(48);
this._blockStreamingEnabled = true;
}
this.blockManager.loadFromArray(bs);
} }
// Террейн (voxel-ландшафт). Поддерживаем 2 формата: // Террейн (voxel-ландшафт). Поддерживаем 2 формата:
@ -8293,7 +8939,7 @@ export class BabylonScene {
if (state.scene.playerModelType) { if (state.scene.playerModelType) {
const pmt = state.scene.playerModelType; const pmt = state.scene.playerModelType;
if (pmt.startsWith('character-')) { if (pmt.startsWith('character-')) {
this._playerModelType = 'skin_bacon-hair'; this._playerModelType = 'skin_y-bot';
} else { } else {
this._playerModelType = pmt; this._playerModelType = pmt;
} }
@ -8610,6 +9256,23 @@ export class BabylonScene {
try { this._gizmoLayer.dispose(); } catch (e) { /* ignore */ } try { this._gizmoLayer.dispose(); } catch (e) { /* ignore */ }
this._gizmoLayer = null; this._gizmoLayer = null;
} }
if (this._multiPivot) {
try { this._multiPivot.dispose(); } catch (e) { /* ignore */ }
this._multiPivot = null;
}
if (this._marqueeEl) {
try { this._marqueeEl.remove(); } catch (e) { /* ignore */ }
this._marqueeEl = null;
}
if (this._hoverRaf) {
try { cancelAnimationFrame(this._hoverRaf); } catch (e) { /* ignore */ }
this._hoverRaf = 0;
}
if (this._hoverLayer) {
try { this._hoverLayer.dispose(); } catch (e) { /* ignore */ }
this._hoverLayer = null;
this._hoverMeshes = [];
}
if (this.selection) { if (this.selection) {
this.selection.dispose(); this.selection.dispose();
this.selection = null; this.selection = null;

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

@ -0,0 +1,593 @@
/**
* MixamoAnimator проигрывает Mixamo-анимации на скелете персонажа.
*
* Mixamo-скины (skin_y-bot, skin_x-bot, и ещё 78) приходят БЕЗ
* AnimationGroups в их собственном GLB. Анимации лежат отдельными
* GLB-файлами в /character-assets/animations/:
*
* idle.glb, walk.glb, run.glb, jump.glb, fall.glb
* emote_capoeira.glb, emote_defeated.glb, emote_shoved.glb, emote_taunt.glb
*
* Каждый GLB содержит ровно одну AnimationGroup, нацеленную на bones
* с именами `mixamorig:Hips`, `mixamorig:Spine` и т.д.
*
* Что делает этот класс:
* 1. Загружает 5 базовых GLB параллельно и кэширует AnimationGroup'ы
* (singleton один loader на сессию).
* 2. Для конкретного скина РЕТАРГЕТИТ AnimationGroup на его кости.
* Mixamo-скины разных вышедших времён имеют префикс `mixamorig:`,
* `mixamorig9:` или вообще без префикса детектим автоматически.
* 3. Управление: `setState('idle'|'walk'|'run'|'jump'|'fall')` +
* плавный кросс-фейд (blending) между состояниями.
* 4. `playEmote(name, onDone)` одноразово проиграть эмоцию поверх,
* после конца автоматически вернуться в текущее состояние.
*
* Bone-имена которые ретаргетим (24 обязательных):
* Hips, Spine, Spine1, Spine2, Neck, Head,
* LeftShoulder, LeftArm, LeftForeArm, LeftHand,
* RightShoulder, RightArm, RightForeArm, RightHand,
* LeftUpLeg, LeftLeg, LeftFoot, LeftToeBase,
* RightUpLeg, RightLeg, RightFoot, RightToeBase
*
* Использование:
* const anim = new MixamoAnimator();
* await anim.load(); // один раз на сессию
* anim.attach(scene, skeleton, modelRoot); // на каждую загрузку скина
* anim.setState('idle');
* // каждый кадр в _tick (необязательно — Babylon сам тикает groups):
* anim.update(dt);
* // эмоция:
* anim.playEmote('emote_taunt');
* // при смене скина:
* anim.detach();
*/
import { SceneLoader, AnimationGroup, Animation } from "@babylonjs/core";
import "@babylonjs/loaders/glTF";
// Базовые состояния — соответствуют файлам *.glb в animations/.
// Базовые (всегда грузятся при старте — нужны для движения):
const BASE_STATES = ["idle", "walk", "run", "jump", "fall"];
// Дополнительные движения (грузятся лениво при первом setState):
const EXTRA_STATES = [
"jump_anticipate", "jump_air", "jump_land",
"jump_fwd_anticipate", "jump_fwd_air", "jump_fwd_land",
"jump_run_anticipate", "jump_run_air", "jump_run_land",
"walk_backward", "run_backward", "run_to_stop", "run_slide",
"jump_forward", "jump_backward", "jump_down",
"crouch_enter", "crouch_idle", "crouch_walk", "crouch_to_stand",
"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",
"sword_idle", "sword_slash",
"push_button", "open_door", "throw_action",
];
// Эмоции (вызываются через playEmote()):
const EMOTES = [
"emote_capoeira", "emote_defeated", "emote_shoved", "emote_taunt",
"emote_salute", "emote_pointing", "emote_no",
"dance_hiphop", "dance_rumba", "dance_breakdance",
];
// Все известные анимации (для опциональной полной предзагрузки)
const ALL_ANIMATIONS = [...BASE_STATES, ...EXTRA_STATES, ...EMOTES];
// Кэш сырых данных анимаций между инстансами (singleton-ish):
// один раз загрузили — используем для всех аватаров.
let _cachedRawTargets = null; // { idle: [{boneName, animations:[Anim]}], walk: [...] , ... }
let _loadPromise = null;
/**
* Строит абсолютный URL для статики Mixamo-анимаций.
* Локально localhost:3000 (rublox-site dev-server),
* на проде rublox.pro/character-assets/.
*/
function _assetsBase() {
if (typeof window === "undefined") return "";
const isLocal = window.location.hostname === "localhost"
|| window.location.hostname === "127.0.0.1";
return isLocal ? "http://localhost:3000" : "https://rublox.pro";
}
/**
* Нормализует имя кости: убирает префикс `mixamorig:`, `mixamorig9:`,
* `mixamorig_` и т.п. Возвращает чистое имя типа `Hips`, `Spine`, `LeftArm`.
*/
function _normalizeBone(name) {
if (!name) return "";
// mixamorig:Hips, mixamorig9:Hips, mixamorig_Hips, Armature|mixamorig:Hips, etc
let n = name;
const colon = n.lastIndexOf(":");
if (colon >= 0) n = n.slice(colon + 1);
n = n.replace(/^mixamorig\d*[_:.]?/i, "");
n = n.replace(/^Armature\|/, "");
return n;
}
/**
* Загружает один GLB-файл с анимациями. Возвращает массив
* { boneName, animations: [Babylon.Animation] } сырые треки,
* привязанные к именам костей (без префикса).
*/
async function _loadAnimGlb(scene, url) {
// ImportAnimations не годится — он сразу target-ит конкретный
// скелет. Нам нужны сырые animations[], чтобы потом каждому
// скину пристёгивать отдельно.
const result = await SceneLoader.LoadAssetContainerAsync(
url.substring(0, url.lastIndexOf("/") + 1),
url.substring(url.lastIndexOf("/") + 1),
scene,
);
const out = [];
// В GLB от Mixamo каждая кость — это TransformNode (или Bone),
// содержит свои keyframe animations. После загрузки они на
// result.transformNodes / result.skeletons[].bones.
const allNodes = [
...(result.transformNodes || []),
...((result.skeletons || []).flatMap(sk => sk.bones || [])),
];
for (const node of allNodes) {
if (!node.animations || node.animations.length === 0) continue;
const cleanName = _normalizeBone(node.name);
if (!cleanName) continue;
out.push({ boneName: cleanName, animations: node.animations.slice() });
}
// Освободим геометрию (если случайно приехала — у анимаций мешей нет)
result.dispose();
return out;
}
/**
* Загрузить базовые анимации (idle/walk/run/jump/fall) один раз.
* Дополнительные анимации (extra + эмоции) грузятся лениво в _ensureLoaded
* при первом обращении это экономит трафик: юзер качает только то что
* реально использует в игре.
*/
export async function loadMixamoAnimations(scene) {
if (_loadPromise) return _loadPromise;
_cachedRawTargets = _cachedRawTargets || {};
_loadPromise = (async () => {
const base = _assetsBase();
const entries = await Promise.all(
BASE_STATES.map(async (name) => {
try {
const tracks = await _loadAnimGlb(
scene, `${base}/character-assets/animations/${name}.glb`);
return [name, tracks];
} catch (e) {
console.warn(`[MixamoAnimator] не загрузилась '${name}':`, e?.message || e);
return [name, []];
}
})
);
for (const [k, v] of entries) _cachedRawTargets[k] = v;
// eslint-disable-next-line no-console
console.log("[MixamoAnimator] базовые анимации загружены:",
Object.entries(_cachedRawTargets).map(([k, v]) => `${k}=${v.length}tracks`).join(", "));
return _cachedRawTargets;
})();
return _loadPromise;
}
/**
* Ленивая подгрузка одной анимации по имени (если ещё не в кэше).
* Возвращает массив tracks или null если не удалось.
*/
async function _ensureLoaded(scene, name) {
if (!_cachedRawTargets) _cachedRawTargets = {};
if (_cachedRawTargets[name]) return _cachedRawTargets[name];
const base = _assetsBase();
try {
const tracks = await _loadAnimGlb(
scene, `${base}/character-assets/animations/${name}.glb`);
_cachedRawTargets[name] = tracks;
// eslint-disable-next-line no-console
console.log(`[MixamoAnimator] lazy-load '${name}': ${tracks.length} tracks`);
return tracks;
} catch (e) {
console.warn(`[MixamoAnimator] не удалось загрузить '${name}':`, e?.message || e);
_cachedRawTargets[name] = [];
return null;
}
}
export class MixamoAnimator {
constructor() {
this.scene = null;
this.skeleton = null;
this.modelRoot = null;
/** Map<state, AnimationGroup> — кастомные группы для ЭТОГО скелета */
this._groups = new Map();
this._currentState = null;
this._currentGroup = null;
this._currentEmote = null;
this._emoteOnDone = null;
this._blendInProgress = false;
}
/**
* Пристёгивает аниматор к конкретному скелету (после загрузки модели).
* scene Babylon Scene, skeleton Babylon Skeleton, modelRoot TransformNode.
*/
attach(scene, skeleton, modelRoot) {
this.scene = scene;
this.skeleton = skeleton;
this.modelRoot = modelRoot;
// Резолвим маппинг "clean name" → Bone (из текущего скелета).
this._cleanToBone = new Map();
for (const b of (skeleton.bones || [])) {
const clean = _normalizeBone(b.name);
if (clean && !this._cleanToBone.has(clean)) {
this._cleanToBone.set(clean, b);
}
}
// Также детектим target-property: TransformNode? linkedTransformNode?
// Mixamo-анимации обычно нацелены на linkedTransformNode'ы (если есть),
// потому что в glTF skin'ы делают joints через nodes, не через Bones.
// Для каждой кости берём её _linkedTransformNode (Babylon API).
this._cleanToTarget = new Map();
for (const [name, bone] of this._cleanToBone) {
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 для конкретного состояния. */
_ensureGroup(state) {
if (this._groups.has(state)) return this._groups.get(state);
if (!_cachedRawTargets || !_cachedRawTargets[state]) return null;
const raw = _cachedRawTargets[state];
const group = new AnimationGroup(`mixamo_${state}`, this.scene);
let attached = 0;
for (const t of raw) {
const target = this._cleanToTarget.get(t.boneName);
if (!target) continue;
for (const anim of t.animations) {
// Клонируем анимацию (одна Babylon.Animation не может
// быть в двух разных AnimationGroup одновременно).
const cloned = anim.clone();
// Mixamo всегда грузит Hips.position — это сдвигает
// персонажа по сцене. В in-place анимациях должно быть
// близко к нулю, но иногда сдвиг есть. Для базовых
// движений (walk/run/jump) фильтруем targetProperty=position
// у кости с именем Hips — её двигает наш PlayerController.
if (t.boneName === "Hips" && cloned.targetProperty === "position") {
// 3-фазная модель прыжка:
// jump_anticipate — присед перед прыжком. baseY = первый кадр
// (стоячая поза → опускается ниже).
// jump_air — физика поднимает _modelRoot, Hips.Y не используем.
// jump_land — приземление с амортизацией. baseY = МИНИМУМ
// (самая низкая точка приседа), так первый кадр будет Y > 0
// (только что приземлились, ноги пружинят к bind),
// середина = 0 (присед на полу), конец = выпрямление.
// Для всех остальных — фильтруем (физика двигает _modelRoot).
const PHASES = new Set([
'jump_anticipate', 'jump_land',
'jump_fwd_anticipate', 'jump_fwd_land',
'jump_run_anticipate', 'jump_run_land',
]);
if (!PHASES.has(state)) {
continue;
}
const rest = this._restPositions?.get('Hips');
try {
const keys = cloned.getKeys();
if (keys && keys.length > 0 && keys[0].value) {
// baseY = МАКСИМУМ Y по клипу. Тогда delta = k.Y - max
// всегда ≤ 0 → Hips только опускается ниже bind.
// jump_land: персонаж приземлился (ноги на полу = bind),
// потом корпус опускается = присед амортизации,
// потом возвращается обратно к bind (выпрямление).
// jump_anticipate: то же — корпус опускается из стоячей.
let maxY = -Infinity;
for (const k of keys) {
const y = k.value.y || 0;
if (y > maxY) maxY = y;
}
const baseY = Number.isFinite(maxY) ? maxY : (keys[0].value.y || 0);
const newKeys = keys.map(k => ({
frame: k.frame,
value: new (k.value.constructor)(
rest ? rest.x : 0,
(rest ? rest.y : 0) + ((k.value.y || 0) - baseY),
rest ? rest.z : 0,
),
inTangent: k.inTangent,
outTangent: k.outTangent,
interpolation: k.interpolation,
}));
cloned.setKeys(newKeys);
}
} catch (e) { continue; }
}
group.addTargetedAnimation(cloned, target);
attached++;
}
}
if (attached === 0) {
group.dispose();
// eslint-disable-next-line no-console
console.warn(`[MixamoAnimator] state='${state}' — 0 целей зарезолвлено, skip`);
return null;
}
// Зацикливаем базовые состояния, кроме jump (он one-shot).
// ВАЖНО: для AnimationGroup нужно ставить loopAnimation=true НА
// САМОМ GROUP до start(). Параметр loop в start() игнорируется в
// некоторых версиях Babylon 7.x.
// One-shot анимации (играются один раз, не зацикливаются):
// jump, crouch_enter, crouch_to_stand, crouch_exit + все эмоции и
// эпизодические действия (hit, die, throw, pickup, gun_fire, gun_reload и т.д.)
const ONE_SHOT = new Set([
"jump", "jump_forward", "jump_backward", "jump_down",
"jump_anticipate", "jump_land",
"jump_fwd_anticipate", "jump_fwd_air", "jump_fwd_land",
"jump_run_anticipate", "jump_run_air", "jump_run_land",
"crouch_enter", "crouch_to_stand",
"climb_to_top",
"hit_react", "die_forward", "die_back",
"throw_action", "pickup", "push_button", "open_door",
"gun_fire", "gun_reload", "sword_slash",
"kick_low", "kick_high", "punch_left",
]);
// emote_* — one-shot (один жест), dance_* — лупим (танцы должны крутиться)
const loopable = !ONE_SHOT.has(state) && !state.startsWith("emote_");
group.loopAnimation = loopable;
group.normalize();
// Safety-net: если Babylon всё равно по какой-то причине отыграл
// клип до конца И не зациклил (что бывает с короткими "still pose"
// клипами от Mixamo вроде Crouched Idle ~0.5s) — перезапускаем
// принудительно. Это даёт стабильно зацикленную анимацию.
if (loopable) {
group.onAnimationGroupEndObservable.add(() => {
if (this._currentGroup === group && !this._currentEmote) {
try {
group.reset();
group.start(true, 1.0, group.from, group.to, false);
} catch (_) {}
}
});
}
// eslint-disable-next-line no-console
console.log(`[MixamoAnimator] group '${state}': ${attached} tracks, loop=${loopable}, duration=${((group.to - group.from) / 60).toFixed(2)}s`);
this._groups.set(state, group);
return group;
}
/** Установить состояние с плавным кросс-фейдом 150 мс.
* Если анимация ещё не подгружена стартует lazy-load, при этом
* setState вернётся синхронно (без ожидания) анимация подхватится
* на следующем тике после успешной загрузки.
*
* Anti-flicker: между переключениями требуется минимальная задержка
* 120мс (кроме переходов в воздух/идл из приземления). Это убирает
* «дрожание» crouch_walk crouch_idle когда игрок едет по диагонали
* и одно из направлений физически дёргается между кадрами. */
setState(state) {
if (this._currentEmote) return; // эмоция блокирует смену состояния
if (state === this._currentState) return;
// Сброс Hips.position в bind-pose при выходе из jump-фаз.
// Иначе последний keyframe анимации остаётся на Hips и idle/walk
// подхватывает смещённую позицию → персонаж проседает.
const JUMP_STATES = new Set([
'jump_air', 'jump_land', 'jump_in_place', 'jump_anticipate',
'jump_fwd_anticipate', 'jump_fwd_air', 'jump_fwd_land',
'jump_run_anticipate', 'jump_run_air', 'jump_run_land',
]);
if (JUMP_STATES.has(this._currentState) && !JUMP_STATES.has(state)
&& this._restPositions) {
const rest = this._restPositions.get('Hips');
const hips = this._cleanToTarget?.get('Hips');
if (rest && hips && hips.position) {
try {
hips.position.x = rest.x;
hips.position.y = rest.y;
hips.position.z = rest.z;
} catch (_) {}
}
}
const now = (typeof performance !== 'undefined' ? performance.now() : Date.now());
// Anti-flicker debounce: не даём переключать состояние чаще чем раз в 120мс,
// КРОМЕ переходов с/на воздушные состояния (jump/fall) — там важна скорость
// и one-shot crouch_enter/crouch_to_stand (они короткие).
const JUMP_VITAL = new Set([
'jump', 'fall', 'jump_air', 'jump_land', 'jump_anticipate',
'jump_fwd_anticipate', 'jump_fwd_air', 'jump_fwd_land',
'jump_run_anticipate', 'jump_run_air', 'jump_run_land',
]);
const isVitalSwitch = JUMP_VITAL.has(state)
|| JUMP_VITAL.has(this._currentState)
|| state === 'crouch_enter' || state === 'crouch_to_stand';
if (!isVitalSwitch && this._lastSwitchAt && (now - this._lastSwitchAt) < 120) {
// Запомним последний запрошенный state — если он не изменится за
// окно debounce, тогда применим, иначе отбросим вспышку.
this._pendingState = state;
if (!this._debounceTimer) {
const delay = Math.max(0, 120 - (now - this._lastSwitchAt));
this._debounceTimer = setTimeout(() => {
this._debounceTimer = null;
const s = this._pendingState;
this._pendingState = null;
if (s && s !== this._currentState) this.setState(s);
}, delay);
}
return;
}
this._lastSwitchAt = now;
// Если ещё не загружено — стартуем lazy-load, но ТЕКУЩУЮ анимацию
// НЕ останавливаем (иначе в момент Ctrl-on/off персонаж зависает
// в bind-pose пока crouch_idle асинхронно качается).
if (!_cachedRawTargets || !_cachedRawTargets[state]) {
if (!this._pendingLoads) this._pendingLoads = new Set();
if (!this._pendingLoads.has(state)) {
this._pendingLoads.add(state);
_ensureLoaded(this.scene, state).then(() => {
this._pendingLoads.delete(state);
});
}
return; // подхватится при следующем setState когда tracks будут
}
const next = this._ensureGroup(state);
if (!next) return;
const prev = this._currentGroup;
// Loop-флаг берём напрямую с group — _ensureGroup уже разрулил
// (one-shot list + emote_* → не лупим).
const loop = next.loopAnimation;
// Лог переключений (только если изменилось — иначе спам)
// eslint-disable-next-line no-console
console.log(`[MixamoAnimator] setState: ${this._currentState || 'none'}${state} (loop=${loop})`);
// Per-state speedRatio: подгоняем длительность под физику.
// jump_fwd_air: Mixamo Jump полёт = 0.43с, физика = 0.73с
// → speedRatio = 0.59 (замедлить чтобы клип не зациклился).
// jump_fwd_air: Mixamo Jump полёт 0.43с, физика 0.73с → 0.59
// jump_run_air: Mixamo Running Jump полёт 0.52с, физика 0.73с → 0.71
const SPEED_RATIO = {
jump_fwd_air: 0.59,
jump_run_air: 0.71,
};
const speedRatio = SPEED_RATIO[state] || 1.0;
// Запустить новую анимацию. Babylon 7 ВНИМАНИЕ: параметр loop
// в start() иногда игнорится — дублируем через loopAnimation
// (выставлен в _ensureGroup).
try {
next.reset();
next.start(loop, speedRatio, next.from, next.to, false);
} catch (e) {
try { next.play(loop); } catch (_) {}
}
// Кросс-фейд через weight (0→1 у новой, 1→0 у старой) за BLEND_MS.
// 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'ов.
if (this._blendObservers && this._blendObservers.length) {
for (const o of this._blendObservers) {
try { this.scene.onBeforeRenderObservable.remove(o); } catch (_) {}
}
}
this._blendObservers = [];
// КРИТИЧНО: при ЛЮБОМ setState останавливаем ВСЕ остальные группы
// кроме новой. Это убирает кейсы когда rapid-switching между
// prev/next/третий оставляет висящую группу из позапрошлого setState
// (и она «крутится» дальше в фоне с весом 1).
for (const g of this._groups.values()) {
if (g !== next) {
// Не стопим текущую blend-исходную — она нужна для фейда.
if (g !== prev) {
try { g.stop(); g.setWeightForAllAnimatables(0); } catch (_) {}
}
}
}
if (prev && prev !== next) {
const startedAt = (typeof performance !== 'undefined' ? performance.now() : Date.now());
const prevGroup = prev;
const nextGroup = next;
const obs = this.scene.onBeforeRenderObservable.add(() => {
const nowMs = (typeof performance !== 'undefined' ? performance.now() : Date.now());
const t = Math.min(1, (nowMs - startedAt) / BLEND_MS);
// Если за это время _currentGroup сменилась ещё раз —
// прекращаем blend (новый setState уже разрулил).
if (this._currentGroup !== nextGroup) {
try { this.scene.onBeforeRenderObservable.remove(obs); } catch (_) {}
return;
}
try {
prevGroup.setWeightForAllAnimatables(1 - t);
nextGroup.setWeightForAllAnimatables(t);
} catch (_) {}
if (t >= 1) {
try { prevGroup.stop(); prevGroup.setWeightForAllAnimatables(0); } catch (_) {}
try { nextGroup.setWeightForAllAnimatables(1); } catch (_) {}
this.scene.onBeforeRenderObservable.remove(obs);
}
});
this._blendObservers.push(obs);
} else {
try { next.setWeightForAllAnimatables(1); } catch (_) {}
}
this._currentState = state;
this._currentGroup = next;
}
/** Проиграть эмоцию (one-shot), потом вернуться в idle.
* Если эмоция ещё не подгружена подгружает на лету и стартует. */
async playEmote(name, onDone) {
const tracks = await _ensureLoaded(this.scene, name);
if (!tracks || tracks.length === 0) {
console.warn(`[MixamoAnimator] эмоция '${name}' не загружена`);
if (onDone) onDone();
return;
}
const group = this._ensureGroup(name);
if (!group) { if (onDone) onDone(); return; }
// Стоп текущего состояния
if (this._currentGroup) {
try { this._currentGroup.stop(); } catch (_) {}
}
this._currentEmote = name;
this._emoteOnDone = onDone || null;
const savedState = this._currentState;
try {
group.start(false, 1.0, group.from, group.to, false);
} catch (e) {
try { group.play(false); } catch (_) {}
}
const onEnd = () => {
this._currentEmote = null;
this._currentState = null; // принудим setState заново запустить
this.setState(savedState || "idle");
if (this._emoteOnDone) {
const cb = this._emoteOnDone;
this._emoteOnDone = null;
try { cb(); } catch (_) {}
}
};
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 */ }
/** Остановить и освободить все группы для этого скелета. */
detach() {
if (this._currentGroup) { try { this._currentGroup.stop(); } catch (_) {} }
for (const g of this._groups.values()) {
try { g.dispose(); } catch (_) {}
}
this._groups.clear();
this._currentGroup = null;
this._currentState = null;
this._currentEmote = null;
this.scene = null;
this.skeleton = null;
this.modelRoot = null;
}
}

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

@ -28,6 +28,28 @@ import {
import { getModelType } from './ModelTypes'; import { getModelType } from './ModelTypes';
import { R15Skeleton } from './R15Skeleton'; import { R15Skeleton } from './R15Skeleton';
import { R15Animator } from './R15Animator'; import { R15Animator } from './R15Animator';
import { MixamoAnimator, loadMixamoAnimations } from './MixamoAnimator';
// Список всех Mixamo-скинов. Должен совпадать со списком в плеере и
// каталоге сайта (rublox-site/src/data/skinsCatalog.js).
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',
'skin_ch09', 'skin_ch10', 'skin_ch11', 'skin_ch13', 'skin_ch14', 'skin_ch15',
'skin_ch16', 'skin_ch17', 'skin_ch18', 'skin_ch19', 'skin_ch20', 'skin_ch21',
'skin_ch22', 'skin_ch23', 'skin_ch24', 'skin_ch29', 'skin_ch31', 'skin_ch32',
'skin_ch33', 'skin_ch34', 'skin_ch35', 'skin_ch39', 'skin_ch40', 'skin_ch42',
'skin_ch43', 'skin_ch44', 'skin_ch45', 'skin_ch46', 'skin_ch47', 'skin_ch48',
'skin_claire', 'skin_demon', 'skin_ely', 'skin_erika-archer',
'skin_eve', 'skin_exo-gray', 'skin_exo-red', 'skin_ganfaul', 'skin_heraklios',
'skin_kachujin', 'skin_kaya', 'skin_knight', 'skin_lola', 'skin_maria',
'skin_maw', 'skin_medea', 'skin_mutant', 'skin_nightshade',
'skin_paladin', 'skin_passive-marker-man', 'skin_peasant-girl', 'skin_peasant-man',
'skin_prisoner', 'skin_pumpkinhulk', 'skin_skeleton-zombie', 'skin_sporty-granny',
'skin_survivor', 'skin_swat', 'skin_ty', 'skin_uriel', 'skin_vampire',
'skin_war-zombie', 'skin_warrok', 'skin_white-clown', 'skin_x-bot', 'skin_y-bot',
]);
// Цикл режимов: первое лицо → за спиной → спереди (показ персонажа лицом). // Цикл режимов: первое лицо → за спиной → спереди (показ персонажа лицом).
// 'front' удобен чтобы оценить скин: камера спереди, смотрит на лицо персонажа. // 'front' удобен чтобы оценить скин: камера спереди, смотрит на лицо персонажа.
@ -64,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;
@ -177,6 +205,7 @@ export class PlayerController {
this._isR15 = false; // флаг: загружен валидный R15-скелет this._isR15 = false; // флаг: загружен валидный R15-скелет
this._r15Skeleton = null; // R15Skeleton — резолвер костей this._r15Skeleton = null; // R15Skeleton — резолвер костей
this._r15Animator = null; // R15Animator — процедурные анимации this._r15Animator = null; // R15Animator — процедурные анимации
this._mixamoAnimator = null; // MixamoAnimator — Mixamo-скины
this._skinManifest = null; // кеш skins_manifest.json this._skinManifest = null; // кеш skins_manifest.json
this._skinOverrides = {}; // overrides текущего скина this._skinOverrides = {}; // overrides текущего скина
@ -312,6 +341,8 @@ export class PlayerController {
this._r15Skeleton = null; this._r15Skeleton = null;
this._r15Animator = null; this._r15Animator = null;
this._isR15 = false; this._isR15 = false;
try { if (this._mixamoAnimator) this._mixamoAnimator.detach(); } catch (e) {}
this._mixamoAnimator = null;
this._modelKind = 'r15'; this._modelKind = 'r15';
this._modelHipHeight = null; this._modelHipHeight = null;
this._nonHumanoidBox = null; this._nonHumanoidBox = null;
@ -654,6 +685,21 @@ export class PlayerController {
async _resolveModelSource() { async _resolveModelSource() {
const typeId = this._modelTypeId || 'character-a'; const typeId = this._modelTypeId || 'character-a';
if (typeId.startsWith('skin_')) { if (typeId.startsWith('skin_')) {
// 2026-06-14: Mixamo-скины (80 шт) — отдельные GLB на rublox-site
// (/character-assets/skins/), без R15-скелета, с Mixamo-rig.
if (MIXAMO_SKINS.has(typeId)) {
const base = (typeof window !== 'undefined'
&& window.location.hostname === 'localhost')
? 'http://localhost:3000'
: 'https://rublox.pro';
return {
file: `${base}/character-assets/skins/${typeId}.glb?v=20260614`,
isR15: false,
kind: 'non-humanoid-rigged',
overrides: {},
isMixamo: true,
};
}
const manifest = await this._loadSkinManifest(); const manifest = await this._loadSkinManifest();
const entry = manifest.find((s) => s.id === typeId); const entry = manifest.find((s) => s.id === typeId);
const baseUrl = this._skinManifestBaseUrl || '/kubikon-assets'; const baseUrl = this._skinManifestBaseUrl || '/kubikon-assets';
@ -893,11 +939,51 @@ export class PlayerController {
} }
// Анимации. // Анимации.
// R15-скины не содержат AnimationGroups (анимируются процедурно // R15-скины — процедурно через R15Animator.
// через R15Animator в _tick). Kenney-модели — наоборот, имеют // Mixamo-скины (non-humanoid-rigged) — через MixamoAnimator
// встроенные AnimationGroups (idle/walk/sprint/jump). // (5 базовых + lazy эмоции грузятся с /character-assets/animations/).
// Kenney-модели — встроенные AnimationGroups (idle/walk/sprint/jump).
this._animations = {}; this._animations = {};
if (!this._isR15) { this._mixamoAnimator = null;
if (source.isMixamo || source.kind === 'non-humanoid-rigged') {
let mixSk = (inst.skeletons && inst.skeletons[0]) || null;
if (!mixSk && container.skeletons && container.skeletons.length > 0) {
mixSk = container.skeletons[0];
}
if (!mixSk) {
const meshWithSkel = root.getChildMeshes(false).find((m) => m.skeleton);
if (meshWithSkel) mixSk = meshWithSkel.skeleton;
}
if (mixSk) {
try {
const animator = new MixamoAnimator();
loadMixamoAnimations(this.scene)
.then(() => {
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) {}
try { window.__mixamo = animator; } catch (e) {}
// eslint-disable-next-line no-console
console.log('[PlayerController] MixamoAnimator готов, скелет=' + mixSk.bones.length + ' bones');
})
.catch((e) => {
// eslint-disable-next-line no-console
console.warn('[PlayerController] MixamoAnimator не загрузился:', e);
});
} catch (e) {
// eslint-disable-next-line no-console
console.warn('[PlayerController] MixamoAnimator init fail:', e);
}
}
} else if (!this._isR15) {
const groups = inst.animationGroups || []; const groups = inst.animationGroups || [];
for (const g of groups) { for (const g of groups) {
const name = (g.name || '').toLowerCase(); const name = (g.name || '').toLowerCase();
@ -2448,22 +2534,27 @@ export class PlayerController {
&& (this._codes.has('ControlLeft') || this._codes.has('ControlRight')); && (this._codes.has('ControlLeft') || this._codes.has('ControlRight'));
if (wantCrouch && !this._crouching) { if (wantCrouch && !this._crouching) {
this._crouching = true; this._crouching = true;
// сдвигаем центр капсулы вниз — низ ног остаётся на земле
const dH = this.HALF_H_CROUCH - this.HALF_H; const dH = this.HALF_H_CROUCH - this.HALF_H;
this.HALF_H = this.HALF_H_CROUCH; this.HALF_H = this.HALF_H_CROUCH;
if (this._pos) this._pos.y += dH; if (this._pos) this._pos.y += dH;
this._crouchEnterPending = true;
this._crouchTransitionUntil = Date.now() + 600;
} else if (!wantCrouch && this._crouching && !this._scriptForcedCrouch) { } else if (!wantCrouch && this._crouching && !this._scriptForcedCrouch) {
this._crouching = false; this._crouching = false;
const dH = this.HALF_H_NORMAL - this.HALF_H; const dH = this.HALF_H_NORMAL - this.HALF_H;
this.HALF_H = this.HALF_H_NORMAL; this.HALF_H = this.HALF_H_NORMAL;
if (this._pos) this._pos.y += dH; if (this._pos) this._pos.y += dH;
this._crouchExitPending = true;
this._crouchTransitionUntil = Date.now() + 600;
} }
// === Горизонтальное движение === // === Горизонтальное движение ===
const forward = new Vector3(Math.sin(this._yaw), 0, Math.cos(this._yaw)); const forward = new Vector3(Math.sin(this._yaw), 0, Math.cos(this._yaw));
const right = new Vector3(Math.cos(this._yaw), 0, -Math.sin(this._yaw)); const right = new Vector3(Math.cos(this._yaw), 0, -Math.sin(this._yaw));
const isSprinting = this._shift; // Crouch имеет ПРИОРИТЕТ над sprint
const speedMult = isSprinting ? this.SPRINT_MULT : 1; const isSprinting = this._shift && !this._crouching;
const crouchMult = this._crouching ? 0.45 : 1;
const speedMult = (isSprinting ? this.SPRINT_MULT : 1) * crouchMult;
const speed = this.WALK_SPEED * speedMult * (this._speedMul || 1) * dt; const speed = this.WALK_SPEED * speedMult * (this._speedMul || 1) * dt;
let moveX = 0, moveZ = 0; let moveX = 0, moveZ = 0;
@ -2547,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;
@ -2632,10 +2869,15 @@ 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'ом
this._pos, this.HALF_W, this.HALF_H, this.HALF_D, // вручную (вылезание на площадку), коллизия не нужна.
moveX, this._vy * dt, moveZ 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; const _bs = this._scene3d || this.scene3d;
if (_bs && _bs._perfMetrics) { if (_bs && _bs._perfMetrics) {
_bs._perfMetrics.physics_ms_sum += performance.now() - _pt0; _bs._perfMetrics.physics_ms_sum += performance.now() - _pt0;
@ -2776,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) {
@ -2886,17 +3153,41 @@ export class PlayerController {
const fwdShift = inWater ? bodyLen * tiltFrac : 0; const fwdShift = inWater ? bodyLen * tiltFrac : 0;
const fx = Math.sin(this._modelYaw); const fx = Math.sin(this._modelYaw);
const fz = Math.cos(this._modelYaw); const fz = Math.cos(this._modelYaw);
// Crouch Y-drop для Mixamo (см. rublox-player PlayerController.js).
let crouchYDrop = 0;
if (this._crouching && this._mixamoAnimator) {
const ms = this._mixamoAnimator._currentState;
if (ms === 'crouch_idle') crouchYDrop = 0.45;
else if (ms === 'crouch_walk') crouchYDrop = 0.25;
else if (ms === 'crouch_enter' || ms === 'crouch_to_stand') crouchYDrop = 0.30;
else crouchYDrop = 0.30;
}
this._modelRoot.position.set( this._modelRoot.position.set(
this._pos.x + fx * fwdShift, this._pos.x + fx * fwdShift,
this._pos.y - this.HALF_H + yLift - this._stepUpVisualOffset, this._pos.y - this.HALF_H + yLift - this._stepUpVisualOffset - crouchYDrop,
this._pos.z + fz * fwdShift this._pos.z + fz * fwdShift
); );
// Поворот модели: // Поворот модели:
// - на лестнице: лицом К лестнице, 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;
@ -3012,6 +3303,133 @@ export class PlayerController {
return; return;
} }
// Mixamo-скин: AnimationGroup для каждого состояния (idle/walk/run/jump/fall
// + crouch_idle/crouch_walk). Грузятся отдельными GLB.
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:
// in_place: jump_* (Mixamo Jumping)
// forward: jump_fwd_* (Mixamo Jump, прыжок с шага)
// run: jump_run_* (Mixamo Running Jump, прыжок с бега)
const jk = this._jumpKind;
const isAirborneJump = jk === 'forward' || jk === 'run';
let stAnticipate, stAir, stLand, landDuration;
if (jk === 'run') {
stAnticipate = 'jump_run_anticipate';
stAir = 'jump_run_air';
stLand = 'jump_run_land';
landDuration = 175;
} else if (jk === 'forward') {
stAnticipate = 'jump_fwd_anticipate';
stAir = 'jump_fwd_air';
stLand = 'jump_fwd_land';
landDuration = 142;
} else {
stAnticipate = 'jump_anticipate';
stAir = 'jump_air';
stLand = 'jump_land';
landDuration = 570;
}
const inAnticipate = this._jumpAnticipateUntil
&& now < this._jumpAnticipateUntil
&& this._jumpPendingImpulse;
const inJumpLand = this._jumpLandUntil && now < this._jumpLandUntil;
// Coyote-фильтр для микро-полётов на ступеньках. При спуске по
// лестнице из блоков персонаж 30-700мс физически в воздухе, и
// jump_air мигает между шагами walk. Критерий — ВЫСОТА падения
// от последней наземной позиции (а не время — полёт может быть
// длинным при спуске лицом к камере). Опустился <1.3 блока И не
// прыгал → ступенька, играем walk/run.
if (result.onGround) {
this._lastGroundY = this._pos.y;
}
const dropFromGround = (this._lastGroundY != null)
? (this._lastGroundY - this._pos.y) : Infinity;
const microAir = !result.onGround
&& !this._jumpHeld // не прыжок со Space
&& !this._wasAirborne // не продолжение реального прыжка
&& dropFromGround < 1.3 // опустился меньше 1.3 блока
&& this._vy < 4; // не подлетает вверх (степ-ап импульс)
if (inAnticipate) {
mState = stAnticipate;
} else if (microAir) {
// Микро-полёт между ступеньками — наземная анимация.
mState = this._crouching
? (isMoving ? 'crouch_walk' : 'crouch_idle')
: (isMoving ? (isSprinting ? 'run' : 'walk') : 'idle');
} else if (!result.onGround) {
mState = stAir;
this._wasAirborne = true;
this._crouchEnterPending = false;
this._crouchExitPending = false;
this._crouchTransitionUntil = 0;
this._jumpAnticipateUntil = 0;
} else if (this._wasAirborne) {
this._jumpLandUntil = now + landDuration;
this._wasAirborne = false;
mState = stLand;
} else if (inJumpLand) {
// Для forward — доигрываем land даже при движении
// (там короткая фаза 142мс)
if (isAirborneJump || !isMoving) mState = stLand;
} else if (this._crouchEnterPending && inCrouchTransition && !isMoving) {
mState = 'crouch_enter';
} else if (this._crouchExitPending && inCrouchTransition && !isMoving) {
mState = 'crouch_to_stand';
} else if (this._crouching) {
this._crouchEnterPending = false;
this._crouchExitPending = false;
mState = isMoving ? 'crouch_walk' : 'crouch_idle';
} else if (inWater) {
mState = isMoving ? 'walk' : 'idle';
} else if (isMoving) {
this._crouchExitPending = false;
this._crouchTransitionUntil = 0;
this._jumpLandUntil = 0; // прерываем jump_land если пошли
mState = isSprinting ? 'run' : 'walk';
} else {
this._crouchExitPending = false;
mState = 'idle';
}
this._mixamoAnimator.setState(mState);
return;
}
// R15-скин: процедурный аниматор (нет glTF AnimationGroups). // R15-скин: процедурный аниматор (нет glTF AnimationGroups).
// Состояния: idle/walk/run/jump/fall. sprint → run. // Состояния: idle/walk/run/jump/fall. sprint → run.
if (this._isR15 && this._r15Animator) { if (this._isR15 && this._r15Animator) {

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'] },
]; ];

View File

@ -826,6 +826,11 @@ export class SelectionManager {
} else if (it.kind === 'primitive') { } else if (it.kind === 'primitive') {
const data = this.primitiveManager?.instances.get(it.ref); const data = this.primitiveManager?.instances.get(it.ref);
if (data?.mesh) this._highlightMesh(data.mesh); if (data?.mesh) this._highlightMesh(data.mesh);
} else if (it.kind === 'userModel') {
const data = this.userModelManager?.instances.get(it.ref);
if (data?.meshes) {
for (const m of data.meshes) this._highlightMesh(m);
}
} }
} }
} }
@ -833,6 +838,149 @@ export class SelectionManager {
/** Получить массив multi-selection. */ /** Получить массив multi-selection. */
getMultiSelection() { return [...this._multi]; } getMultiSelection() { return [...this._multi]; }
/**
* Установить multi-выделение из списка {kind, ref} (рамка выделения).
* additive=true добавить к текущему (Ctrl+рамка), иначе заменить.
* Если в итоге 0 объектов clear; если 1 обычный single-select.
*/
setMultiSelection(items, additive = false) {
const eq = (a, b) => {
if (a.kind !== b.kind) return false;
if (a.kind === 'block') return a.ref.x === b.ref.x && a.ref.y === b.ref.y && a.ref.z === b.ref.z;
return a.ref === b.ref;
};
let next;
if (additive) {
// Стартуем с текущего multi (или текущего single, развёрнутого в элемент).
next = [...this._multi];
if (next.length === 0 && this._selection) {
const s = this._selection;
if (s.type === 'block') next.push({ kind: 'block', ref: { x: s.gridX, y: s.gridY, z: s.gridZ } });
else if (s.type === 'model') next.push({ kind: 'model', ref: s.instanceId });
else if (s.type === 'primitive') next.push({ kind: 'primitive', ref: s.id });
else if (s.type === 'userModel') next.push({ kind: 'userModel', ref: s.instanceId });
}
for (const it of items) {
if (!next.some(x => eq(x, it))) next.push(it);
}
} else {
next = [...items];
}
this._removeHighlight();
if (next.length === 0) {
this._multi = [];
this._selection = null;
this._notifyChange();
return;
}
if (next.length === 1) {
this._multi = [];
const only = next[0];
if (only.kind === 'block') this.selectBlockAt(only.ref.x, only.ref.y, only.ref.z);
else if (only.kind === 'model') this.selectModelByInstanceId(only.ref);
else if (only.kind === 'primitive') this.selectPrimitiveById(only.ref);
else if (only.kind === 'userModel') this.selectUserModelByInstanceId(only.ref);
return;
}
this._multi = next;
this._highlightAllMulti();
this._selection = { type: 'multi', count: this._multi.length, items: [...this._multi] };
this._notifyChange();
}
/**
* Развернуть multi-элемент в его data + mesh + текущую позицию.
* Возвращает { kind, data, mesh, pos:{x,y,z} } или null.
*/
_resolveMultiItem(it) {
if (it.kind === 'block') {
const mesh = this.blockManager?.blocks.get(`${it.ref.x},${it.ref.y},${it.ref.z}`);
return { kind: 'block', mesh, pos: { x: it.ref.x, y: it.ref.y, z: it.ref.z }, ref: it.ref };
}
if (it.kind === 'model') {
const d = this.modelManager?.instances.get(it.ref);
if (!d) return null;
return { kind: 'model', data: d, mesh: d.rootMesh, pos: { x: d.x || 0, y: d.y || 0, z: d.z || 0 } };
}
if (it.kind === 'userModel') {
const d = this.userModelManager?.instances.get(it.ref);
if (!d) return null;
return { kind: 'userModel', data: d, mesh: d.rootNode, pos: { x: d.x || 0, y: d.y || 0, z: d.z || 0 } };
}
if (it.kind === 'primitive') {
const d = this.primitiveManager?.instances.get(it.ref);
if (!d) return null;
return { kind: 'primitive', data: d, mesh: d.mesh, pos: { x: d.x || 0, y: d.y || 0, z: d.z || 0 } };
}
return null;
}
/** Центр multi-выделения (среднее позиций всех объектов). */
getMultiCenter() {
if (!this._multi.length) return null;
let sx = 0, sy = 0, sz = 0, n = 0;
for (const it of this._multi) {
const r = this._resolveMultiItem(it);
if (!r) continue;
sx += r.pos.x; sy += r.pos.y; sz += r.pos.z; n++;
}
if (!n) return null;
return { x: sx / n, y: sy / n, z: sz / n };
}
/**
* Сдвинуть ВСЕ объекты multi-выделения на (dx,dy,dz).
* Блоки переустанавливаются (block-операция через grid-координаты),
* модели/примитивы/user-модели двигают свой root-mesh и data.
* Блоки двигаем с округлением дельты к целым клеткам (сетка).
*/
moveMultiBy(dx, dy, dz) {
if (!this._multi.length) return;
// Блоки: только целочисленный сдвиг по сетке. Накапливаем дробную
// часть снаружи (в BabylonScene), сюда приходит уже округлённая для
// блоков дельта — но на всякий случай округляем здесь.
const bdx = Math.round(dx), bdy = Math.round(dy), bdz = Math.round(dz);
const newBlocks = [];
for (const it of this._multi) {
if (it.kind === 'block') {
const { x, y, z } = it.ref;
const typeId = this.blockManager?.blocks.get(`${x},${y},${z}`)?.metadata?.blockTypeId;
if (typeId == null) continue;
this.blockManager.removeBlock(x, y, z);
newBlocks.push({ x: x + bdx, y: Math.max(0, y + bdy), z: z + bdz, typeId, ref: it.ref });
}
}
for (const nb of newBlocks) {
this.blockManager.addBlock(nb.x, nb.y, nb.z, nb.typeId);
// Обновляем ссылку в _multi на новую клетку.
nb.ref.x = nb.x; nb.ref.y = nb.y; nb.ref.z = nb.z;
}
// Модели / примитивы / user-модели — двигаем плавно.
for (const it of this._multi) {
if (it.kind === 'block') continue;
const r = this._resolveMultiItem(it);
if (!r || !r.data) continue;
r.data.x = (r.data.x || 0) + dx;
r.data.y = (r.data.y || 0) + dy;
r.data.z = (r.data.z || 0) + dz;
if (r.mesh) {
if (r.data._worldMatrixFrozen) {
try { r.mesh.unfreezeWorldMatrix?.(); } catch (e) {}
r.data._worldMatrixFrozen = false;
}
r.mesh.position.set(r.data.x, r.data.y, r.data.z);
}
}
// Перерисовать подсветку (меши блоков пересозданы).
this._removeHighlight();
this._highlightAllMulti();
this.modelManager?._notifyChange?.();
this.primitiveManager?._notifyChange?.();
this.userModelManager && this._scene3d?._syncUserModelColliders?.();
this._notifyChange();
}
/** Выделить ВСЁ в сцене (Ctrl+A). */ /** Выделить ВСЁ в сцене (Ctrl+A). */
selectAll() { selectAll() {
this._removeHighlight(); this._removeHighlight();

View File

@ -76,6 +76,14 @@ export class VehicleManager {
veh.bodyInstanceId = bodyId; veh.bodyInstanceId = bodyId;
const inst = this._models.instances.get(bodyId); const inst = this._models.instances.get(bodyId);
if (inst && inst.rootMesh) { if (inst && inst.rootMesh) {
// Кузов машины — динамический объект: им двигает VehicleManager
// (через парентинг к chassisNode). Исключаем из LOD-freeze, иначе
// freezeWorldMatrix замораживает меш и он перестаёт следовать за
// chassisNode → «едешь на невидимой машине, видимая стоит».
// (LOD меряет дистанцию по локальной root.position запарентенного
// кузова ≈0,0,0 — некорректно, и замораживает машину.)
inst._spawnedAtRuntime = true;
inst._isVehicleBody = true;
// Габариты AABB + вертикальный offset кузова СЧИТАЕМ ДО парентинга // Габариты AABB + вертикальный offset кузова СЧИТАЕМ ДО парентинга
// (в мировых координатах, кузов ещё в (x,y,z)). // (в мировых координатах, кузов ещё в (x,y,z)).
try { try {

View File

@ -115,7 +115,7 @@ const KubikonPlayer = () => {
const mpSyncRef = useRef(null); const mpSyncRef = useRef(null);
/** Выбранный R15-скин текущего игрока (из rublox_equipped_skin). /** Выбранный R15-скин текущего игрока (из rublox_equipped_skin).
* Грузится при старте, уходит в мультиплеер как modelType. */ * Грузится при старте, уходит в мультиплеер как modelType. */
const skinFolderRef = useRef('skin_bacon-hair'); const skinFolderRef = useRef('skin_y-bot');
const [meta, setMeta] = useState(null); // { title, description, user_id, ... } const [meta, setMeta] = useState(null); // { title, description, user_id, ... }
const [forbidden, setForbidden] = useState(false); const [forbidden, setForbidden] = useState(false);
@ -446,22 +446,43 @@ const KubikonPlayer = () => {
}); });
// === Персональный скин игрока === // === Персональный скин игрока ===
// Грузим выбранный скин из БД (rublox_equipped_skin) и // Источник скина по приоритету:
// применяем его к локальному игроку ДО enterPlayMode // 1) hash-параметр #skin=<id> в URL (если сайт передал)
// тогда player.setModelType подхватит правильный скин. // 2) БД (rublox_equipped_skin через /equipped-skin)
// Этот же skinFolder уйдёт в мультиплеер как modelType, // 3) localStorage студии (fallback для отладки)
// чтобы соперники видели наш реальный скин. // 4) skin_y-bot (дефолт)
let mySkin = 'skin_bacon-hair'; let mySkin = 'skin_y-bot';
if (userId) { try {
const m = window.location.hash.match(/[#&]skin=([\w-]+)/);
if (m && m[1]) {
mySkin = m[1];
console.log('[KubikonPlayer] skin from URL:', mySkin);
}
} catch (e) {}
if (mySkin === 'skin_y-bot' && userId) {
try { try {
const skinRes = await Kubikon3DApi.getEquippedSkin(userId); const skinRes = await Kubikon3DApi.getEquippedSkin(userId);
const sf = skinRes?.data?.skin_folder; const sf = skinRes?.data?.skin_folder;
if (sf && typeof sf === 'string') mySkin = sf; if (sf && typeof sf === 'string') {
mySkin = sf;
console.log('[KubikonPlayer] skin from DB:', mySkin);
}
} catch (e) { } catch (e) {
// Сеть/ошибка играем с дефолтным скином, не блокируем.
console.warn('[KubikonPlayer] equipped-skin load failed', e); console.warn('[KubikonPlayer] equipped-skin load failed', e);
} }
} }
const isLocalDev = (typeof window !== 'undefined'
&& (window.location.hostname === 'localhost'
|| window.location.hostname === '127.0.0.1'));
if (mySkin === 'skin_y-bot' && isLocalDev) {
try {
const localPick = localStorage.getItem('rublox_selected_skin');
if (localPick && typeof localPick === 'string') {
mySkin = localPick;
console.log('[KubikonPlayer] skin from local LS:', mySkin);
}
} catch (e) {}
}
skinFolderRef.current = mySkin; skinFolderRef.current = mySkin;
try { scene.setPlayerModelType?.(mySkin); } catch (e) {} try { scene.setPlayerModelType?.(mySkin); } catch (e) {}
@ -646,7 +667,7 @@ const KubikonPlayer = () => {
// загружен при старте в skinFolderRef). Сервер всё равно перепроверит // загружен при старте в skinFolderRef). Сервер всё равно перепроверит
// скин по userId из JWT и при расхождении возьмёт значение из БД // скин по userId из JWT и при расхождении возьмёт значение из БД
// так каждый игрок виден соперникам в своём реальном скине. // так каждый игрок виден соперникам в своём реальном скине.
const modelType = skinFolderRef.current || 'skin_bacon-hair'; const modelType = skinFolderRef.current || 'skin_y-bot';
const room = await client.joinOrCreate('battle', { const room = await client.joinOrCreate('battle', {
projectId: projectMeta?.id || projectId, projectId: projectMeta?.id || projectId,
token: tokenRaw, token: tokenRaw,
@ -768,7 +789,8 @@ const KubikonPlayer = () => {
|| root.webkitRequestFullscreen || root.webkitRequestFullscreen
|| root.mozRequestFullScreen || root.mozRequestFullScreen
|| root.msRequestFullscreen; || root.msRequestFullscreen;
if (req) { // В десктоп-приложении (Electron) окно и так на весь экран FS не нужен.
if (req && !(typeof window !== 'undefined' && window.__RUBLOX_DESKTOP__)) {
try { await req.call(root); } catch (e) { /* отменено */ } try { await req.call(root); } catch (e) { /* отменено */ }
} }
setMobileStartTapped(true); setMobileStartTapped(true);