feat(week4): модальные сцены, кастомные скины и вики-гайды #15
@ -411,6 +411,11 @@ const KubikonEditor = () => {
|
||||
const [inventoryState, setInventoryState] = useState({ slots: [], activeIndex: 0 });
|
||||
// Идёт ли загрузка проекта (для overlay-крутилки)
|
||||
const [sceneLoading, setSceneLoading] = useState(true);
|
||||
// Ref-зеркало для sceneLoading — нужно doSave/markDirty, чтобы блокировать
|
||||
// автосейв до окончания загрузки (иначе пустая стартовая сцена перетрёт
|
||||
// реальный проект в БД при reload страницы).
|
||||
const sceneLoadingRef = useRef(true);
|
||||
useEffect(() => { sceneLoadingRef.current = sceneLoading; }, [sceneLoading]);
|
||||
// Прогресс загрузки (читается из window.__kubikonLoadProgress через polling)
|
||||
const [loadProgress, setLoadProgress] = useState({ percent: 0, label: 'Подготовка…' });
|
||||
// Polling прогресса пока sceneLoading=true. BabylonScene/TerrainManager
|
||||
@ -769,6 +774,13 @@ const KubikonEditor = () => {
|
||||
// ещё может крутиться. Без явного flush последние правки не попадут
|
||||
// в this._scripts[] до сериализации.
|
||||
try { scriptEditorFlushRef.current?.(); } catch (_) {}
|
||||
// Если загрузка ещё не завершилась — НЕЛЬЗЯ сохранять.
|
||||
// Иначе пустая стартовая сцена (до loadFromState) затрёт реальный
|
||||
// проект в БД при reload страницы (баг 2026-05-29).
|
||||
if (sceneLoadingRef.current) {
|
||||
console.warn('[KubikonEditor] save: skip (scene still loading)');
|
||||
return;
|
||||
}
|
||||
// Если загрузка упала — не сохраняем. Иначе пустая сцена
|
||||
// (с дефолтным state до load) затрёт существующий проект.
|
||||
if (loadFailedRef.current) {
|
||||
@ -1617,6 +1629,21 @@ const KubikonEditor = () => {
|
||||
setBanWarningOpen(true);
|
||||
return;
|
||||
}
|
||||
// Перед публикацией: обязательная обложка.
|
||||
// Не пускаем в ленту игры без превью — выглядит как
|
||||
// мусор и убивает рекомендательную выдачу.
|
||||
const thumb = metaRef.current?.thumbnail;
|
||||
if (!thumb || typeof thumb !== 'string' || thumb.length < 100) {
|
||||
alert(
|
||||
'Чтобы опубликовать игру, добавь обложку.\n\n'
|
||||
+ 'Открой «Настройки» → раздел «Обложка» → '
|
||||
+ 'либо нажми «Снять текущий вид», либо загрузи '
|
||||
+ 'свою картинку. После этого вернись и нажми '
|
||||
+ '«Опубликовать».'
|
||||
);
|
||||
setSettingsModalOpen(true);
|
||||
return;
|
||||
}
|
||||
// Перед публикацией принудительно сохраняем
|
||||
try { await doSave(); } catch (e) { /* ignore */ }
|
||||
setPublishModalOpen(true);
|
||||
@ -3141,6 +3168,15 @@ const KubikonEditor = () => {
|
||||
setEmailNotice(true);
|
||||
return;
|
||||
}
|
||||
// Обложки нет — бэк-страховка (фронт уже не должен
|
||||
// дойти сюда, но если кто-то старый клиент шлёт — словим).
|
||||
if (e?.response?.status === 400
|
||||
&& e.response.data?.error === 'thumbnail_required') {
|
||||
setPublishModalOpen(false);
|
||||
alert(e.response.data.message || 'Добавь обложку игры в настройках.');
|
||||
setSettingsModalOpen(true);
|
||||
return;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}}
|
||||
|
||||
@ -114,12 +114,33 @@ export class BillboardUiManager {
|
||||
mesh.metadata._billboardMaterial = mat;
|
||||
}
|
||||
|
||||
// Ориентация на камеру (BillboardMode_ALL = и X, и Y, и Z).
|
||||
if (face === 'camera') {
|
||||
mesh.billboardMode = Mesh.BILLBOARDMODE_ALL;
|
||||
} else {
|
||||
// Ориентация на камеру. Babylon-quirk: BILLBOARDMODE_ALL игнорирует
|
||||
// mesh.scaling.x=-1 и mesh.rotation.y=π — невозможно отзеркалить
|
||||
// плоскость. Делаем ручной поворот в onBeforeRenderObservable:
|
||||
// нацеливаем mesh на камеру + ставим rotation.y += π, тогда мы
|
||||
// видим back-side нормально (т.к. фактически она стала front).
|
||||
mesh.billboardMode = Mesh.BILLBOARDMODE_NONE;
|
||||
// Снимаем старую подписку (на случай пере-applyToMesh)
|
||||
if (mesh.metadata._billboardLookObs) {
|
||||
this.scene.onBeforeRenderObservable.remove(mesh.metadata._billboardLookObs);
|
||||
mesh.metadata._billboardLookObs = null;
|
||||
}
|
||||
if (face === 'camera') {
|
||||
// Ручной look-at вместо BILLBOARDMODE.
|
||||
// CreatePlane FRONT в -Z (Babylon left-handed), поэтому +π —
|
||||
// чтобы FRONT смотрел на камеру.
|
||||
const obs = this.scene.onBeforeRenderObservable.add(() => {
|
||||
if (mesh.isDisposed()) return;
|
||||
const cam = this.scene.activeCamera;
|
||||
if (!cam) return;
|
||||
const dx = cam.position.x - mesh.position.x;
|
||||
const dz = cam.position.z - mesh.position.z;
|
||||
mesh.rotation.y = Math.atan2(dx, dz) + Math.PI;
|
||||
});
|
||||
mesh.metadata._billboardLookObs = obs;
|
||||
}
|
||||
mesh.scaling.x = Math.abs(mesh.scaling.x || 1);
|
||||
mesh.metadata._billboardMirrorX = false; // canvas-mirror не нужен
|
||||
|
||||
// Сохраняем state в data для сериализации и для hit-теста кликов.
|
||||
data.billboard = {
|
||||
@ -129,6 +150,8 @@ export class BillboardUiManager {
|
||||
elements: billboardOpts.elements || null,
|
||||
};
|
||||
|
||||
dyn._kubikonMirrorX = mesh.metadata._billboardMirrorX === true;
|
||||
dyn._kubikonOwnerMesh = mesh;
|
||||
this._render(dyn, template, content, billboardOpts.elements);
|
||||
}
|
||||
|
||||
@ -162,6 +185,7 @@ export class BillboardUiManager {
|
||||
*/
|
||||
pickButtonAt(data, uvX, uvY) {
|
||||
if (!data.billboard) return null;
|
||||
// Текстура рисуется напрямую — UV из raycast соответствует canvas-пикселю.
|
||||
const px = uvX * TEXTURE_W;
|
||||
const py = (1 - uvY) * TEXTURE_H; // UV.y перевёрнута относительно canvas
|
||||
// Кастомные elements имеют приоритет (если заданы)
|
||||
@ -226,10 +250,10 @@ export class BillboardUiManager {
|
||||
/** Главная функция рендера — рисует контент на canvas DynamicTexture. */
|
||||
_render(dyn, template, content, elements, pressedButtonId) {
|
||||
const ctx = dyn.getContext();
|
||||
ctx.save();
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
ctx.clearRect(0, 0, TEXTURE_W, TEXTURE_H);
|
||||
|
||||
if (elements && Array.isArray(elements)) {
|
||||
// Кастомный режим — рисуем массив элементов
|
||||
this._renderElements(ctx, elements, pressedButtonId);
|
||||
} else {
|
||||
switch (template) {
|
||||
@ -249,7 +273,8 @@ export class BillboardUiManager {
|
||||
this._renderBanner(ctx, content);
|
||||
}
|
||||
}
|
||||
dyn.update(false /* invertY */);
|
||||
ctx.restore();
|
||||
dyn.update(true);
|
||||
}
|
||||
|
||||
/** Скруглённый прямоугольник + заливка градиентом + обводка. */
|
||||
|
||||
@ -211,13 +211,16 @@ export class PrimitiveManager {
|
||||
// создаются отдельно в addInstance.
|
||||
return MeshBuilder.CreateSphere(name,
|
||||
{ diameterX: sx, diameterY: sy, diameterZ: sz, segments: 12 }, this.scene);
|
||||
case 'billboard':
|
||||
case 'billboard': {
|
||||
// 3D-табличка — плоскость с пропорциями таблички (sx × sy),
|
||||
// sz используется как «толщина рамки» (визуально-незаметная).
|
||||
// Использует CreatePlane для одностороннего рендера, но в
|
||||
// BillboardUiManager backFaceCulling=false → видно с обеих сторон.
|
||||
return MeshBuilder.CreatePlane(name,
|
||||
{ width: sx, height: sy, sideOrientation: Mesh.DOUBLESIDE }, this.scene);
|
||||
// sz — толщина рамки (визуально-незаметная).
|
||||
// ВАЖНО: FRONTSIDE, не DOUBLESIDE — иначе сквозь back-side
|
||||
// видно зеркальную сторону UV (текст справа-налево).
|
||||
// BillboardMode разворачивает FRONT к камере.
|
||||
const m = MeshBuilder.CreatePlane(name,
|
||||
{ width: sx, height: sy, sideOrientation: Mesh.FRONTSIDE }, this.scene);
|
||||
return m;
|
||||
}
|
||||
case 'plane':
|
||||
return MeshBuilder.CreateBox(name,
|
||||
{ width: sx, height: sy, depth: sz }, this.scene);
|
||||
|
||||
@ -1335,6 +1335,10 @@ const game = {
|
||||
opts = opts || {};
|
||||
// Алиас: 'light:point' — это примитив-лампа.
|
||||
if (type === 'light:point' || type === 'light') type = 'primitive:light';
|
||||
// Шорткаты — позволяем писать просто 'billboard' / 'cube' / 'trigger' и т.п.
|
||||
// вместо 'primitive:billboard'. Если нет двоеточия — это шорткат
|
||||
// на primitive:<type>.
|
||||
if (type.indexOf(':') < 0) type = 'primitive:' + type;
|
||||
const x = Number(opts.x) || 0;
|
||||
const y = Number(opts.y) || 0;
|
||||
const z = Number(opts.z) || 0;
|
||||
@ -1357,7 +1361,9 @@ const game = {
|
||||
kind, subType, x, y, z,
|
||||
sx: opts.sx, sy: opts.sy, sz: opts.sz,
|
||||
color: opts.color, material: opts.material,
|
||||
rotationX: opts.rotationX,
|
||||
rotationY: opts.rotationY,
|
||||
rotationZ: opts.rotationZ,
|
||||
name: opts.name,
|
||||
brightness: opts.brightness, range: opts.range,
|
||||
effect: opts.effect,
|
||||
@ -1370,6 +1376,11 @@ const game = {
|
||||
visible: opts.visible,
|
||||
// textureAsset — id картинки из ассетов проекта на грани.
|
||||
textureAsset: opts.textureAsset,
|
||||
// Billboard-специфичные (template/content/face/elements)
|
||||
template: opts.template,
|
||||
content: opts.content,
|
||||
face: opts.face,
|
||||
elements: opts.elements,
|
||||
ref,
|
||||
});
|
||||
if (opts.lifetime != null) _scheduleDelete(ref, opts.lifetime);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user