feat(week4): модальные сцены, кастомные скины и вики-гайды #15

Merged
min merged 3 commits from feat/week4-modals-skins-guides into main 2026-05-29 22:46:17 +00:00
4 changed files with 89 additions and 14 deletions
Showing only changes of commit d6cc986aa9 - Show all commits

View File

@ -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;
} }
}} }}

View File

@ -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 += π, тогда мы
mesh.billboardMode = Mesh.BILLBOARDMODE_NONE; // видим 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-теста кликов. // Сохраняем 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);
} }
/** Скруглённый прямоугольник + заливка градиентом + обводка. */ /** Скруглённый прямоугольник + заливка градиентом + обводка. */

View File

@ -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);

View File

@ -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);