From d6cc986aa93b86eb77debe6ce19ea818d0747720 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=98=D0=9D?= Date: Fri, 29 May 2026 13:38:09 +0300 Subject: [PATCH] =?UTF-8?q?fix(billboard+autosave+spawn):=20=D0=B7=D0=B5?= =?UTF-8?q?=D1=80=D0=BA=D0=B0=D0=BB=D0=BE=20=D1=82=D0=B5=D0=BA=D1=81=D1=82?= =?UTF-8?q?=D0=B0,=20=D0=B7=D0=B0=D1=89=D0=B8=D1=82=D0=B0=20=D0=BE=D1=82?= =?UTF-8?q?=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B7=D0=B0=D0=BF=D0=B8=D1=81=D0=B8?= =?UTF-8?q?,=20=D1=88=D0=BE=D1=80=D1=82=D0=BA=D0=B0=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1) Билборд (Магазин апгрейдов, ID=1906): текст и иконки отрисовывались зеркально из-за того что BILLBOARDMODE_ALL разворачивает FRONT-сторону plane так что мы видим back-side с зеркальным UV. mesh.scaling.x=-1 игнорируется billboardMode. Решение: отключить billboardMode, вместо него каждый кадр в onBeforeRenderObservable ставим mesh.rotation.y = atan2(dx,dz) + PI — front смотрит на камеру → UV рисуется правильно. 2) Autosave перезаписывал реальный проект пустой стартовой сценой при reload страницы (баг #1893, #1905 — оба перетёрты). Добавил sceneLoadingRef guard в doSave: пока sceneLoading=true, autosave запрещён. 3) Запрет публикации без обложки — фронт (alert + open Settings) и бэк (400 thumbnail_required если pd.thumbnail < 100 байт). 4) Scripting API: - шорткат: game.scene.spawn('billboard',...) вместо 'primitive:billboard' (применяется ко всем примитивам) - проброс template/face/content/elements в scene.spawn для билбордов - PrimitiveManager.updateInstance — поддержка billboardOpts patch'а 5) Тест-игра 'Магазин апгрейдов' ID=1906 — 4 shop-item билборда + banner + shop-purchase кнопка 'Сбросить апгрейды' + HUD рубликов. Co-Authored-By: Claude Opus 4.7 --- src/editor/KubikonEditor.jsx | 36 +++++++++++++++++++++ src/editor/engine/BillboardUiManager.js | 41 +++++++++++++++++++----- src/editor/engine/PrimitiveManager.js | 15 +++++---- src/editor/engine/ScriptSandboxWorker.js | 11 +++++++ 4 files changed, 89 insertions(+), 14 deletions(-) diff --git a/src/editor/KubikonEditor.jsx b/src/editor/KubikonEditor.jsx index 3c204da..88bcd79 100644 --- a/src/editor/KubikonEditor.jsx +++ b/src/editor/KubikonEditor.jsx @@ -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; } }} diff --git a/src/editor/engine/BillboardUiManager.js b/src/editor/engine/BillboardUiManager.js index d735042..5c85917 100644 --- a/src/editor/engine/BillboardUiManager.js +++ b/src/editor/engine/BillboardUiManager.js @@ -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 { - mesh.billboardMode = Mesh.BILLBOARDMODE_NONE; + // Ориентация на камеру. 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); } /** Скруглённый прямоугольник + заливка градиентом + обводка. */ diff --git a/src/editor/engine/PrimitiveManager.js b/src/editor/engine/PrimitiveManager.js index e8aac7c..a03d023 100644 --- a/src/editor/engine/PrimitiveManager.js +++ b/src/editor/engine/PrimitiveManager.js @@ -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); diff --git a/src/editor/engine/ScriptSandboxWorker.js b/src/editor/engine/ScriptSandboxWorker.js index 04542db..4daf63d 100644 --- a/src/editor/engine/ScriptSandboxWorker.js +++ b/src/editor/engine/ScriptSandboxWorker.js @@ -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:. + 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);