Compare commits
24 Commits
feat/lua-5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 542949eaff | |||
|
|
b638842948 | ||
|
|
22026f2f1d | ||
| 89bdeff657 | |||
| b457a29a95 | |||
| 143e529d11 | |||
| 2e1d915922 | |||
|
|
a9a9668071 | ||
|
|
04c593ef5a | ||
| 4913da49ff | |||
|
|
e6cfcad2c0 | ||
| ade30363c8 | |||
|
|
1fb15eb87b | ||
|
|
897dc08d3e | ||
| 396ef08c59 | |||
|
|
8f0266f8c2 | ||
| a697438661 | |||
|
|
1229bdad3f | ||
| 7d8a41d4d8 | |||
|
|
ba71f5f4b9 | ||
|
|
2f9d6a21f6 | ||
|
|
b03027e3d5 | ||
|
|
8ccea76dc0 | ||
| f7441b0bd6 |
@ -59,10 +59,28 @@ STORAGE_ROOT = os.environ.get('STORAGE_ROOT', '/opt/roblox-assets')
|
|||||||
PUBLIC_ASSET_BASE = os.environ.get('PUBLIC_ASSET_BASE', 'https://assets.rublox.pro/roblox')
|
PUBLIC_ASSET_BASE = os.environ.get('PUBLIC_ASSET_BASE', 'https://assets.rublox.pro/roblox')
|
||||||
|
|
||||||
MAX_RBXL_SIZE = 50 * 1024 * 1024 # 50 MB
|
MAX_RBXL_SIZE = 50 * 1024 * 1024 # 50 MB
|
||||||
ALLOWED_USER_IDS = [1] # пока только МИН
|
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
CORS(app, resources={r'/*': {'origins': '*'}})
|
# CORS открыт для всех источников — фронт студии живёт на studio.rublox.pro,
|
||||||
|
# api-rbxl проксируется через NPM на minecraftia-school.ru/api-rbxl/*.
|
||||||
|
# Поддерживаем preflight (OPTIONS) явно через after_request — иногда
|
||||||
|
# flask-cors не отдавал заголовки для OPTIONS если NPM их перекрывал.
|
||||||
|
CORS(app, resources={r'/*': {'origins': '*'}}, supports_credentials=False)
|
||||||
|
|
||||||
|
|
||||||
|
@app.after_request
|
||||||
|
def _add_cors_headers(resp):
|
||||||
|
resp.headers['Access-Control-Allow-Origin'] = '*'
|
||||||
|
resp.headers['Access-Control-Allow-Methods'] = 'GET, POST, OPTIONS'
|
||||||
|
resp.headers['Access-Control-Allow-Headers'] = 'Content-Type, X-User-Id, X-User-Login'
|
||||||
|
resp.headers['Access-Control-Max-Age'] = '3600'
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/import/rbxl/analyze', methods=['OPTIONS'])
|
||||||
|
@app.route('/import/rbxl/create', methods=['OPTIONS'])
|
||||||
|
def _preflight():
|
||||||
|
return '', 204
|
||||||
|
|
||||||
# Devlog для удалённой отладки dev-сессий студии: фронт пушит сюда
|
# Devlog для удалённой отладки dev-сессий студии: фронт пушит сюда
|
||||||
# console.error/warn, failed network requests, неожиданные exceptions.
|
# console.error/warn, failed network requests, неожиданные exceptions.
|
||||||
@ -93,8 +111,7 @@ def auth_check(req) -> int:
|
|||||||
uid = int(user_id_str)
|
uid = int(user_id_str)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise RuntimeError(f'Bad X-User-Id: {user_id_str!r}')
|
raise RuntimeError(f'Bad X-User-Id: {user_id_str!r}')
|
||||||
if uid not in ALLOWED_USER_IDS:
|
# Импорт открыт всем (см. вики «Импорт из Roblox»).
|
||||||
raise RuntimeError(f'User {uid} not allowed (only МИН)')
|
|
||||||
return uid
|
return uid
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -149,7 +149,7 @@ const AI_CONTEXT = `Ты — помощник по написанию скрип
|
|||||||
|
|
||||||
=== СЦЕНА game.scene ===
|
=== СЦЕНА game.scene ===
|
||||||
spawn(type, opts) -> объект. type: 'cube'|'sphere'|'cylinder'|'cone'|'pyramid'|'torus'|'wedge'|'plane' (примитивы), 'model:ID', 'block:ID', 'vehicle:car', 'light:point', 'billboard', 'trigger'.
|
spawn(type, opts) -> объект. type: 'cube'|'sphere'|'cylinder'|'cone'|'pyramid'|'torus'|'wedge'|'plane' (примитивы), 'model:ID', 'block:ID', 'vehicle:car', 'light:point', 'billboard', 'trigger'.
|
||||||
opts: {x,y,z, sx,sy,sz, rotationX,rotationY,rotationZ, color:'#hex', material:'matte'|'neon'|'metal'|'glass'|'studs', name, anchored:true(висит)/false(падает), canCollide, visible, mass, lifetime(сек до авто-удаления)}
|
opts: {x,y,z, sx,sy,sz, rotationX,rotationY,rotationZ, color:'#hex', material:'matte'|'neon'|'metal'|'glass'|'studs'|'chrome'|'water'|'iridescent', name, anchored:true(висит)/false(падает), canCollide, visible, mass, lifetime(сек до авто-удаления)}
|
||||||
delete(ref); move(ref,x,y,z); setRotation(ref,rx,ry,rz); setColor(ref,'#hex'); setMaterial(ref,name); setVisible(ref,bool); setCollide(ref,bool); setOpacity(ref,0..1); setScale(ref,sx,sy,sz)
|
delete(ref); move(ref,x,y,z); setRotation(ref,rx,ry,rz); setColor(ref,'#hex'); setMaterial(ref,name); setVisible(ref,bool); setCollide(ref,bool); setOpacity(ref,0..1); setScale(ref,sx,sy,sz)
|
||||||
setLabel(ref,text,opts); clearLabel(ref); setData(ref,key,val); getData(ref,key)
|
setLabel(ref,text,opts); clearLabel(ref); setData(ref,key,val); getData(ref,key)
|
||||||
find(name)->[...]; findOne(name)->объект|null; all('primitive'|'model'|'block')
|
find(name)->[...]; findOne(name)->объект|null; all('primitive'|'model'|'block')
|
||||||
@ -201,6 +201,10 @@ shake(amp, sec); setFov(deg); focusOn(ref,{distance,height}); cutscene([{x,y,z}.
|
|||||||
=== ОКРУЖЕНИЕ game.environment ===
|
=== ОКРУЖЕНИЕ game.environment ===
|
||||||
setSkyColor('#hex'); setFog({enabled,color,density}); setTimeOfDay(0..24)
|
setSkyColor('#hex'); setFog({enabled,color,density}); setTimeOfDay(0..24)
|
||||||
|
|
||||||
|
=== ГРАФИКА/ЭФФЕКТЫ game.graphics (по умолч. выкл) ===
|
||||||
|
setPreset('off'|'low'|'medium'|'high'|'ultra'|'cinematic'|'vivid'|'night'|'retro'|'soft')
|
||||||
|
setBloom(bool,{intensity:0..1,threshold:0..1}); setVignette(0..1.5); setColorGrading({contrast,saturation,exposure}); setShadows('off'|'hard'|'soft'|'medium'|'high'); setSSAO(bool); setDepthOfField(bool); setAntialiasing(bool); off()
|
||||||
|
|
||||||
=== ИНВЕНТАРЬ / ПРЕДМЕТЫ ===
|
=== ИНВЕНТАРЬ / ПРЕДМЕТЫ ===
|
||||||
game.items.define([{id,name,emoji,rarity:'common'|'rare'|'epic'|'legendary',maxStack,onUseEffect:'heal:50'}])
|
game.items.define([{id,name,emoji,rarity:'common'|'rare'|'epic'|'legendary',maxStack,onUseEffect:'heal:50'}])
|
||||||
game.inventory.give(id,count); .take(id,count); .has(id); .open(); .list()
|
game.inventory.give(id,count); .take(id,count); .has(id); .open(); .list()
|
||||||
@ -223,7 +227,8 @@ game.log(...) — в консоль; game.random(min,max,целое?); game.clam
|
|||||||
- Повороты в РАДИАНАХ (Math.PI/2 = 90°).
|
- Повороты в РАДИАНАХ (Math.PI/2 = 90°).
|
||||||
- Счётчики/общую логику держи в ОДНОМ глобальном скрипте; объекты шлют game.broadcast (скрипты объектов не видят переменные друг друга).
|
- Счётчики/общую логику держи в ОДНОМ глобальном скрипте; объекты шлют game.broadcast (скрипты объектов не видят переменные друг друга).
|
||||||
- spawn примитива: тип без префикса ('cube'), модели — с 'model:', машина — 'vehicle:car'.
|
- spawn примитива: тип без префикса ('cube'), модели — с 'model:', машина — 'vehicle:car'.
|
||||||
- material только: matte, neon, metal, glass, studs.
|
- material: matte, neon, metal, glass, studs, chrome (зеркало), water (вода), iridescent (переливы).
|
||||||
|
- эффекты включаются через game.graphics.setPreset(...) или в настройках игры; по умолчанию выкл.
|
||||||
- Для сбора предмета: game.self.onTouch(()=>{ game.broadcast('coin'); game.self.delete(); }).
|
- Для сбора предмета: game.self.onTouch(()=>{ game.broadcast('coin'); game.self.delete(); }).
|
||||||
- Не используй require() и DOM/window — только game.* и обычный JS.
|
- Не используй require() и DOM/window — только game.* и обычный JS.
|
||||||
|
|
||||||
@ -5597,6 +5602,152 @@ end)`}</Code>}
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════
|
||||||
|
// РАЗДЕЛ — ГРАФИКА И ЭФФЕКТЫ (шейдеры)
|
||||||
|
// ════════════════════════════════════════════════════
|
||||||
|
{
|
||||||
|
id: 'graphics',
|
||||||
|
icon: 'sparkles',
|
||||||
|
title: 'Графика и эффекты',
|
||||||
|
summary: 'Шейдеры-эффекты: свечение, цветокоррекция, тени, красивые материалы (хром, вода, переливы). Через настройки и из скриптов.',
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
id: 'gfx-what',
|
||||||
|
title: 'GR1. Что такое эффекты (шейдеры)',
|
||||||
|
body: (
|
||||||
|
<>
|
||||||
|
<p>
|
||||||
|
<b>Эффекты (шейдеры)</b> делают картинку игры красивее —
|
||||||
|
как «шейдер-паки» в Майнкрафте. Это:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li><b>Свечение (Bloom)</b> — яркие объекты (неон, лампы,
|
||||||
|
солнце) светятся и переливаются;</li>
|
||||||
|
<li><b>Цветокоррекция</b> — насыщенность и контраст, как
|
||||||
|
в кино;</li>
|
||||||
|
<li><b>Виньетка</b> — мягкое затемнение по краям экрана;</li>
|
||||||
|
<li><b>Тени</b> — мягкие или резкие, контактные тени в углах;</li>
|
||||||
|
<li><b>Глубина резкости</b> — размытие дальнего плана;</li>
|
||||||
|
<li><b>Сглаживание</b> — убирает «лесенки» на краях.</li>
|
||||||
|
</ul>
|
||||||
|
<Note>
|
||||||
|
По умолчанию эффекты <b>выключены</b> — игра выглядит как
|
||||||
|
раньше. Включи их в настройках или из скрипта, когда захочешь
|
||||||
|
«прокачать» картинку. На слабых компах/телефонах тяжёлые
|
||||||
|
эффекты <b>сами</b> упрощаются, чтобы игра не тормозила.
|
||||||
|
</Note>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gfx-settings',
|
||||||
|
title: 'GR2. Включить эффекты в настройках',
|
||||||
|
body: (
|
||||||
|
<>
|
||||||
|
<Step n={1}>
|
||||||
|
Открой <kbd className="kbd">Настройки</kbd> игры (шестерёнка
|
||||||
|
вверху).
|
||||||
|
</Step>
|
||||||
|
<Step n={2}>
|
||||||
|
Найди раздел <b>«Графика и эффекты»</b> и выбери <b>пресет</b>:
|
||||||
|
</Step>
|
||||||
|
<ul>
|
||||||
|
<li><b>Выключено</b> — без эффектов (по умолчанию);</li>
|
||||||
|
<li><b>Низкое / Среднее / Высокое / Ультра</b> — всё больше
|
||||||
|
красоты (и нагрузки);</li>
|
||||||
|
<li><b>🎬 Кино</b> — контраст + виньетка + глубина резкости;</li>
|
||||||
|
<li><b>🌈 Сочное</b> — яркие насыщенные цвета;</li>
|
||||||
|
<li><b>🌙 Ночь</b> — тёмная атмосфера, сильное свечение;</li>
|
||||||
|
<li><b>📺 Ретро</b> — старый ламповый вид;</li>
|
||||||
|
<li><b>☁️ Мягкое</b> — нежная пастельная картинка.</li>
|
||||||
|
</ul>
|
||||||
|
<Step n={3}>
|
||||||
|
Или настрой вручную галочками (свечение, сглаживание,
|
||||||
|
виньетка, контактные тени) и ползунками (насыщенность,
|
||||||
|
контраст). Нажми <kbd className="kbd">Сохранить</kbd> — эффект
|
||||||
|
появится сразу.
|
||||||
|
</Step>
|
||||||
|
<Try>
|
||||||
|
Поставь несколько неоновых кубов, включи пресет «Ночь» — они
|
||||||
|
будут красиво светиться в темноте.
|
||||||
|
</Try>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gfx-materials',
|
||||||
|
title: 'GR3. Красивые материалы',
|
||||||
|
body: (
|
||||||
|
<>
|
||||||
|
<p>
|
||||||
|
У каждого примитива есть <b>материал</b> (в свойствах объекта
|
||||||
|
или при создании из скрипта). Кроме обычных есть «шейдерные»:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li><b>neon</b> — светится (works с Bloom особенно красиво);</li>
|
||||||
|
<li><b>metal</b> — металлический блик;</li>
|
||||||
|
<li><b>chrome</b> — зеркальный хром;</li>
|
||||||
|
<li><b>glass</b> — прозрачное стекло;</li>
|
||||||
|
<li><b>water</b> — полупрозрачная вода с бликами;</li>
|
||||||
|
<li><b>iridescent</b> — переливы (бензиновая плёнка, кристалл);</li>
|
||||||
|
<li><b>studs</b> — лего-пупырышки;</li>
|
||||||
|
<li><b>matte</b> — матовый (без блеска, по умолчанию).</li>
|
||||||
|
</ul>
|
||||||
|
<ScriptKind kind="global" />
|
||||||
|
<Code>{`// хромированная сфера
|
||||||
|
game.scene.spawn('sphere', { x: 0, y: 2, z: 0, color: '#dfe6f0', material: 'chrome' });
|
||||||
|
// светящийся неон-куб
|
||||||
|
game.scene.spawn('cube', { x: 3, y: 2, z: 0, color: '#00ffd0', material: 'neon' });
|
||||||
|
// переливающийся кристалл
|
||||||
|
game.scene.spawn('cone', { x: -3, y: 2, z: 0, color: '#a06bff', material: 'iridescent' });
|
||||||
|
|
||||||
|
// поменять материал существующего объекта:
|
||||||
|
const obj = game.scene.findOne('Вода');
|
||||||
|
obj.material = 'water';`}</Code>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gfx-api',
|
||||||
|
title: 'GR4. Эффекты из скриптов (game.graphics)',
|
||||||
|
body: (
|
||||||
|
<>
|
||||||
|
<p>
|
||||||
|
Эффекты можно включать и менять <b>на ходу</b> из скрипта —
|
||||||
|
например затемнить мир ночью или включить «кино» в катсцене.
|
||||||
|
</p>
|
||||||
|
<ScriptKind kind="global" />
|
||||||
|
<Code>{`// применить готовый пресет
|
||||||
|
game.graphics.setPreset('cinematic');
|
||||||
|
|
||||||
|
// точечная настройка
|
||||||
|
game.graphics.setBloom(true, { intensity: 0.7 }); // свечение
|
||||||
|
game.graphics.setVignette(0.5); // затемнение краёв
|
||||||
|
game.graphics.setColorGrading({ saturation: 1.4, contrast: 1.1 });
|
||||||
|
game.graphics.setShadows('soft'); // off|hard|soft|medium|high
|
||||||
|
game.graphics.setSSAO(true); // контактные тени
|
||||||
|
game.graphics.setDepthOfField(true); // размытие дальнего плана
|
||||||
|
|
||||||
|
// выключить всё
|
||||||
|
game.graphics.off();`}</Code>
|
||||||
|
<p>Пример — плавно «сделать ночь» при входе в пещеру:</p>
|
||||||
|
<Code>{`const cave = game.scene.findOne('ВходВПещеру');
|
||||||
|
cave.onTouch(() => {
|
||||||
|
game.graphics.setPreset('night');
|
||||||
|
game.environment.setTimeOfDay(0);
|
||||||
|
game.ui.showText('Темнеет...', 2);
|
||||||
|
});`}</Code>
|
||||||
|
<Note>
|
||||||
|
Эффекты применяются ко всему экрану. Если игра должна
|
||||||
|
работать на телефонах — не включай разом DoF + SSAO + ультра-
|
||||||
|
тени: движок их урежет, но лучше выбрать пресет полегче.
|
||||||
|
</Note>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -83,7 +83,7 @@ export function highlightCode(text, lang) {
|
|||||||
// Классифицируем
|
// Классифицируем
|
||||||
if (tok.startsWith('--') || tok.startsWith('//') || tok.startsWith('/*')) {
|
if (tok.startsWith('--') || tok.startsWith('//') || tok.startsWith('/*')) {
|
||||||
tokens.push({ type: 'comment', text: tok });
|
tokens.push({ type: 'comment', text: tok });
|
||||||
} else if (/^["'`\[]/.test(tok)) {
|
} else if (/^["'`[]/.test(tok)) {
|
||||||
tokens.push({ type: 'string', text: tok });
|
tokens.push({ type: 'string', text: tok });
|
||||||
} else if (/^\d/.test(tok)) {
|
} else if (/^\d/.test(tok)) {
|
||||||
tokens.push({ type: 'number', text: tok });
|
tokens.push({ type: 'number', text: tok });
|
||||||
|
|||||||
440
src/editor/GameDecorModal.jsx
Normal file
440
src/editor/GameDecorModal.jsx
Normal file
@ -0,0 +1,440 @@
|
|||||||
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
|
import cl from './GameSettingsModal.module.css';
|
||||||
|
import Icon from './Icon';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GameDecorModal — «оформление» игры: Графика и эффекты, Стартовый экран
|
||||||
|
* (Ken Burns), Экран загрузки. Вынесено из настроек во вкладку «Игра»
|
||||||
|
* (TopRibbon) тремя отдельными кнопками. Открывается с нужной секцией через
|
||||||
|
* проп `section` ('graphics' | 'startscreen' | 'loadingscreen'), вверху —
|
||||||
|
* табы для переключения между ними.
|
||||||
|
*
|
||||||
|
* Props:
|
||||||
|
* open — открыто ли
|
||||||
|
* section — какую секцию показать первой
|
||||||
|
* initial — { loading_screen:{...}, graphics:{...} } (из scene/проекта)
|
||||||
|
* onClose — закрыть без сохранения
|
||||||
|
* onSave(data) — data = { loading_screen, graphics } (как в GameSettingsModal)
|
||||||
|
*/
|
||||||
|
const MAX_IMG_BYTES = 500 * 1024;
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
{ id: 'graphics', title: 'Графика', icon: 'sparkles' },
|
||||||
|
{ id: 'startscreen', title: 'Стартовый экран', icon: 'loader' },
|
||||||
|
{ id: 'loadingscreen', title: 'Экран загрузки', icon: 'loader' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const GameDecorModal = ({ open, section = 'graphics', initial, onClose, onSave }) => {
|
||||||
|
const [tab, setTab] = useState(section);
|
||||||
|
|
||||||
|
// Экран загрузки
|
||||||
|
const [loadingLogo, setLoadingLogo] = useState('');
|
||||||
|
const [loadingAccent, setLoadingAccent] = useState('#ffc020');
|
||||||
|
const [loadingSpinner, setLoadingSpinner] = useState(true);
|
||||||
|
const [loadingSkip, setLoadingSkip] = useState(false);
|
||||||
|
// Стартовый Ken-Burns экран
|
||||||
|
const [lsEnabled, setLsEnabled] = useState(true);
|
||||||
|
const [lsBackground, setLsBackground] = useState('');
|
||||||
|
const [lsCover, setLsCover] = useState('');
|
||||||
|
const [lsStyle, setLsStyle] = useState('ken-burns');
|
||||||
|
const [lsPlaceName, setLsPlaceName] = useState('');
|
||||||
|
const [lsStudioName, setLsStudioName] = useState('');
|
||||||
|
const [lsVerified, setLsVerified] = useState(false);
|
||||||
|
const [lsDuration, setLsDuration] = useState(2.5);
|
||||||
|
const [lsProgressBar, setLsProgressBar] = useState(true);
|
||||||
|
// Графика
|
||||||
|
const [gfxPreset, setGfxPreset] = useState('off');
|
||||||
|
const [gfxBloom, setGfxBloom] = useState(false);
|
||||||
|
const [gfxFxaa, setGfxFxaa] = useState(false);
|
||||||
|
const [gfxVignette, setGfxVignette] = useState(false);
|
||||||
|
const [gfxSsao, setGfxSsao] = useState(false);
|
||||||
|
const [gfxSaturation, setGfxSaturation] = useState(1.0);
|
||||||
|
const [gfxContrast, setGfxContrast] = useState(1.0);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const logoInputRef = useRef(null);
|
||||||
|
const lsBgInputRef = useRef(null);
|
||||||
|
const lsCoverInputRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setTab(section || 'graphics');
|
||||||
|
const ls = initial?.loading_screen || {};
|
||||||
|
setLoadingLogo(ls.logo || '');
|
||||||
|
setLoadingAccent(ls.accentColor || '#ffc020');
|
||||||
|
setLoadingSpinner(ls.defaultSpinner !== false);
|
||||||
|
setLoadingSkip(!!ls.defaultSkipButton);
|
||||||
|
setLsEnabled(ls.enabled !== false);
|
||||||
|
setLsBackground(ls.background || '');
|
||||||
|
setLsCover(ls.cover || '');
|
||||||
|
setLsStyle(ls.style || 'ken-burns');
|
||||||
|
setLsPlaceName(ls.placeName || '');
|
||||||
|
setLsStudioName(ls.studioName || '');
|
||||||
|
setLsVerified(!!ls.verified);
|
||||||
|
setLsDuration(Number.isFinite(ls.duration) && ls.duration > 0 ? ls.duration : 2.5);
|
||||||
|
setLsProgressBar(ls.progressBar !== false);
|
||||||
|
const gx = initial?.graphics || {};
|
||||||
|
setGfxPreset(gx.preset || 'off');
|
||||||
|
setGfxBloom(!!(gx.bloom && gx.bloom.enabled));
|
||||||
|
setGfxFxaa(!!gx.fxaa);
|
||||||
|
setGfxVignette(!!(gx.vignette && gx.vignette.enabled));
|
||||||
|
setGfxSsao(!!gx.ssao);
|
||||||
|
setGfxSaturation((gx.grading && Number.isFinite(gx.grading.saturation)) ? gx.grading.saturation : 1.0);
|
||||||
|
setGfxContrast((gx.grading && Number.isFinite(gx.grading.contrast)) ? gx.grading.contrast : 1.0);
|
||||||
|
setError('');
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open, section]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
const handleLogoSelect = (e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
if (!/^image\/(png|jpeg|webp)$/.test(file.type)) { setError('Логотип: только PNG, JPG или WEBP'); return; }
|
||||||
|
if (file.size > MAX_IMG_BYTES) { setError('Логотип слишком большой (макс. 500 КБ)'); return; }
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (ev) => { setLoadingLogo(ev.target.result); setError(''); };
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
};
|
||||||
|
const handleLsImage = (e, setter) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
if (!/^image\/(png|jpeg|webp)$/.test(file.type)) { setError('Только PNG, JPG или WEBP'); return; }
|
||||||
|
if (file.size > MAX_IMG_BYTES) { setError('Изображение слишком большое (макс. 500 КБ)'); return; }
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (ev) => { setter(ev.target.result); setError(''); };
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyPresetToToggles = (preset) => {
|
||||||
|
setGfxPreset(preset);
|
||||||
|
const P = {
|
||||||
|
off: { b: false, f: false, v: false, s: false, sat: 1.0, con: 1.0 },
|
||||||
|
low: { b: true, f: true, v: false, s: false, sat: 1.0, con: 1.0 },
|
||||||
|
medium: { b: true, f: true, v: true, s: false, sat: 1.1, con: 1.05 },
|
||||||
|
high: { b: true, f: true, v: true, s: true, sat: 1.2, con: 1.1 },
|
||||||
|
ultra: { b: true, f: true, v: true, s: true, sat: 1.25, con: 1.12 },
|
||||||
|
cinematic: { b: true, f: true, v: true, s: true, sat: 1.05, con: 1.18 },
|
||||||
|
vivid: { b: true, f: true, v: false, s: false, sat: 1.5, con: 1.1 },
|
||||||
|
night: { b: true, f: true, v: true, s: true, sat: 0.85, con: 1.2 },
|
||||||
|
retro: { b: false, f: false, v: true, s: false, sat: 0.7, con: 1.3 },
|
||||||
|
soft: { b: true, f: true, v: true, s: false, sat: 1.05, con: 0.95 },
|
||||||
|
}[preset];
|
||||||
|
if (!P) return;
|
||||||
|
setGfxBloom(P.b); setGfxFxaa(P.f); setGfxVignette(P.v);
|
||||||
|
setGfxSsao(P.s); setGfxSaturation(P.sat); setGfxContrast(P.con);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onSave({
|
||||||
|
loading_screen: {
|
||||||
|
logo: loadingLogo || null,
|
||||||
|
accentColor: loadingAccent || '#ffc020',
|
||||||
|
defaultSpinner: loadingSpinner,
|
||||||
|
defaultSkipButton: loadingSkip,
|
||||||
|
enabled: lsEnabled,
|
||||||
|
background: lsBackground || null,
|
||||||
|
cover: lsCover || null,
|
||||||
|
style: lsStyle || 'ken-burns',
|
||||||
|
placeName: lsPlaceName.trim(),
|
||||||
|
studioName: lsStudioName.trim(),
|
||||||
|
verified: lsVerified,
|
||||||
|
duration: Math.max(1, Math.min(10, Number(lsDuration) || 2.5)),
|
||||||
|
progressBar: lsProgressBar,
|
||||||
|
},
|
||||||
|
graphics: {
|
||||||
|
preset: gfxPreset,
|
||||||
|
bloom: { enabled: gfxBloom },
|
||||||
|
fxaa: gfxFxaa,
|
||||||
|
vignette: { enabled: gfxVignette },
|
||||||
|
ssao: gfxSsao,
|
||||||
|
grading: {
|
||||||
|
enabled: (gfxSaturation !== 1.0 || gfxContrast !== 1.0),
|
||||||
|
saturation: Number(gfxSaturation) || 1.0,
|
||||||
|
contrast: Number(gfxContrast) || 1.0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cl.overlay} onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
||||||
|
<div className={cl.modal}>
|
||||||
|
<div className={cl.header}>
|
||||||
|
<div className={cl.title}><Icon name="sparkles" size={16} /> Оформление игры</div>
|
||||||
|
<button className={cl.closeBtn} onClick={onClose}><Icon name="close" size={14} /></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Табы разделов */}
|
||||||
|
<div style={{ display: 'flex', gap: 6, padding: '0 18px', marginTop: 6, flexWrap: 'wrap' }}>
|
||||||
|
{TABS.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTab(t.id)}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 6,
|
||||||
|
padding: '8px 14px', borderRadius: 8, cursor: 'pointer',
|
||||||
|
border: '1px solid ' + (tab === t.id ? 'rgba(120,150,255,0.6)' : 'rgba(255,255,255,0.10)'),
|
||||||
|
background: tab === t.id ? 'rgba(90,120,255,0.18)' : 'transparent',
|
||||||
|
color: tab === t.id ? '#dfe6ff' : '#aab', fontSize: 13, fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name={t.icon} size={13} /> {t.title}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className={cl.body}>
|
||||||
|
|
||||||
|
{/* === ГРАФИКА === */}
|
||||||
|
{tab === 'graphics' && (
|
||||||
|
<div className={cl.field}>
|
||||||
|
<div className={cl.fieldHint} style={{ marginBottom: 10 }}>
|
||||||
|
Свечение, цветокоррекция, тени и сглаживание (как шейдеры).
|
||||||
|
По умолчанию выключено. На слабых устройствах тяжёлые эффекты
|
||||||
|
урезаются автоматически. Также управляется из скриптов (game.graphics).
|
||||||
|
</div>
|
||||||
|
<label style={{ display: 'flex', flexDirection: 'column', gap: 4, marginBottom: 12 }}>
|
||||||
|
<span style={{ fontSize: 12, color: '#aab' }}>Пресет</span>
|
||||||
|
<select className={cl.select} value={gfxPreset}
|
||||||
|
onChange={(e) => applyPresetToToggles(e.target.value)}>
|
||||||
|
<option value="off">Выключено</option>
|
||||||
|
<option value="low">Низкое (свечение)</option>
|
||||||
|
<option value="medium">Среднее</option>
|
||||||
|
<option value="high">Высокое</option>
|
||||||
|
<option value="ultra">Ультра (с глубиной резкости)</option>
|
||||||
|
<option value="cinematic">🎬 Кино</option>
|
||||||
|
<option value="vivid">🌈 Сочное</option>
|
||||||
|
<option value="night">🌙 Ночь</option>
|
||||||
|
<option value="retro">📺 Ретро</option>
|
||||||
|
<option value="soft">☁️ Мягкое</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div className={cl.togglesRow}>
|
||||||
|
<label className={cl.toggleRow}>
|
||||||
|
<input type="checkbox" className={cl.toggle}
|
||||||
|
checked={gfxBloom} onChange={(e) => { setGfxBloom(e.target.checked); setGfxPreset('custom'); }} />
|
||||||
|
<div className={cl.toggleText}>
|
||||||
|
<div className={cl.toggleTitle}>Свечение (Bloom)</div>
|
||||||
|
<div className={cl.toggleHint}><span>Яркие объекты светятся</span></div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label className={cl.toggleRow}>
|
||||||
|
<input type="checkbox" className={cl.toggle}
|
||||||
|
checked={gfxFxaa} onChange={(e) => { setGfxFxaa(e.target.checked); setGfxPreset('custom'); }} />
|
||||||
|
<div className={cl.toggleText}>
|
||||||
|
<div className={cl.toggleTitle}>Сглаживание</div>
|
||||||
|
<div className={cl.toggleHint}><span>Убирает «лесенки» на краях</span></div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label className={cl.toggleRow}>
|
||||||
|
<input type="checkbox" className={cl.toggle}
|
||||||
|
checked={gfxVignette} onChange={(e) => { setGfxVignette(e.target.checked); setGfxPreset('custom'); }} />
|
||||||
|
<div className={cl.toggleText}>
|
||||||
|
<div className={cl.toggleTitle}>Виньетка</div>
|
||||||
|
<div className={cl.toggleHint}><span>Мягкое затемнение по краям</span></div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label className={cl.toggleRow}>
|
||||||
|
<input type="checkbox" className={cl.toggle}
|
||||||
|
checked={gfxSsao} onChange={(e) => { setGfxSsao(e.target.checked); setGfxPreset('custom'); }} />
|
||||||
|
<div className={cl.toggleText}>
|
||||||
|
<div className={cl.toggleTitle}>Контактные тени (SSAO)</div>
|
||||||
|
<div className={cl.toggleHint}><span>Затемнение в углах и стыках</span></div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 16, marginTop: 12, flexWrap: 'wrap' }}>
|
||||||
|
<label style={{ display: 'flex', flexDirection: 'column', gap: 4, flex: 1, minWidth: 160 }}>
|
||||||
|
<span style={{ fontSize: 12, color: '#aab' }}>Насыщенность: {gfxSaturation.toFixed(2)}</span>
|
||||||
|
<input type="range" min="0.5" max="2" step="0.05" value={gfxSaturation}
|
||||||
|
onChange={(e) => { setGfxSaturation(Number(e.target.value)); setGfxPreset('custom'); }} />
|
||||||
|
</label>
|
||||||
|
<label style={{ display: 'flex', flexDirection: 'column', gap: 4, flex: 1, minWidth: 160 }}>
|
||||||
|
<span style={{ fontSize: 12, color: '#aab' }}>Контраст: {gfxContrast.toFixed(2)}</span>
|
||||||
|
<input type="range" min="0.5" max="1.6" step="0.05" value={gfxContrast}
|
||||||
|
onChange={(e) => { setGfxContrast(Number(e.target.value)); setGfxPreset('custom'); }} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* === СТАРТОВЫЙ ЭКРАН (Ken Burns) === */}
|
||||||
|
{tab === 'startscreen' && (
|
||||||
|
<div className={cl.field}>
|
||||||
|
<div className={cl.fieldHint} style={{ marginBottom: 10 }}>
|
||||||
|
Что видит игрок при входе в игру: размытый фон с медленным движением (Ken Burns), карточка-витрина по центру, название места и автор.
|
||||||
|
</div>
|
||||||
|
<label className={cl.toggleRow} style={{ marginBottom: 10 }}>
|
||||||
|
<input type="checkbox" className={cl.toggle}
|
||||||
|
checked={lsEnabled} onChange={(e) => setLsEnabled(e.target.checked)} />
|
||||||
|
<div className={cl.toggleText}>
|
||||||
|
<div className={cl.toggleTitle}>Показывать стартовый экран</div>
|
||||||
|
<div className={cl.toggleHint}><span>Если выключено — игрок сразу попадает в 3D-сцену</span></div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
{lsEnabled && (
|
||||||
|
<>
|
||||||
|
<div style={{ display: 'flex', gap: 14, marginBottom: 12, flexWrap: 'wrap' }}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
<div style={{
|
||||||
|
width: 130, height: 74, borderRadius: 8, background: '#15192a',
|
||||||
|
border: '1px solid rgba(255,255,255,0.12)', overflow: 'hidden',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
backgroundImage: lsBackground ? `url(${lsBackground})` : 'none',
|
||||||
|
backgroundSize: 'cover', backgroundPosition: 'center',
|
||||||
|
}}>
|
||||||
|
{!lsBackground && <span style={{ color: '#5a6178', fontSize: 11 }}>фон (размытый)</span>}
|
||||||
|
</div>
|
||||||
|
<button type="button" className={cl.actionBtn} onClick={() => lsBgInputRef.current?.click()}>
|
||||||
|
<Icon name="folder" size={14} /> Фон
|
||||||
|
</button>
|
||||||
|
{lsBackground && (
|
||||||
|
<button type="button" className={cl.removeBtn} style={{ alignSelf: 'flex-start' }} onClick={() => setLsBackground('')}>
|
||||||
|
<Icon name="close" size={13} /> Убрать
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<input ref={lsBgInputRef} type="file" accept="image/png,image/jpeg,image/webp"
|
||||||
|
style={{ display: 'none' }} onChange={(e) => handleLsImage(e, setLsBackground)} />
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
<div style={{
|
||||||
|
width: 74, height: 74, borderRadius: 12, background: '#15192a',
|
||||||
|
border: '1px solid rgba(255,255,255,0.12)', overflow: 'hidden',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
backgroundImage: lsCover ? `url(${lsCover})` : 'none',
|
||||||
|
backgroundSize: 'cover', backgroundPosition: 'center',
|
||||||
|
}}>
|
||||||
|
{!lsCover && <span style={{ color: '#5a6178', fontSize: 10, textAlign: 'center' }}>карточка</span>}
|
||||||
|
</div>
|
||||||
|
<button type="button" className={cl.actionBtn} onClick={() => lsCoverInputRef.current?.click()}>
|
||||||
|
<Icon name="folder" size={14} /> Карточка
|
||||||
|
</button>
|
||||||
|
{lsCover && (
|
||||||
|
<button type="button" className={cl.removeBtn} style={{ alignSelf: 'flex-start' }} onClick={() => setLsCover('')}>
|
||||||
|
<Icon name="close" size={13} /> Убрать
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<input ref={lsCoverInputRef} type="file" accept="image/png,image/jpeg,image/webp"
|
||||||
|
style={{ display: 'none' }} onChange={(e) => handleLsImage(e, setLsCover)} />
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, flex: 1, minWidth: 180 }}>
|
||||||
|
<input type="text" className={cl.input} placeholder="Название места (по умолчанию = название игры)"
|
||||||
|
value={lsPlaceName} maxLength={40}
|
||||||
|
onChange={(e) => setLsPlaceName(e.target.value)} />
|
||||||
|
<input type="text" className={cl.input} placeholder="Имя автора"
|
||||||
|
value={lsStudioName} maxLength={40}
|
||||||
|
onChange={(e) => setLsStudioName(e.target.value)} />
|
||||||
|
<label className={cl.toggleRow}>
|
||||||
|
<input type="checkbox" className={cl.toggle}
|
||||||
|
checked={lsVerified} onChange={(e) => setLsVerified(e.target.checked)} />
|
||||||
|
<div className={cl.toggleText}>
|
||||||
|
<div className={cl.toggleTitle}>Галочка verified</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 14, alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
||||||
|
<label style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
<span style={{ fontSize: 12, color: '#aab' }}>Стиль анимации</span>
|
||||||
|
<select className={cl.input} value={lsStyle} onChange={(e) => setLsStyle(e.target.value)}>
|
||||||
|
<option value="ken-burns">Ken Burns (плавный pan+zoom)</option>
|
||||||
|
<option value="static">Статичный фон</option>
|
||||||
|
<option value="parallax">Параллакс (по мыши)</option>
|
||||||
|
<option value="particles">Частицы (искры)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
<span style={{ fontSize: 12, color: '#aab' }}>Длительность: {Number(lsDuration).toFixed(1)} с</span>
|
||||||
|
<input type="range" min="1" max="10" step="0.5" value={lsDuration}
|
||||||
|
onChange={(e) => setLsDuration(Number(e.target.value))}
|
||||||
|
style={{ width: 160 }} />
|
||||||
|
</label>
|
||||||
|
<label className={cl.toggleRow}>
|
||||||
|
<input type="checkbox" className={cl.toggle}
|
||||||
|
checked={lsProgressBar} onChange={(e) => setLsProgressBar(e.target.checked)} />
|
||||||
|
<div className={cl.toggleText}>
|
||||||
|
<div className={cl.toggleTitle}>Прогресс-бар</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* === ЭКРАН ЗАГРУЗКИ === */}
|
||||||
|
{tab === 'loadingscreen' && (
|
||||||
|
<div className={cl.field}>
|
||||||
|
<div className={cl.fieldHint} style={{ marginBottom: 10 }}>
|
||||||
|
Логотип и цвет акцента для экранов загрузки между мирами (game.loading).
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 14, alignItems: 'center', marginBottom: 12 }}>
|
||||||
|
<div style={{
|
||||||
|
width: 96, height: 54, borderRadius: 8, background: '#15192a',
|
||||||
|
border: '1px solid rgba(255,255,255,0.12)', display: 'flex',
|
||||||
|
alignItems: 'center', justifyContent: 'center', overflow: 'hidden', flex: '0 0 auto',
|
||||||
|
}}>
|
||||||
|
{loadingLogo
|
||||||
|
? <img src={loadingLogo} alt="Логотип" style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' }} />
|
||||||
|
: <span style={{ color: '#5a6178', fontSize: 11 }}>лого = обложка</span>}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
<button type="button" className={cl.actionBtn} onClick={() => logoInputRef.current?.click()}>
|
||||||
|
<Icon name="folder" size={14} /> Логотип игры
|
||||||
|
</button>
|
||||||
|
{loadingLogo && (
|
||||||
|
<button type="button" className={cl.removeBtn} style={{ alignSelf: 'flex-start' }} onClick={() => setLoadingLogo('')}>
|
||||||
|
<Icon name="close" size={13} /> Убрать
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<input ref={logoInputRef} type="file" accept="image/png,image/jpeg,image/webp"
|
||||||
|
style={{ display: 'none' }} onChange={handleLogoSelect} />
|
||||||
|
</div>
|
||||||
|
<label style={{ display: 'flex', flexDirection: 'column', gap: 4, marginLeft: 'auto' }}>
|
||||||
|
<span style={{ fontSize: 12, color: '#aab' }}>Цвет акцента</span>
|
||||||
|
<input type="color" value={loadingAccent}
|
||||||
|
onChange={(e) => setLoadingAccent(e.target.value)}
|
||||||
|
style={{ width: 48, height: 32, border: 'none', background: 'none', cursor: 'pointer' }} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className={cl.togglesRow}>
|
||||||
|
<label className={cl.toggleRow}>
|
||||||
|
<input type="checkbox" className={cl.toggle}
|
||||||
|
checked={loadingSpinner} onChange={(e) => setLoadingSpinner(e.target.checked)} />
|
||||||
|
<div className={cl.toggleText}>
|
||||||
|
<div className={cl.toggleTitle}>Спиннер</div>
|
||||||
|
<div className={cl.toggleHint}><span>Показывать «ЗАГРУЗКА» по умолчанию</span></div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label className={cl.toggleRow}>
|
||||||
|
<input type="checkbox" className={cl.toggle}
|
||||||
|
checked={loadingSkip} onChange={(e) => setLoadingSkip(e.target.checked)} />
|
||||||
|
<div className={cl.toggleText}>
|
||||||
|
<div className={cl.toggleTitle}>Кнопка «Пропустить»</div>
|
||||||
|
<div className={cl.toggleHint}><span>Показывать по умолчанию</span></div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <div className={cl.error}><Icon name="warning" size={14} /> {error}</div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={cl.footer}>
|
||||||
|
<button type="button" className={cl.secondaryBtn} onClick={onClose}>Отмена</button>
|
||||||
|
<button type="submit" className={cl.primaryBtn}>
|
||||||
|
<Icon name="save" size={13} /> Сохранить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GameDecorModal;
|
||||||
@ -45,26 +45,8 @@ const GameSettingsModal = ({ open, initial, onClose, onSave, onCaptureScreenshot
|
|||||||
const [multiplayer, setMultiplayer] = useState(false);
|
const [multiplayer, setMultiplayer] = useState(false);
|
||||||
const [maxPlayers, setMaxPlayers] = useState(10);
|
const [maxPlayers, setMaxPlayers] = useState(10);
|
||||||
const [isTest, setIsTest] = useState(false);
|
const [isTest, setIsTest] = useState(false);
|
||||||
// Задача 12: экран загрузки
|
|
||||||
const [loadingLogo, setLoadingLogo] = useState('');
|
|
||||||
const [loadingAccent, setLoadingAccent] = useState('#ffc020');
|
|
||||||
const [loadingSpinner, setLoadingSpinner] = useState(true);
|
|
||||||
const [loadingSkip, setLoadingSkip] = useState(false);
|
|
||||||
// Задача 05: стартовый Ken-Burns экран
|
|
||||||
const [lsEnabled, setLsEnabled] = useState(true);
|
|
||||||
const [lsBackground, setLsBackground] = useState('');
|
|
||||||
const [lsCover, setLsCover] = useState('');
|
|
||||||
const [lsStyle, setLsStyle] = useState('ken-burns');
|
|
||||||
const [lsPlaceName, setLsPlaceName] = useState('');
|
|
||||||
const [lsStudioName, setLsStudioName] = useState('');
|
|
||||||
const [lsVerified, setLsVerified] = useState(false);
|
|
||||||
const [lsDuration, setLsDuration] = useState(2.5);
|
|
||||||
const [lsProgressBar, setLsProgressBar] = useState(true);
|
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const fileInputRef = useRef(null);
|
const fileInputRef = useRef(null);
|
||||||
const logoInputRef = useRef(null);
|
|
||||||
const lsBgInputRef = useRef(null);
|
|
||||||
const lsCoverInputRef = useRef(null);
|
|
||||||
|
|
||||||
// Заполняем поля ОДИН РАЗ при открытии модала.
|
// Заполняем поля ОДИН РАЗ при открытии модала.
|
||||||
// Не зависим от `initial` — родитель часто передаёт литерал-объект,
|
// Не зависим от `initial` — родитель часто передаёт литерал-объект,
|
||||||
@ -78,21 +60,6 @@ const GameSettingsModal = ({ open, initial, onClose, onSave, onCaptureScreenshot
|
|||||||
setIsPublic(!!initial?.is_public);
|
setIsPublic(!!initial?.is_public);
|
||||||
setMultiplayer(!!initial?.multiplayer);
|
setMultiplayer(!!initial?.multiplayer);
|
||||||
setIsTest(!!initial?.is_test);
|
setIsTest(!!initial?.is_test);
|
||||||
const ls = initial?.loading_screen || {};
|
|
||||||
setLoadingLogo(ls.logo || '');
|
|
||||||
setLoadingAccent(ls.accentColor || '#ffc020');
|
|
||||||
setLoadingSpinner(ls.defaultSpinner !== false);
|
|
||||||
setLoadingSkip(!!ls.defaultSkipButton);
|
|
||||||
// Задача 05:
|
|
||||||
setLsEnabled(ls.enabled !== false);
|
|
||||||
setLsBackground(ls.background || '');
|
|
||||||
setLsCover(ls.cover || '');
|
|
||||||
setLsStyle(ls.style || 'ken-burns');
|
|
||||||
setLsPlaceName(ls.placeName || '');
|
|
||||||
setLsStudioName(ls.studioName || '');
|
|
||||||
setLsVerified(!!ls.verified);
|
|
||||||
setLsDuration(Number.isFinite(ls.duration) && ls.duration > 0 ? ls.duration : 2.5);
|
|
||||||
setLsProgressBar(ls.progressBar !== false);
|
|
||||||
setMaxPlayers(
|
setMaxPlayers(
|
||||||
typeof initial?.max_players === 'number'
|
typeof initial?.max_players === 'number'
|
||||||
? Math.max(2, Math.min(50, initial.max_players))
|
? Math.max(2, Math.min(50, initial.max_players))
|
||||||
@ -129,27 +96,6 @@ const GameSettingsModal = ({ open, initial, onClose, onSave, onCaptureScreenshot
|
|||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLogoSelect = (e) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
if (!/^image\/(png|jpeg|webp)$/.test(file.type)) { setError('Логотип: только PNG, JPG или WEBP'); return; }
|
|
||||||
if (file.size > MAX_THUMBNAIL_BYTES) { setError('Логотип слишком большой (макс. 500 КБ)'); return; }
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = (ev) => { setLoadingLogo(ev.target.result); setError(''); };
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Задача 05: универсальный загрузчик изображения (фон / cover-карточка).
|
|
||||||
const handleLsImage = (e, setter) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
if (!/^image\/(png|jpeg|webp)$/.test(file.type)) { setError('Только PNG, JPG или WEBP'); return; }
|
|
||||||
if (file.size > MAX_THUMBNAIL_BYTES) { setError('Изображение слишком большое (макс. 500 КБ)'); return; }
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = (ev) => { setter(ev.target.result); setError(''); };
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = (e) => {
|
const handleSubmit = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const trimmedTitle = title.trim();
|
const trimmedTitle = title.trim();
|
||||||
@ -174,22 +120,6 @@ const GameSettingsModal = ({ open, initial, onClose, onSave, onCaptureScreenshot
|
|||||||
multiplayer,
|
multiplayer,
|
||||||
max_players: Math.max(2, Math.min(50, Number(maxPlayers) || 10)),
|
max_players: Math.max(2, Math.min(50, Number(maxPlayers) || 10)),
|
||||||
is_test: isTest,
|
is_test: isTest,
|
||||||
loading_screen: {
|
|
||||||
logo: loadingLogo || null,
|
|
||||||
accentColor: loadingAccent || '#ffc020',
|
|
||||||
defaultSpinner: loadingSpinner,
|
|
||||||
defaultSkipButton: loadingSkip,
|
|
||||||
// Задача 05:
|
|
||||||
enabled: lsEnabled,
|
|
||||||
background: lsBackground || null,
|
|
||||||
cover: lsCover || null,
|
|
||||||
style: lsStyle || 'ken-burns',
|
|
||||||
placeName: lsPlaceName.trim(),
|
|
||||||
studioName: lsStudioName.trim(),
|
|
||||||
verified: lsVerified,
|
|
||||||
duration: Math.max(1, Math.min(10, Number(lsDuration) || 2.5)),
|
|
||||||
progressBar: lsProgressBar,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -370,172 +300,6 @@ const GameSettingsModal = ({ open, initial, onClose, onSave, onCaptureScreenshot
|
|||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Экран загрузки (задача 12) */}
|
|
||||||
<div className={cl.field} style={{ borderTop: '1px solid rgba(255,255,255,0.08)', paddingTop: 14, marginTop: 4 }}>
|
|
||||||
<div className={cl.fieldLabel} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
||||||
<Icon name="loader" size={13} /> Экран загрузки
|
|
||||||
</div>
|
|
||||||
<div className={cl.fieldHint} style={{ marginBottom: 10 }}>
|
|
||||||
Логотип и цвет акцента для экранов загрузки между мирами (game.loading).
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', gap: 14, alignItems: 'center', marginBottom: 12 }}>
|
|
||||||
<div style={{
|
|
||||||
width: 96, height: 54, borderRadius: 8, background: '#15192a',
|
|
||||||
border: '1px solid rgba(255,255,255,0.12)', display: 'flex',
|
|
||||||
alignItems: 'center', justifyContent: 'center', overflow: 'hidden', flex: '0 0 auto',
|
|
||||||
}}>
|
|
||||||
{loadingLogo
|
|
||||||
? <img src={loadingLogo} alt="Логотип" style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' }} />
|
|
||||||
: <span style={{ color: '#5a6178', fontSize: 11 }}>лого = обложка</span>}
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
||||||
<button type="button" className={cl.actionBtn} onClick={() => logoInputRef.current?.click()}>
|
|
||||||
<Icon name="folder" size={14} /> Логотип игры
|
|
||||||
</button>
|
|
||||||
{loadingLogo && (
|
|
||||||
<button type="button" className={cl.removeBtn} style={{ alignSelf: 'flex-start' }} onClick={() => setLoadingLogo('')}>
|
|
||||||
<Icon name="close" size={13} /> Убрать
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<input ref={logoInputRef} type="file" accept="image/png,image/jpeg,image/webp"
|
|
||||||
style={{ display: 'none' }} onChange={handleLogoSelect} />
|
|
||||||
</div>
|
|
||||||
<label style={{ display: 'flex', flexDirection: 'column', gap: 4, marginLeft: 'auto' }}>
|
|
||||||
<span style={{ fontSize: 12, color: '#aab' }}>Цвет акцента</span>
|
|
||||||
<input type="color" value={loadingAccent}
|
|
||||||
onChange={(e) => setLoadingAccent(e.target.value)}
|
|
||||||
style={{ width: 48, height: 32, border: 'none', background: 'none', cursor: 'pointer' }} />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className={cl.togglesRow}>
|
|
||||||
<label className={cl.toggleRow}>
|
|
||||||
<input type="checkbox" className={cl.toggle}
|
|
||||||
checked={loadingSpinner} onChange={(e) => setLoadingSpinner(e.target.checked)} />
|
|
||||||
<div className={cl.toggleText}>
|
|
||||||
<div className={cl.toggleTitle}>Спиннер</div>
|
|
||||||
<div className={cl.toggleHint}><span>Показывать «ЗАГРУЗКА» по умолчанию</span></div>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
<label className={cl.toggleRow}>
|
|
||||||
<input type="checkbox" className={cl.toggle}
|
|
||||||
checked={loadingSkip} onChange={(e) => setLoadingSkip(e.target.checked)} />
|
|
||||||
<div className={cl.toggleText}>
|
|
||||||
<div className={cl.toggleTitle}>Кнопка «Пропустить»</div>
|
|
||||||
<div className={cl.toggleHint}><span>Показывать по умолчанию</span></div>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Стартовый экран — Ken Burns + название места (задача 05) */}
|
|
||||||
<div className={cl.field} style={{ borderTop: '1px solid rgba(255,255,255,0.08)', paddingTop: 14, marginTop: 4 }}>
|
|
||||||
<div className={cl.fieldLabel} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
||||||
<Icon name="loader" size={13} /> Стартовый экран входа (Ken Burns)
|
|
||||||
</div>
|
|
||||||
<div className={cl.fieldHint} style={{ marginBottom: 10 }}>
|
|
||||||
Что видит игрок при входе в игру: размытый фон с медленным движением (Ken Burns), карточка-витрина по центру, название места и автор.
|
|
||||||
</div>
|
|
||||||
<label className={cl.toggleRow} style={{ marginBottom: 10 }}>
|
|
||||||
<input type="checkbox" className={cl.toggle}
|
|
||||||
checked={lsEnabled} onChange={(e) => setLsEnabled(e.target.checked)} />
|
|
||||||
<div className={cl.toggleText}>
|
|
||||||
<div className={cl.toggleTitle}>Показывать стартовый экран</div>
|
|
||||||
<div className={cl.toggleHint}><span>Если выключено — игрок сразу попадает в 3D-сцену</span></div>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{lsEnabled && (
|
|
||||||
<>
|
|
||||||
{/* Фон + карточка */}
|
|
||||||
<div style={{ display: 'flex', gap: 14, marginBottom: 12, flexWrap: 'wrap' }}>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
||||||
<div style={{
|
|
||||||
width: 130, height: 74, borderRadius: 8, background: '#15192a',
|
|
||||||
border: '1px solid rgba(255,255,255,0.12)', overflow: 'hidden',
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
backgroundImage: lsBackground ? `url(${lsBackground})` : 'none',
|
|
||||||
backgroundSize: 'cover', backgroundPosition: 'center',
|
|
||||||
}}>
|
|
||||||
{!lsBackground && <span style={{ color: '#5a6178', fontSize: 11 }}>фон (размытый)</span>}
|
|
||||||
</div>
|
|
||||||
<button type="button" className={cl.actionBtn} onClick={() => lsBgInputRef.current?.click()}>
|
|
||||||
<Icon name="folder" size={14} /> Фон
|
|
||||||
</button>
|
|
||||||
{lsBackground && (
|
|
||||||
<button type="button" className={cl.removeBtn} style={{ alignSelf: 'flex-start' }} onClick={() => setLsBackground('')}>
|
|
||||||
<Icon name="close" size={13} /> Убрать
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<input ref={lsBgInputRef} type="file" accept="image/png,image/jpeg,image/webp"
|
|
||||||
style={{ display: 'none' }} onChange={(e) => handleLsImage(e, setLsBackground)} />
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
||||||
<div style={{
|
|
||||||
width: 74, height: 74, borderRadius: 12, background: '#15192a',
|
|
||||||
border: '1px solid rgba(255,255,255,0.12)', overflow: 'hidden',
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
backgroundImage: lsCover ? `url(${lsCover})` : 'none',
|
|
||||||
backgroundSize: 'cover', backgroundPosition: 'center',
|
|
||||||
}}>
|
|
||||||
{!lsCover && <span style={{ color: '#5a6178', fontSize: 10, textAlign: 'center' }}>карточка</span>}
|
|
||||||
</div>
|
|
||||||
<button type="button" className={cl.actionBtn} onClick={() => lsCoverInputRef.current?.click()}>
|
|
||||||
<Icon name="folder" size={14} /> Карточка
|
|
||||||
</button>
|
|
||||||
{lsCover && (
|
|
||||||
<button type="button" className={cl.removeBtn} style={{ alignSelf: 'flex-start' }} onClick={() => setLsCover('')}>
|
|
||||||
<Icon name="close" size={13} /> Убрать
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<input ref={lsCoverInputRef} type="file" accept="image/png,image/jpeg,image/webp"
|
|
||||||
style={{ display: 'none' }} onChange={(e) => handleLsImage(e, setLsCover)} />
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, flex: 1, minWidth: 180 }}>
|
|
||||||
<input type="text" className={cl.input} placeholder="Название места (по умолчанию = название игры)"
|
|
||||||
value={lsPlaceName} maxLength={40}
|
|
||||||
onChange={(e) => setLsPlaceName(e.target.value)} />
|
|
||||||
<input type="text" className={cl.input} placeholder="Имя автора"
|
|
||||||
value={lsStudioName} maxLength={40}
|
|
||||||
onChange={(e) => setLsStudioName(e.target.value)} />
|
|
||||||
<label className={cl.toggleRow}>
|
|
||||||
<input type="checkbox" className={cl.toggle}
|
|
||||||
checked={lsVerified} onChange={(e) => setLsVerified(e.target.checked)} />
|
|
||||||
<div className={cl.toggleText}>
|
|
||||||
<div className={cl.toggleTitle}>Галочка verified</div>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Стиль + длительность + прогресс */}
|
|
||||||
<div style={{ display: 'flex', gap: 14, alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
|
||||||
<label style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
|
||||||
<span style={{ fontSize: 12, color: '#aab' }}>Стиль анимации</span>
|
|
||||||
<select className={cl.input} value={lsStyle} onChange={(e) => setLsStyle(e.target.value)}>
|
|
||||||
<option value="ken-burns">Ken Burns (плавный pan+zoom)</option>
|
|
||||||
<option value="static">Статичный фон</option>
|
|
||||||
<option value="parallax">Параллакс (по мыши)</option>
|
|
||||||
<option value="particles">Частицы (искры)</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
|
||||||
<span style={{ fontSize: 12, color: '#aab' }}>Длительность: {Number(lsDuration).toFixed(1)} с</span>
|
|
||||||
<input type="range" min="1" max="10" step="0.5" value={lsDuration}
|
|
||||||
onChange={(e) => setLsDuration(Number(e.target.value))}
|
|
||||||
style={{ width: 160 }} />
|
|
||||||
</label>
|
|
||||||
<label className={cl.toggleRow}>
|
|
||||||
<input type="checkbox" className={cl.toggle}
|
|
||||||
checked={lsProgressBar} onChange={(e) => setLsProgressBar(e.target.checked)} />
|
|
||||||
<div className={cl.toggleText}>
|
|
||||||
<div className={cl.toggleTitle}>Прогресс-бар</div>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && <div className={cl.error}><Icon name="warning" size={14} /> {error}</div>}
|
{error && <div className={cl.error}><Icon name="warning" size={14} /> {error}</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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}/></>),
|
||||||
|
|||||||
@ -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}>
|
||||||
|
|||||||
@ -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';
|
||||||
@ -14,6 +15,7 @@ import { getModelThumbnail } from './engine/ModelThumbnails';
|
|||||||
import * as Kubikon3DApi from '../api/Kubikon3DService';
|
import * as Kubikon3DApi from '../api/Kubikon3DService';
|
||||||
import { REALTIME_HTTP } from '../api/API';
|
import { REALTIME_HTTP } from '../api/API';
|
||||||
import GameSettingsModal from './GameSettingsModal';
|
import GameSettingsModal from './GameSettingsModal';
|
||||||
|
import GameDecorModal from './GameDecorModal';
|
||||||
import SkinManagerModal from './SkinManagerModal';
|
import SkinManagerModal from './SkinManagerModal';
|
||||||
import PublishModal from './PublishModal';
|
import PublishModal from './PublishModal';
|
||||||
import EmailConfirmNotice from '../components/EmailConfirmNotice/EmailConfirmNotice';
|
import EmailConfirmNotice from '../components/EmailConfirmNotice/EmailConfirmNotice';
|
||||||
@ -45,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 секунд тишины → авто-сохранение
|
||||||
|
|
||||||
// Шаблон глобального скрипта.
|
// Шаблон глобального скрипта.
|
||||||
@ -468,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);
|
||||||
@ -639,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) {
|
||||||
@ -818,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);
|
||||||
@ -899,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);
|
||||||
@ -925,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]);
|
||||||
@ -934,6 +1071,9 @@ const KubikonEditor = () => {
|
|||||||
// settingsModalOpen — настройки игры (Roblox Game Settings)
|
// settingsModalOpen — настройки игры (Roblox Game Settings)
|
||||||
// initialModalOpen — инициальный диалог при создании новой игры
|
// initialModalOpen — инициальный диалог при создании новой игры
|
||||||
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
|
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
|
||||||
|
// Модал «Оформление» (графика/стартовый экран/экран загрузки) — из вкладки «Игра».
|
||||||
|
const [decorModalOpen, setDecorModalOpen] = useState(false);
|
||||||
|
const [decorSection, setDecorSection] = useState('graphics');
|
||||||
// Задача 07: модал управления скинами проекта + список всех скинов (манифест).
|
// Задача 07: модал управления скинами проекта + список всех скинов (манифест).
|
||||||
const [skinManagerOpen, setSkinManagerOpen] = useState(false);
|
const [skinManagerOpen, setSkinManagerOpen] = useState(false);
|
||||||
const [allSkinsList, setAllSkinsList] = useState([]);
|
const [allSkinsList, setAllSkinsList] = useState([]);
|
||||||
@ -1685,6 +1825,13 @@ const KubikonEditor = () => {
|
|||||||
return pd?.scene?.loadingScreen || null;
|
return pd?.scene?.loadingScreen || null;
|
||||||
} catch { return null; }
|
} catch { return null; }
|
||||||
})()) || null,
|
})()) || null,
|
||||||
|
// Графика/эффекты из scene-JSON (для модала настроек).
|
||||||
|
graphics: (data.project_data && (() => {
|
||||||
|
try {
|
||||||
|
const pd = typeof data.project_data === 'string' ? JSON.parse(data.project_data) : data.project_data;
|
||||||
|
return pd?.scene?.graphics || null;
|
||||||
|
} catch { return null; }
|
||||||
|
})()) || null,
|
||||||
};
|
};
|
||||||
// Состояние публикации (этап 3)
|
// Состояние публикации (этап 3)
|
||||||
setProjectStatus({
|
setProjectStatus({
|
||||||
@ -1733,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);
|
||||||
@ -1980,11 +2127,6 @@ const KubikonEditor = () => {
|
|||||||
const handleSettingsSave = (data) => {
|
const handleSettingsSave = (data) => {
|
||||||
metaRef.current = { ...metaRef.current, ...data };
|
metaRef.current = { ...metaRef.current, ...data };
|
||||||
setProjectName(data.title);
|
setProjectName(data.title);
|
||||||
// Задача 12: конфиг экрана загрузки → в сцену (попадёт в project_data.scene
|
|
||||||
// через toJSON). Логотип-дефолт = обложка проекта.
|
|
||||||
try {
|
|
||||||
sceneRef.current?.setLoadingConfig?.(data.loading_screen || null, data.thumbnail);
|
|
||||||
} catch (e) { /* ignore */ }
|
|
||||||
setSettingsModalOpen(false);
|
setSettingsModalOpen(false);
|
||||||
setInitialModalOpen(false);
|
setInitialModalOpen(false);
|
||||||
if (autoSaveTimerRef.current) {
|
if (autoSaveTimerRef.current) {
|
||||||
@ -1995,6 +2137,28 @@ const KubikonEditor = () => {
|
|||||||
doSave();
|
doSave();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Сохранить «Оформление» (графика / стартовый экран / экран загрузки) из
|
||||||
|
// модала, открытого во вкладке «Игра». Применяем сразу (превью) + в сцену.
|
||||||
|
const handleDecorSave = (data) => {
|
||||||
|
try {
|
||||||
|
sceneRef.current?.setLoadingConfig?.(
|
||||||
|
data.loading_screen || null, metaRef.current?.thumbnail);
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
try {
|
||||||
|
if (data.graphics) sceneRef.current?.setGraphics?.(data.graphics);
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
// запомним в metaRef, чтобы модал открылся с актуальными значениями
|
||||||
|
metaRef.current = {
|
||||||
|
...metaRef.current,
|
||||||
|
loading_screen: data.loading_screen,
|
||||||
|
graphics: data.graphics,
|
||||||
|
};
|
||||||
|
setDecorModalOpen(false);
|
||||||
|
dirtyRef.current = true;
|
||||||
|
doSave();
|
||||||
|
};
|
||||||
|
const openDecor = (sec) => { setDecorSection(sec); setDecorModalOpen(true); };
|
||||||
|
|
||||||
// Закрыть инициальный диалог: если пользователь не сохранил — возвращаемся в Studio.
|
// Закрыть инициальный диалог: если пользователь не сохранил — возвращаемся в Studio.
|
||||||
const handleInitialClose = () => {
|
const handleInitialClose = () => {
|
||||||
setInitialModalOpen(false);
|
setInitialModalOpen(false);
|
||||||
@ -2004,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()) {
|
||||||
@ -2015,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 (_) {}
|
||||||
@ -2033,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');
|
||||||
@ -2186,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()}
|
||||||
@ -2331,6 +2565,9 @@ const KubikonEditor = () => {
|
|||||||
}}
|
}}
|
||||||
onSkins={() => setSkinManagerOpen(true)}
|
onSkins={() => setSkinManagerOpen(true)}
|
||||||
onInvite={handleInvite}
|
onInvite={handleInvite}
|
||||||
|
onGraphics={() => openDecor('graphics')}
|
||||||
|
onStartScreen={() => openDecor('startscreen')}
|
||||||
|
onLoadingScreen={() => openDecor('loadingscreen')}
|
||||||
collabActive={collabActive}
|
collabActive={collabActive}
|
||||||
collabPeers={collabPeers}
|
collabPeers={collabPeers}
|
||||||
hasSelection={!!selection}
|
hasSelection={!!selection}
|
||||||
@ -3394,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}
|
||||||
@ -3619,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}
|
||||||
@ -3756,6 +4003,7 @@ const KubikonEditor = () => {
|
|||||||
markDirty();
|
markDirty();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -3788,6 +4036,14 @@ const KubikonEditor = () => {
|
|||||||
onSave={handleSettingsSave}
|
onSave={handleSettingsSave}
|
||||||
onCaptureScreenshot={captureSceneScreenshot}
|
onCaptureScreenshot={captureSceneScreenshot}
|
||||||
/>
|
/>
|
||||||
|
{/* Оформление: графика / стартовый экран / экран загрузки (вкладка «Игра») */}
|
||||||
|
<GameDecorModal
|
||||||
|
open={decorModalOpen}
|
||||||
|
section={decorSection}
|
||||||
|
initial={metaRef.current}
|
||||||
|
onClose={() => setDecorModalOpen(false)}
|
||||||
|
onSave={handleDecorSave}
|
||||||
|
/>
|
||||||
{/* Задача 07: управление скинами проекта (стартовый, магазин, рублики, кастомные .glb) */}
|
{/* Задача 07: управление скинами проекта (стартовый, магазин, рублики, кастомные .glb) */}
|
||||||
<SkinManagerModal
|
<SkinManagerModal
|
||||||
open={skinManagerOpen}
|
open={skinManagerOpen}
|
||||||
|
|||||||
@ -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;
|
/* Правая панель шире (280→320) — длинные имена объектов не обрезаются,
|
||||||
|
больше места под список и свойства. Левая чуть уже (240→224). */
|
||||||
|
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 {
|
||||||
|
|||||||
@ -234,6 +234,7 @@ const TopRibbon = (props) => {
|
|||||||
activeTool, onToolChange,
|
activeTool, onToolChange,
|
||||||
isPlaying, onPlayToggle, onSetSpawn,
|
isPlaying, onPlayToggle, onSetSpawn,
|
||||||
onSkins, onInvite, collabActive, collabPeers,
|
onSkins, onInvite, collabActive, collabPeers,
|
||||||
|
onGraphics, onStartScreen, onLoadingScreen,
|
||||||
hasSelection,
|
hasSelection,
|
||||||
onDuplicate, onAlignToFloor, onDelete,
|
onDuplicate, onAlignToFloor, onDelete,
|
||||||
onClearScene,
|
onClearScene,
|
||||||
@ -450,6 +451,25 @@ const TopRibbon = (props) => {
|
|||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
{/* Оформление — графика/эффекты, стартовый экран, экран загрузки. */}
|
||||||
|
<Group title="Оформление">
|
||||||
|
<RibbonBtn
|
||||||
|
iconName="sparkles" label="Графика"
|
||||||
|
onClick={onGraphics}
|
||||||
|
title="Графика и эффекты: свечение, цвет, тени (шейдеры)"
|
||||||
|
/>
|
||||||
|
<RibbonBtn
|
||||||
|
iconName="loader" label="Стартовый экран"
|
||||||
|
onClick={onStartScreen}
|
||||||
|
title="Стартовый экран входа (Ken Burns): фон, карточка, название"
|
||||||
|
/>
|
||||||
|
<RibbonBtn
|
||||||
|
iconName="loader" label="Экран загрузки"
|
||||||
|
onClick={onLoadingScreen}
|
||||||
|
title="Экран загрузки между мирами: логотип, цвет, спиннер"
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
{/* «Окружение» (время суток / амбиент / музыка) и
|
{/* «Окружение» (время суток / амбиент / музыка) и
|
||||||
«Скин игрока» переехали в иерархию объектов сцены:
|
«Скин игрока» переехали в иерархию объектов сцены:
|
||||||
🌞 Освещение / 🎵 Звук / 👤 Игрок. */}
|
🌞 Освещение / 🎵 Звук / 👤 Игрок. */}
|
||||||
|
|||||||
@ -35,6 +35,8 @@ import {
|
|||||||
ParticleSystem,
|
ParticleSystem,
|
||||||
Texture,
|
Texture,
|
||||||
Ray,
|
Ray,
|
||||||
|
Matrix,
|
||||||
|
HighlightLayer,
|
||||||
PointerEventTypes,
|
PointerEventTypes,
|
||||||
Tools as BabylonTools,
|
Tools as BabylonTools,
|
||||||
ColorCurves,
|
ColorCurves,
|
||||||
@ -98,6 +100,7 @@ import { GdForest } from './GdForest';
|
|||||||
import { GdPlayerCube } from './GdPlayerCube';
|
import { GdPlayerCube } from './GdPlayerCube';
|
||||||
import { GdPlayerTrail } from './GdPlayerTrail';
|
import { GdPlayerTrail } from './GdPlayerTrail';
|
||||||
import { GdPostFx } from './GdPostFx';
|
import { GdPostFx } from './GdPostFx';
|
||||||
|
import { GraphicsManager } from './GraphicsManager';
|
||||||
import { PhysicsAABB } from './PhysicsAABB';
|
import { PhysicsAABB } from './PhysicsAABB';
|
||||||
import { PlayerController } from './PlayerController';
|
import { PlayerController } from './PlayerController';
|
||||||
import { SelectionManager } from './SelectionManager';
|
import { SelectionManager } from './SelectionManager';
|
||||||
@ -197,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-фикс для скульпт/выровнять
|
||||||
@ -213,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;
|
||||||
@ -346,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);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -1368,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); // снэп для блоков
|
||||||
@ -1381,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);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Привязка гизмо к выделенному
|
// Привязка гизмо к выделенному
|
||||||
@ -1551,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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1800,6 +1867,47 @@ export class BabylonScene {
|
|||||||
|
|
||||||
getShadowQuality() { return this._shadowQuality || 'soft'; }
|
getShadowQuality() { return this._shadowQuality || 'soft'; }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Система графики/эффектов («шейдеры»). Лениво создаём GraphicsManager
|
||||||
|
* (постобработка: bloom/FXAA/виньетка/цветокор/DoF + управление тенями/SSAO).
|
||||||
|
* По умолчанию ВЫКЛЮЧЕНО — вызывается только когда автор настроил графику.
|
||||||
|
*/
|
||||||
|
_ensureGraphics() {
|
||||||
|
if (this._graphics) {
|
||||||
|
// камера могла смениться (режимы камеры) — синхронизируем
|
||||||
|
const cam = this.scene?.activeCamera || this.camera;
|
||||||
|
if (cam) this._graphics.setCamera(cam);
|
||||||
|
return this._graphics;
|
||||||
|
}
|
||||||
|
const cam = this.scene?.activeCamera || this.camera;
|
||||||
|
if (!this.scene || !cam) return null;
|
||||||
|
this._graphics = new GraphicsManager(this.scene, cam, this, {
|
||||||
|
mobile: !!this._isMobileMode,
|
||||||
|
});
|
||||||
|
return this._graphics;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Применить настройки графики. settings: {preset} и/или секции
|
||||||
|
* (bloom/vignette/grading/dof/ssao/fxaa/shadows). */
|
||||||
|
setGraphics(settings) {
|
||||||
|
const g = this._ensureGraphics();
|
||||||
|
if (!g) return null;
|
||||||
|
const cfg = g.apply(settings || {});
|
||||||
|
this._graphicsConfig = cfg;
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Текущая конфигурация графики (для serialize). */
|
||||||
|
getGraphicsState() {
|
||||||
|
return this._graphics ? this._graphics.serialize() : (this._graphicsConfig || null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Выключить все эффекты. */
|
||||||
|
disableGraphics() {
|
||||||
|
if (this._graphics) this._graphics.disableAll();
|
||||||
|
this._graphicsConfig = null;
|
||||||
|
}
|
||||||
|
|
||||||
/** Включить/выключить SSAO пост-эффект (контактные тени).
|
/** Включить/выключить SSAO пост-эффект (контактные тени).
|
||||||
*
|
*
|
||||||
* Используем SSAORenderingPipeline v1 (не v2). v2 требует
|
* Используем SSAORenderingPipeline v1 (не v2). v2 требует
|
||||||
@ -2024,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);
|
||||||
}
|
}
|
||||||
@ -2365,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2402,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';
|
||||||
@ -2436,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) {
|
||||||
@ -2473,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;
|
||||||
@ -2494,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) {
|
||||||
@ -2746,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 },
|
||||||
@ -2762,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 },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -3962,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();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Обновить гизмо под текущее выделение.
|
* Обновить гизмо под текущее выделение.
|
||||||
*/
|
*/
|
||||||
@ -3972,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;
|
||||||
@ -3986,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.
|
||||||
@ -4027,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)
|
||||||
@ -5458,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 = [
|
||||||
@ -5816,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();
|
||||||
@ -5860,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 };
|
||||||
|
|
||||||
@ -5914,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(); }
|
||||||
@ -6147,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);
|
||||||
@ -7689,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() : [];
|
||||||
@ -7805,6 +8473,7 @@ export class BabylonScene {
|
|||||||
crosshair: this._crosshair || 'dot',
|
crosshair: this._crosshair || 'dot',
|
||||||
shadowQuality: this._shadowQuality || 'soft',
|
shadowQuality: this._shadowQuality || 'soft',
|
||||||
environment: this.environment ? this.environment.serialize() : null,
|
environment: this.environment ? this.environment.serialize() : null,
|
||||||
|
graphics: this.getGraphicsState(),
|
||||||
// Кастомные настройки света — слайдеры из «Свет и атмосфера»
|
// Кастомные настройки света — слайдеры из «Свет и атмосфера»
|
||||||
lighting: {
|
lighting: {
|
||||||
sunIntensity: this._sunIntensity ?? this._sunLight?.intensity ?? 0.8,
|
sunIntensity: this._sunIntensity ?? this._sunLight?.intensity ?? 0.8,
|
||||||
@ -7902,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 формата:
|
||||||
@ -8250,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;
|
||||||
}
|
}
|
||||||
@ -8298,6 +8987,12 @@ export class BabylonScene {
|
|||||||
if (state.scene.environment && this.environment) {
|
if (state.scene.environment && this.environment) {
|
||||||
this.environment.load(state.scene.environment);
|
this.environment.load(state.scene.environment);
|
||||||
}
|
}
|
||||||
|
// Графика/эффекты (шейдеры). Применяем только если автор что-то настроил
|
||||||
|
// и это не 'off' — иначе не трогаем рендер (чистая картинка, дефолт).
|
||||||
|
if (state.scene.graphics && state.scene.graphics.preset
|
||||||
|
&& state.scene.graphics.preset !== 'off') {
|
||||||
|
try { this.setGraphics(state.scene.graphics); } catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
// Кастомные настройки света/цветокоррекции — применяем через
|
// Кастомные настройки света/цветокоррекции — применяем через
|
||||||
// setLightingProps (он сам подхватит default-ы если значения нет).
|
// setLightingProps (он сам подхватит default-ы если значения нет).
|
||||||
if (state.scene.lighting) {
|
if (state.scene.lighting) {
|
||||||
@ -8561,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;
|
||||||
|
|||||||
@ -3252,6 +3252,10 @@ export class GameRuntime {
|
|||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (cmd === 'graphics.set') {
|
||||||
|
try { this.scene3d?.setGraphics?.(payload || {}); } catch (e) {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (cmd === 'player.setCrouch') {
|
if (cmd === 'player.setCrouch') {
|
||||||
const player = this.scene3d?.player;
|
const player = this.scene3d?.player;
|
||||||
if (player) {
|
if (player) {
|
||||||
|
|||||||
@ -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: 'Двойной прыжок',
|
||||||
|
|||||||
328
src/editor/engine/GraphicsManager.js
Normal file
328
src/editor/engine/GraphicsManager.js
Normal file
@ -0,0 +1,328 @@
|
|||||||
|
/**
|
||||||
|
* GraphicsManager — система визуальных эффектов («шейдеры») для игр Рублокса.
|
||||||
|
*
|
||||||
|
* Управляет:
|
||||||
|
* - постобработкой экрана через Babylon DefaultRenderingPipeline:
|
||||||
|
* bloom (свечение), FXAA (сглаживание), виньетка, цветокоррекция
|
||||||
|
* (контраст/насыщенность/экспозиция), тонмаппинг, глубина резкости (DoF);
|
||||||
|
* - качеством теней (через scene3d.setShadowQuality);
|
||||||
|
* - контактными тенями SSAO (через scene3d.setSsaoEnabled).
|
||||||
|
*
|
||||||
|
* Управляется И из настроек игры (вкладка «Графика»), И из скриптов
|
||||||
|
* (game.graphics.*). По умолчанию ВСЁ ВЫКЛЮЧЕНО (preset 'off') — старые игры
|
||||||
|
* не меняются, FPS не страдает. Автор включает осознанно.
|
||||||
|
*
|
||||||
|
* Mobile-safe: на слабых устройствах тяжёлые эффекты (DoF, SSAO, ultra-тени,
|
||||||
|
* HDR-bloom) автоматически урезаются, даже если в пресете включены.
|
||||||
|
*
|
||||||
|
* Один и тот же класс используется в студии и плеере (фича-парность).
|
||||||
|
*
|
||||||
|
* Использование:
|
||||||
|
* const gfx = new GraphicsManager(scene, camera, scene3d, { mobile });
|
||||||
|
* gfx.apply({ preset: 'cinematic' });
|
||||||
|
* gfx.apply({ bloom: { enabled: true, intensity: 0.7 } });
|
||||||
|
* gfx.dispose();
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
DefaultRenderingPipeline, Color4, ImageProcessingConfiguration,
|
||||||
|
} from '@babylonjs/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Именованные пресеты. Каждый — полный набор настроек. 'off' = чистая картинка
|
||||||
|
* (pipeline не создаётся вовсе). Значения подобраны так, чтобы быть заметными,
|
||||||
|
* но не «кислотными».
|
||||||
|
*/
|
||||||
|
export const GRAPHICS_PRESETS = {
|
||||||
|
off: {
|
||||||
|
bloom: { enabled: false },
|
||||||
|
fxaa: false,
|
||||||
|
vignette: { enabled: false },
|
||||||
|
grading: { enabled: false },
|
||||||
|
dof: { enabled: false },
|
||||||
|
ssao: false,
|
||||||
|
shadows: null, // null = не трогаем текущее качество теней
|
||||||
|
},
|
||||||
|
// Лёгкий: только мягкое свечение + сглаживание. Дёшево, годится почти везде.
|
||||||
|
low: {
|
||||||
|
bloom: { enabled: true, intensity: 0.3, threshold: 0.9 },
|
||||||
|
fxaa: true,
|
||||||
|
vignette: { enabled: false },
|
||||||
|
grading: { enabled: false },
|
||||||
|
dof: { enabled: false },
|
||||||
|
ssao: false,
|
||||||
|
shadows: 'hard',
|
||||||
|
},
|
||||||
|
// Средний: свечение + лёгкая виньетка + чуть насыщенности.
|
||||||
|
medium: {
|
||||||
|
bloom: { enabled: true, intensity: 0.45, threshold: 0.85 },
|
||||||
|
fxaa: true,
|
||||||
|
vignette: { enabled: true, weight: 0.5 },
|
||||||
|
grading: { enabled: true, contrast: 1.05, saturation: 1.1, exposure: 1.0 },
|
||||||
|
dof: { enabled: false },
|
||||||
|
ssao: false,
|
||||||
|
shadows: 'soft',
|
||||||
|
},
|
||||||
|
// Высокий: всё кроме DoF, SSAO включён.
|
||||||
|
high: {
|
||||||
|
bloom: { enabled: true, intensity: 0.6, threshold: 0.82 },
|
||||||
|
fxaa: true,
|
||||||
|
vignette: { enabled: true, weight: 0.6 },
|
||||||
|
grading: { enabled: true, contrast: 1.1, saturation: 1.2, exposure: 1.05 },
|
||||||
|
dof: { enabled: false },
|
||||||
|
ssao: true,
|
||||||
|
shadows: 'soft',
|
||||||
|
},
|
||||||
|
// Ультра: + глубина резкости + мягкие каскадные тени.
|
||||||
|
ultra: {
|
||||||
|
bloom: { enabled: true, intensity: 0.7, threshold: 0.8 },
|
||||||
|
fxaa: true,
|
||||||
|
vignette: { enabled: true, weight: 0.65 },
|
||||||
|
grading: { enabled: true, contrast: 1.12, saturation: 1.25, exposure: 1.05 },
|
||||||
|
dof: { enabled: true, focusDistance: 18, focalLength: 50, aperture: 1.2 },
|
||||||
|
ssao: true,
|
||||||
|
shadows: 'high',
|
||||||
|
},
|
||||||
|
// === Стилевые пресеты (художественные) ===
|
||||||
|
cinematic: {
|
||||||
|
bloom: { enabled: true, intensity: 0.55, threshold: 0.8 },
|
||||||
|
fxaa: true,
|
||||||
|
vignette: { enabled: true, weight: 0.85 },
|
||||||
|
grading: { enabled: true, contrast: 1.18, saturation: 1.05, exposure: 1.0 },
|
||||||
|
dof: { enabled: true, focusDistance: 22, focalLength: 60, aperture: 1.0 },
|
||||||
|
ssao: true,
|
||||||
|
shadows: 'soft',
|
||||||
|
},
|
||||||
|
vivid: {
|
||||||
|
bloom: { enabled: true, intensity: 0.65, threshold: 0.78 },
|
||||||
|
fxaa: true,
|
||||||
|
vignette: { enabled: false },
|
||||||
|
grading: { enabled: true, contrast: 1.1, saturation: 1.5, exposure: 1.1 },
|
||||||
|
dof: { enabled: false },
|
||||||
|
ssao: false,
|
||||||
|
shadows: 'soft',
|
||||||
|
},
|
||||||
|
night: {
|
||||||
|
bloom: { enabled: true, intensity: 0.8, threshold: 0.7 },
|
||||||
|
fxaa: true,
|
||||||
|
vignette: { enabled: true, weight: 1.0 },
|
||||||
|
grading: { enabled: true, contrast: 1.2, saturation: 0.85, exposure: 0.8 },
|
||||||
|
dof: { enabled: false },
|
||||||
|
ssao: true,
|
||||||
|
shadows: 'soft',
|
||||||
|
},
|
||||||
|
retro: {
|
||||||
|
bloom: { enabled: false },
|
||||||
|
fxaa: false, // намеренно «пиксельно»
|
||||||
|
vignette: { enabled: true, weight: 1.2 },
|
||||||
|
grading: { enabled: true, contrast: 1.3, saturation: 0.7, exposure: 0.95 },
|
||||||
|
dof: { enabled: false },
|
||||||
|
ssao: false,
|
||||||
|
shadows: 'hard',
|
||||||
|
},
|
||||||
|
soft: {
|
||||||
|
bloom: { enabled: true, intensity: 0.4, threshold: 0.88 },
|
||||||
|
fxaa: true,
|
||||||
|
vignette: { enabled: true, weight: 0.4 },
|
||||||
|
grading: { enabled: true, contrast: 0.95, saturation: 1.05, exposure: 1.05 },
|
||||||
|
dof: { enabled: false },
|
||||||
|
ssao: false,
|
||||||
|
shadows: 'soft',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Глубокое слияние пресета и пользовательских оверрайдов.
|
||||||
|
function _mergeConfig(base, over) {
|
||||||
|
const out = JSON.parse(JSON.stringify(base || {}));
|
||||||
|
if (!over) return out;
|
||||||
|
for (const k of Object.keys(over)) {
|
||||||
|
const v = over[k];
|
||||||
|
if (v && typeof v === 'object' && !Array.isArray(v)) {
|
||||||
|
out[k] = { ...(out[k] || {}), ...v };
|
||||||
|
} else {
|
||||||
|
out[k] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GraphicsManager {
|
||||||
|
/**
|
||||||
|
* @param scene Babylon Scene
|
||||||
|
* @param camera активная камера (для pipeline)
|
||||||
|
* @param scene3d ссылка на BabylonScene (для setShadowQuality / setSsaoEnabled / света)
|
||||||
|
* @param opts { mobile:boolean }
|
||||||
|
*/
|
||||||
|
constructor(scene, camera, scene3d, opts = {}) {
|
||||||
|
this.scene = scene;
|
||||||
|
this.camera = camera;
|
||||||
|
this.scene3d = scene3d;
|
||||||
|
this.mobile = !!opts.mobile;
|
||||||
|
this._pipeline = null;
|
||||||
|
// Текущая активная конфигурация (после merge + mobile-clamp).
|
||||||
|
this.config = _mergeConfig(GRAPHICS_PRESETS.off, null);
|
||||||
|
this.config.preset = 'off';
|
||||||
|
this.enabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Сменить камеру (например после смены режима камеры) — пересобрать pipeline. */
|
||||||
|
setCamera(camera) {
|
||||||
|
if (camera === this.camera) return;
|
||||||
|
this.camera = camera;
|
||||||
|
if (this.enabled) this._rebuildPipeline();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Применить настройки графики. Принимает либо {preset}, либо отдельные
|
||||||
|
* секции (bloom/vignette/grading/dof/ssao/fxaa/shadows), либо и то и другое
|
||||||
|
* (оверрайды поверх пресета). Сохраняет состояние в this.config.
|
||||||
|
*/
|
||||||
|
apply(settings = {}) {
|
||||||
|
let cfg;
|
||||||
|
if (settings.preset && GRAPHICS_PRESETS[settings.preset]) {
|
||||||
|
cfg = _mergeConfig(GRAPHICS_PRESETS[settings.preset], settings);
|
||||||
|
cfg.preset = settings.preset;
|
||||||
|
} else {
|
||||||
|
// частичный апдейт поверх текущего
|
||||||
|
cfg = _mergeConfig(this.config, settings);
|
||||||
|
cfg.preset = settings.preset || this.config.preset || 'custom';
|
||||||
|
}
|
||||||
|
this.config = this._clampForMobile(cfg);
|
||||||
|
this._applyConfig();
|
||||||
|
return this.config;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Полностью выключить эффекты (как preset 'off'). */
|
||||||
|
disableAll() {
|
||||||
|
return this.apply({ preset: 'off' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Текущая конфигурация (для serialize). */
|
||||||
|
serialize() {
|
||||||
|
// Храним «как просили» (preset + явные оверрайды). Для простоты — весь cfg.
|
||||||
|
return JSON.parse(JSON.stringify(this.config));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- внутреннее ---
|
||||||
|
|
||||||
|
/** На слабых устройствах гасим самое дорогое, что бы ни просили. */
|
||||||
|
_clampForMobile(cfg) {
|
||||||
|
if (!this.mobile) return cfg;
|
||||||
|
const c = JSON.parse(JSON.stringify(cfg));
|
||||||
|
if (c.dof) c.dof.enabled = false; // DoF дорогой
|
||||||
|
c.ssao = false; // SSAO дорогой
|
||||||
|
if (c.shadows === 'high' || c.shadows === 'medium') c.shadows = 'hard';
|
||||||
|
// bloom оставляем, но без HDR (решается в _rebuildPipeline)
|
||||||
|
c._mobileClamped = true;
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
_applyConfig() {
|
||||||
|
const c = this.config;
|
||||||
|
const anyPipelineFx = (c.bloom && c.bloom.enabled) || c.fxaa
|
||||||
|
|| (c.vignette && c.vignette.enabled) || (c.grading && c.grading.enabled)
|
||||||
|
|| (c.dof && c.dof.enabled);
|
||||||
|
|
||||||
|
// Тени и SSAO — через scene3d (они вне pipeline).
|
||||||
|
try {
|
||||||
|
if (c.shadows && this.scene3d?.setShadowQuality) {
|
||||||
|
this.scene3d.setShadowQuality(c.shadows);
|
||||||
|
}
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
try {
|
||||||
|
if (this.scene3d?.setSsaoEnabled) this.scene3d.setSsaoEnabled(!!c.ssao);
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
|
||||||
|
if (!anyPipelineFx) {
|
||||||
|
this.enabled = false;
|
||||||
|
this._disposePipeline();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.enabled = true;
|
||||||
|
this._rebuildPipeline();
|
||||||
|
}
|
||||||
|
|
||||||
|
_rebuildPipeline() {
|
||||||
|
this._disposePipeline();
|
||||||
|
if (!this.scene || !this.camera) return;
|
||||||
|
const c = this.config;
|
||||||
|
const useHdr = (c.bloom && c.bloom.enabled) && !this.mobile;
|
||||||
|
|
||||||
|
const p = new DefaultRenderingPipeline('rbx_graphics', useHdr, this.scene, [this.camera]);
|
||||||
|
|
||||||
|
// Bloom
|
||||||
|
p.bloomEnabled = !!(c.bloom && c.bloom.enabled);
|
||||||
|
if (p.bloomEnabled) {
|
||||||
|
p.bloomThreshold = c.bloom.threshold ?? 0.85;
|
||||||
|
p.bloomWeight = c.bloom.intensity ?? 0.5;
|
||||||
|
p.bloomKernel = this.mobile ? 32 : 64;
|
||||||
|
p.bloomScale = 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// FXAA
|
||||||
|
p.fxaaEnabled = !!c.fxaa;
|
||||||
|
p.samples = this.mobile ? 1 : 4;
|
||||||
|
|
||||||
|
// Image processing: виньетка + цветокоррекция + (опц.) тонмаппинг
|
||||||
|
const ip = p.imageProcessing;
|
||||||
|
if (ip) {
|
||||||
|
p.imageProcessingEnabled = true;
|
||||||
|
ip.toneMappingEnabled = false; // как в GdPostFx — иначе картинка темнеет
|
||||||
|
// экспозиция/контраст из grading
|
||||||
|
if (c.grading && c.grading.enabled) {
|
||||||
|
ip.exposure = c.grading.exposure ?? 1.0;
|
||||||
|
ip.contrast = c.grading.contrast ?? 1.0;
|
||||||
|
ip.colorCurvesEnabled = true;
|
||||||
|
try {
|
||||||
|
const curves = ip.colorCurves;
|
||||||
|
if (curves) {
|
||||||
|
// saturation: 1.0 = норма → curves в диапазоне примерно -100..100
|
||||||
|
const sat = c.grading.saturation ?? 1.0;
|
||||||
|
curves.globalSaturation = Math.round((sat - 1.0) * 60);
|
||||||
|
}
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
} else {
|
||||||
|
ip.exposure = 1.0; ip.contrast = 1.0;
|
||||||
|
}
|
||||||
|
// виньетка
|
||||||
|
if (c.vignette && c.vignette.enabled) {
|
||||||
|
ip.vignetteEnabled = true;
|
||||||
|
ip.vignetteWeight = c.vignette.weight ?? 0.6;
|
||||||
|
ip.vignetteColor = new Color4(0, 0, 0, 0);
|
||||||
|
ip.vignetteStretch = 0.3;
|
||||||
|
ip.vignetteCameraFov = 0.5;
|
||||||
|
ip.vignetteBlendMode = ImageProcessingConfiguration.VIGNETTEMODE_MULTIPLY;
|
||||||
|
} else {
|
||||||
|
ip.vignetteEnabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Depth of Field (глубина резкости) — только desktop
|
||||||
|
if (c.dof && c.dof.enabled && !this.mobile) {
|
||||||
|
p.depthOfFieldEnabled = true;
|
||||||
|
try {
|
||||||
|
p.depthOfFieldBlurLevel = 1; // 0..2
|
||||||
|
p.depthOfField.focusDistance = (c.dof.focusDistance ?? 18) * 1000; // мм
|
||||||
|
p.depthOfField.focalLength = c.dof.focalLength ?? 50;
|
||||||
|
p.depthOfField.fStop = c.dof.aperture ?? 1.2;
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
} else {
|
||||||
|
p.depthOfFieldEnabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._pipeline = p;
|
||||||
|
}
|
||||||
|
|
||||||
|
_disposePipeline() {
|
||||||
|
if (this._pipeline) {
|
||||||
|
try { this._pipeline.dispose(); } catch (e) { /* ignore */ }
|
||||||
|
this._pipeline = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
this._disposePipeline();
|
||||||
|
this.scene = null;
|
||||||
|
this.camera = null;
|
||||||
|
this.scene3d = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
593
src/editor/engine/MixamoAnimator.js
Normal file
593
src/editor/engine/MixamoAnimator.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
* - позиция (x, y, z)
|
* - позиция (x, y, z)
|
||||||
* - размер (sx, sy, sz)
|
* - размер (sx, sy, sz)
|
||||||
* - цвет (#hex)
|
* - цвет (#hex)
|
||||||
* - материал ('matte' | 'metal' | 'glass' | 'neon')
|
* - материал ('matte'|'metal'|'glass'|'neon'|'studs'|'chrome'|'water'|'iridescent')
|
||||||
* - canCollide (bool) — участвует ли в физике коллизий
|
* - canCollide (bool) — участвует ли в физике коллизий
|
||||||
* - visible (bool) — рисуется ли (anchored — пока заготовка)
|
* - visible (bool) — рисуется ли (anchored — пока заготовка)
|
||||||
*
|
*
|
||||||
@ -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`;
|
||||||
@ -538,7 +610,9 @@ export class PrimitiveManager {
|
|||||||
break;
|
break;
|
||||||
case 'glass':
|
case 'glass':
|
||||||
mat.alpha = 0.4;
|
mat.alpha = 0.4;
|
||||||
mat.specularColor = new Color3(0.5, 0.5, 0.5);
|
mat.specularColor = new Color3(0.8, 0.85, 0.9);
|
||||||
|
mat.specularPower = 96; // более чёткий блик на стекле
|
||||||
|
mat.backFaceCulling = false; // видно «толщину» — глубже эффект
|
||||||
break;
|
break;
|
||||||
case 'neon':
|
case 'neon':
|
||||||
mat.emissiveColor = Color3.FromHexString(color || '#888888');
|
mat.emissiveColor = Color3.FromHexString(color || '#888888');
|
||||||
@ -571,6 +645,39 @@ export class PrimitiveManager {
|
|||||||
mat.specularColor = new Color3(0, 0, 0);
|
mat.specularColor = new Color3(0, 0, 0);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 'chrome': {
|
||||||
|
// Хром/зеркало: яркий узкий блик + лёгкое самосвечение цвета,
|
||||||
|
// чтобы поверхность «играла» даже без cubemap-отражений (их не
|
||||||
|
// грузим — экономим ассеты и FPS на детских машинах).
|
||||||
|
const cc = Color3.FromHexString(color || '#cfd6e0');
|
||||||
|
mat.diffuseColor = new Color3(cc.r * 0.6, cc.g * 0.6, cc.b * 0.6);
|
||||||
|
mat.specularColor = new Color3(1, 1, 1);
|
||||||
|
mat.specularPower = 128; // узкий резкий блик = «металл»
|
||||||
|
mat.emissiveColor = new Color3(cc.r * 0.12, cc.g * 0.12, cc.b * 0.14);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'water': {
|
||||||
|
// Вода: полупрозрачный голубой с бликами. Анимацию ряби делает
|
||||||
|
// GraphicsManager/Environment по флагу mesh._isWater (опционально).
|
||||||
|
const wc = Color3.FromHexString(color || '#3aa0ff');
|
||||||
|
mat.diffuseColor = wc;
|
||||||
|
mat.alpha = 0.55;
|
||||||
|
mat.specularColor = new Color3(0.9, 0.95, 1.0);
|
||||||
|
mat.specularPower = 64;
|
||||||
|
mat.emissiveColor = new Color3(wc.r * 0.1, wc.g * 0.14, wc.b * 0.2);
|
||||||
|
mesh._isWater = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'iridescent': {
|
||||||
|
// Переливы: насыщенное самосвечение + блик. Цвет «бензиновой
|
||||||
|
// плёнки» — приятно для кристаллов, мыльных пузырей, порталов.
|
||||||
|
const ic = Color3.FromHexString(color || '#a06bff');
|
||||||
|
mat.diffuseColor = ic;
|
||||||
|
mat.emissiveColor = new Color3(ic.r * 0.5, ic.g * 0.35, ic.b * 0.6);
|
||||||
|
mat.specularColor = new Color3(1, 1, 1);
|
||||||
|
mat.specularPower = 96;
|
||||||
|
break;
|
||||||
|
}
|
||||||
case 'matte':
|
case 'matte':
|
||||||
mat.specularColor = new Color3(0, 0, 0);
|
mat.specularColor = new Color3(0, 0, 0);
|
||||||
break;
|
break;
|
||||||
@ -738,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 кажутся правильными. Простой способ —
|
||||||
@ -897,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/тайлинг + текстура
|
||||||
@ -983,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,
|
||||||
|
|||||||
@ -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'] },
|
||||||
];
|
];
|
||||||
|
|||||||
@ -3890,6 +3890,53 @@ const game = {
|
|||||||
_send('environment.setTimeOfDay', { hours: h });
|
_send('environment.setTimeOfDay', { hours: h });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* graphics — визуальные эффекты («шейдеры»): постобработка, свечение,
|
||||||
|
* цветокоррекция, тени. По умолчанию всё выключено.
|
||||||
|
*/
|
||||||
|
graphics: {
|
||||||
|
/** Применить пресет: 'off'|'low'|'medium'|'high'|'ultra'|
|
||||||
|
* 'cinematic'|'vivid'|'night'|'retro'|'soft'. */
|
||||||
|
setPreset(preset) {
|
||||||
|
if (typeof preset !== 'string') return;
|
||||||
|
_send('graphics.set', { preset });
|
||||||
|
},
|
||||||
|
/** Тонкая настройка (поверх текущего): передай любые секции
|
||||||
|
* {bloom, vignette, grading, dof, ssao, fxaa, shadows}. */
|
||||||
|
set(settings) {
|
||||||
|
if (typeof settings !== 'object' || !settings) return;
|
||||||
|
_send('graphics.set', settings);
|
||||||
|
},
|
||||||
|
/** Свечение. on:bool, opts:{intensity:0..1, threshold:0..1}. */
|
||||||
|
setBloom(on, opts) {
|
||||||
|
_send('graphics.set', { bloom: { enabled: !!on, ...(opts || {}) } });
|
||||||
|
},
|
||||||
|
/** Виньетка (затемнение углов). weight: 0..1.5, 0 = выкл. */
|
||||||
|
setVignette(weight) {
|
||||||
|
const w = Number(weight) || 0;
|
||||||
|
_send('graphics.set', { vignette: { enabled: w > 0, weight: w } });
|
||||||
|
},
|
||||||
|
/** Цветокоррекция: {contrast, saturation, exposure} (1.0 = норма). */
|
||||||
|
setColorGrading(opts) {
|
||||||
|
if (typeof opts !== 'object' || !opts) return;
|
||||||
|
_send('graphics.set', { grading: { enabled: true, ...opts } });
|
||||||
|
},
|
||||||
|
/** Сглаживание (FXAA). */
|
||||||
|
setAntialiasing(on) { _send('graphics.set', { fxaa: !!on }); },
|
||||||
|
/** Глубина резкости: on:bool, opts:{focusDistance, focalLength, aperture}. */
|
||||||
|
setDepthOfField(on, opts) {
|
||||||
|
_send('graphics.set', { dof: { enabled: !!on, ...(opts || {}) } });
|
||||||
|
},
|
||||||
|
/** Качество теней: 'off'|'hard'|'soft'|'medium'|'high'. */
|
||||||
|
setShadows(quality) {
|
||||||
|
if (typeof quality !== 'string') return;
|
||||||
|
_send('graphics.set', { shadows: quality });
|
||||||
|
},
|
||||||
|
/** Контактные тени (SSAO). */
|
||||||
|
setSSAO(on) { _send('graphics.set', { ssao: !!on }); },
|
||||||
|
/** Полностью выключить эффекты. */
|
||||||
|
off() { _send('graphics.set', { preset: 'off' }); },
|
||||||
|
},
|
||||||
save: {
|
save: {
|
||||||
/** Прочитать namespace. fn(data) — data это сохранённый объект или null. */
|
/** Прочитать namespace. fn(data) — data это сохранённый объект или null. */
|
||||||
get(namespace, fn) {
|
get(namespace, fn) {
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user