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