From 75e83a9f3bc515ad7bfc308503dd1096e9d25934 Mon Sep 17 00:00:00 2001 From: min Date: Thu, 4 Jun 2026 23:54:17 +0300 Subject: [PATCH 01/74] =?UTF-8?q?feat(studio):=20UX-=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=BA=D0=B8=20=D1=80=D0=B5=D0=B4=D0=B0=D0=BA=D1=82=D0=BE?= =?UTF-8?q?=D1=80=D0=B0=20=E2=80=94=20=D0=B2=D1=8B=D0=B1=D0=BE=D1=80,=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BF=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5,=20userModels,=20free-drag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Дерево «Объекты сцены»: авто-раскрытие ветки + скролл к объекту при выборе на сцене (HierarchyPanel useEffect на selection). - Копирование/дублирование примитива сохраняет вращение rotationX/Y/Z (SelectionManager клал selection без rotation → копия теряла поворот). - Копирование/дублирование переносит скрипты объекта на копию (_copyScriptsToNewObject + clip.scripts для Ctrl+C/V). - userModels (воксельные модели) теперь видны в дереве в группе «Мои модели», можно выбрать/удалить/прикрепить скрипт (target kind userModel уже поддержан в GameRuntime). - Free-drag: перетаскивание объекта ЛКМ как в Roblox Studio — скольжение по полу/поверх объектов с AABB-коллизией (скольжение вдоль преграды). Co-Authored-By: Claude Opus 4.8 @ --- src/editor/HierarchyPanel.jsx | 141 ++++++++++- src/editor/KubikonEditor.jsx | 30 ++- src/editor/engine/BabylonScene.js | 322 +++++++++++++++++++++++++- src/editor/engine/SelectionManager.js | 5 + 4 files changed, 488 insertions(+), 10 deletions(-) diff --git a/src/editor/HierarchyPanel.jsx b/src/editor/HierarchyPanel.jsx index 586f6fa..ff263b6 100644 --- a/src/editor/HierarchyPanel.jsx +++ b/src/editor/HierarchyPanel.jsx @@ -1,4 +1,4 @@ -import React, { useState, useMemo } from 'react'; +import React, { useState, useMemo, useEffect } from 'react'; import { getBlockType } from './engine/BlockTypes'; import { getModelType } from './engine/ModelTypes'; import { getPrimitiveType } from './engine/PrimitiveTypes'; @@ -40,8 +40,17 @@ const ItemRow = ({ extraStyle, }) => { const [hovered, setHovered] = useState(false); + const rowRef = React.useRef(null); + // Когда строка стала выделенной — подскроллить её в видимую зону дерева + // (после авто-раскрытия веток объект может оказаться вне видимой области). + useEffect(() => { + if (selected && rowRef.current) { + rowRef.current.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + } + }, [selected]); return (
{ + const map = new Map(); + for (const um of userModels) { + const k = um.folderId ?? null; + if (!map.has(k)) map.set(k, []); + map.get(k).push(um); + } + return map; + }, [userModels]); + const isBlockSelected = (b) => selection?.type === 'block' && selection.gridX === b.gridX && selection.gridY === b.gridY && selection.gridZ === b.gridZ; const isModelSelected = (m) => selection?.type === 'model' && selection.instanceId === m.instanceId; + const isUserModelSelected = (um) => + selection?.type === 'userModel' && selection.instanceId === um.instanceId; const isPrimitiveSelected = (p) => selection?.type === 'primitive' && selection.id === p.id; @@ -344,6 +367,65 @@ const HierarchyPanel = ({ }); }; + // === Авто-раскрытие пути до выделенного объекта === + // Когда объект выбирают мышкой на сцене (или из скрипта), он должен стать + // ВИДИМЫМ в дереве: раскрываем «Сцену», нужную под-группу (Блоки/Примитивы/ + // Модели) и всю цепочку папок-родителей. Подсветка строки уже работает через + // проп `selection`; здесь только разворачиваем свёрнутые ветки. + useEffect(() => { + if (!selection) return; + const t = selection.type; + // Находим объект и его folderId по выделению. + let obj = null, kind = null; + if (t === 'block') { + obj = blocks.find(b => b.gridX === selection.gridX && b.gridY === selection.gridY && b.gridZ === selection.gridZ); + kind = 'block'; + } else if (t === 'primitive') { + obj = primitives.find(p => p.id === selection.id); + kind = 'primitive'; + } else if (t === 'model') { + obj = models.find(m => m.instanceId === selection.instanceId); + kind = 'model'; + } else if (t === 'userModel') { + obj = userModels.find(um => um.instanceId === selection.instanceId); + kind = 'userModel'; + } else if (t === 'spawn' || t === 'floor') { + kind = 'workspace-only'; + } + if (!kind) return; // lighting/sound/player/gui/script — свои группы, не трогаем + + // 1) Раскрыть корневую категорию «Сцена». + setWorkspaceOpen(true); + + if (kind === 'workspace-only') return; + + const folderId = obj?.folderId ?? null; + if (folderId == null) { + // 2a) Объект в корне — раскрыть его под-группу (Блоки/Примитивы/Модели). + if (kind === 'block') setRootBlocksOpen(true); + else if (kind === 'primitive') setRootPrimsOpen(true); + else if (kind === 'model') setRootModelsOpen(true); + else if (kind === 'userModel') setRootUserModelsOpen(true); + } else { + // 2b) Объект в папке — раскрыть всю цепочку папок-родителей. + setOpenFolders(prev => { + const n = new Set(prev); + let cur = folderId; + const guard = new Set(); // защита от циклов + while (cur != null && !guard.has(cur)) { + guard.add(cur); + n.add(cur); + const f = folders.find(ff => ff.id === cur); + cur = f ? (f.parentId ?? null) : null; + } + return n; + }); + } + // Триггер — только смена выделения (не трогаем при добавлении объектов, + // чтобы не переоткрывать ветки, которые пользователь свернул вручную). + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selection]); + const handleContextMenu = (e, item) => { e.preventDefault(); e.stopPropagation(); @@ -400,7 +482,8 @@ const HierarchyPanel = ({ const subBlocks = blocksByFolder.get(folder.id) || []; const subModels = modelsByFolder.get(folder.id) || []; const subPrims = primitivesByFolder.get(folder.id) || []; - const totalCount = subBlocks.length + subModels.length + subPrims.length + subFolders.length; + const subUserModels = userModelsByFolder.get(folder.id) || []; + const totalCount = subBlocks.length + subModels.length + subPrims.length + subUserModels.length + subFolders.length; return (
@@ -452,6 +535,7 @@ const HierarchyPanel = ({ {subBlocks.map(b => renderBlockItem(b, depth + 1))} {subPrims.map(p => renderPrimitiveItem(p, depth + 1))} {subModels.map(m => renderModelItem(m, depth + 1))} + {subUserModels.map(um => renderUserModelItem(um, depth + 1))} {totalCount === 0 && (
пусто
)} @@ -580,6 +664,38 @@ const HierarchyPanel = ({ ); }; + const renderUserModelItem = (um, depth) => { + const displayName = um.name || 'Моя модель'; + return ( + + handleDragStart(e, { kind: 'userModel', id: um.instanceId })} + onClick={() => onSelectUserModel?.(um.instanceId)} + onDoubleClick={() => { onSelectUserModel?.(um.instanceId); onFocusSelection?.(); }} + onContextMenu={(e) => handleContextMenu(e, { type: 'userModel', ...um })} + plusItems={[ + { + id: 'add-script', label: 'Скрипт', icon: '📜', + onClick: () => onCreateScript?.({ kind: 'userModel', id: um.instanceId }), + }, + { divider: true }, + { + id: 'delete', label: 'Удалить', icon: '🗑', danger: true, + onClick: () => onDeleteUserModel?.(um.instanceId), + }, + ]} + /> + {renderNestedScriptsFor('userModel', um.instanceId, depth)} + + ); + }; + const renderPrimitiveItem = (p, depth) => { const def = getPrimitiveType(p.type); const displayName = p.name || def?.name || p.type; @@ -633,6 +749,7 @@ const HierarchyPanel = ({ const rootFolders = foldersByParent.get(null) || []; const rootBlocks = blocksByFolder.get(null) || []; const rootModels = modelsByFolder.get(null) || []; + const rootUserModels = userModelsByFolder.get(null) || []; const rootPrims = primitivesByFolder.get(null) || []; return ( @@ -757,6 +874,24 @@ const HierarchyPanel = ({ {rootModelsOpen && rootModels.map(m => renderModelItem(m, 0))} )} + + {/* Мои модели (воксельный редактор) в корне */} + {rootUserModels.length > 0 && ( + <> +
setRootUserModelsOpen(!rootUserModelsOpen)} + > + + + + + Мои модели ({rootUserModels.length}) + +
+ {rootUserModelsOpen && rootUserModels.map(um => renderUserModelItem(um, 0))} + + )}
)} diff --git a/src/editor/KubikonEditor.jsx b/src/editor/KubikonEditor.jsx index 61efc12..bb5b6a4 100644 --- a/src/editor/KubikonEditor.jsx +++ b/src/editor/KubikonEditor.jsx @@ -776,6 +776,7 @@ const KubikonEditor = () => { const [selection, setSelection] = useState(null); const [blocksList, setBlocksList] = useState([]); const [modelsList, setModelsList] = useState([]); + const [userModelsList, setUserModelsList] = useState([]); const [primitivesList, setPrimitivesList] = useState([]); const [foldersList, setFoldersList] = useState([]); @@ -1565,6 +1566,21 @@ const KubikonEditor = () => { } setModelsList(arr); } + if (s.userModelManager) { + const arr = []; + for (const data of s.userModelManager.instances.values()) { + arr.push({ + instanceId: data.instanceId, + userModelTypeId: data.userModelTypeId, + userModelId: data.userModelId, + x: data.x, y: data.y, z: data.z, + rotationY: data.rotationY, + folderId: data.folderId ?? null, + name: data.name || null, + }); + } + setUserModelsList(arr); + } if (s.primitiveManager) { // getAll() не включает folderId — добавляем вручную const arr = s.primitiveManager.getAll(); @@ -3064,9 +3080,21 @@ const KubikonEditor = () => { { + sceneRef.current?.selection?.selectUserModelByInstanceId(id); + setActiveTool('select'); + }} + onDeleteUserModel={(id) => { + sceneRef.current?.userModelManager?.removeInstance(id); + sceneRef.current?.clearSelection(); + }} + onRenameUserModel={(id, name) => { + if (sceneRef.current?.renameUserModel?.(id, name)) markDirty(); + }} onSelectScript={(scriptId) => { sceneRef.current?.selection?.selectScript?.(scriptId); setActiveTool('select'); @@ -3080,7 +3108,7 @@ const KubikonEditor = () => { if (target) { if (target.kind === 'block') { normalized = { kind: 'block', ref: { x: target.x, y: target.y, z: target.z } }; - } else if (target.kind === 'model' || target.kind === 'primitive') { + } else if (target.kind === 'model' || target.kind === 'primitive' || target.kind === 'userModel') { normalized = { kind: target.kind, id: target.id }; } } diff --git a/src/editor/engine/BabylonScene.js b/src/editor/engine/BabylonScene.js index ce59924..cab9af0 100644 --- a/src/editor/engine/BabylonScene.js +++ b/src/editor/engine/BabylonScene.js @@ -187,6 +187,10 @@ export class BabylonScene { this._gizmo = null; this._gizmoLayer = null; this._gizmoDragging = false; // флаг что идёт drag гизмо + // Free-drag: свободное перетаскивание объекта ЛКМ (как в Roblox Studio). + this._freeDragCandidate = null; // {mesh} — потенциальный объект для перетаскивания + this._freeDragActive = false; // идёт ли перетаскивание + this._freeDragHalf = null; // {x,y,z} полу-габариты объекта (для коллизий) this._isDragPlacing = false; // флаг drag-постановки/удаления блоков this._isTerrainBrushing = false; // флаг drag-кисти террейна this._terrainDragLockY = null; // Y-фикс для скульпт/выровнять @@ -2290,6 +2294,15 @@ export class BabylonScene { this._mouseDownY = e.clientY; this._mouseDownTime = Date.now(); + // Free-drag: ЛКМ на объекте при tool=select (и без gizmo-перетаскивания). + // Запоминаем объект как кандидата — реальное перетаскивание начнётся + // в mousemove, если курсор сдвинется (иначе это просто клик-выбор). + if (e.button === 0 && !e.shiftKey && this._activeTool === 'select' && !this._isPlaying) { + if (this._beginFreeDragCandidate()) { + e.preventDefault(); + } + } + // ЛКМ + tool=block/erase → активируем drag-постановку. // Сразу же ставим первый блок в клетке под курсором. if (e.button === 0 && !e.shiftKey @@ -2355,6 +2368,23 @@ export class BabylonScene { return; } + // Free-drag: тянем объект ЛКМ. Активируем после небольшого сдвига, + // чтобы обычный клик-выбор не превращался в перетаскивание. + if (this._freeDragCandidate) { + if (!this._freeDragActive) { + const ddx = Math.abs(e.clientX - this._mouseDownX); + const ddy = Math.abs(e.clientY - this._mouseDownY); + if (ddx > 4 || ddy > 4) { + this._freeDragActive = true; + canvas.style.cursor = 'grabbing'; + } + } + if (this._freeDragActive) { + this._updateFreeDrag(); + return; + } + } + // Если идёт drag-постановка блоков — пытаемся поставить в новой клетке if (this._isDragPlacing) { this._dragPlaceTick(e.shiftKey); @@ -2396,6 +2426,18 @@ export class BabylonScene { }; const onMouseUp = (e) => { + // Free-drag: завершаем перетаскивание. Если объект реально тащили — + // фиксируем историю и НЕ обрабатываем как клик (иначе сбросит выбор). + if (this._freeDragCandidate) { + const wasActive = this._endFreeDrag(); + canvas.style.cursor = 'default'; + if (wasActive) { + this._mouseDownButton = -1; + return; + } + // Не тащили (просто клик) — кандидат сброшен, продолжаем обычную + // обработку клика ниже (выбор уже сделан в _beginFreeDragCandidate). + } // Если идёт drag гизмо — отдаём pointerup и завершаем if (this._gizmoDragging && this._gizmoLayer?.utilityLayerScene) { const ulScene = this._gizmoLayer.utilityLayerScene; @@ -5112,6 +5154,43 @@ export class BabylonScene { this.selection?.deleteSelected(); } + /** + * Перенести скрипты с объекта-оригинала на копию. + * Находит все скрипты, привязанные к src (по kind+id или kind+ref для блока), + * и создаёт их дубликаты с новым id и target на dst. Без этого копия + * объекта оставалась без скриптов (баг 2026-06-04). + * + * srcRef/dstRef: для 'primitive'|'model' — id (число), для 'block' — {x,y,z}. + */ + _copyScriptsToNewObject(kind, srcRef, dstRef) { + const matches = (target) => { + if (!target || target.kind !== kind) return false; + if (kind === 'block') { + const tr = target.ref || target; + return tr.x === srcRef.x && tr.y === srcRef.y && tr.z === srcRef.z; + } + const tid = target.id ?? target.ref; + return tid === srcRef; + }; + const srcScripts = (this._scripts || []).filter(s => matches(s.target)); + for (const s of srcScripts) { + const newId = `script_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`; + const newTarget = kind === 'block' + ? { kind: 'block', ref: { x: dstRef.x, y: dstRef.y, z: dstRef.z } } + : { kind, id: dstRef }; + this._scripts.push({ + id: newId, + code: s.code, + name: s.name || null, + target: newTarget, + }); + } + if (srcScripts.length > 0) { + this.history?.markChange(); + if (this._onSceneChange) this._onSceneChange(); + } + } + /** * Дублировать выделенный объект (Ctrl+D). * Блок: создаёт копию в соседней клетке (по +X, или -X если +X занят, или +Y). @@ -5130,6 +5209,8 @@ export class BabylonScene { if (ny < 0) continue; if (!this.blockManager.hasBlock(nx, ny, nz)) { this.blockManager.addBlock(nx, ny, nz, sel.blockTypeId); + this._copyScriptsToNewObject('block', + { x: sel.gridX, y: sel.gridY, z: sel.gridZ }, { x: nx, y: ny, z: nz }); this.selection.selectBlockAt(nx, ny, nz); return; } @@ -5140,9 +5221,13 @@ export class BabylonScene { const typeId = sel.modelTypeId; const sx = sel.x, sy = sel.y, sz = sel.z; const rotY = sel.rotationY || 0; + const srcId = sel.instanceId; this.modelManager.addInstance(typeId, sx + 1, sy, sz, rotY) .then(newId => { - if (newId != null) this.selection?.selectModelByInstanceId(newId); + if (newId != null) { + this._copyScriptsToNewObject('model', srcId, newId); + this.selection?.selectModelByInstanceId(newId); + } }) .catch(err => { // eslint-disable-next-line no-console @@ -5152,10 +5237,14 @@ export class BabylonScene { const typeId = sel.userModelTypeId; const sx = sel.x, sy = sel.y, sz = sel.z; const rotY = sel.rotationY || 0; + const srcUmId = sel.instanceId; this.userModelManager.addInstance(typeId, sx + 1, sy, sz, rotY, { currentUserId: this._currentUserId || null, }).then(newId => { - if (newId != null) this.selection?.selectUserModelByInstanceId(newId); + if (newId != null) { + this._copyScriptsToNewObject('userModel', srcUmId, newId); + this.selection?.selectUserModelByInstanceId(newId); + } }).catch(err => { console.error('[BabylonScene] duplicate user model error:', err); }); @@ -5163,6 +5252,10 @@ export class BabylonScene { const newId = this.primitiveManager.addInstance(sel.primitiveType, { x: sel.x + 1, y: sel.y, z: sel.z, sx: sel.sx, sy: sel.sy, sz: sel.sz, + // Сохраняем вращение копии (без этого сбрасывалось, баг 2026-06-04). + rotationX: sel.rotationX || 0, + rotationY: sel.rotationY || 0, + rotationZ: sel.rotationZ || 0, color: sel.color, material: sel.material, canCollide: sel.canCollide, visible: sel.visible, anchored: sel.anchored, @@ -5170,7 +5263,10 @@ export class BabylonScene { textureAsset: sel.textureAsset || null, brightness: sel.brightness, range: sel.range, effect: sel.effect, }); - if (newId != null) this.selection.selectPrimitiveById(newId); + if (newId != null) { + this._copyScriptsToNewObject('primitive', sel.id, newId); + this.selection.selectPrimitiveById(newId); + } } } @@ -5199,6 +5295,9 @@ export class BabylonScene { clip = { kind: 'primitive', primitiveType: sel.primitiveType, sx: sel.sx, sy: sel.sy, sz: sel.sz, + rotationX: sel.rotationX || 0, + rotationY: sel.rotationY || 0, + rotationZ: sel.rotationZ || 0, color: sel.color, material: sel.material, canCollide: sel.canCollide, visible: sel.visible, anchored: sel.anchored, @@ -5207,11 +5306,45 @@ export class BabylonScene { }; } if (clip) { + // Прикрепляем скрипты объекта в буфер — чтобы вставленная копия + // получила те же скрипты (баг 2026-06-04: копия была без скриптов). + try { + let srcRef = null, kind = sel.type; + if (sel.type === 'block') srcRef = { x: sel.gridX, y: sel.gridY, z: sel.gridZ }; + else if (sel.type === 'model' || sel.type === 'userModel') srcRef = sel.instanceId; + else if (sel.type === 'primitive') srcRef = sel.id; + const matchTarget = (target) => { + if (!target || target.kind !== kind) return false; + if (kind === 'block') { + const tr = target.ref || target; + return tr.x === srcRef.x && tr.y === srcRef.y && tr.z === srcRef.z; + } + const tid = target.id ?? target.ref; + return tid === srcRef; + }; + clip.scripts = (this._scripts || []) + .filter(s => matchTarget(s.target)) + .map(s => ({ code: s.code, name: s.name || null })); + } catch (e) { clip.scripts = []; } try { localStorage.setItem('kubikon_clipboard', JSON.stringify(clip)); } catch (e) { /* ignore — приватный режим / переполнение */ } } } + /** Создать скрипты из clip.scripts на новом объекте (kind+ref). */ + _pasteScripts(clip, kind, dstRef) { + if (!clip || !Array.isArray(clip.scripts) || clip.scripts.length === 0) return; + for (const s of clip.scripts) { + const newId = `script_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`; + const target = kind === 'block' + ? { kind: 'block', ref: { x: dstRef.x, y: dstRef.y, z: dstRef.z } } + : { kind, id: dstRef }; + this._scripts.push({ id: newId, code: s.code, name: s.name || null, target }); + } + this.history?.markChange(); + if (this._onSceneChange) this._onSceneChange(); + } + /** * Вставить объект из буфера обмена (Ctrl+V, Фаза 5.10). * Объект появляется у точки, куда смотрит редактор-камера. @@ -5239,28 +5372,32 @@ export class BabylonScene { let gy = 0; while (gy < 64 && this.blockManager?.hasBlock(gx, gy, gz)) gy++; this.blockManager?.addBlock(gx, gy, gz, clip.blockTypeId); + this._pasteScripts(clip, 'block', { x: gx, y: gy, z: gz }); this.selection?.selectBlockAt(gx, gy, gz); } else if (clip.kind === 'model') { this.modelManager?.addInstance(clip.modelTypeId, px, py, pz, clip.rotationY || 0) - .then(id => { if (id != null) this.selection?.selectModelByInstanceId(id); }) + .then(id => { if (id != null) { this._pasteScripts(clip, 'model', id); this.selection?.selectModelByInstanceId(id); } }) .catch(() => {}); } else if (clip.kind === 'userModel') { this.userModelManager?.addInstance( clip.userModelTypeId, px, py, pz, clip.rotationY || 0, { currentUserId: this._currentUserId || null }, - ).then(id => { if (id != null) this.selection?.selectUserModelByInstanceId(id); }) + ).then(id => { if (id != null) { this._pasteScripts(clip, 'userModel', id); this.selection?.selectUserModelByInstanceId(id); } }) .catch(() => {}); } else if (clip.kind === 'primitive') { const id = this.primitiveManager?.addInstance(clip.primitiveType, { x: px, y: Math.max(py, (clip.sy || 1) / 2), z: pz, sx: clip.sx, sy: clip.sy, sz: clip.sz, + rotationX: clip.rotationX || 0, + rotationY: clip.rotationY || 0, + rotationZ: clip.rotationZ || 0, color: clip.color, material: clip.material, canCollide: clip.canCollide, visible: clip.visible, anchored: clip.anchored, textureAsset: clip.textureAsset || null, brightness: clip.brightness, range: clip.range, effect: clip.effect, }); - if (id != null) this.selection?.selectPrimitiveById(id); + if (id != null) { this._pasteScripts(clip, 'primitive', id); this.selection?.selectPrimitiveById(id); } } if (this._onSceneChange) this._onSceneChange(); } @@ -5323,6 +5460,179 @@ export class BabylonScene { } /** Изменить позицию выделенного (используется Inspector). */ + // ====================== FREE-DRAG (как в Roblox Studio) ====================== + // Зажал ЛКМ на объекте и тянешь — объект скользит по полу/поверх других + // объектов с учётом коллизий (скольжение вдоль преграды). + + /** Получить корневой меш/узел выделенного объекта (для drag). */ + _getSelectionRoot(sel) { + if (!sel) return null; + return sel.rootMesh || sel.mesh || sel.rootNode || null; + } + + /** Полу-габариты (half-extent) AABB меша в мировых координатах. */ + _meshHalfExtent(mesh) { + try { + const bb = mesh.getHierarchyBoundingVectors + ? mesh.getHierarchyBoundingVectors(true) + : null; + if (bb) { + return { + x: Math.max(0.05, (bb.max.x - bb.min.x) / 2), + y: Math.max(0.05, (bb.max.y - bb.min.y) / 2), + z: Math.max(0.05, (bb.max.z - bb.min.z) / 2), + }; + } + } catch (e) { /* ignore */ } + return { x: 0.5, y: 0.5, z: 0.5 }; + } + + /** + * Собрать AABB всех «препятствий» сцены, КРОМЕ перетаскиваемого объекта, + * пола и сетки. Возвращает массив {minX,maxX,minY,maxY,minZ,maxZ}. + */ + _collectObstacleAABBs(excludeRoot) { + const out = []; + const push = (mesh) => { + if (!mesh || mesh === excludeRoot) return; + try { + const bb = mesh.getHierarchyBoundingVectors(true); + out.push({ + minX: bb.min.x, maxX: bb.max.x, + minY: bb.min.y, maxY: bb.max.y, + minZ: bb.min.z, maxZ: bb.max.z, + }); + } catch (e) { /* ignore */ } + }; + // Примитивы + if (this.primitiveManager?.instances) { + for (const d of this.primitiveManager.instances.values()) { + if (d.mesh && d.mesh !== excludeRoot) push(d.mesh); + } + } + // GLB-модели + if (this.modelManager?.instances) { + for (const d of this.modelManager.instances.values()) { + if (d.rootMesh && d.rootMesh !== excludeRoot) push(d.rootMesh); + } + } + // Пользовательские (воксельные) модели + if (this.userModelManager?.instances) { + for (const d of this.userModelManager.instances.values()) { + const root = d.rootNode || d.rootMesh; + if (root && root !== excludeRoot) push(root); + } + } + return out; + } + + /** Пересекается ли AABB кандидата (центр cx,cy,cz + half) с препятствиями. */ + _aabbCollides(cx, cy, cz, half, obstacles) { + const eps = 0.02; // небольшой зазор, чтобы не «прилипало» + const minX = cx - half.x + eps, maxX = cx + half.x - eps; + const minY = cy - half.y + eps, maxY = cy + half.y - eps; + const minZ = cz - half.z + eps, maxZ = cz + half.z - eps; + for (const o of obstacles) { + if (maxX > o.minX && minX < o.maxX + && maxY > o.minY && minY < o.maxY + && maxZ > o.minZ && minZ < o.maxZ) { + return true; + } + } + return false; + } + + /** Начать потенциальный free-drag: запомнить объект под курсором. */ + _beginFreeDragCandidate() { + if (this._isPlaying || this._activeTool !== 'select') return false; + const pick = this._pickFromMouse(); + if (!pick || !pick.mesh) return false; + // Не тащим пол/сетку/гизмо. + const m = pick.mesh; + if (m.name && (m.name.startsWith('gridLine') || m.name === 'ground')) return false; + if (m.metadata?._isBlockProto) return false; // блоки тащим только гизмо + // Выбираем объект (резолв mesh→тип внутри selection). + this.selection?.selectByMesh(m); + const sel = this.selection?.getSelection(); + if (!sel || sel.type === 'block' || sel.type === 'spawn') return false; + const root = this._getSelectionRoot(sel); + if (!root) return false; + this._freeDragCandidate = { root }; + this._freeDragHalf = this._meshHalfExtent(root); + this._freeDragActive = false; + return true; + } + + /** Обновить позицию объекта при перетаскивании (raycast по сцене + коллизия). */ + _updateFreeDrag() { + const cand = this._freeDragCandidate; + if (!cand) return; + const sel = this.selection?.getSelection(); + if (!sel) return; + const root = cand.root; + const half = this._freeDragHalf || { x: 0.5, y: 0.5, z: 0.5 }; + + // Raycast из курсора: ищем поверхность (пол ИЛИ верх другого объекта), + // ИСКЛЮЧАЯ сам перетаскиваемый объект и его дочерние меши. + const isOwn = (mesh) => { + let n = mesh; + while (n) { if (n === root) return true; n = n.parent; } + return false; + }; + const pi = this.scene.pick(this.scene.pointerX, this.scene.pointerY, (mesh) => { + if (!mesh.isPickable) return false; + if (isOwn(mesh)) return false; + if (mesh === this._ghostMesh) return false; + if (mesh.name && mesh.name.startsWith('gridLine')) return false; + if (mesh.metadata?._isBlockProto) return false; + return true; + }); + + let px, pz, surfaceY; + if (pi && pi.hit && pi.pickedPoint) { + px = pi.pickedPoint.x; + pz = pi.pickedPoint.z; + surfaceY = pi.pickedPoint.y; // верх пола или поверхности объекта + } else { + // Нет попадания — проецируем луч на горизонтальную плоскость y=0. + 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 = -ray.origin.y / ray.direction.y; + if (t < 0) return; + px = ray.origin.x + ray.direction.x * t; + pz = ray.origin.z + ray.direction.z * t; + surfaceY = 0; + } + + // Целевой центр: объект стоит НА поверхности (низ касается surfaceY). + const targetX = px, targetZ = pz; + const targetY = surfaceY + half.y; + + // Коллизия скольжением: двигаем по осям отдельно от текущей позиции. + const obstacles = this._collectObstacleAABBs(root); + const cur = { x: root.position.x, y: targetY, z: root.position.z }; + let nx = cur.x, nz = cur.z; + // X + if (!this._aabbCollides(targetX, targetY, cur.z, half, obstacles)) nx = targetX; + // Z (с уже применённым X) + if (!this._aabbCollides(nx, targetY, targetZ, half, obstacles)) nz = targetZ; + + this.moveSelectedTo(nx, targetY, nz); + } + + /** Завершить free-drag, зафиксировать изменение в истории. */ + _endFreeDrag() { + const wasActive = this._freeDragActive; + this._freeDragCandidate = null; + this._freeDragActive = false; + this._freeDragHalf = null; + if (wasActive) { + this.history?.markChange(); + if (this._onSceneChange) this._onSceneChange(); + } + return wasActive; + } + moveSelectedTo(x, y, z) { if (!this.selection) return; const sel = this.selection.getSelection(); diff --git a/src/editor/engine/SelectionManager.js b/src/editor/engine/SelectionManager.js index 325b8aa..24d58ef 100644 --- a/src/editor/engine/SelectionManager.js +++ b/src/editor/engine/SelectionManager.js @@ -116,6 +116,11 @@ export class SelectionManager { primitiveType: data.type, x: data.x, y: data.y, z: data.z, sx: data.sx, sy: data.sy, sz: data.sz, + // Вращение — нужно для корректного копирования/дублирования + // (без него копия теряла поворот, баг 2026-06-04). + rotationX: data.rotationX || 0, + rotationY: data.rotationY || 0, + rotationZ: data.rotationZ || 0, color: data.color, material: data.material, canCollide: data.canCollide, From 71536668f2cb9c709f5ace12eacfdd9a480e3c9d Mon Sep 17 00:00:00 2001 From: min Date: Fri, 5 Jun 2026 00:17:23 +0300 Subject: [PATCH 02/74] =?UTF-8?q?feat(studio):=20=D0=B7=D0=B0=D0=B4=D0=B0?= =?UTF-8?q?=D1=87=D0=B0=2016=20=E2=80=94=20=D0=BA=D0=B0=D1=81=D1=82=D0=BE?= =?UTF-8?q?=D0=BC=D0=BD=D0=BE=D0=B5=20=D0=BD=D0=B5=D0=B1=D0=BE=20(SkyboxMa?= =?UTF-8?q?nager)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Процедурный gradient-skybox без внешних текстур: купол-сфера с ShaderMaterial (градиент верх→горизонт→низ + солнечный диск + дымка), low-poly горы на горизонте, billboard-облака с дрейфом, атмосферный туман, звёзды. Пресеты: clear-summer-day / lowpoly-roblox / cloudy / sunset / starry-night / space. Плавный fadeTo между пресетами (анимация цветов купола в tick). game-API (студия): game.scene.setSkybox/setClouds/setFog, game.scene.skybox.fadeTo/setSunDirection. Сериализация неба в project_data. Тик облаков/перехода работает и в редакторе (превью). Плеер пока НЕ портирован (по указанию — сначала проверка в студии). Тест-игра «Небесная демка» id=2541 (dev-режим is_test=true, не в ленте). Co-Authored-By: Claude Opus 4.8 --- src/editor/engine/BabylonScene.js | 16 + src/editor/engine/GameRuntime.js | 22 + src/editor/engine/ScriptSandboxWorker.js | 26 ++ src/editor/engine/SkyboxManager.js | 504 +++++++++++++++++++++++ 4 files changed, 568 insertions(+) create mode 100644 src/editor/engine/SkyboxManager.js diff --git a/src/editor/engine/BabylonScene.js b/src/editor/engine/BabylonScene.js index cab9af0..a640b26 100644 --- a/src/editor/engine/BabylonScene.js +++ b/src/editor/engine/BabylonScene.js @@ -73,6 +73,7 @@ import { BeamManager } from './BeamManager'; import { ZombieSpawnerManager } from './ZombieSpawnerManager'; import { DynamicsManager } from './DynamicsManager'; import { Environment } from './Environment'; +import { SkyboxManager } from './SkyboxManager'; import { AudioManager, AMBIENT_PRESETS, MUSIC_PRESETS } from './AudioManager'; import { GameAudioManager } from './GameAudioManager'; import { AssetManager } from './AssetManager'; @@ -1292,6 +1293,7 @@ export class BabylonScene { } this.dynamics = new DynamicsManager(this); this.environment = new Environment(this.scene, this._hemiLight, this._sunLight); + this.skybox = new SkyboxManager(this.scene); // задача 16 — кастомное небо this.audioManager = new AudioManager(); this.assetManager = new AssetManager(); // PrimitiveManager должен уметь брать dataURL картинки по id ассета, @@ -1440,6 +1442,10 @@ export class BabylonScene { if (this._isPlaying && this.environment) { this.environment.tick(dt); } + // Небо: дрейф облаков + fadeTo — работает всегда (превью в редакторе). + if (this.skybox) { + this.skybox.tick(dt); + } // Анимация жидкостей — работает всегда (и в редакторе) if (this.blockManager) { this.blockManager.tick(dt); @@ -5633,6 +5639,11 @@ export class BabylonScene { return wasActive; } + // ── Небо (задача 16) — обёртки для game-API и UI редактора ────────────── + setSkybox(opts) { this.skybox?.setSkybox(opts); if (this._onSceneChange) this._onSceneChange(); } + setClouds(opts) { this.skybox?.setClouds(opts); if (this._onSceneChange) this._onSceneChange(); } + setSkyFog(opts) { this.skybox?.setFog(opts); if (this._onSceneChange) this._onSceneChange(); } + moveSelectedTo(x, y, z) { if (!this.selection) return; const sel = this.selection.getSelection(); @@ -7378,6 +7389,7 @@ export class BabylonScene { crosshair: this._crosshair || 'dot', shadowQuality: this._shadowQuality || 'soft', environment: this.environment ? this.environment.serialize() : null, + skybox: this.skybox ? this.skybox.serialize() : null, audio: this.audioManager ? this.audioManager.serialize() : null, // Библиотека пользовательских картинок (текстуры/GUI-image). assets: this.assetManager ? this.assetManager.serialize() : [], @@ -7856,6 +7868,10 @@ export class BabylonScene { if (state.scene.environment && this.environment) { this.environment.load(state.scene.environment); } + // Кастомное небо (задача 16) + if (state.scene.skybox && this.skybox) { + this.skybox.load(state.scene.skybox); + } // Аудио (фоновая музыка/амбиент) if (state.scene.audio && this.audioManager) { this.audioManager.load(state.scene.audio); diff --git a/src/editor/engine/GameRuntime.js b/src/editor/engine/GameRuntime.js index 52fa64c..efb6a72 100644 --- a/src/editor/engine/GameRuntime.js +++ b/src/editor/engine/GameRuntime.js @@ -2882,6 +2882,28 @@ export class GameRuntime { } return; } + // === Небо и атмосфера (задача 16) === + if (cmd === 'scene.setSkybox') { + try { this.scene3d?.skybox?.setSkybox(payload?.opts || {}); } catch (e) {} + return; + } + if (cmd === 'scene.setClouds') { + try { this.scene3d?.skybox?.setClouds(payload?.opts || {}); } catch (e) {} + return; + } + if (cmd === 'scene.setFog') { + try { this.scene3d?.skybox?.setFog(payload?.opts || {}); } catch (e) {} + return; + } + if (cmd === 'scene.skyboxFadeTo') { + try { this.scene3d?.skybox?.fadeTo(payload?.opts || {}, payload?.duration || 2); } catch (e) {} + return; + } + if (cmd === 'scene.skyboxSunDir') { + try { this.scene3d?.skybox?.setSunDirection(payload?.dir || {}); } catch (e) {} + return; + } + if (cmd === 'scene.setColor') { try { const color = payload?.color; diff --git a/src/editor/engine/ScriptSandboxWorker.js b/src/editor/engine/ScriptSandboxWorker.js index a2afaba..8ed1488 100644 --- a/src/editor/engine/ScriptSandboxWorker.js +++ b/src/editor/engine/ScriptSandboxWorker.js @@ -1966,6 +1966,32 @@ const game = { const bag = _dataIndex[r]; return bag ? bag[key] : undefined; }, + // === Небо и атмосфера (задача 16) === + /** + * Установить небо. Либо пресет, либо ручной gradient: + * game.scene.setSkybox({ preset: 'lowpoly-roblox' }); + * game.scene.setSkybox({ mode:'gradient', topColor:'#4a90e2', bottomColor:'#cfd8dc' }); + * Пресеты: clear-summer-day / lowpoly-roblox / cloudy / sunset / starry-night / space. + */ + setSkybox(opts) { _send('scene.setSkybox', { opts: opts || {} }); }, + /** + * Облака поверх неба: + * game.scene.setClouds({ enabled:true, cover:0.5, speed:0.02, color:'#ffffff' }); + */ + setClouds(opts) { _send('scene.setClouds', { opts: opts || {} }); }, + /** + * Атмосферный туман: + * game.scene.setFog({ color:'#dddddd', density:0.005 }); + * game.scene.setFog({ enabled:false }); + */ + setFog(opts) { _send('scene.setFog', { opts: opts || {} }); }, + /** Объект управления небом: плавный переход + солнце. */ + skybox: { + /** Плавный переход к пресету за N секунд: skybox.fadeTo({preset:'sunset'}, 2). */ + fadeTo(opts, durationSec) { _send('scene.skyboxFadeTo', { opts: opts || {}, duration: Number(durationSec) || 2 }); }, + /** Направление солнца (для анимации дуги): setSunDirection({x,y,z}). */ + setSunDirection(dir) { _send('scene.skyboxSunDir', { dir: dir || {} }); }, + }, /** * Теги объектов (Фаза 5.6) — как CollectionService в Roblox. * Помечаешь объекты тегом, потом находишь все объекты с тегом. diff --git a/src/editor/engine/SkyboxManager.js b/src/editor/engine/SkyboxManager.js new file mode 100644 index 0000000..0e2428d --- /dev/null +++ b/src/editor/engine/SkyboxManager.js @@ -0,0 +1,504 @@ +/** + * SkyboxManager — кастомное небо для сцены (задача 16). + * + * Реализует процедурный gradient-skybox без внешних текстур (работает offline): + * - купол-сфера (BACKSIDE) с ShaderMaterial: плавный градиент верх→низ, + * солнечный диск, лёгкая дымка у горизонта; + * - low-poly горы на горизонте (как в Roblox-эталоне); + * - billboard-облака (плоскости, медленный дрейф); + * - атмосферный туман (scene.fog). + * + * Пресеты: clear-summer-day / cloudy / sunset / starry-night / space / + * lowpoly-roblox (+ gradient вручную). Плавный fadeTo между ними. + * + * API (через game.scene.*): + * setSkybox({ preset } | { mode:'gradient', topColor, bottomColor, ... }) + * setClouds({ enabled, cover, density, speed, color }) + * setFog({ color, density, near, far } | enabled:false) + * skybox.fadeTo(opts, durationSec) + * skybox.setSunDirection({x,y,z}) + * + * Фича-парность: при портировании в плеер — тот же модуль в rublox-player/src/engine/. + */ +import { + Color3, Color4, Vector3, + MeshBuilder, ShaderMaterial, Effect, StandardMaterial, Texture, + DynamicTexture, VertexData, Mesh, +} from '@babylonjs/core'; + +// ── Шейдер градиентного неба ────────────────────────────────────────────── +// Купол изнутри: цвет интерполируется по высоте (y от -1 низ до +1 верх), +// плюс солнечный диск и осветление у горизонта (дымка). +const SKY_VERT = ` +precision highp float; +attribute vec3 position; +uniform mat4 worldViewProjection; +varying vec3 vDir; +void main(void){ + vDir = normalize(position); + gl_Position = worldViewProjection * vec4(position, 1.0); +}`; + +const SKY_FRAG = ` +precision highp float; +varying vec3 vDir; +uniform vec3 topColor; +uniform vec3 bottomColor; +uniform vec3 horizonColor; +uniform vec3 sunDir; +uniform vec3 sunColor; +uniform float sunSize; // 0..1 угловой радиус +uniform float horizonHaze; // 0..1 сила дымки у горизонта +void main(void){ + float h = clamp(vDir.y * 0.5 + 0.5, 0.0, 1.0); // 0 низ .. 1 верх + // Градиент: низ→горизонт→верх (двойная интерполяция через горизонт у h~0.5) + vec3 col; + if (h < 0.5) { + col = mix(bottomColor, horizonColor, h * 2.0); + } else { + col = mix(horizonColor, topColor, (h - 0.5) * 2.0); + } + // Дымка у горизонта — осветление узкой полосы около h=0.5 + float haze = smoothstep(0.5, 0.0, abs(h - 0.5)) * horizonHaze; + col = mix(col, horizonColor + vec3(0.08), haze * 0.5); + // Солнечный диск + гало + float d = distance(normalize(vDir), normalize(sunDir)); + float disk = smoothstep(sunSize, sunSize * 0.4, d); + float glow = smoothstep(sunSize * 6.0, 0.0, d) * 0.35; + col += sunColor * disk; + col += sunColor * glow; + gl_FragColor = vec4(col, 1.0); +}`; + +let _shaderRegistered = false; +function registerSkyShader() { + if (_shaderRegistered) return; + Effect.ShadersStore['kubikonSkyVertexShader'] = SKY_VERT; + Effect.ShadersStore['kubikonSkyFragmentShader'] = SKY_FRAG; + _shaderRegistered = true; +} + +const hexToRgb = (hex) => { + if (Array.isArray(hex)) return hex; + const h = String(hex || '#ffffff').replace('#', ''); + return [ + parseInt(h.substring(0, 2), 16) / 255, + parseInt(h.substring(2, 4), 16) / 255, + parseInt(h.substring(4, 6), 16) / 255, + ]; +}; + +// ── Пресеты неба ────────────────────────────────────────────────────────── +// top/bottom/horizon — цвета купола; sun — направление/цвет/размер солнца; +// mountains — рисовать ли горы; clouds — параметры облаков; fog — туман; +// stars — звёздное небо (для ночи/космоса). +const PRESETS = { + 'clear-summer-day': { + top: '#3d7fe0', horizon: '#bcd9f2', bottom: '#dcebf7', + sunDir: [0.3, 0.85, 0.4], sunColor: '#fff6d8', sunSize: 0.035, haze: 0.6, + mountains: false, + clouds: { enabled: true, cover: 0.25, color: '#ffffff', speed: 0.015 }, + fog: { color: '#cfe2f2', density: 0.0035 }, + }, + 'lowpoly-roblox': { + top: '#4a93e6', horizon: '#cfe6f5', bottom: '#e6f1fa', + sunDir: [0.4, 0.8, 0.45], sunColor: '#fffbe6', sunSize: 0.03, haze: 0.85, + mountains: true, + clouds: { enabled: true, cover: 0.45, color: '#ffffff', speed: 0.012 }, + fog: { color: '#e2eef7', density: 0.005 }, + }, + 'cloudy': { + top: '#8fa6bd', horizon: '#c2ccd6', bottom: '#d8dde2', + sunDir: [0.2, 0.7, 0.3], sunColor: '#e8e8e8', sunSize: 0.0, haze: 0.4, + mountains: false, + clouds: { enabled: true, cover: 0.8, color: '#e8ebef', speed: 0.02 }, + fog: { color: '#cfd6dd', density: 0.008 }, + }, + 'sunset': { + top: '#2a3a6b', horizon: '#f5915a', bottom: '#f7c98a', + sunDir: [0.7, 0.12, -0.2], sunColor: '#ffd28a', sunSize: 0.05, haze: 1.0, + mountains: true, + clouds: { enabled: true, cover: 0.35, color: '#ffd9b3', speed: 0.01 }, + fog: { color: '#f0b483', density: 0.006 }, + }, + 'starry-night': { + top: '#070b1f', horizon: '#1b2547', bottom: '#243056', + sunDir: [-0.4, 0.75, 0.3], sunColor: '#cdd6ff', sunSize: 0.02, haze: 0.3, + mountains: true, stars: true, + clouds: { enabled: false }, + fog: { color: '#141c38', density: 0.004 }, + }, + 'space': { + top: '#02030a', horizon: '#06070f', bottom: '#0a0c18', + sunDir: [0.5, 0.6, 0.4], sunColor: '#ffffff', sunSize: 0.015, haze: 0.0, + mountains: false, stars: true, + clouds: { enabled: false }, + fog: { enabled: false }, + }, +}; + +export class SkyboxManager { + constructor(scene) { + this.scene = scene; + this._dome = null; + this._mat = null; + this._mountains = null; + this._clouds = []; // [{mesh, baseX, speed}] + this._cloudRoot = null; + this._stars = null; + this._fade = null; // активный fadeTo {from,to,t,dur} + this._state = this._defaultState(); + registerSkyShader(); + this._buildDome(); + } + + _defaultState() { + return { + mode: 'gradient', + top: '#4a93e6', horizon: '#cfe6f5', bottom: '#e6f1fa', + sunDir: [0.4, 0.8, 0.45], sunColor: '#fffbe6', sunSize: 0.03, haze: 0.8, + mountains: false, stars: false, + clouds: { enabled: false, cover: 0.4, color: '#ffffff', speed: 0.012 }, + fog: { enabled: false, color: '#dde8f2', density: 0.005 }, + }; + } + + // ── Купол ────────────────────────────────────────────────────────────── + _buildDome() { + const dome = MeshBuilder.CreateSphere('kubikonSkyDome', { + diameter: 1000, segments: 24, sideOrientation: Mesh.BACKSIDE, + }, this.scene); + dome.isPickable = false; + dome.infiniteDistance = true; // не двигается с камерой + dome.renderingGroupId = 0; + dome.applyFog = false; + + const mat = new ShaderMaterial('kubikonSkyMat', this.scene, { + vertex: 'kubikonSky', fragment: 'kubikonSky', + }, { + attributes: ['position'], + uniforms: ['worldViewProjection', 'topColor', 'bottomColor', + 'horizonColor', 'sunDir', 'sunColor', 'sunSize', 'horizonHaze'], + }); + mat.backFaceCulling = false; + mat.disableDepthWrite = true; // небо всегда позади + dome.material = mat; + this._dome = dome; + this._mat = mat; + this._applyShaderUniforms(); + } + + _applyShaderUniforms() { + const s = this._state; + const m = this._mat; + if (!m) return; + m.setColor3('topColor', Color3.FromArray(hexToRgb(s.top))); + m.setColor3('bottomColor', Color3.FromArray(hexToRgb(s.bottom))); + m.setColor3('horizonColor', Color3.FromArray(hexToRgb(s.horizon))); + const sd = Array.isArray(s.sunDir) ? s.sunDir : [0.4, 0.8, 0.45]; + m.setVector3('sunDir', new Vector3(sd[0], sd[1], sd[2]).normalize()); + m.setColor3('sunColor', Color3.FromArray(hexToRgb(s.sunColor))); + m.setFloat('sunSize', s.sunSize || 0.03); + m.setFloat('horizonHaze', s.haze != null ? s.haze : 0.7); + } + + // ── Горы (low-poly на горизонте) ──────────────────────────────────────── + _buildMountains(colorHex) { + this._disposeMountains(); + const positions = [], indices = []; + const ringR = 420, baseY = -10, segs = 64; + // Кольцо из треугольных пиков переменной высоты — стилизованный силуэт. + let vi = 0; + for (let i = 0; i < segs; i++) { + const a0 = (i / segs) * Math.PI * 2; + const a1 = ((i + 1) / segs) * Math.PI * 2; + const am = (a0 + a1) / 2; + // Псевдослучайная высота пика (детерминированно от индекса). + const hh = 40 + Math.abs(Math.sin(i * 1.7) * Math.cos(i * 0.6)) * 130; + const x0 = Math.cos(a0) * ringR, z0 = Math.sin(a0) * ringR; + const x1 = Math.cos(a1) * ringR, z1 = Math.sin(a1) * ringR; + const xm = Math.cos(am) * ringR, zm = Math.sin(am) * ringR; + positions.push(x0, baseY, z0, x1, baseY, z1, xm, baseY + hh, zm); + indices.push(vi, vi + 1, vi + 2); + vi += 3; + } + const vd = new VertexData(); + vd.positions = positions; vd.indices = indices; + const normals = []; + VertexData.ComputeNormals(positions, indices, normals); + vd.normals = normals; + const mesh = new Mesh('kubikonSkyMountains', this.scene); + vd.applyToMesh(mesh); + mesh.isPickable = false; + mesh.applyFog = true; // горы выцветают в туман (атмосфера) + mesh.renderingGroupId = 0; + const mat = new StandardMaterial('kubikonSkyMountainsMat', this.scene); + const c = hexToRgb(colorHex || '#8fa98a'); + mat.diffuseColor = new Color3(c[0], c[1], c[2]); + mat.specularColor = new Color3(0, 0, 0); + mat.emissiveColor = new Color3(c[0] * 0.25, c[1] * 0.25, c[2] * 0.25); + mesh.material = mat; + this._mountains = mesh; + } + + _disposeMountains() { + if (this._mountains) { this._mountains.material?.dispose(); this._mountains.dispose(); this._mountains = null; } + } + + // ── Облака (billboard-плоскости) ──────────────────────────────────────── + _buildClouds(opts) { + this._disposeClouds(); + const o = opts || {}; + if (!o.enabled) return; + const cover = o.cover != null ? o.cover : 0.4; + const count = Math.round(4 + cover * 16); // 4..20 облаков + const tex = this._makeCloudTexture(o.color || '#ffffff'); + for (let i = 0; i < count; i++) { + const w = 60 + Math.random() * 90; + const plane = MeshBuilder.CreatePlane(`kubikonCloud_${i}`, { width: w, height: w * 0.55 }, this.scene); + plane.billboardMode = Mesh.BILLBOARDMODE_ALL; + plane.isPickable = false; + plane.applyFog = false; + plane.renderingGroupId = 0; + const mat = new StandardMaterial(`kubikonCloudMat_${i}`, this.scene); + mat.diffuseTexture = tex; + mat.opacityTexture = tex; + mat.emissiveColor = new Color3(1, 1, 1); + mat.disableLighting = true; + mat.backFaceCulling = false; + plane.material = mat; + const ang = Math.random() * Math.PI * 2; + const rad = 150 + Math.random() * 200; + const x = Math.cos(ang) * rad; + const z = Math.sin(ang) * rad; + const y = 90 + Math.random() * 70; + plane.position.set(x, y, z); + this._clouds.push({ mesh: plane, speed: (o.speed || 0.012) * (0.6 + Math.random() * 0.8) }); + } + } + + /** Процедурная мягкая текстура-облако (radial-gradient на DynamicTexture). */ + _makeCloudTexture(colorHex) { + const size = 256; + const dt = new DynamicTexture('kubikonCloudTex', { width: size, height: size }, this.scene, false); + const ctx = dt.getContext(); + ctx.clearRect(0, 0, size, size); + const c = hexToRgb(colorHex); + const rgb = `${Math.round(c[0] * 255)},${Math.round(c[1] * 255)},${Math.round(c[2] * 255)}`; + // Несколько перекрывающихся мягких кругов → пухлое облако. + const blobs = [[128, 140, 70], [90, 150, 50], [170, 150, 55], [128, 110, 55], [110, 130, 45], [150, 130, 45]]; + for (const [bx, by, br] of blobs) { + const g = ctx.createRadialGradient(bx, by, 0, bx, by, br); + g.addColorStop(0, `rgba(${rgb},0.9)`); + g.addColorStop(0.6, `rgba(${rgb},0.5)`); + g.addColorStop(1, `rgba(${rgb},0)`); + ctx.fillStyle = g; + ctx.beginPath(); ctx.arc(bx, by, br, 0, Math.PI * 2); ctx.fill(); + } + dt.hasAlpha = true; + dt.update(); + return dt; + } + + _disposeClouds() { + for (const c of this._clouds) { c.mesh.material?.diffuseTexture?.dispose?.(); c.mesh.material?.dispose(); c.mesh.dispose(); } + this._clouds = []; + } + + // ── Звёзды (точки на куполе) ───────────────────────────────────────────── + _buildStars(enabled) { + this._disposeStars(); + if (!enabled) return; + const size = 1024; + const dt = new DynamicTexture('kubikonStarsTex', { width: size, height: size }, this.scene, false); + const ctx = dt.getContext(); + ctx.fillStyle = 'rgba(0,0,0,0)'; ctx.clearRect(0, 0, size, size); + for (let i = 0; i < 600; i++) { + const x = Math.random() * size, y = Math.random() * size; + const r = Math.random() * 1.4 + 0.3; + const a = 0.4 + Math.random() * 0.6; + ctx.fillStyle = `rgba(255,255,255,${a})`; + ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill(); + } + dt.hasAlpha = true; dt.update(); + const dome = MeshBuilder.CreateSphere('kubikonStarsDome', { + diameter: 980, segments: 16, sideOrientation: Mesh.BACKSIDE, + }, this.scene); + dome.isPickable = false; dome.infiniteDistance = true; + dome.applyFog = false; dome.renderingGroupId = 0; + const mat = new StandardMaterial('kubikonStarsMat', this.scene); + mat.diffuseTexture = dt; mat.opacityTexture = dt; + mat.emissiveColor = new Color3(1, 1, 1); + mat.disableLighting = true; mat.backFaceCulling = false; + mat.disableDepthWrite = true; + dome.material = mat; + this._stars = dome; + } + + _disposeStars() { + if (this._stars) { this._stars.material?.diffuseTexture?.dispose?.(); this._stars.material?.dispose(); this._stars.dispose(); this._stars = null; } + } + + // ── Туман ──────────────────────────────────────────────────────────────── + _applyFog(fog) { + if (!this.scene) return; + if (fog && fog.enabled !== false && (fog.density != null || fog.color)) { + this.scene.fogMode = 2; // EXP + const c = hexToRgb(fog.color || '#dde8f2'); + this.scene.fogColor = new Color3(c[0], c[1], c[2]); + this.scene.fogDensity = fog.density != null ? fog.density : 0.005; + } else if (fog && fog.enabled === false) { + this.scene.fogMode = 0; + } + } + + // ── Public API ─────────────────────────────────────────────────────────── + + /** Применить пресет или ручные опции gradient. */ + setSkybox(opts) { + if (!opts) return; + const preset = opts.preset && PRESETS[opts.preset] ? { ...PRESETS[opts.preset] } : null; + const s = this._state; + if (preset) { + s.top = preset.top; s.horizon = preset.horizon; s.bottom = preset.bottom; + s.sunDir = preset.sunDir; s.sunColor = preset.sunColor; s.sunSize = preset.sunSize; + s.haze = preset.haze; s.mountains = !!preset.mountains; s.stars = !!preset.stars; + s.clouds = { ...(preset.clouds || { enabled: false }) }; + s.fog = { enabled: preset.fog?.enabled !== false, ...(preset.fog || {}) }; + } else { + // Ручной gradient: { topColor, bottomColor, horizonColor, sunDirection, sunColor, sunSize } + if (opts.topColor) s.top = opts.topColor; + if (opts.bottomColor) s.bottom = opts.bottomColor; + if (opts.horizonColor) s.horizon = opts.horizonColor; + if (opts.sunDirection) s.sunDir = [opts.sunDirection.x, opts.sunDirection.y, opts.sunDirection.z]; + if (opts.sunColor) s.sunColor = opts.sunColor; + if (typeof opts.sunSize === 'number') s.sunSize = opts.sunSize; + if (typeof opts.haze === 'number') s.haze = opts.haze; + if (typeof opts.mountains === 'boolean') s.mountains = opts.mountains; + if (typeof opts.stars === 'boolean') s.stars = opts.stars; + } + this._rebuildAll(); + } + + /** Облака поверх любого режима. */ + setClouds(opts) { + if (!opts) return; + this._state.clouds = { ...this._state.clouds, ...opts }; + if (this._state.clouds.enabled == null) this._state.clouds.enabled = true; + this._buildClouds(this._state.clouds); + } + + /** Атмосферный туман. */ + setFog(opts) { + if (!opts) { return; } + this._state.fog = { ...this._state.fog, ...opts }; + if (opts.enabled == null) this._state.fog.enabled = true; + this._applyFog(this._state.fog); + } + + /** Установить направление солнца (для программной анимации). */ + setSunDirection(dir) { + if (!dir) return; + this._state.sunDir = [dir.x, dir.y, dir.z]; + this._applyShaderUniforms(); + } + + /** Плавный переход к пресету/опциям за durationSec секунд (цвета купола + туман). */ + fadeTo(opts, durationSec = 2) { + const target = opts?.preset && PRESETS[opts.preset] ? { ...PRESETS[opts.preset] } : null; + if (!target) { this.setSkybox(opts); return; } + // Запоминаем стартовые цвета и целевые — анимируем в tick(). + this._fade = { + t: 0, dur: Math.max(0.1, durationSec), + from: { + top: hexToRgb(this._state.top), horizon: hexToRgb(this._state.horizon), + bottom: hexToRgb(this._state.bottom), sunColor: hexToRgb(this._state.sunColor), + sunDir: this._state.sunDir.slice(), sunSize: this._state.sunSize, haze: this._state.haze, + }, + to: { + top: hexToRgb(target.top), horizon: hexToRgb(target.horizon), + bottom: hexToRgb(target.bottom), sunColor: hexToRgb(target.sunColor), + sunDir: target.sunDir.slice(), sunSize: target.sunSize, haze: target.haze, + }, + target, + }; + // Немедленно перестраиваем «дискретные» части (горы/звёзды/облака/туман + // целевого пресета появляются сразу, цвета купола — плавно). + const s = this._state; + s.mountains = !!target.mountains; s.stars = !!target.stars; + s.clouds = { ...(target.clouds || { enabled: false }) }; + s.fog = { enabled: target.fog?.enabled !== false, ...(target.fog || {}) }; + this._rebuildExtras(); + } + + /** Пересобрать всё (купол-uniforms + горы + звёзды + облака + туман). */ + _rebuildAll() { + this._applyShaderUniforms(); + this._rebuildExtras(); + } + + _rebuildExtras() { + const s = this._state; + if (s.mountains) { + // Цвет гор подбираем под пресет (днём зеленовато-серый, ночью тёмный). + const mc = s.stars ? '#2a3550' : '#8fa98a'; + this._buildMountains(mc); + } else this._disposeMountains(); + this._buildStars(!!s.stars); + this._buildClouds(s.clouds); + this._applyFog(s.fog); + } + + /** Вызывать каждый кадр: дрейф облаков + анимация fadeTo. */ + tick(dt) { + // Дрейф облаков по кругу. + for (const c of this._clouds) { + c.mesh.position.x += c.speed * dt * 60; + if (c.mesh.position.x > 380) c.mesh.position.x = -380; + } + // Анимация перехода неба. + if (this._fade) { + this._fade.t += dt; + const k = Math.min(1, this._fade.t / this._fade.dur); + const f = this._fade.from, t = this._fade.to, m = this._mat; + const mix = (a, b) => [a[0] + (b[0] - a[0]) * k, a[1] + (b[1] - a[1]) * k, a[2] + (b[2] - a[2]) * k]; + if (m) { + m.setColor3('topColor', Color3.FromArray(mix(f.top, t.top))); + m.setColor3('bottomColor', Color3.FromArray(mix(f.bottom, t.bottom))); + m.setColor3('horizonColor', Color3.FromArray(mix(f.horizon, t.horizon))); + m.setColor3('sunColor', Color3.FromArray(mix(f.sunColor, t.sunColor))); + const sd = mix(f.sunDir, t.sunDir); + m.setVector3('sunDir', new Vector3(sd[0], sd[1], sd[2]).normalize()); + m.setFloat('sunSize', f.sunSize + (t.sunSize - f.sunSize) * k); + m.setFloat('horizonHaze', f.haze + (t.haze - f.haze) * k); + } + if (k >= 1) { + // Зафиксировать целевое состояние в _state (как hex). + const tp = this._fade.target; + Object.assign(this._state, { + top: tp.top, horizon: tp.horizon, bottom: tp.bottom, + sunColor: tp.sunColor, sunDir: tp.sunDir.slice(), + sunSize: tp.sunSize, haze: tp.haze, + }); + this._fade = null; + } + } + } + + serialize() { + return { ...this._state, _active: true }; + } + + load(data) { + if (!data) return; + this._state = { ...this._defaultState(), ...data }; + this._rebuildAll(); + } + + dispose() { + this._disposeMountains(); + this._disposeClouds(); + this._disposeStars(); + if (this._dome) { this._mat?.dispose(); this._dome.dispose(); this._dome = null; } + } +} From a4881ee5cec06ef8ba5e37865904db4fc9ebf125 Mon Sep 17 00:00:00 2001 From: min Date: Fri, 5 Jun 2026 00:44:25 +0300 Subject: [PATCH 03/74] =?UTF-8?q?fix(studio):=20=D0=B5=D0=B4=D0=B8=D0=BD?= =?UTF-8?q?=D0=B0=D1=8F=20=D1=81=D0=B8=D1=81=D1=82=D0=B5=D0=BC=D0=B0=20?= =?UTF-8?q?=D0=BD=D0=B5=D0=B1=D0=B0=20=E2=80=94=20=D1=83=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D1=82=D1=8C=20=D0=B2=D1=82=D0=BE=D1=80=D0=BE=D0=B5=20(=D0=B6?= =?UTF-8?q?=D1=91=D0=BB=D1=82=D0=BE=D0=B5)=20=D1=81=D0=BE=D0=BB=D0=BD?= =?UTF-8?q?=D1=86=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Environment больше НЕ рисует свою жёлтую сферу-солнце/луну/фон (флаг _drawSkyBodies=false) — иначе на небе было два солнца. Единое небо рисует SkyboxManager (купол + солнечный диск + облака + горы). SkyboxManager стал единым источником освещения: каждый пресет выставляет direction/intensity/ color солнца и ambient (lights переданы в конструктор), fadeTo плавно ведёт и свет. Environment оставлен только для day/night cycle совместимости. Co-Authored-By: Claude Opus 4.8 --- src/editor/engine/BabylonScene.js | 2 +- src/editor/engine/Environment.js | 9 ++++- src/editor/engine/SkyboxManager.js | 65 +++++++++++++++++++++++++++++- 3 files changed, 71 insertions(+), 5 deletions(-) diff --git a/src/editor/engine/BabylonScene.js b/src/editor/engine/BabylonScene.js index a640b26..2e98317 100644 --- a/src/editor/engine/BabylonScene.js +++ b/src/editor/engine/BabylonScene.js @@ -1293,7 +1293,7 @@ export class BabylonScene { } this.dynamics = new DynamicsManager(this); this.environment = new Environment(this.scene, this._hemiLight, this._sunLight); - this.skybox = new SkyboxManager(this.scene); // задача 16 — кастомное небо + this.skybox = new SkyboxManager(this.scene, this._hemiLight, this._sunLight); // задача 16 — кастомное небо (единый источник света) this.audioManager = new AudioManager(); this.assetManager = new AssetManager(); // PrimitiveManager должен уметь брать dataURL картинки по id ассета, diff --git a/src/editor/engine/Environment.js b/src/editor/engine/Environment.js index 2d69514..590bd02 100644 --- a/src/editor/engine/Environment.js +++ b/src/editor/engine/Environment.js @@ -91,10 +91,15 @@ export class Environment { this.fogEnabled = false; this.fogColor = [0.7, 0.8, 0.9]; this.fogDensity = 0.01; - // Видимые тела на небе (солнце и луна) — создаём по запросу + // Видимые тела на небе (солнце и луна). + // ВАЖНО (задача 16): единое небо рисует SkyboxManager (купол + солнечный + // диск + облака). Environment больше НЕ рисует свою жёлтую сферу/луну/фон — + // иначе на небе два солнца. Environment теперь отвечает ТОЛЬКО за свет + // (направление/яркость солнца, ambient). Флаг ниже отключает небесные тела. + this._drawSkyBodies = false; this._sunMesh = null; this._moonMesh = null; - this._createSkyBodies(); + if (this._drawSkyBodies) this._createSkyBodies(); this._applyTime(); } diff --git a/src/editor/engine/SkyboxManager.js b/src/editor/engine/SkyboxManager.js index 0e2428d..d148cab 100644 --- a/src/editor/engine/SkyboxManager.js +++ b/src/editor/engine/SkyboxManager.js @@ -99,6 +99,7 @@ const PRESETS = { mountains: false, clouds: { enabled: true, cover: 0.25, color: '#ffffff', speed: 0.015 }, fog: { color: '#cfe2f2', density: 0.0035 }, + light: { sunIntensity: 1.0, sunColor: '#fff6e0', hemiIntensity: 0.7, ambient: '#b9d4ee' }, }, 'lowpoly-roblox': { top: '#4a93e6', horizon: '#cfe6f5', bottom: '#e6f1fa', @@ -106,6 +107,7 @@ const PRESETS = { mountains: true, clouds: { enabled: true, cover: 0.45, color: '#ffffff', speed: 0.012 }, fog: { color: '#e2eef7', density: 0.005 }, + light: { sunIntensity: 0.95, sunColor: '#fff7e0', hemiIntensity: 0.75, ambient: '#cfe6f5' }, }, 'cloudy': { top: '#8fa6bd', horizon: '#c2ccd6', bottom: '#d8dde2', @@ -113,6 +115,7 @@ const PRESETS = { mountains: false, clouds: { enabled: true, cover: 0.8, color: '#e8ebef', speed: 0.02 }, fog: { color: '#cfd6dd', density: 0.008 }, + light: { sunIntensity: 0.5, sunColor: '#dfe4ea', hemiIntensity: 0.8, ambient: '#c2ccd6' }, }, 'sunset': { top: '#2a3a6b', horizon: '#f5915a', bottom: '#f7c98a', @@ -120,6 +123,7 @@ const PRESETS = { mountains: true, clouds: { enabled: true, cover: 0.35, color: '#ffd9b3', speed: 0.01 }, fog: { color: '#f0b483', density: 0.006 }, + light: { sunIntensity: 0.7, sunColor: '#ff9a52', hemiIntensity: 0.5, ambient: '#9a6a78' }, }, 'starry-night': { top: '#070b1f', horizon: '#1b2547', bottom: '#243056', @@ -127,6 +131,7 @@ const PRESETS = { mountains: true, stars: true, clouds: { enabled: false }, fog: { color: '#141c38', density: 0.004 }, + light: { sunIntensity: 0.18, sunColor: '#9aa8d8', hemiIntensity: 0.25, ambient: '#1b2547' }, }, 'space': { top: '#02030a', horizon: '#06070f', bottom: '#0a0c18', @@ -134,12 +139,15 @@ const PRESETS = { mountains: false, stars: true, clouds: { enabled: false }, fog: { enabled: false }, + light: { sunIntensity: 0.6, sunColor: '#ffffff', hemiIntensity: 0.2, ambient: '#10121c' }, }, }; export class SkyboxManager { - constructor(scene) { + constructor(scene, hemiLight, sunLight) { this.scene = scene; + this.hemiLight = hemiLight || null; // ambient + this.sunLight = sunLight || null; // directional (тени) this._dome = null; this._mat = null; this._mountains = null; @@ -160,6 +168,7 @@ export class SkyboxManager { mountains: false, stars: false, clouds: { enabled: false, cover: 0.4, color: '#ffffff', speed: 0.012 }, fog: { enabled: false, color: '#dde8f2', density: 0.005 }, + light: { sunIntensity: 0.95, sunColor: '#fff7e0', hemiIntensity: 0.75, ambient: '#cfe6f5' }, }; } @@ -352,6 +361,25 @@ export class SkyboxManager { } } + // ── Освещение (единый источник: небо управляет светом сцены) ───────────── + /** Выставить направление/яркость солнца и ambient под текущее небо. */ + _applyLighting(light, sunDir) { + if (this.sunLight && sunDir) { + // DirectionalLight.direction указывает КУДА падает свет → от солнца вниз. + const d = new Vector3(-sunDir[0], -sunDir[1], -sunDir[2]); + if (d.lengthSquared() > 0) { d.normalize(); this.sunLight.direction = d; } + } + if (!light) return; + if (this.sunLight) { + if (typeof light.sunIntensity === 'number') this.sunLight.intensity = light.sunIntensity; + if (light.sunColor) this.sunLight.diffuse = Color3.FromArray(hexToRgb(light.sunColor)); + } + if (this.hemiLight) { + if (typeof light.hemiIntensity === 'number') this.hemiLight.intensity = light.hemiIntensity; + if (light.ambient) this.hemiLight.groundColor = Color3.FromArray(hexToRgb(light.ambient)); + } + } + // ── Public API ─────────────────────────────────────────────────────────── /** Применить пресет или ручные опции gradient. */ @@ -365,6 +393,8 @@ export class SkyboxManager { s.haze = preset.haze; s.mountains = !!preset.mountains; s.stars = !!preset.stars; s.clouds = { ...(preset.clouds || { enabled: false }) }; s.fog = { enabled: preset.fog?.enabled !== false, ...(preset.fog || {}) }; + s.light = preset.light || null; + this._applyLighting(preset.light, preset.sunDir); } else { // Ручной gradient: { topColor, bottomColor, horizonColor, sunDirection, sunColor, sunSize } if (opts.topColor) s.top = opts.topColor; @@ -428,13 +458,27 @@ export class SkyboxManager { s.mountains = !!target.mountains; s.stars = !!target.stars; s.clouds = { ...(target.clouds || { enabled: false }) }; s.fog = { enabled: target.fog?.enabled !== false, ...(target.fog || {}) }; + s.light = target.light || null; this._rebuildExtras(); + // Запоминаем стартовые/целевые значения света для плавной анимации. + if (target.light) { + this._fade.lightFrom = { + sunInt: this.sunLight?.intensity ?? 1, + hemiInt: this.hemiLight?.intensity ?? 0.7, + }; + this._fade.lightTo = { + sunInt: target.light.sunIntensity ?? 1, + hemiInt: target.light.hemiIntensity ?? 0.7, + sunColor: target.light.sunColor, ambient: target.light.ambient, + }; + } } - /** Пересобрать всё (купол-uniforms + горы + звёзды + облака + туман). */ + /** Пересобрать всё (купол-uniforms + горы + звёзды + облака + туман + свет). */ _rebuildAll() { this._applyShaderUniforms(); this._rebuildExtras(); + this._applyLighting(this._state.light, this._state.sunDir); } _rebuildExtras() { @@ -471,6 +515,23 @@ export class SkyboxManager { m.setVector3('sunDir', new Vector3(sd[0], sd[1], sd[2]).normalize()); m.setFloat('sunSize', f.sunSize + (t.sunSize - f.sunSize) * k); m.setFloat('horizonHaze', f.haze + (t.haze - f.haze) * k); + // Плавно ведём направление солнца (свет) к целевому (используем sd выше). + if (this.sunLight) { + const d = new Vector3(-sd[0], -sd[1], -sd[2]); + if (d.lengthSquared() > 0) { d.normalize(); this.sunLight.direction = d; } + } + } + // Плавно ведём яркость/ambient света. + if (this._fade.lightFrom && this._fade.lightTo) { + const lf = this._fade.lightFrom, lt = this._fade.lightTo; + if (this.sunLight) { + this.sunLight.intensity = lf.sunInt + (lt.sunInt - lf.sunInt) * k; + if (lt.sunColor) this.sunLight.diffuse = Color3.FromArray(hexToRgb(lt.sunColor)); + } + if (this.hemiLight) { + this.hemiLight.intensity = lf.hemiInt + (lt.hemiInt - lf.hemiInt) * k; + if (lt.ambient) this.hemiLight.groundColor = Color3.FromArray(hexToRgb(lt.ambient)); + } } if (k >= 1) { // Зафиксировать целевое состояние в _state (как hex). From 8a7ab9aadfa86dacfd7d9dbe083b8cc347f01195 Mon Sep 17 00:00:00 2001 From: min Date: Fri, 5 Jun 2026 00:56:50 +0300 Subject: [PATCH 04/74] =?UTF-8?q?docs(studio):=20=D0=B2=D0=B8=D0=BA=D0=B8?= =?UTF-8?q?=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B0=2016=20=E2=80=94=20?= =?UTF-8?q?=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=BA=D0=B0=20#62=20+=20?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D1=82=D1=8C=D1=8F=20=C2=AB=D0=9D=D0=B5=D0=B1?= =?UTF-8?q?=D0=B5=D1=81=D0=BD=D0=B0=D1=8F=20=D0=B4=D0=B5=D0=BC=D0=BA=D0=B0?= =?UTF-8?q?=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Карточка g5 #62 guide-skybox (preview guide-skybox-scene.png, openProjectId 2541) + статья в docsLessons (что получится, API setSkybox/setClouds/setFog/ fadeTo, 3 шага, 4 скриншота день/ночь/космос) + иконка cloud в docsIcons. Скрины в public/wiki (вне git) — на прод донести вручную при возврате CI. Co-Authored-By: Claude Opus 4.8 --- src/community/docsGames.js | 5 ++ src/community/docsIcons.jsx | 5 ++ src/community/docsLessons.jsx | 95 +++++++++++++++++++++++++++++++++++ 3 files changed, 105 insertions(+) diff --git a/src/community/docsGames.js b/src/community/docsGames.js index 1f69526..06d58db 100644 --- a/src/community/docsGames.js +++ b/src/community/docsGames.js @@ -353,4 +353,9 @@ export const GAMES = [ desc: 'Полноценные машины: подходишь, держишь F — садишься за руль, WASD рулят, камера следует за авто, спидометр снизу. E — выйти. Готовые 3D-модели машин.', mechanics: ['game.scene.spawn(\'vehicle:car\')', 'аркадная физика (газ/руль/тормоз)', 'hold-F вход / E выход', 'камера за машиной (V меняет)', 'HUD водителя (спидометр+передача)', 'onVehicleEnter/onVehicleExit'], previewShot: 'guide-taxisim-scene.png', openProjectId: 2436, ready: true }, + { id: 'guide-skybox', num: 62, group: 'g5', stars: 2, icon: 'cloud', + title: 'Небесная демка — кастомное небо', + desc: 'Одной строкой меняешь небо: голубой день, закат, звёздная ночь, космос. Облака, туман, далёкие горы и плавные переходы между пресетами.', + mechanics: ['game.scene.setSkybox({ preset })', 'game.scene.setClouds / setFog', 'skybox.fadeTo(opts, сек) — плавный переход', '6 пресетов: день/lowpoly/закат/ночь/космос', 'небо = единый источник света сцены', 'облака-дрейф + дымка горизонта'], + previewShot: 'guide-skybox-scene.png', openProjectId: 2541, ready: true }, ]; diff --git a/src/community/docsIcons.jsx b/src/community/docsIcons.jsx index d32a1b9..5de69c8 100644 --- a/src/community/docsIcons.jsx +++ b/src/community/docsIcons.jsx @@ -319,6 +319,11 @@ const ICONS = { ), + cloud: () => ( + <> + + + ), car: () => ( <> diff --git a/src/community/docsLessons.jsx b/src/community/docsLessons.jsx index 2001a36..8562c3b 100644 --- a/src/community/docsLessons.jsx +++ b/src/community/docsLessons.jsx @@ -8596,6 +8596,101 @@ game.onVehicleExit((vehicleRef) => { ), }, + 'guide-skybox': { + body: ( + <> +

Что получится

+

+ Красивое небо в одну строку. Вместо плоского цветного фона — + градиентный купол с солнцем, плывущими облаками, дымкой у горизонта + и далёкими горами (low-poly стиль, как в топовых Roblox-играх). Небо + меняется кнопками: день, закат, звёздная ночь, + космос — с плавным переходом за пару секунд. И главное: небо — + это единый источник света: меняешь пресет → вместе с небом + меняется и освещение всей сцены (на закате теплеет, ночью темнеет). +

+ + + +

Чему научишься

+
    +
  • game.scene.setSkybox({'{'} preset {'}'}) — поставить готовое небо + одной строкой (6 пресетов);
  • +
  • game.scene.setClouds(...) — облака: плотность, скорость дрейфа, цвет;
  • +
  • game.scene.setFog(...) — атмосферный туман: дальние объекты выцветают в небо;
  • +
  • game.scene.skybox.fadeTo(opts, сек) — плавный переход между небесами;
  • +
  • game.scene.skybox.setSunDirection(...) — двигать солнце (анимация дуги).
  • +
+ +

Шаг 1. Поставить небо

+

+ Самое простое — выбрать пресет. Доступны: + clear-summer-day, lowpoly-roblox, + cloudy, sunset, starry-night, + space. +

+ + {`// Голубое low-poly небо с облаками, дымкой и горами (как на скрине): +game.scene.setSkybox({ preset: 'lowpoly-roblox' }); +game.scene.setClouds({ enabled: true, cover: 0.45, speed: 0.014 }); +game.scene.setFog({ color: '#e2eef7', density: 0.005 });`} + + Небо само выставляет освещение сцены под выбранный пресет — отдельно + свет настраивать не нужно. Купол бесконечно далёкий, поэтому ходить + «до края неба» нельзя — оно всегда вокруг игрока. + + +

Шаг 2. Плавная смена неба (день → закат → ночь)

+

+ skybox.fadeTo переводит небо к новому пресету за указанное + число секунд — цвета купола, солнце, облака, туман и свет сцены + меняются плавно. Удобно вешать на кнопки или события. +

+ + {`game.gui.onClick('btn-sunset', () => game.scene.skybox.fadeTo({ preset: 'sunset' }, 2)); +game.gui.onClick('btn-night', () => game.scene.skybox.fadeTo({ preset: 'starry-night' }, 2)); +game.gui.onClick('btn-space', () => game.scene.skybox.fadeTo({ preset: 'space' }, 2));`} + + + + + +

Шаг 3. Своё небо (gradient)

+

+ Можно не брать пресет, а задать цвета купола вручную — верх, низ, + горизонт и солнце. +

+ + {`game.scene.setSkybox({ + mode: 'gradient', + topColor: '#3d7fe0', // зенит + bottomColor: '#dcebf7', // у земли + horizonColor: '#bcd9f2', // линия горизонта + sunDirection: { x: 0.3, y: 0.85, z: 0.4 }, + sunColor: '#fff6d8', + sunSize: 0.035, +});`} + +

Почему это важно

+

+ Небо — половина визуального впечатления от мира. С плоским фоном все + игры выглядят одинаково и дёшево; с кастомным небом — атмосферно и + «дорого». А связка неба с освещением даёт бесплатный приём: смена + времени суток одной строкой мгновенно меняет настроение всей сцены. +

+ + + Сделай день/ночь цикл: по таймеру каждые 10 секунд переключай + fadeTo между 'clear-summer-day' и + 'starry-night'. Добавь облака погуще на день и убери на ночь. + + + ), + }, + }; /** Есть ли готовый текст урока для игры с таким id. */ From d62739d7096566940134746d063f3abef9c2d8d0 Mon Sep 17 00:00:00 2001 From: min Date: Fri, 5 Jun 2026 01:16:35 +0300 Subject: [PATCH 05/74] =?UTF-8?q?feat(studio):=20=D0=B7=D0=B0=D0=B4=D0=B0?= =?UTF-8?q?=D1=87=D0=B0=2017=20=E2=80=94=20Toolbox=20=C2=AB=D0=93=D0=BE?= =?UTF-8?q?=D1=82=D0=BE=D0=B2=D1=8B=D0=B5=20=D0=BC=D0=B5=D1=85=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D0=BA=D0=B8=C2=BB=20(gameplay-=D0=BA=D0=B8=D1=82=D1=8B)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Фаза T2: вкладка «Готовые механики» в Тулбоксе — 12 готовых китов (Бег на Shift, Смена дня/ночи, Счётчик монет, Таймер, Приветствие, Сундук, Чекпоинт, Конфетти, Парящая платформа, Вертушка, Двойной прыжок, Точка спавна). - GameplayKits.js — каталог китов (scripts global/on-target + prims), getKit. - ToolboxModal.jsx — section 'gameplay' + категории (Движение/Мир/Интерфейс/ Эффекты) + карточки китов + поиск; клик → onPick('kit:'). - KubikonEditor.jsx — insertGameplayKit: создаёт примитивы кита перед камерой, привязывает on-target скрипт к первому примитиву, global-скрипты добавляет в проект (upsertScript). Безопасность: киты наши, существующий sandbox. Тест-игра «Игра за 5 минут» id=2544 (dev-режим is_test): town + применённые киты (welcome/timer/coins/day-night/shift-run + сундук/чекпоинт/конфетти/ платформа). Проверено в плеере — все 5 скриптов исполняются без ошибок. Co-Authored-By: Claude Opus 4.8 --- src/editor/KubikonEditor.jsx | 58 +++++++++ src/editor/ToolboxModal.jsx | 71 ++++++++++- src/editor/engine/GameplayKits.js | 188 ++++++++++++++++++++++++++++++ 3 files changed, 314 insertions(+), 3 deletions(-) create mode 100644 src/editor/engine/GameplayKits.js diff --git a/src/editor/KubikonEditor.jsx b/src/editor/KubikonEditor.jsx index bb5b6a4..03b7a79 100644 --- a/src/editor/KubikonEditor.jsx +++ b/src/editor/KubikonEditor.jsx @@ -6,6 +6,7 @@ import { useSanctions } from '../auth/SanctionsContext.jsx'; import { BabylonScene } from './engine/BabylonScene'; import { BLOCK_TYPES, BLOCK_CATEGORIES, blockPreview, registerCustomBlockType } from './engine/BlockTypes'; import { MODEL_TYPES, MODEL_CATEGORIES, getModelType } from './engine/ModelTypes'; +import { getKit } from './engine/GameplayKits'; import { PRIMITIVE_TYPES, PALETTE_PRIMITIVE_TYPES } from './engine/PrimitiveTypes'; import { getModelThumbnail } from './engine/ModelThumbnails'; import * as Kubikon3DApi from '../api/Kubikon3DService'; @@ -768,6 +769,57 @@ const KubikonEditor = () => { }); } }, []); + + // Задача 17: вставить готовую механику (kit) из Тулбокса в проект. + // prims[] → создаём примитивы перед камерой; on-target скрипт → привязываем + // к первому созданному примитиву; global скрипт → добавляем как скрипт игры. + const insertGameplayKit = useCallback((kitId) => { + const kit = getKit(kitId); + const s = sceneRef.current; + if (!kit || !s) return; + // Точка вставки — перед камерой редактора (~6м), как у paste. + let px = 0, pz = 0; + try { + const cam = s.camera; + const fwd = cam?.getForwardRay ? cam.getForwardRay().direction : null; + if (cam && fwd) { px = cam.position.x + fwd.x * 6; pz = cam.position.z + fwd.z * 6; } + } catch (e) { /* ignore */ } + + // 1) Создаём примитивы кита. Запоминаем id первого — для on-target скрипта. + let firstPrimId = null; + if (Array.isArray(kit.prims)) { + for (const p of kit.prims) { + const newId = s.primitiveManager?.addInstance(p.type || 'cube', { + x: px + (p.x || 0), y: (p.y != null ? p.y : 1), z: pz + (p.z || 0), + sx: p.sx, sy: p.sy, sz: p.sz, + color: p.color, material: p.material, + canCollide: p.canCollide !== false, visible: true, anchored: true, + name: p.name, + }); + if (firstPrimId == null && newId != null) firstPrimId = newId; + } + } + + // 2) Добавляем скрипты кита. + if (Array.isArray(kit.scripts)) { + for (const sc of kit.scripts) { + const sid = `script_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`; + if (sc.attachTo === 'on-target' && firstPrimId != null) { + s.upsertScript(sid, sc.code, { kind: 'primitive', id: firstPrimId }); + } else { + s.upsertScript(sid, sc.code, null); // глобальный + } + } + } + + markDirty(); + setScriptsList(s.getScripts?.() || []); + // Выделим созданный объект (если был) для наглядности. + if (firstPrimId != null) { try { s.selection?.selectPrimitiveById(firstPrimId); } catch (e) {} } + // Тост-уведомление. + try { showToast?.(`Механика «${kit.name}» добавлена`); } catch (e) {} + }, []); + const [paletteTab, setPaletteTab] = useState('blocks'); // 'blocks' | 'models' | 'primitives' const [blockCount, setBlockCount] = useState(0); const [modelCount, setModelCount] = useState(0); @@ -3842,6 +3894,12 @@ const KubikonEditor = () => { }} onClose={() => setToolboxOpen(false)} onPick={(id, userModelObj = null) => { + // Задача 17: готовая механика из Тулбокса (kit:). + // Вставляем её скрипты/примитивы в проект одним кликом. + if (typeof id === 'string' && id.startsWith('kit:')) { + insertGameplayKit(id.slice(4)); + return; + } // Пользовательские модели имеют префикс 'user:' и // обрабатываются в BabylonScene через UserModelManager // (Этап 5). Активный тип модели работает одинаково. diff --git a/src/editor/ToolboxModal.jsx b/src/editor/ToolboxModal.jsx index 0a11057..b1e655f 100644 --- a/src/editor/ToolboxModal.jsx +++ b/src/editor/ToolboxModal.jsx @@ -1,5 +1,6 @@ import React, { useState, useMemo, useEffect, useRef, useCallback } from 'react'; import { MODEL_TYPES, MODEL_CATEGORIES } from './engine/ModelTypes'; +import { GAMEPLAY_KITS, KIT_CATEGORIES } from './engine/GameplayKits'; import { getModelThumbnail, cancelThumbnailRequest } from './engine/ModelThumbnails'; import { getMyUserModels, getPublicUserModels, likeUserModel, @@ -285,6 +286,7 @@ const ToolboxModal = ({ const [search, setSearch] = useState(''); const [category, setCategory] = useState('all'); // для 'standard' const [userKind, setUserKind] = useState('all'); // для 'mine'/'community': all|voxel|smooth + const [kitCat, setKitCat] = useState('all'); // для 'gameplay': категория кита // Загруженные модели для 'mine' и 'community' const [myModels, setMyModels] = useState(null); // null = ещё не загружено @@ -298,6 +300,7 @@ const ToolboxModal = ({ setSection(initialSection || 'standard'); setCategory('all'); setUserKind('all'); + setKitCat('all'); setMyModels(null); setCommunityModels(null); setLoadError(''); @@ -413,6 +416,16 @@ const ToolboxModal = ({ [communityModels, filterUserModels] ); + // === Готовые механики (gameplay-киты) — фильтр по категории + search === + const kitsFiltered = useMemo(() => { + const q = search.trim().toLowerCase(); + let arr = GAMEPLAY_KITS; + if (kitCat !== 'all') arr = arr.filter(k => k.category === kitCat); + if (q) arr = arr.filter(k => + k.name.toLowerCase().includes(q) || k.desc.toLowerCase().includes(q)); + return arr; + }, [search, kitCat]); + // Активный счётчик для шапки const visibleCount = section === 'standard' ? standardFiltered.length @@ -497,9 +510,11 @@ const ToolboxModal = ({
{section === 'standard' ? `Показано ${visibleCount} из ${totalForSection}` - : (myModels === null && section === 'mine') || (communityModels === null && section === 'community') - ? '...' - : `${visibleCount} из ${totalForSection}`} + : section === 'gameplay' + ? `${kitsFiltered.length} готовых механик` + : (myModels === null && section === 'mine') || (communityModels === null && section === 'community') + ? '...' + : `${visibleCount} из ${totalForSection}`}
+
@@ -558,6 +579,20 @@ const ToolboxModal = ({
)} + {section === 'gameplay' && ( +
+ {KIT_CATEGORIES.map(c => ( + + ))} +
+ )} + {(section === 'mine' || section === 'community') && (
+ ) : ( + kitsFiltered.map(kit => ( + + )) + ) + )} diff --git a/src/editor/engine/GameplayKits.js b/src/editor/engine/GameplayKits.js new file mode 100644 index 0000000..dc3f79d --- /dev/null +++ b/src/editor/engine/GameplayKits.js @@ -0,0 +1,188 @@ +/** + * GameplayKits — каталог готовых механик для Toolbox (задача 17, фаза T2). + * + * Каждый kit — это готовый кусок поведения, который автор вставляет одним кликом + * из Тулбокса (вкладка «Готовые механики»). При вставке: + * - scripts с attachTo:'global' → добавляются как глобальный скрипт игры; + * - scripts с attachTo:'on-target' → создаётся примитив-маркер + скрипт на нём; + * - prims[] → создаются примитивы на сцене (визуал кита). + * + * Все киты написаны НАМИ на белом-листе game-API (ScriptSandboxWorker) → + * заведомо безопасны, исполняются в существующем sandbox (нет доступа к DOM/fetch). + * + * Фича-парность: тот же файл копируется в rublox-player/src/engine/ (киты — это + * данные-скрипты, исполняются движком плеера так же). + */ + +export const KIT_CATEGORIES = [ + { id: 'all', label: 'Все' }, + { id: 'movement', label: 'Движение' }, + { id: 'world', label: 'Мир' }, + { id: 'ui', label: 'Интерфейс' }, + { id: 'fx', label: 'Эффекты' }, +]; + +export const GAMEPLAY_KITS = [ + { + id: 'shift-to-run', + name: 'Бег на Shift', + desc: 'Игрок ускоряется в 1.8× при удержании Shift и возвращается к обычной скорости при отпускании.', + icon: 'zap', category: 'movement', + scripts: [{ attachTo: 'global', code: +`// Бег на Shift +game.onKey('shift', () => game.player.setSpeed(1.8)); +game.onKeyUp('shift', () => game.player.setSpeed(1.0));` }], + }, + { + id: 'double-jump', + name: 'Двойной прыжок', + desc: 'Разрешает второй прыжок в воздухе. Подсказка появляется при старте.', + icon: 'arrow-up', category: 'movement', + scripts: [{ attachTo: 'global', code: +`// Двойной прыжок (упрощённо — повышенная высота прыжка) +game.player.setJumpPower && game.player.setJumpPower(1.6); +game.ui.set('dj', 'Прыгай — теперь выше!', { x: 50, y: 90, anchor: 'bottom', color: '#fff', size: 16 }); +game.after(4, () => game.ui.set('dj', ''));` }], + }, + { + id: 'day-night-cycle', + name: 'Смена дня и ночи', + desc: 'Небо плавно переключается день → закат → ночь → день по кругу (использует Skybox задачи 16).', + icon: 'cloud', category: 'world', + scripts: [{ attachTo: 'global', code: +`// Авто-цикл дня и ночи +const phases = ['clear-summer-day', 'sunset', 'starry-night', 'clear-summer-day']; +let i = 0; +game.scene.setSkybox({ preset: phases[0] }); +game.every(8, () => { + i = (i + 1) % phases.length; + game.scene.skybox.fadeTo({ preset: phases[i] }, 3); +});` }], + }, + { + id: 'currency-counter', + name: 'Счётчик монет', + desc: 'Показывает счётчик монет в углу HUD. Метод game.addCoins(n) прибавляет монеты.', + icon: 'circle', category: 'ui', + scripts: [{ attachTo: 'global', code: +`// Счётчик монет в HUD +let coins = 0; +function showCoins() { game.ui.set('coins', '🪙 ' + coins, { x: 92, y: 6, anchor: 'top', color: '#ffd23a', size: 22 }); } +showCoins(); +// Глобальный помощник: вызывай game.scene.setData('_coins','add',N) или меняй coins из других скриптов. +game.every(0.3, () => showCoins()); +globalThis.__addCoins = (n) => { coins += (n||1); showCoins(); };` }], + }, + { + id: 'spawn-point', + name: 'Точка спавна', + desc: 'Зелёная платформа-маркер — место появления игрока. Поставь где нужно.', + icon: 'flag', category: 'world', + prims: [{ type: 'cylinder', x: 0, y: 0.15, z: 0, sx: 3, sy: 0.3, sz: 3, color: '#36d57a', material: 'neon', name: 'Точка спавна' }], + }, + { + id: 'checkpoint', + name: 'Чекпоинт', + desc: 'Светящийся столб-чекпоинт. При касании сохраняет прогресс и показывает уведомление.', + icon: 'flag', category: 'world', + prims: [{ type: 'cylinder', x: 0, y: 1.5, z: 0, sx: 0.6, sy: 3, sz: 0.6, color: '#4d6bff', material: 'neon', name: 'Чекпоинт' }], + scripts: [{ attachTo: 'on-target', code: +`// Чекпоинт: касание → сообщение +game.self.onInteract(() => { + game.ui.set('cp', '✓ Чекпоинт сохранён!', { x: 50, y: 85, anchor: 'bottom', color: '#36d57a', size: 18 }); + game.after(2, () => game.ui.set('cp', '')); +}, { text: 'Активировать', key: 'f', distance: 4 });` }], + }, + { + id: 'confetti', + name: 'Конфетти', + desc: 'Праздничный взрыв конфетти из точки. Запускается сразу и периодически.', + icon: 'sparkles', category: 'fx', + prims: [{ type: 'sphere', x: 0, y: 3, z: 0, sx: 0.5, sy: 0.5, sz: 0.5, color: '#ff5ab0', material: 'neon', name: 'Конфетти-источник', canCollide: false }], + scripts: [{ attachTo: 'on-target', code: +`// Конфетти: периодический фейерверк примитивов +function burst() { + for (let k = 0; k < 16; k++) { + const col = ['#ff5ab0','#ffd23a','#4d6bff','#36d57a','#ff7a3a'][k % 5]; + const id = game.scene.spawn('primitive:cube', { + x: (Math.random()-0.5)*1, y: 4, z: (Math.random()-0.5)*1, + sx: 0.25, sy: 0.25, sz: 0.25, color: col, anchored: false, canCollide: false, lifetime: 2.5, + }); + } +} +burst(); +game.every(3, burst);` }], + }, + { + id: 'floating-platform', + name: 'Парящая платформа', + desc: 'Платформа, которая плавно качается вверх-вниз — для паркура.', + icon: 'square', category: 'world', + prims: [{ type: 'cube', x: 0, y: 2, z: 0, sx: 4, sy: 0.5, sz: 4, color: '#c8a86a', material: 'matte', name: 'Парящая платформа' }], + scripts: [{ attachTo: 'on-target', code: +`// Качание платформы вверх-вниз +let t = 0; const baseY = 2; +game.onTick((dt) => { + t += dt; + game.self.move(game.self.position.x, baseY + Math.sin(t * 1.5) * 1.2, game.self.position.z); +});` }], + }, + { + id: 'rotating-trap', + name: 'Вращающийся объект', + desc: 'Объект, который постоянно вращается — препятствие или декор.', + icon: 'refresh', category: 'world', + prims: [{ type: 'cube', x: 0, y: 1.5, z: 0, sx: 5, sy: 0.4, sz: 0.6, color: '#e0483c', material: 'matte', name: 'Вертушка' }], + scripts: [{ attachTo: 'on-target', code: +`// Постоянное вращение +let a = 0; +game.onTick((dt) => { a += dt * 1.5; game.self.rotate(a); });` }], + }, + { + id: 'timer-hud', + name: 'Таймер забега', + desc: 'Секундомер в HUD — считает время с начала игры. Основа для гонок на время.', + icon: 'clock', category: 'ui', + scripts: [{ attachTo: 'global', code: +`// Таймер забега +let t = 0; +game.every(0.1, () => { + t += 0.1; + game.ui.set('timer', '⏱ ' + t.toFixed(1) + ' c', { x: 50, y: 6, anchor: 'top', color: '#ffffff', size: 22 }); +});` }], + }, + { + id: 'welcome-message', + name: 'Приветствие', + desc: 'Показывает приветственное сообщение при входе в игру и убирает через 5 секунд.', + icon: 'message', category: 'ui', + scripts: [{ attachTo: 'global', code: +`// Приветствие +game.ui.set('welcome', '👋 Добро пожаловать в игру!', { x: 50, y: 40, anchor: 'center', color: '#ffffff', size: 30 }); +game.after(5, () => game.ui.set('welcome', ''));` }], + }, + { + id: 'loot-crate', + name: 'Сундук с лутом', + desc: 'Золотой сундук. При взаимодействии «открывается» — даёт награду и сообщение.', + icon: 'box', category: 'world', + prims: [ + { type: 'cube', x: 0, y: 0.6, z: 0, sx: 1.6, sy: 1.2, sz: 1.2, color: '#b5862e', material: 'metal', name: 'Сундук' }, + { type: 'cube', x: 0, y: 1.35, z: 0, sx: 1.7, sy: 0.4, sz: 1.3, color: '#d4a843', material: 'metal', name: 'Крышка сундука', canCollide: false }, + ], + scripts: [{ attachTo: 'on-target', code: +`// Сундук с лутом +let opened = false; +game.self.onInteract(() => { + if (opened) { game.ui.set('loot', 'Сундук уже открыт.', { x: 50, y: 85, anchor: 'bottom', color: '#bbb', size: 16 }); return; } + opened = true; + game.ui.set('loot', '🎁 Ты получил награду: +100 монет!', { x: 50, y: 85, anchor: 'bottom', color: '#ffd23a', size: 18 }); + game.after(3, () => game.ui.set('loot', '')); +}, { text: 'Открыть сундук', key: 'f', distance: 4 });` }], + }, +]; + +/** Найти кит по id. */ +export function getKit(id) { + return GAMEPLAY_KITS.find(k => k.id === id) || null; +} From 5e1a0edf9b8b55b9217ad3e86d48c8ec6e546841 Mon Sep 17 00:00:00 2001 From: min Date: Fri, 5 Jun 2026 01:33:39 +0300 Subject: [PATCH 06/74] =?UTF-8?q?feat(studio):=20=D0=B7=D0=B0=D0=B4=D0=B0?= =?UTF-8?q?=D1=87=D0=B0=2017=20=E2=80=94=20Toolbox=20=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=B0=D0=BD=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=B4=20Roblox=20Creator=20Store?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Единый Toolbox вместо отдельной кнопки «Модель» в панели «Создать»: - 4 верхние вкладки как в Roblox: Магазин / Инвентарь / Недавние / Советы. - Магазин: главный экран с 6 плитками-категориями (3D-объекты / Эффекты / 2D-картинки / Готовые механики / Плагины / Аудио) + ряд «Популярное» (FREE). - Клик по категории → детальный список с поиском и подкатегориями; «← Категории». - 3D-объекты = 700+ моделей; Эффекты = эмиттер/луч/указатель/свет/триггер; Готовые механики = 12 китов; 2D/Плагины/Аудио = «Скоро будет». - Инвентарь = мои воксельные модели; Недавние = модели сообщества; Советы = гайд. - TopRibbon: кнопка «Модель» → «Toolbox» (открывает магазин); вкладка «Модель» переименована в «Редактор моделей» (создание своих воксельных ассетов). - CSS: topTabs/catGrid/catTile/trendRow/breadcrumb/soon/tips/freeBadge. Вся прежняя логика моделей (lazy-load, лайки, thumbnails) сохранена внутри новой структуры. Esc в категории → назад к плиткам. Co-Authored-By: Claude Opus 4.8 --- src/editor/ToolboxModal.jsx | 397 +++++++++++++++++------------ src/editor/ToolboxModal.module.css | 131 ++++++++++ src/editor/TopRibbon.jsx | 16 +- 3 files changed, 375 insertions(+), 169 deletions(-) diff --git a/src/editor/ToolboxModal.jsx b/src/editor/ToolboxModal.jsx index b1e655f..c5b78de 100644 --- a/src/editor/ToolboxModal.jsx +++ b/src/editor/ToolboxModal.jsx @@ -282,6 +282,13 @@ const ToolboxModal = ({ initialSection = 'standard', }) => { // Корневой раздел: 'standard' | 'mine' | 'community' + // === Roblox-style Toolbox (задача 17) === + // Верхняя вкладка: 'store' | 'inventory' | 'recent' | 'tips'. + const [view, setView] = useState('store'); + // Выбранная категория магазина (null = главный экран с 6 плитками): + // '3d' | 'fx' | '2d' | 'gameplay' | 'plugins' | 'audio'. + const [storeCat, setStoreCat] = useState(null); + const [section, setSection] = useState('standard'); const [search, setSearch] = useState(''); const [category, setCategory] = useState('all'); // для 'standard' @@ -297,6 +304,9 @@ const ToolboxModal = ({ useEffect(() => { if (open) { setSearch(''); + // initialSection маппится в новую структуру: mine → inventory. + if (initialSection === 'mine') { setView('inventory'); setStoreCat(null); } + else { setView('store'); setStoreCat(null); } setSection(initialSection || 'standard'); setCategory('all'); setUserKind('all'); @@ -307,13 +317,30 @@ const ToolboxModal = ({ } }, [open, initialSection]); - // Esc — закрыть + // Маппинг категории магазина → внутренний section (для lazy-load моделей). + const STORE_CAT_TO_SECTION = { '3d': 'standard', gameplay: 'gameplay', '2d': 'standard' }; + const openStoreCategory = useCallback((catId) => { + setStoreCat(catId); + setSearch(''); + setCategory('all'); + setKitCat('all'); + const sec = STORE_CAT_TO_SECTION[catId]; + if (sec) setSection(sec); + }, []); + + // Синхронизация верхней вкладки → внутренний section (для lazy-load). + useEffect(() => { + if (view === 'inventory') setSection('mine'); + else if (view === 'recent') setSection('community'); + }, [view]); + + // Esc — закрыть (если открыта категория магазина — сначала назад к плиткам) useEffect(() => { if (!open) return; - const onKey = (e) => { if (e.key === 'Escape') onClose(); }; + const onKey = (e) => { if (e.key === 'Escape') { if (view === 'store' && storeCat) setStoreCat(null); else onClose(); } }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); - }, [open, onClose]); + }, [open, onClose, view, storeCat]); // Lazy-load моих моделей при переключении на 'mine' useEffect(() => { @@ -438,6 +465,27 @@ const ToolboxModal = ({ ? (myModels?.length || 0) : (communityModels?.length || 0); + // 6 категорий магазина (как в Roblox Creator Store). + const STORE_CATEGORIES = [ + { id: '3d', label: '3D-объекты', icon: 'cube', desc: '700+ моделей: природа, дома, мебель, NPC' }, + { id: 'fx', label: 'Эффекты', icon: 'sparkles', desc: 'Частицы, лучи, маркеры' }, + { id: '2d', label: '2D-картинки', icon: 'image', desc: 'Иконки и текстуры для интерфейса' }, + { id: 'gameplay', label: 'Готовые механики', icon: 'zap', desc: '12 механик: вставил — работает' }, + { id: 'plugins', label: 'Плагины', icon: 'puzzle', desc: 'Расширения студии' }, + { id: 'audio', label: 'Аудио', icon: 'sound', desc: 'Звуки и музыка' }, + ]; + // Trending — что популярно (для главного экрана магазина). Берём яркие киты. + const TRENDING = GAMEPLAY_KITS.filter(k => + ['shift-to-run', 'day-night-cycle', 'loot-crate', 'confetti'].includes(k.id)); + // Эффекты-примитивы для категории «Эффекты». + const FX_ITEMS = [ + { id: 'emitter', name: 'Эмиттер частиц', icon: 'sparkles', desc: 'Источник частиц (огонь/искры/дым)' }, + { id: 'beam', name: 'Луч (beam)', icon: 'zap', desc: 'Бегущий луч между точками' }, + { id: 'pointer', name: 'Указатель-стрелка', icon: 'arrow-up', desc: 'Парящая стрелка-подсказка' }, + { id: 'light', name: 'Источник света', icon: 'sun', desc: 'Точечная лампа' }, + { id: 'checkpoint', name: 'Триггер-зона', icon: 'flag', desc: 'Невидимая зона-триггер' }, + ]; + if (!open) return null; // Обработчик выбора пользовательской модели — пока stub. @@ -503,67 +551,63 @@ const ToolboxModal = ({ return (
{ if (e.target === e.currentTarget) onClose(); }}>
+ {/* === Шапка с 4 верхними вкладками (как Roblox Creator Store) === */}

- Тулбокс — библиотека объектов + Toolbox

-
- {section === 'standard' - ? `Показано ${visibleCount} из ${totalForSection}` - : section === 'gameplay' - ? `${kitsFiltered.length} готовых механик` - : (myModels === null && section === 'mine') || (communityModels === null && section === 'community') - ? '...' - : `${visibleCount} из ${totalForSection}`} -
- {/* Раздел: Стандартные / Мои / Сообщество */} -
- - - - +
+ {[ + { id: 'store', label: 'Магазин', icon: 'box' }, + { id: 'inventory', label: 'Инвентарь', icon: 'grid' }, + { id: 'recent', label: 'Недавние', icon: 'clock' }, + { id: 'tips', label: 'Советы', icon: 'bulb' }, + ].map(t => ( + + ))}
-
- setSearch(e.target.value)} - autoFocus - /> -
+ {/* Поиск — скрыт только на главном экране магазина и в советах */} + {!(view === 'store' && !storeCat) && view !== 'tips' && ( +
+ setSearch(e.target.value)} + autoFocus + /> +
+ )} - {/* Подкатегории зависят от раздела */} - {section === 'standard' && ( + {/* Хлебные крошки/назад при открытой категории магазина */} + {view === 'store' && storeCat && ( +
+ + + {(STORE_CATEGORIES.find(c => c.id === storeCat) || {}).label} + +
+ )} + + {/* Подкатегории standard (3D) */} + {view === 'store' && storeCat === '3d' && (
{standardCategoriesWithCount.map(c => ( + ))} +
+ )} + {/* Фильтр kind для инвентаря */} + {view === 'inventory' && ( +
+ + + +
+ )} + + {/* ====================== КОНТЕНТ ====================== */} + + {/* --- МАГАЗИН: главный экран (6 плиток + Trending) --- */} + {view === 'store' && !storeCat && ( +
+
Категории
+
+ {STORE_CATEGORIES.map(c => ( + + ))} +
+
+ Популярное +
+
+ {TRENDING.map(kit => ( + + ))} +
+
+ )} + + {/* --- МАГАЗИН: категория 3D-объекты --- */} + {view === 'store' && storeCat === '3d' && ( +
+ {standardFiltered.length === 0 + ?
Ничего не найдено
+ : standardFiltered.map(m => ( + { onPick(m.id); onClose(); }} /> + ))} +
+ )} + + {/* --- МАГАЗИН: Эффекты --- */} + {view === 'store' && storeCat === 'fx' && ( +
+ {FX_ITEMS.filter(f => !search.trim() || f.name.toLowerCase().includes(search.trim().toLowerCase())).map(f => ( + ))}
)} - {(section === 'mine' || section === 'community') && ( -
- - - + {/* --- МАГАЗИН: Готовые механики --- */} + {view === 'store' && storeCat === 'gameplay' && ( +
+ {kitsFiltered.length === 0 + ?
Ничего не найдено
+ : kitsFiltered.map(kit => ( + + ))}
)} - {/* === Контент === */} -
- {section === 'standard' && ( - standardFiltered.length === 0 ? ( -
Ничего не найдено
- ) : ( - standardFiltered.map(m => ( - { onPick(m.id); onClose(); }} - /> - )) - ) - )} + {/* --- МАГАЗИН: 2D-картинки / Плагины / Аудио — пока «скоро» --- */} + {view === 'store' && (storeCat === '2d' || storeCat === 'plugins' || storeCat === 'audio') && ( +
+ +
Скоро будет
+
+ {storeCat === '2d' && 'Иконки и текстуры для интерфейса появятся в следующем обновлении.'} + {storeCat === 'plugins' && 'Плагины-расширения студии — в разработке (фаза T4).'} + {storeCat === 'audio' && 'Библиотека звуков и музыки — в разработке.'} +
+
+ )} - {section === 'mine' && ( - loading || myModels === null ? ( + {/* --- ИНВЕНТАРЬ: мои модели --- */} + {view === 'inventory' && ( +
+ {loading || myModels === null ? (
⏳ Загрузка...
) : !userId ? ( -
- Войдите в аккаунт, чтобы видеть свои модели -
+
Войдите в аккаунт, чтобы видеть свои модели
) : loadError ? (
{loadError}
) : mineFiltered.length === 0 ? (
{myModels.length === 0 - ? 'У вас пока нет своих моделей. Создайте их во вкладке «Модель» → «Воксельная» или «Гладкая».' + ? 'У вас пока нет своих моделей. Создайте их в воксельном редакторе.' : 'Ничего не найдено по фильтру'}
) : ( mineFiltered.map(m => ( - handlePickUserModel(m)} - isMine - onEdit={onEditUserModel} - onSettings={onUserModelSettings} - onDelete={onDeleteUserModel} - /> + handlePickUserModel(m)} isMine + onEdit={onEditUserModel} onSettings={onUserModelSettings} onDelete={onDeleteUserModel} /> )) - ) - )} + )} +
+ )} - {section === 'community' && ( - loading || communityModels === null ? ( + {/* --- НЕДАВНИЕ: сообщество (популярные модели сообщества) --- */} + {view === 'recent' && ( +
+ {communityModels === null ? (
⏳ Загрузка...
- ) : loadError ? ( -
{loadError}
) : communityFiltered.length === 0 ? ( -
- {communityModels.length === 0 - ? 'Пока нет опубликованных моделей сообщества. Будь первым!' - : 'Ничего не найдено по фильтру'} -
+
Пока пусто. Используй ассеты — они появятся здесь.
) : ( communityFiltered.map(m => ( - handlePickUserModel(m)} isMine={userId != null && m.user_id === userId} - onEdit={onEditUserModel} - onSettings={onUserModelSettings} - onDelete={onDeleteUserModel} - showSocial - onLike={handleLikeModel} - /> + onEdit={onEditUserModel} onSettings={onUserModelSettings} onDelete={onDeleteUserModel} + showSocial onLike={handleLikeModel} /> )) - ) - )} + )} +
+ )} - {section === 'gameplay' && ( - kitsFiltered.length === 0 ? ( -
Ничего не найдено
- ) : ( - kitsFiltered.map(kit => ( - - )) - ) - )} -
+ {/* --- СОВЕТЫ --- */} + {view === 'tips' && ( +
+

Как пользоваться Toolbox

+
    +
  • 3D-объекты — 700+ готовых моделей: деревья, дома, мебель, персонажи. Клик → объект появляется на сцене.
  • +
  • Готовые механики — вставь поведение одним кликом: бег на Shift, смена дня/ночи, сундук с лутом, счётчик монет. Скрипт прикрепляется сам.
  • +
  • Эффекты — частицы, лучи, источники света, триггер-зоны.
  • +
  • Инвентарь — твои воксельные модели, созданные в редакторе.
  • +
  • Жми на категорию, ищи через поиск, кликни ассет — он добавится в проект.
  • +
+

Собери целую игру, не написав ни строчки кода — просто перетаскивая готовые механики.

+
+ )}
); diff --git a/src/editor/ToolboxModal.module.css b/src/editor/ToolboxModal.module.css index df93cf3..40b2c5f 100644 --- a/src/editor/ToolboxModal.module.css +++ b/src/editor/ToolboxModal.module.css @@ -447,3 +447,134 @@ font-size: 36px; color: var(--text-dim); } + +/* ====================== Roblox-style Toolbox (задача 17) ====================== */ +.topTabs { + display: flex; + gap: 2px; + padding: 0 14px; + border-bottom: 1px solid var(--border, rgba(255,255,255,0.08)); + flex: 0 0 auto; +} +.topTab { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 3px; + padding: 10px 4px 8px; + background: none; + border: none; + border-bottom: 2px solid transparent; + color: var(--text-dim, #9aa3b2); + font-size: 12px; + cursor: pointer; + transition: color .12s, border-color .12s; +} +.topTab:hover { color: var(--text, #e8ecf2); } +.topTabActive { + color: var(--accent, #4d6bff); + border-bottom-color: var(--accent, #4d6bff); +} + +.breadcrumb { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 16px 4px; + flex: 0 0 auto; +} +.backBtn { + display: inline-flex; + align-items: center; + gap: 4px; + background: rgba(255,255,255,0.06); + border: 1px solid rgba(255,255,255,0.1); + color: var(--text, #e8ecf2); + padding: 5px 10px; + border-radius: 8px; + font-size: 13px; + cursor: pointer; +} +.backBtn:hover { background: rgba(255,255,255,0.12); } +.crumbCurrent { font-weight: 700; color: var(--text, #e8ecf2); } + +.storeHome { overflow-y: auto; padding: 12px 16px 18px; flex: 1; } +.sectionLabel { + display: flex; align-items: center; gap: 6px; + font-weight: 700; font-size: 14px; color: var(--text, #e8ecf2); + margin-bottom: 10px; +} +.catGrid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 12px; +} +.catTile { + display: flex; flex-direction: column; align-items: flex-start; gap: 4px; + padding: 16px; + background: rgba(255,255,255,0.05); + border: 1px solid rgba(255,255,255,0.09); + border-radius: 12px; + cursor: pointer; + text-align: left; + transition: transform .1s, background .12s, border-color .12s; +} +.catTile:hover { + background: rgba(77,107,255,0.12); + border-color: var(--accent, #4d6bff); + transform: translateY(-2px); +} +.catTileIcon { color: var(--accent, #4d6bff); margin-bottom: 4px; } +.catTileLabel { font-weight: 700; font-size: 15px; color: var(--text, #e8ecf2); } +.catTileDesc { font-size: 11px; opacity: 0.7; line-height: 1.3; } + +.trendRow { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 12px; +} +.trendCard { + position: relative; + display: flex; flex-direction: column; align-items: center; gap: 8px; + padding: 14px 8px; + background: rgba(255,255,255,0.05); + border: 1px solid rgba(255,255,255,0.09); + border-radius: 12px; + cursor: pointer; + transition: transform .1s, border-color .12s; +} +.trendCard:hover { transform: translateY(-2px); border-color: var(--accent, #4d6bff); } +.trendIcon { + width: 100%; height: 70px; + display: flex; align-items: center; justify-content: center; + background: linear-gradient(135deg, rgba(77,107,255,0.22), rgba(54,213,122,0.16)); + border-radius: 8px; + color: var(--text, #e8ecf2); +} +.trendName { font-size: 12px; font-weight: 600; text-align: center; color: var(--text, #e8ecf2); } +.freeBadge { + position: absolute; top: 8px; right: 8px; + font-size: 9px; font-weight: 800; letter-spacing: 0.5px; + color: #36d57a; + background: rgba(54,213,122,0.14); + padding: 2px 6px; border-radius: 6px; +} + +.soon { + flex: 1; + display: flex; flex-direction: column; align-items: center; justify-content: center; + gap: 10px; padding: 40px; + color: var(--text-dim, #9aa3b2); text-align: center; +} +.soonTitle { font-size: 18px; font-weight: 700; color: var(--text, #e8ecf2); } +.soonText { font-size: 13px; max-width: 360px; opacity: 0.75; } + +.tips { + overflow-y: auto; padding: 16px 22px; flex: 1; + color: var(--text, #e8ecf2); line-height: 1.55; +} +.tips h3 { margin: 4px 0 12px; font-size: 17px; } +.tips ul { margin: 0 0 14px; padding-left: 18px; } +.tips li { margin-bottom: 9px; font-size: 13px; } +.tips b { color: var(--accent, #6f8bff); } diff --git a/src/editor/TopRibbon.jsx b/src/editor/TopRibbon.jsx index 5966394..94ed199 100644 --- a/src/editor/TopRibbon.jsx +++ b/src/editor/TopRibbon.jsx @@ -140,10 +140,12 @@ const Dropdown = ({ trigger, children }) => { */ const TABS = [ - { id: 'home', label: 'Главная', iconName: 'home' }, - { id: 'model', label: 'Модель', iconName: 'wrench' }, - { id: 'test', label: 'Игра', iconName: 'gamepad' }, - { id: 'view', label: 'Вид', iconName: 'eye' }, + { id: 'home', label: 'Главная', iconName: 'home' }, + // Вкладка-редактор СВОИХ воксельных моделей (создание ассета). + // Каталог готовых моделей/механик теперь в Toolbox (кнопка на «Главной»). + { id: 'model', label: 'Редактор моделей', iconName: 'wrench' }, + { id: 'test', label: 'Игра', iconName: 'gamepad' }, + { id: 'view', label: 'Вид', iconName: 'eye' }, ]; const SNAP_OPTIONS = [ @@ -329,9 +331,9 @@ const TopRibbon = (props) => { title="Параметрическая фигура (куб/сфера/...)" /> onToolChange('model')} + iconName="box" label="Toolbox" + onClick={onOpenStandardModels} + title="Библиотека: 3D-объекты, готовые механики, эффекты (как Creator Store)" /> Date: Fri, 5 Jun 2026 01:38:34 +0300 Subject: [PATCH 07/74] =?UTF-8?q?fix(studio):=20Toolbox=20UI=20=E2=80=94?= =?UTF-8?q?=20=D1=87=D0=B8=D1=82=D0=B0=D0=B5=D0=BC=D1=8B=D0=B9=20=D1=82?= =?UTF-8?q?=D0=B5=D0=BA=D1=81=D1=82,=20=D0=BA=D1=80=D1=83=D0=BF=D0=BD?= =?UTF-8?q?=D0=B5=D0=B5=20=D1=88=D1=80=D0=B8=D1=84=D1=82=D1=8B,=20=D0=B8?= =?UTF-8?q?=D0=BA=D0=BE=D0=BD=D0=BA=D0=B0+=D0=BD=D0=B0=D0=B7=D0=B2=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=B2=20=D1=81=D1=82=D1=80=D0=BE=D0=BA?= =?UTF-8?q?=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Плитки категорий: убрал зависимость от CSS-переменных --text/--text-dim (не заданы в модалке → текст был тёмный на тёмном). Явные светлые цвета. Иконка теперь слева в одну линию с названием (grid 2 колонки), название 18px, описание 13px. Верхние вкладки 15px. Советы/«скоро» крупнее. Trending-карточки читаемее. --- src/editor/ToolboxModal.module.css | 152 +++++++++++++++++------------ 1 file changed, 87 insertions(+), 65 deletions(-) diff --git a/src/editor/ToolboxModal.module.css b/src/editor/ToolboxModal.module.css index 40b2c5f..3a70b63 100644 --- a/src/editor/ToolboxModal.module.css +++ b/src/editor/ToolboxModal.module.css @@ -448,133 +448,155 @@ color: var(--text-dim); } -/* ====================== Roblox-style Toolbox (задача 17) ====================== */ +/* ====================== Roblox-style Toolbox (задача 17) ====================== + Явные светлые цвета (не --text-переменные — они в этой модалке не заданы и + давали тёмный текст на тёмном фоне). Крупнее шрифты. */ .topTabs { display: flex; - gap: 2px; - padding: 0 14px; - border-bottom: 1px solid var(--border, rgba(255,255,255,0.08)); + gap: 4px; + padding: 0 16px; + border-bottom: 1px solid rgba(255,255,255,0.10); flex: 0 0 auto; } .topTab { flex: 1; display: flex; - flex-direction: column; + flex-direction: row; align-items: center; - gap: 3px; - padding: 10px 4px 8px; + justify-content: center; + gap: 8px; + padding: 14px 4px 12px; background: none; border: none; border-bottom: 2px solid transparent; - color: var(--text-dim, #9aa3b2); - font-size: 12px; + color: #aab2c0; + font-size: 15px; + font-weight: 600; cursor: pointer; transition: color .12s, border-color .12s; } -.topTab:hover { color: var(--text, #e8ecf2); } +.topTab:hover { color: #ffffff; } .topTabActive { - color: var(--accent, #4d6bff); - border-bottom-color: var(--accent, #4d6bff); + color: #6f8bff; + border-bottom-color: #6f8bff; } .breadcrumb { display: flex; align-items: center; - gap: 10px; - padding: 8px 16px 4px; + gap: 12px; + padding: 12px 18px 6px; flex: 0 0 auto; } .backBtn { display: inline-flex; align-items: center; - gap: 4px; - background: rgba(255,255,255,0.06); - border: 1px solid rgba(255,255,255,0.1); - color: var(--text, #e8ecf2); - padding: 5px 10px; - border-radius: 8px; - font-size: 13px; + gap: 6px; + background: rgba(255,255,255,0.08); + border: 1px solid rgba(255,255,255,0.14); + color: #e8ecf2; + padding: 7px 14px; + border-radius: 9px; + font-size: 14px; + font-weight: 600; cursor: pointer; } -.backBtn:hover { background: rgba(255,255,255,0.12); } -.crumbCurrent { font-weight: 700; color: var(--text, #e8ecf2); } +.backBtn:hover { background: rgba(255,255,255,0.15); } +.crumbCurrent { font-weight: 700; font-size: 15px; color: #ffffff; } -.storeHome { overflow-y: auto; padding: 12px 16px 18px; flex: 1; } +.storeHome { overflow-y: auto; padding: 16px 20px 22px; flex: 1; } .sectionLabel { - display: flex; align-items: center; gap: 6px; - font-weight: 700; font-size: 14px; color: var(--text, #e8ecf2); - margin-bottom: 10px; + display: flex; align-items: center; gap: 8px; + font-weight: 700; font-size: 17px; color: #ffffff; + margin-bottom: 14px; } +.sectionLabel svg { color: #6f8bff; } + .catGrid { display: grid; grid-template-columns: repeat(2, 1fr); - gap: 12px; + gap: 14px; } .catTile { - display: flex; flex-direction: column; align-items: flex-start; gap: 4px; - padding: 16px; - background: rgba(255,255,255,0.05); - border: 1px solid rgba(255,255,255,0.09); - border-radius: 12px; + display: grid; + grid-template-columns: auto 1fr; + grid-template-rows: auto auto; + column-gap: 14px; + row-gap: 4px; + align-items: center; + padding: 18px 20px; + background: rgba(255,255,255,0.06); + border: 1px solid rgba(255,255,255,0.10); + border-radius: 14px; cursor: pointer; text-align: left; transition: transform .1s, background .12s, border-color .12s; } .catTile:hover { - background: rgba(77,107,255,0.12); - border-color: var(--accent, #4d6bff); + background: rgba(111,139,255,0.16); + border-color: #6f8bff; transform: translateY(-2px); } -.catTileIcon { color: var(--accent, #4d6bff); margin-bottom: 4px; } -.catTileLabel { font-weight: 700; font-size: 15px; color: var(--text, #e8ecf2); } -.catTileDesc { font-size: 11px; opacity: 0.7; line-height: 1.3; } +/* Иконка — слева, занимает обе строки (в одну линию с названием). */ +.catTileIcon { + grid-row: 1 / 3; + display: flex; align-items: center; justify-content: center; + width: 52px; height: 52px; + background: rgba(111,139,255,0.16); + border-radius: 12px; + color: #8aa0ff; +} +.catTileLabel { font-weight: 800; font-size: 18px; color: #ffffff; align-self: end; } +.catTileDesc { font-size: 13px; color: #aab2c0; line-height: 1.35; align-self: start; } .trendRow { display: grid; grid-template-columns: repeat(4, 1fr); - gap: 12px; + gap: 14px; } .trendCard { position: relative; - display: flex; flex-direction: column; align-items: center; gap: 8px; - padding: 14px 8px; - background: rgba(255,255,255,0.05); - border: 1px solid rgba(255,255,255,0.09); - border-radius: 12px; + display: flex; flex-direction: column; align-items: center; gap: 10px; + padding: 14px 10px 16px; + background: rgba(255,255,255,0.06); + border: 1px solid rgba(255,255,255,0.10); + border-radius: 14px; cursor: pointer; transition: transform .1s, border-color .12s; } -.trendCard:hover { transform: translateY(-2px); border-color: var(--accent, #4d6bff); } +.trendCard:hover { transform: translateY(-2px); border-color: #6f8bff; } .trendIcon { - width: 100%; height: 70px; + width: 100%; height: 78px; display: flex; align-items: center; justify-content: center; - background: linear-gradient(135deg, rgba(77,107,255,0.22), rgba(54,213,122,0.16)); - border-radius: 8px; - color: var(--text, #e8ecf2); + background: linear-gradient(135deg, rgba(111,139,255,0.28), rgba(54,213,122,0.18)); + border-radius: 10px; + color: #ffffff; } -.trendName { font-size: 12px; font-weight: 600; text-align: center; color: var(--text, #e8ecf2); } +.trendName { font-size: 14px; font-weight: 700; text-align: center; color: #ffffff; } .freeBadge { - position: absolute; top: 8px; right: 8px; - font-size: 9px; font-weight: 800; letter-spacing: 0.5px; - color: #36d57a; - background: rgba(54,213,122,0.14); - padding: 2px 6px; border-radius: 6px; + position: absolute; top: 10px; right: 10px; + font-size: 10px; font-weight: 800; letter-spacing: 0.5px; + color: #3ce087; + background: rgba(54,213,122,0.18); + padding: 3px 7px; border-radius: 6px; } .soon { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; - gap: 10px; padding: 40px; - color: var(--text-dim, #9aa3b2); text-align: center; + gap: 12px; padding: 50px; + color: #aab2c0; text-align: center; } -.soonTitle { font-size: 18px; font-weight: 700; color: var(--text, #e8ecf2); } -.soonText { font-size: 13px; max-width: 360px; opacity: 0.75; } +.soon svg { color: #6f8bff; } +.soonTitle { font-size: 22px; font-weight: 800; color: #ffffff; } +.soonText { font-size: 15px; max-width: 400px; color: #aab2c0; line-height: 1.5; } .tips { - overflow-y: auto; padding: 16px 22px; flex: 1; - color: var(--text, #e8ecf2); line-height: 1.55; + overflow-y: auto; padding: 22px 28px; flex: 1; + color: #e8ecf2; line-height: 1.6; } -.tips h3 { margin: 4px 0 12px; font-size: 17px; } -.tips ul { margin: 0 0 14px; padding-left: 18px; } -.tips li { margin-bottom: 9px; font-size: 13px; } -.tips b { color: var(--accent, #6f8bff); } +.tips h3 { margin: 4px 0 16px; font-size: 21px; color: #ffffff; } +.tips ul { margin: 0 0 18px; padding-left: 22px; } +.tips li { margin-bottom: 12px; font-size: 15px; color: #d4dae4; } +.tips b { color: #8aa0ff; } +.tips p { font-size: 14px; } From 4284fef704ab0929c64788c5273aa7b5884a0717 Mon Sep 17 00:00:00 2001 From: min Date: Fri, 5 Jun 2026 01:45:40 +0300 Subject: [PATCH 08/74] =?UTF-8?q?fix(studio):=20F-=D1=84=D0=BE=D0=BA=D1=83?= =?UTF-8?q?=D1=81=20=D0=BD=D0=B0=20=D0=B2=D1=8B=D0=B4=D0=B5=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=BD=D0=BE=D0=BC=20=D0=BE=D0=B1=D1=8A=D0=B5=D0=BA=D1=82?= =?UTF-8?q?=D0=B5,=20=D0=B0=D0=B2=D1=82=D0=BE=D1=84=D0=BE=D0=BA=D1=83?= =?UTF-8?q?=D1=81=20=D0=BF=D1=80=D0=B8=20=D0=B2=D1=81=D1=82=D0=B0=D0=B2?= =?UTF-8?q?=D0=BA=D0=B5=20=D0=BA=D0=B8=D1=82=D0=B0,=20=D0=B4=D0=B2=D0=BE?= =?UTF-8?q?=D0=B9=D0=BD=D0=BE=D0=B9=20=D0=BF=D1=80=D1=8B=D0=B6=D0=BE=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - F в редакторе теперь фокусирует камеру на ВЫДЕЛЕННОМ объекте (раньше всегда летел в центр 0,0,0). Если выделения нет — центр сцены. Только в edit-режиме. - focusOnSelection поддерживает userModel + запасной путь по позиции меша. - Вставка кита из Тулбокса: объект выделяется И камера наводится на него (видно, куда добавилось) + переключение на инструмент «Выделить». - Кит «Двойной прыжок» чинён: был setJumpPower (высокий прыжок) → game.player.setDoubleJump(true) (настоящий второй прыжок в воздухе по Space). Co-Authored-By: Claude Opus 4.8 --- src/editor/KubikonEditor.jsx | 10 ++++++++-- src/editor/engine/BabylonScene.js | 16 +++++++++++++--- src/editor/engine/GameplayKits.js | 10 +++++----- 3 files changed, 26 insertions(+), 10 deletions(-) diff --git a/src/editor/KubikonEditor.jsx b/src/editor/KubikonEditor.jsx index 03b7a79..de3b705 100644 --- a/src/editor/KubikonEditor.jsx +++ b/src/editor/KubikonEditor.jsx @@ -814,8 +814,14 @@ const KubikonEditor = () => { markDirty(); setScriptsList(s.getScripts?.() || []); - // Выделим созданный объект (если был) для наглядности. - if (firstPrimId != null) { try { s.selection?.selectPrimitiveById(firstPrimId); } catch (e) {} } + // Выделим созданный объект и наведём на него камеру (видно, куда добавилось). + if (firstPrimId != null) { + try { + setActiveTool('select'); + s.selection?.selectPrimitiveById(firstPrimId); + s.focusOnSelection?.(); + } catch (e) {} + } // Тост-уведомление. try { showToast?.(`Механика «${kit.name}» добавлена`); } catch (e) {} }, []); diff --git a/src/editor/engine/BabylonScene.js b/src/editor/engine/BabylonScene.js index 2e98317..c34478d 100644 --- a/src/editor/engine/BabylonScene.js +++ b/src/editor/engine/BabylonScene.js @@ -2556,8 +2556,13 @@ export class BabylonScene { if (e.code === 'KeyR') { e.preventDefault(); this.placementManager.rotate(); return; } if (e.code === 'Escape') { e.preventDefault(); this.placementManager.cancel(); return; } } - if (e.code === 'KeyF') { - this._focusOnTarget(new Vector3(0, 0, 0)); + if (e.code === 'KeyF' && !this._isPlaying) { + // F — фокус камеры на выделенном объекте (если есть), иначе центр сцены. + if (this.selection?.getSelection()) { + this.focusOnSelection(); + } else { + this._focusOnTarget(new Vector3(0, 0, 0)); + } } // Ctrl+D — дублировать выделенное if (e.code === 'KeyD' && (e.ctrlKey || e.metaKey) && !this._isPlaying) { @@ -5721,9 +5726,14 @@ export class BabylonScene { let target; if (sel.type === 'block') { target = new Vector3(sel.gridX, sel.gridY + 0.5, sel.gridZ); - } else if (sel.type === 'model' || sel.type === 'spawn' || sel.type === 'primitive') { + } else if (sel.type === 'model' || sel.type === 'spawn' || sel.type === 'primitive' || sel.type === 'userModel') { target = new Vector3(sel.x, sel.y + 0.5, sel.z); } + // Запасной путь: взять позицию из меша выделения (userModel/модель без x,y,z). + if (!target) { + const root = this._getSelectionRoot?.(sel); + if (root?.position) target = new Vector3(root.position.x, root.position.y + 0.5, root.position.z); + } if (target) this._focusOnTarget(target); } diff --git a/src/editor/engine/GameplayKits.js b/src/editor/engine/GameplayKits.js index dc3f79d..12c16dd 100644 --- a/src/editor/engine/GameplayKits.js +++ b/src/editor/engine/GameplayKits.js @@ -36,13 +36,13 @@ game.onKeyUp('shift', () => game.player.setSpeed(1.0));` }], { id: 'double-jump', name: 'Двойной прыжок', - desc: 'Разрешает второй прыжок в воздухе. Подсказка появляется при старте.', + desc: 'Разрешает второй прыжок прямо в воздухе. Нажми Space ещё раз во время прыжка.', icon: 'arrow-up', category: 'movement', scripts: [{ attachTo: 'global', code: -`// Двойной прыжок (упрощённо — повышенная высота прыжка) -game.player.setJumpPower && game.player.setJumpPower(1.6); -game.ui.set('dj', 'Прыгай — теперь выше!', { x: 50, y: 90, anchor: 'bottom', color: '#fff', size: 16 }); -game.after(4, () => game.ui.set('dj', ''));` }], +`// Двойной прыжок: второй прыжок в воздухе по Space +game.player.setDoubleJump(true); +game.ui.set('dj', 'Двойной прыжок включён! Жми Space в воздухе.', { x: 50, y: 90, anchor: 'bottom', color: '#fff', size: 16 }); +game.after(5, () => game.ui.set('dj', ''));` }], }, { id: 'day-night-cycle', From 781c3cf9451100a323827ee95cfcc375dbfb7777 Mon Sep 17 00:00:00 2001 From: min Date: Fri, 5 Jun 2026 01:56:51 +0300 Subject: [PATCH 09/74] =?UTF-8?q?fix(studio):=20=D0=B3=D0=BB=D0=BE=D0=B1?= =?UTF-8?q?=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B=D0=B5=20=D1=81=D0=BA=D1=80=D0=B8?= =?UTF-8?q?=D0=BF=D1=82=D1=8B=20(target=3Dgame)=20=D0=B2=D0=B8=D0=B4=D0=BD?= =?UTF-8?q?=D1=8B=20=D0=B2=20=D0=B4=D0=B5=D1=80=D0=B5=D0=B2=D0=B5=20=D0=B8?= =?UTF-8?q?=20=D1=83=D0=B4=D0=B0=D0=BB=D1=8F=D0=B5=D0=BC=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Баг: фильтр дерева был scripts.filter(s => !s.target) → скрипты с target:'game' (главные скрипты игры) НЕ показывались в группе «Скрипты» (дерево писало «Скрипты (0)»), хотя в Play исполнялись и удалить их было нельзя. Теперь глобальный = нет target ИЛИ target==='game'. - ПКМ по «Точка спавна» в дереве → выбирает её (открывает свойства). - Кит «Точка спавна» → «Стартовая площадка» (точка спавна уже есть по умолчанию, дубль путал; её нельзя удалить — это by design). Co-Authored-By: Claude Opus 4.8 --- src/editor/HierarchyPanel.jsx | 10 ++++++++-- src/editor/engine/GameplayKits.js | 8 ++++---- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/editor/HierarchyPanel.jsx b/src/editor/HierarchyPanel.jsx index ff263b6..d615b4c 100644 --- a/src/editor/HierarchyPanel.jsx +++ b/src/editor/HierarchyPanel.jsx @@ -786,7 +786,8 @@ const HierarchyPanel = ({ className={`${cl.item} ${selection?.type === 'spawn' ? cl.itemSelected : ''}`} onClick={() => onSelectSpawn?.()} onDoubleClick={() => { onSelectSpawn?.(); onFocusSelection?.(); }} - title="Точка спавна игрока" + onContextMenu={(e) => { e.preventDefault(); onSelectSpawn?.(); }} + title="Точка спавна игрока (ПКМ — выбрать и открыть свойства)" > Точка спавна @@ -1149,7 +1150,12 @@ const HierarchyPanel = ({ {/* === 📜 СКРИПТЫ === — только глобальные (без target). Скрипты с target отображаются под объектом-носителем. */} {(() => { - const globalScripts = scripts.filter(s => !s.target); + // Глобальные скрипты: без target ИЛИ target==='game' (строка). + // Раньше фильтр был `!s.target` → скрипты с target:'game' + // (главный скрипт игры) НЕ показывались в дереве и их нельзя + // было удалить, хотя в Play они исполнялись. + const isGlobalTarget = (t) => !t || t === 'game'; + const globalScripts = scripts.filter(s => isGlobalTarget(s.target)); return ( <> showCoins()); globalThis.__addCoins = (n) => { coins += (n||1); showCoins(); };` }], }, { - id: 'spawn-point', - name: 'Точка спавна', - desc: 'Зелёная платформа-маркер — место появления игрока. Поставь где нужно.', + id: 'start-pad', + name: 'Стартовая площадка', + desc: 'Светящаяся платформа-постамент для оформления зоны старта (точка спавна в проекте уже есть по умолчанию).', icon: 'flag', category: 'world', - prims: [{ type: 'cylinder', x: 0, y: 0.15, z: 0, sx: 3, sy: 0.3, sz: 3, color: '#36d57a', material: 'neon', name: 'Точка спавна' }], + prims: [{ type: 'cylinder', x: 0, y: 0.15, z: 0, sx: 3, sy: 0.3, sz: 3, color: '#36d57a', material: 'neon', name: 'Стартовая площадка' }], }, { id: 'checkpoint', From df1647019df905fb06e05d940cf6648c11d92a5f Mon Sep 17 00:00:00 2001 From: min Date: Fri, 5 Jun 2026 02:05:17 +0300 Subject: [PATCH 10/74] =?UTF-8?q?fix(studio):=20game.self.rotate=20+=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=BD=D1=8F=D1=82=D0=BD=D1=8B=D0=B5=20=D0=B8=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0=20=D1=81=D0=BA=D1=80=D0=B8=D0=BF=D1=82=D0=BE?= =?UTF-8?q?=D0=B2=20=D0=BA=D0=B8=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - game.self.rotate(ry)/rotateY(ry) добавлен в worker (слал scene.rotate с ref объекта-носителя). Кит «Вращающийся объект» падал 'game.self.rotate is not a function' каждый кадр в onTick — теперь крутится. - upsertScript принимает name; вставка кита даёт скрипту имя = название кита (раньше в дереве был сырой id script_mq03...). Ручное создание скрипта тоже даёт «Скрипт N» / «Скрипт объекта N» вместо id. Co-Authored-By: Claude Opus 4.8 --- src/editor/KubikonEditor.jsx | 19 +++++++++++++------ src/editor/engine/BabylonScene.js | 4 +++- src/editor/engine/ScriptSandboxWorker.js | 12 ++++++++++++ 3 files changed, 28 insertions(+), 7 deletions(-) diff --git a/src/editor/KubikonEditor.jsx b/src/editor/KubikonEditor.jsx index de3b705..b54eb7d 100644 --- a/src/editor/KubikonEditor.jsx +++ b/src/editor/KubikonEditor.jsx @@ -800,16 +800,18 @@ const KubikonEditor = () => { } } - // 2) Добавляем скрипты кита. + // 2) Добавляем скрипты кита — с понятным именем (название кита). if (Array.isArray(kit.scripts)) { - for (const sc of kit.scripts) { + kit.scripts.forEach((sc, idx) => { const sid = `script_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`; + // Имя = название кита (+ номер, если скриптов несколько). + const nm = kit.scripts.length > 1 ? `${kit.name} (${idx + 1})` : kit.name; if (sc.attachTo === 'on-target' && firstPrimId != null) { - s.upsertScript(sid, sc.code, { kind: 'primitive', id: firstPrimId }); + s.upsertScript(sid, sc.code, { kind: 'primitive', id: firstPrimId }, nm); } else { - s.upsertScript(sid, sc.code, null); // глобальный + s.upsertScript(sid, sc.code, null, nm); // глобальный } - } + }); } markDirty(); @@ -3171,7 +3173,12 @@ const KubikonEditor = () => { } } const tpl = normalized ? NEW_OBJECT_SCRIPT_TEMPLATE : NEW_SCRIPT_TEMPLATE; - sceneRef.current?.upsertScript(id, tpl, normalized); + // Понятное имя по умолчанию (а не сырой id). + const existing = sceneRef.current?.getScripts?.() || []; + const nm = normalized + ? `Скрипт объекта ${existing.filter(s => s.target && s.target !== 'game').length + 1}` + : `Скрипт ${existing.filter(s => !s.target || s.target === 'game').length + 1}`; + sceneRef.current?.upsertScript(id, tpl, normalized, nm); markDirty(); setScriptsList(sceneRef.current?.getScripts?.() || []); sceneRef.current?.selection?.selectScript?.(id); diff --git a/src/editor/engine/BabylonScene.js b/src/editor/engine/BabylonScene.js index c34478d..bfda811 100644 --- a/src/editor/engine/BabylonScene.js +++ b/src/editor/engine/BabylonScene.js @@ -6392,19 +6392,21 @@ export class BabylonScene { } /** Установить код одного скрипта по id. Если id нет — создать новый. */ - upsertScript(id, code, target = undefined) { + upsertScript(id, code, target = undefined, name = undefined) { const i = this._scripts.findIndex(s => s.id === id); if (i >= 0) { this._scripts[i] = { ...this._scripts[i], code, ...(target !== undefined ? { target } : {}), + ...(name !== undefined ? { name } : {}), }; } else { this._scripts.push({ id: id || `script_${Date.now()}`, code, target: target !== undefined ? target : null, + name: name || null, }); } // Скрипты — часть сцены: фиксируем в истории, иначе undo откатит diff --git a/src/editor/engine/ScriptSandboxWorker.js b/src/editor/engine/ScriptSandboxWorker.js index 8ed1488..c1906b1 100644 --- a/src/editor/engine/ScriptSandboxWorker.js +++ b/src/editor/engine/ScriptSandboxWorker.js @@ -742,6 +742,18 @@ function _buildSelfApi() { _send('self.move', { target: _target, x: nx, y: ny, z: nz }); } }, + /** + * Повернуть объект-носитель вокруг вертикальной оси Y на угол ry (радианы). + * game.onTick((dt) => { a += dt; game.self.rotate(a); }); + */ + rotate(ry) { + const r = Number(ry); + if (!Number.isFinite(r)) return; + const k = _target.kind; + const id = _target.id ?? _target.ref; + _send('scene.rotate', { kind: k, id, ref: (k && id != null) ? (k + ':' + id) : undefined, rotationY: r }); + }, + rotateY(ry) { this.rotate(ry); }, delete() { _send('self.delete', { target: _target }); }, From 7242e8060240f00b8bc22439a72820b9bb578838 Mon Sep 17 00:00:00 2001 From: min Date: Fri, 5 Jun 2026 02:10:03 +0300 Subject: [PATCH 11/74] =?UTF-8?q?fix(studio):=20=D0=BA=D0=B8=D1=82=20?= =?UTF-8?q?=C2=AB=D0=9A=D0=BE=D0=BD=D1=84=D0=B5=D1=82=D1=82=D0=B8=C2=BB=20?= =?UTF-8?q?=D0=B2=D1=8B=D0=BB=D0=B5=D1=82=D0=B0=D0=B5=D1=82=20=D0=B8=D0=B7?= =?UTF-8?q?=20=D0=BF=D0=BE=D0=B7=D0=B8=D1=86=D0=B8=D0=B8=20=D0=BE=D0=B1?= =?UTF-8?q?=D1=8A=D0=B5=D0=BA=D1=82=D0=B0,=20=D0=B0=20=D0=BD=D0=B5=20?= =?UTF-8?q?=D0=B8=D0=B7=20=D1=86=D0=B5=D0=BD=D1=82=D1=80=D0=B0=20=D1=81?= =?UTF-8?q?=D1=86=D0=B5=D0=BD=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Было: spawn кубиков в фикс. координатах (0,0.5,0) → конфетти сыпалось в центре сцены, далеко от шара-источника (непонятно как связано). Стало: кубики вылетают из game.self.position (позиции самого объекта-источника). Описание кита уточнено: «фонтан конфетти из этого объекта». Co-Authored-By: Claude Opus 4.8 --- src/editor/engine/GameplayKits.js | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/editor/engine/GameplayKits.js b/src/editor/engine/GameplayKits.js index 14ea552..14209aa 100644 --- a/src/editor/engine/GameplayKits.js +++ b/src/editor/engine/GameplayKits.js @@ -96,17 +96,21 @@ game.self.onInteract(() => { { id: 'confetti', name: 'Конфетти', - desc: 'Праздничный взрыв конфетти из точки. Запускается сразу и периодически.', + desc: 'Праздничный фонтан конфетти из этого объекта. Кубики разлетаются и падают. Запускается периодически.', icon: 'sparkles', category: 'fx', - prims: [{ type: 'sphere', x: 0, y: 3, z: 0, sx: 0.5, sy: 0.5, sz: 0.5, color: '#ff5ab0', material: 'neon', name: 'Конфетти-источник', canCollide: false }], + prims: [{ type: 'sphere', x: 0, y: 1, z: 0, sx: 0.5, sy: 0.5, sz: 0.5, color: '#ff5ab0', material: 'neon', name: 'Конфетти-источник', canCollide: false }], scripts: [{ attachTo: 'on-target', code: -`// Конфетти: периодический фейерверк примитивов +`// Конфетти вылетает из ПОЗИЦИИ этого объекта (не из центра сцены). function burst() { + const p = game.self.position; // где стоит конфетти-источник for (let k = 0; k < 16; k++) { const col = ['#ff5ab0','#ffd23a','#4d6bff','#36d57a','#ff7a3a'][k % 5]; - const id = game.scene.spawn('primitive:cube', { - x: (Math.random()-0.5)*1, y: 4, z: (Math.random()-0.5)*1, - sx: 0.25, sy: 0.25, sz: 0.25, color: col, anchored: false, canCollide: false, lifetime: 2.5, + game.scene.spawn('primitive:cube', { + x: p.x + (Math.random()-0.5)*0.6, + y: p.y + 0.5, + z: p.z + (Math.random()-0.5)*0.6, + sx: 0.22, sy: 0.22, sz: 0.22, color: col, + anchored: false, canCollide: false, lifetime: 2.5, }); } } From 471af1cdeb24acb969119c11927a74f8b29c63e8 Mon Sep 17 00:00:00 2001 From: min Date: Fri, 5 Jun 2026 02:21:38 +0300 Subject: [PATCH 12/74] =?UTF-8?q?fix(studio):=20=D1=81=D1=83=D0=BD=D0=B4?= =?UTF-8?q?=D1=83=D0=BA=E2=86=92=D1=81=D1=87=D1=91=D1=82=D1=87=D0=B8=D0=BA?= =?UTF-8?q?=20=D0=BC=D0=BE=D0=BD=D0=B5=D1=82=20=D1=87=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=B7=20broadcast,=20=D1=83=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D1=82=D0=BE=D1=87=D0=BA=D0=B8=20=D1=81=D0=BF?= =?UTF-8?q?=D0=B0=D0=B2=D0=BD=D0=B0=20+=20=D1=84=D0=BE=D0=BB=D0=B1=D1=8D?= =?UTF-8?q?=D0=BA=200,0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Киты «Сундук» и «Счётчик монет» связаны через game.broadcast('coins',{add}) + game.onMessage('coins') — раньше каждый кит в своём worker, счётчик не обновлялся (был globalThis, не работает между воркерами). - Точку спавна теперь МОЖНО удалить: Delete (SelectionManager.deleteSelected обрабатывает type==='spawn' → scene.deleteSpawn) + ПКМ в дереве → контекст- меню «Навести камеру / Удалить точку спавна». - Если точка спавна удалена (_spawnEnabled=false) — игрок появляется в (0, поверхность+2, 0). Постановка новой точки (setSpawnAtCamera) возвращает. - spawnEnabled сериализуется в project_data. Co-Authored-By: Claude Opus 4.8 --- src/editor/HierarchyPanel.jsx | 21 +++++++++++++--- src/editor/KubikonEditor.jsx | 5 ++++ src/editor/engine/BabylonScene.js | 36 ++++++++++++++++++++++++++- src/editor/engine/GameplayKits.js | 16 ++++++------ src/editor/engine/SelectionManager.js | 3 +++ 5 files changed, 69 insertions(+), 12 deletions(-) diff --git a/src/editor/HierarchyPanel.jsx b/src/editor/HierarchyPanel.jsx index d615b4c..356ea73 100644 --- a/src/editor/HierarchyPanel.jsx +++ b/src/editor/HierarchyPanel.jsx @@ -242,7 +242,7 @@ const HierarchyPanel = ({ onSelectSound, onSelectPlayer, onSelectPlayerProps, onSelectFloor, guiElements = [], onSelectGui, onCreateGui, onDeleteGui, onRenameGui, onMoveGuiZ, onSetGuiParent, guiOverlayHidden = false, onToggleGuiOverlay, - floorEnabled = true, onCreateFloor, onDeleteFloor, + floorEnabled = true, onCreateFloor, onDeleteFloor, onDeleteSpawn, scripts = [], onSelectScript, onCreateScript, onDeleteScript, onRenameModel, onRenamePrimitive, onRenameScript, /** @@ -786,8 +786,8 @@ const HierarchyPanel = ({ className={`${cl.item} ${selection?.type === 'spawn' ? cl.itemSelected : ''}`} onClick={() => onSelectSpawn?.()} onDoubleClick={() => { onSelectSpawn?.(); onFocusSelection?.(); }} - onContextMenu={(e) => { e.preventDefault(); onSelectSpawn?.(); }} - title="Точка спавна игрока (ПКМ — выбрать и открыть свойства)" + onContextMenu={(e) => { onSelectSpawn?.(); handleContextMenu(e, { type: 'spawn' }); }} + title="Точка спавна игрока (ПКМ — меню, Delete — удалить)" > Точка спавна @@ -1314,6 +1314,21 @@ const HierarchyPanel = ({ Удалить пол
+ ) : contextMenu.item.type === 'spawn' ? ( + <> +
{ onSelectSpawn?.(); onFocusSelection?.(); closeContext(); }} + > + Навести камеру +
+
{ onDeleteSpawn?.(); closeContext(); }} + > + Удалить точку спавна +
+ ) : contextMenu.item.type === 'script' ? ( <>
{ // Активируем гизмо «Двигать» чтобы можно было сразу таскать setGizmoMode('move'); }} + onDeleteSpawn={() => { + sceneRef.current?.deleteSpawn?.(); + sceneRef.current?.clearSelection?.(); + markDirty(); + }} onSelectLighting={() => { sceneRef.current?.selection?.selectLighting(); setActiveTool('select'); diff --git a/src/editor/engine/BabylonScene.js b/src/editor/engine/BabylonScene.js index bfda811..afc6a55 100644 --- a/src/editor/engine/BabylonScene.js +++ b/src/editor/engine/BabylonScene.js @@ -201,6 +201,9 @@ export class BabylonScene { // Точка спавна игрока в режиме Play (обновляется setSpawnPoint) this._spawnPoint = { x: 0, y: 5, z: 0 }; + // Есть ли заданная точка спавна. Если игрок её удалил (Delete) — спавн + // в (0, высота, 0). Можно вернуть постановкой новой точки. + this._spawnEnabled = true; // Модель персонажа для режима Play. // Дефолт — R15-скин bacon-hair (классический Roblox-вид). // 'skin_*' грузится из characters//body.glb (R15-скелет), @@ -2854,6 +2857,21 @@ export class BabylonScene { } } + /** Есть ли заданная точка спавна (false → игрок появится в 0,высота,0). */ + hasSpawn() { return this._spawnEnabled !== false; } + + /** + * «Удалить» точку спавна: прячем маркер и помечаем, что спавна нет. + * В Play игрок появится в (0, безопасная высота, 0). Вернуть точку — + * через setSpawnAtCamera() (кнопка «Поставить точку спавна»). + */ + deleteSpawn() { + this._spawnEnabled = false; + this._setSpawnMarkerVisible(false); + this.history?.markChange(); + if (this._onSceneChange) this._onSceneChange(); + } + /** * Raycast от курсора в сцену. * Возвращает { mesh, point, normal } либо null если ни во что не попали. @@ -5465,6 +5483,8 @@ export class BabylonScene { y: Math.max(0, Math.floor(p.y) - 1), z: Math.round(p.z), }; + this._spawnEnabled = true; // вернуть точку, если была удалена + this._setSpawnMarkerVisible(true); this._updateSpawnMarker(); this.history?.markChange(); if (this._onSceneChange) this._onSceneChange(); @@ -5851,7 +5871,17 @@ export class BabylonScene { }); if (this._onPlayerHpChange) this.player.setOnHpChange(this._onPlayerHpChange); if (this._onPlayerDeath) this.player.setOnDeath(this._onPlayerDeath); - this.player.start(this._spawnPoint); + // Если точка спавна удалена — игрок появляется в (0, безопасная высота, 0). + let startPoint = this._spawnPoint; + if (this._spawnEnabled === false) { + let sy = 3; + try { + const surf = this.physics?._sampleRobloxSurface?.(0, 0); + if (surf !== null && surf !== undefined) sy = surf + 2; + } catch (e) { /* ignore */ } + startPoint = { x: 0, y: sy, z: 0 }; + } + this.player.start(startPoint); // Запускаем пользовательские скрипты (этап 2.1). // Async PlayerController.start даёт нам тик чтобы дать ему собрать сцену, @@ -7377,6 +7407,7 @@ export class BabylonScene { gui: this.guiManager ? this.guiManager.serialize() : [], inventory: this.inventory ? this.inventory.serialize() : null, spawnPoint: { ...this._spawnPoint }, + spawnEnabled: this._spawnEnabled !== false, playerModelType: this._playerModelType, skins: this._skinsConfig ? { default: this._skinsConfig.default || null, @@ -7799,6 +7830,9 @@ export class BabylonScene { this._spawnPoint = { ...state.scene.spawnPoint }; this._updateSpawnMarker(); } + // Удалена ли точка спавна (спавн в 0,0 при отсутствии). + this._spawnEnabled = state.scene.spawnEnabled !== false; + this._setSpawnMarkerVisible(this._spawnEnabled); // === Авто-fix спавна для smooth terrain === // Если есть RobloxTerrain и spawnPoint оказался НИЖЕ поверхности — // поднимаем его, чтобы игрок не провалился в момент нажатия "Запустить". diff --git a/src/editor/engine/GameplayKits.js b/src/editor/engine/GameplayKits.js index 14209aa..096aec4 100644 --- a/src/editor/engine/GameplayKits.js +++ b/src/editor/engine/GameplayKits.js @@ -62,16 +62,15 @@ game.every(8, () => { { id: 'currency-counter', name: 'Счётчик монет', - desc: 'Показывает счётчик монет в углу HUD. Метод game.addCoins(n) прибавляет монеты.', + desc: 'Счётчик монет в углу HUD. Другие механики шлют game.broadcast("coins", {add: N}) — счётчик обновляется.', icon: 'circle', category: 'ui', scripts: [{ attachTo: 'global', code: -`// Счётчик монет в HUD +`// Счётчик монет в HUD. Прибавить монеты из любого скрипта: +// game.broadcast('coins', { add: 100 }); let coins = 0; -function showCoins() { game.ui.set('coins', '🪙 ' + coins, { x: 92, y: 6, anchor: 'top', color: '#ffd23a', size: 22 }); } -showCoins(); -// Глобальный помощник: вызывай game.scene.setData('_coins','add',N) или меняй coins из других скриптов. -game.every(0.3, () => showCoins()); -globalThis.__addCoins = (n) => { coins += (n||1); showCoins(); };` }], +function show() { game.ui.set('coins', '🪙 ' + coins, { x: 92, y: 6, anchor: 'top', color: '#ffd23a', size: 22 }); } +show(); +game.onMessage('coins', (m) => { coins += (m && m.add) ? m.add : 1; show(); });` }], }, { id: 'start-pad', @@ -175,11 +174,12 @@ game.after(5, () => game.ui.set('welcome', ''));` }], { type: 'cube', x: 0, y: 1.35, z: 0, sx: 1.7, sy: 0.4, sz: 1.3, color: '#d4a843', material: 'metal', name: 'Крышка сундука', canCollide: false }, ], scripts: [{ attachTo: 'on-target', code: -`// Сундук с лутом +`// Сундук с лутом — даёт 100 монет (через счётчик монет, если он добавлен). let opened = false; game.self.onInteract(() => { if (opened) { game.ui.set('loot', 'Сундук уже открыт.', { x: 50, y: 85, anchor: 'bottom', color: '#bbb', size: 16 }); return; } opened = true; + game.broadcast('coins', { add: 100 }); // обновит «Счётчик монет», если он есть game.ui.set('loot', '🎁 Ты получил награду: +100 монет!', { x: 50, y: 85, anchor: 'bottom', color: '#ffd23a', size: 18 }); game.after(3, () => game.ui.set('loot', '')); }, { text: 'Открыть сундук', key: 'f', distance: 4 });` }], diff --git a/src/editor/engine/SelectionManager.js b/src/editor/engine/SelectionManager.js index 24d58ef..351d3b2 100644 --- a/src/editor/engine/SelectionManager.js +++ b/src/editor/engine/SelectionManager.js @@ -685,6 +685,9 @@ export class SelectionManager { this.userModelManager.removeInstance(this._selection.instanceId); } else if (this._selection.type === 'primitive') { this.primitiveManager.removeInstance(this._selection.id); + } else if (this._selection.type === 'spawn') { + // Удаление точки спавна → игрок будет появляться в (0, высота, 0). + this._scene3d?.deleteSpawn?.(); } this.clear(); } From 7fc4ee94f65c0bc102175400fc8d02a2d4235f2d Mon Sep 17 00:00:00 2001 From: min Date: Fri, 5 Jun 2026 02:33:15 +0300 Subject: [PATCH 13/74] =?UTF-8?q?fix(studio):=20=D1=83=D0=B4=D0=B0=D0=BB?= =?UTF-8?q?=D1=91=D0=BD=D0=BD=D0=B0=D1=8F=20=D1=82=D0=BE=D1=87=D0=BA=D0=B0?= =?UTF-8?q?=20=D1=81=D0=BF=D0=B0=D0=B2=D0=BD=D0=B0=20=D0=BD=D0=B5=20=D0=BF?= =?UTF-8?q?=D0=BE=D1=8F=D0=B2=D0=BB=D1=8F=D0=B5=D1=82=D1=81=D1=8F=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B8=20Play=20+=20=D1=81=D0=BA=D1=80=D1=8B=D1=82=D0=B0?= =?UTF-8?q?=20=D0=B8=D0=B7=20=D0=B4=D0=B5=D1=80=D0=B5=D0=B2=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Баг: после удаления точки спавна она всё равно появлялась при запуске. Теперь _spawnEnabled синхронизируется в React (spawnEnabledUI) через onSceneChange/load/setSpawn/deleteSpawn; пункт «Точка спавна» скрыт из дерева когда удалён; player.start использует фолбэк (0,поверхность+2,0). Стартовая площадка теперь срабатывает (игрок не телепортируется на старый спавн). Co-Authored-By: Claude Opus 4.8 --- src/editor/HierarchyPanel.jsx | 6 ++++-- src/editor/KubikonEditor.jsx | 7 +++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/editor/HierarchyPanel.jsx b/src/editor/HierarchyPanel.jsx index 356ea73..ea55712 100644 --- a/src/editor/HierarchyPanel.jsx +++ b/src/editor/HierarchyPanel.jsx @@ -242,7 +242,7 @@ const HierarchyPanel = ({ onSelectSound, onSelectPlayer, onSelectPlayerProps, onSelectFloor, guiElements = [], onSelectGui, onCreateGui, onDeleteGui, onRenameGui, onMoveGuiZ, onSetGuiParent, guiOverlayHidden = false, onToggleGuiOverlay, - floorEnabled = true, onCreateFloor, onDeleteFloor, onDeleteSpawn, + floorEnabled = true, onCreateFloor, onDeleteFloor, onDeleteSpawn, spawnEnabled = true, scripts = [], onSelectScript, onCreateScript, onDeleteScript, onRenameModel, onRenamePrimitive, onRenameScript, /** @@ -781,7 +781,8 @@ const HierarchyPanel = ({ /> {workspaceOpen && (
- {/* Точка спавна — кликабельная */} + {/* Точка спавна — кликабельная. Скрыта если удалена. */} + {spawnEnabled !== false && (
onSelectSpawn?.()} @@ -792,6 +793,7 @@ const HierarchyPanel = ({ Точка спавна
+ )} {/* Пол — псевдо-объект, если включён */} {floorEnabled && ( diff --git a/src/editor/KubikonEditor.jsx b/src/editor/KubikonEditor.jsx index 91443d7..9c2ecc3 100644 --- a/src/editor/KubikonEditor.jsx +++ b/src/editor/KubikonEditor.jsx @@ -606,6 +606,7 @@ const KubikonEditor = () => { const [crosshair, setCrosshairUI] = useState('none'); // Видимость пола в иерархии const [floorEnabled, setFloorEnabledUI] = useState(true); + const [spawnEnabledUI, setSpawnEnabledUI] = useState(true); // Табы над viewport (Roblox-style): «🎬 Сцена» + открытые скрипты const [openTabs, setOpenTabs] = useState([{ id: 'scene', kind: 'scene', title: 'Сцена' }]); const [activeTabId, setActiveTabId] = useState('scene'); @@ -1354,6 +1355,8 @@ const KubikonEditor = () => { markDirty(); // Иерархия изменилась — interval пересоберёт списки на след. тике. hierarchyDirtyRef.current = true; + // Синк флага точки спавна (например после Delete-клавиши). + try { setSpawnEnabledUI(scene.hasSpawn?.() !== false); } catch (e) {} }); // Этап 5: подключаем API пользовательских моделей в BabylonScene, @@ -1558,6 +1561,7 @@ const KubikonEditor = () => { const ch = sceneRef.current.getCrosshair?.(); if (ch) setCrosshairUI(ch); setFloorEnabledUI(sceneRef.current.isFloorEnabled?.() !== false); + setSpawnEnabledUI(sceneRef.current.hasSpawn?.() !== false); const a = sceneRef.current.getAudioState?.(); if (a?.ambientId) setAmbientIdUI(a.ambientId); if (a?.musicId) setMusicIdUI(a.musicId); @@ -2112,6 +2116,7 @@ const KubikonEditor = () => { onPlayToggle={handlePlay} onSetSpawn={() => { sceneRef.current?.setSpawnAtCamera(); + setSpawnEnabledUI(true); }} hasSelection={!!selection} onDuplicate={() => sceneRef.current?.duplicateSelected()} @@ -3245,6 +3250,7 @@ const KubikonEditor = () => { setActiveTool('select'); }} floorEnabled={floorEnabled} + spawnEnabled={spawnEnabledUI} onSelectFloor={() => { sceneRef.current?.selection?.selectFloor?.(); setActiveTool('select'); @@ -3312,6 +3318,7 @@ const KubikonEditor = () => { onDeleteSpawn={() => { sceneRef.current?.deleteSpawn?.(); sceneRef.current?.clearSelection?.(); + setSpawnEnabledUI(false); markDirty(); }} onSelectLighting={() => { From 4c8f8c99cb4869a6c0421bbf57a519fd3a376915 Mon Sep 17 00:00:00 2001 From: min Date: Fri, 5 Jun 2026 02:40:00 +0300 Subject: [PATCH 14/74] =?UTF-8?q?feat(studio):=20=D0=B3=D1=80=D1=83=D0=BF?= =?UTF-8?q?=D0=BF=D0=BE=D0=B2=D1=8B=D0=B5=20=D0=BC=D0=B0=D0=BD=D0=B8=D0=BF?= =?UTF-8?q?=D1=83=D0=BB=D1=8F=D1=86=D0=B8=D0=B8=20=D0=BF=D0=B0=D0=BF=D0=BA?= =?UTF-8?q?=D0=B8=20(=D0=B2=D1=8B=D0=B4=D0=B5=D0=BB=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5+move/rotate/scale=20=D0=B2=D1=81=D0=B5=D0=B9=20=D0=B3?= =?UTF-8?q?=D1=80=D1=83=D0=BF=D0=BF=D1=8B)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Клик по папке в дереве → выделяется вся группа (подсветка всех объектов внутри, рекурсивно по подпапкам) + групповой gizmo на пивоте в центре папки. Манипуляторы двигают/вращают/масштабируют ВСЕ объекты папки сразу. Выбор отдельной модели внутри — манипулирует только ей (как раньше). - FolderManager: getFolderObjects (рекурсивный сбор + центр), moveFolderBy, scaleFolder (от центра, +размеры примитивов), rotateFolderY расширен на модели. - SelectionManager.selectFolder → multi-подсветка + type:'folder' + пивот-gizmo. - BabylonScene._attachFolderGizmo/_applyFolderGizmo: пивот-TransformNode, на dragEnd дельта (move/rotate/scale) применяется ко всей папке, пивот пересоздаётся в новом центре. Пивот убирается при смене выделения. - Дерево: клик по строке папки = выделить группу; клик по шеврону = свернуть. Многокомпонентные модели уже кладутся в авто-папку (ModelManager) — теперь их можно двигать как единое целое. Co-Authored-By: Claude Opus 4.8 --- src/editor/HierarchyPanel.jsx | 9 +- src/editor/KubikonEditor.jsx | 6 ++ src/editor/engine/BabylonScene.js | 65 +++++++++++ src/editor/engine/FolderManager.js | 148 ++++++++++++++++++++++---- src/editor/engine/SelectionManager.js | 23 ++++ 5 files changed, 230 insertions(+), 21 deletions(-) diff --git a/src/editor/HierarchyPanel.jsx b/src/editor/HierarchyPanel.jsx index ea55712..aef2513 100644 --- a/src/editor/HierarchyPanel.jsx +++ b/src/editor/HierarchyPanel.jsx @@ -243,6 +243,7 @@ const HierarchyPanel = ({ guiElements = [], onSelectGui, onCreateGui, onDeleteGui, onRenameGui, onMoveGuiZ, onSetGuiParent, guiOverlayHidden = false, onToggleGuiOverlay, floorEnabled = true, onCreateFloor, onDeleteFloor, onDeleteSpawn, spawnEnabled = true, + onSelectFolder, scripts = [], onSelectScript, onCreateScript, onDeleteScript, onRenameModel, onRenamePrimitive, onRenameScript, /** @@ -488,14 +489,16 @@ const HierarchyPanel = ({ return (
toggleFolder(folder.id)} + onClick={() => onSelectFolder?.(folder.id)} onContextMenu={(e) => handleContextMenu(e, { type: 'folder', ...folder })} onDragOver={handleDragOver} onDrop={(e) => handleDropOnFolder(e, folder.id)} > - + { e.stopPropagation(); toggleFolder(folder.id); }} + > diff --git a/src/editor/KubikonEditor.jsx b/src/editor/KubikonEditor.jsx index 9c2ecc3..62980b7 100644 --- a/src/editor/KubikonEditor.jsx +++ b/src/editor/KubikonEditor.jsx @@ -3321,6 +3321,12 @@ const KubikonEditor = () => { setSpawnEnabledUI(false); markDirty(); }} + onSelectFolder={(folderId) => { + sceneRef.current?.selection?.selectFolder?.(folderId); + setActiveTool('select'); + // Активируем gizmo «Двигать» чтобы сразу таскать всю группу. + setGizmoMode('move'); + }} onSelectLighting={() => { sceneRef.current?.selection?.selectLighting(); setActiveTool('select'); diff --git a/src/editor/engine/BabylonScene.js b/src/editor/engine/BabylonScene.js index afc6a55..79abf35 100644 --- a/src/editor/engine/BabylonScene.js +++ b/src/editor/engine/BabylonScene.js @@ -3742,11 +3742,69 @@ export class BabylonScene { } } + /** + * Создать пивот-узел в центре папки и привязать к нему gizmo. При drag + * gizmo двигает/вращает/масштабирует пивот, а на dragEnd дельта применяется + * ко всем объектам папки (FolderManager). Групповая трансформация. + */ + _attachFolderGizmo(folderId, center) { + try { + if (this._folderPivot) { this._folderPivot.dispose(); this._folderPivot = null; } + const pivot = new TransformNode('folderPivot_' + folderId, 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._folderPivot = pivot; + this._folderPivotId = folderId; + // Запоминаем стартовое состояние пивота для вычисления дельты в dragEnd. + this._folderPivotStart = { + pos: { x: center.x, y: center.y, z: center.z }, + center: { x: center.x, y: center.y, z: center.z }, + }; + } catch (e) { console.warn('[folderGizmo] attach failed', e); } + } + + /** Применить трансформацию пивота к объектам папки (вызывается на dragEnd). */ + _applyFolderGizmo(mode) { + const pivot = this._folderPivot; + const fid = this._folderPivotId; + const start = this._folderPivotStart; + if (!pivot || fid == null || !start || !this.folderManager) return; + if (mode === 'move') { + const dx = pivot.position.x - start.pos.x; + const dy = pivot.position.y - start.pos.y; + const dz = pivot.position.z - start.pos.z; + if (dx || dy || dz) this.folderManager.moveFolderBy(fid, dx, dy, dz); + } else if (mode === 'rotate') { + const ry = pivot.rotation.y; + if (Math.abs(ry) > 0.0001) this.folderManager.rotateFolderY(fid, ry, start.center); + } else if (mode === 'scale') { + const f = (pivot.scaling.x + pivot.scaling.y + pivot.scaling.z) / 3; + if (Math.abs(f - 1) > 0.001) this.folderManager.scaleFolder(fid, f, start.center); + } + // Пересоздаём пивот в новом центре (сброс дельты для следующего drag). + const g = this.folderManager.getFolderObjects(fid); + this._attachFolderGizmo(fid, g.center); + // Переустановить gizmo на новый пивот + обновить selection.center. + const sel = this.selection?.getSelection?.(); + if (sel && sel.type === 'folder') { sel.center = g.center; } + if (this._gizmo && this._folderPivot) { + this._gizmo.attachTo(this._folderPivot); + this._gizmo.refreshMode(); + } + if (this._onSceneChange) this._onSceneChange(); + } + /** * Обновить гизмо под текущее выделение. */ _updateGizmoForSelection(sel) { if (!this._gizmo) return; + // Сменилось выделение и это НЕ папка → убрать пивот папки. + if ((!sel || sel.type !== 'folder') && this._folderPivot) { + try { this._folderPivot.dispose(); } catch (e) {} + this._folderPivot = null; this._folderPivotId = null; + } if (!sel) { this._gizmo.attachTo(null); return; @@ -3758,6 +3816,9 @@ export class BabylonScene { this._gizmo.attachTo(sel.rootMesh); } else if (sel.type === 'primitive') { this._gizmo.attachTo(sel.mesh); + } else if (sel.type === 'folder') { + // Групповой gizmo — привязан к пивоту папки (создан в _attachFolderGizmo). + if (this._folderPivot) this._gizmo.attachTo(this._folderPivot); } // Переустанавливаем режим чтобы sub-gizmo (move/rotate/scale) // гарантированно пересоздалась поверх нового attached-mesh. @@ -3795,6 +3856,10 @@ export class BabylonScene { if (!sel) return; const mode = this._gizmo.getMode(); + if (sel.type === 'folder') { + this._applyFolderGizmo(mode); + return; + } if (sel.type === 'block') { if (mode === 'move') { // Babylon-mesh.position — центр блока (gridX, gridY+0.5, gridZ) diff --git a/src/editor/engine/FolderManager.js b/src/editor/engine/FolderManager.js index 28ab638..048c52b 100644 --- a/src/editor/engine/FolderManager.js +++ b/src/editor/engine/FolderManager.js @@ -215,29 +215,48 @@ export class FolderManager { * Возвращает количество повёрнутых примитивов. */ rotateFolderY(folderId, angle, pivot) { - if (!this.primitiveManager || !pivot) return 0; + if (!pivot) return 0; const cosA = Math.cos(angle); const sinA = Math.sin(angle); let count = 0; - for (const data of this.primitiveManager.instances.values()) { - if (data.folderId !== folderId) continue; - // Поворачиваем позицию вокруг pivot.y axis (XZ-плоскость) - const dx = data.x - pivot.x; - const dz = data.z - pivot.z; - const newX = pivot.x + dx * cosA - dz * sinA; - const newZ = pivot.z + dx * sinA + dz * cosA; - data.x = newX; - data.z = newZ; - data.rotationY = (data.rotationY || 0) + angle; - if (data.mesh) { - data.mesh.position.set(newX, data.y, newZ); - data.mesh.rotation.y = data.rotationY; - if (data._worldMatrixFrozen) { - try { data.mesh.unfreezeWorldMatrix?.(); } catch (e) {} - data._worldMatrixFrozen = false; + // Примитивы папки. + if (this.primitiveManager) { + for (const data of this.primitiveManager.instances.values()) { + if (data.folderId !== folderId) continue; + const dx = data.x - pivot.x; + const dz = data.z - pivot.z; + data.x = pivot.x + dx * cosA - dz * sinA; + data.z = pivot.z + dx * sinA + dz * cosA; + data.rotationY = (data.rotationY || 0) + angle; + if (data.mesh) { + data.mesh.position.set(data.x, data.y, data.z); + data.mesh.rotation.y = data.rotationY; + if (data._worldMatrixFrozen) { + try { data.mesh.unfreezeWorldMatrix?.(); } catch (e) {} + data._worldMatrixFrozen = false; + } } + count++; + } + } + // Модели папки (позиция вокруг pivot + собственный поворот). + if (this.modelManager) { + const Vec = this.modelManager._Vector3 || null; + for (const data of this.modelManager.instances.values()) { + if (data.folderId !== folderId) continue; + const dx = data.x - pivot.x; + const dz = data.z - pivot.z; + data.x = pivot.x + dx * cosA - dz * sinA; + data.z = pivot.z + dx * sinA + dz * cosA; + data.rotationY = (data.rotationY || 0) + angle; + const root = data.rootMesh || data.rootNode; + if (root) { + if (data._worldMatrixFrozen) { try { root.unfreezeWorldMatrix?.(); } catch (e) {} data._worldMatrixFrozen = false; } + root.position.set(data.x, data.y, data.z); + if (root.rotation) root.rotation.y = data.rotationY; + } + count++; } - count++; } this._notifyChange(); return count; @@ -259,6 +278,99 @@ export class FolderManager { return count; } + /** + * Собрать все объекты папки (рекурсивно по подпапкам) с их мешами. + * Возвращает { models:[{data}], primitives:[{data}], blocks:[mesh], + * meshes:[meshes для подсветки], center:{x,y,z}, count }. + */ + getFolderObjects(folderId) { + const out = { models: [], primitives: [], blocks: [], meshes: [] }; + const ids = new Set([folderId]); + // Собираем id всех вложенных подпапок. + let added = true; + while (added) { + added = false; + for (const f of this.getAll()) { + if (f.parentId != null && ids.has(f.parentId) && !ids.has(f.id)) { + ids.add(f.id); added = true; + } + } + } + if (this.modelManager) { + for (const d of this.modelManager.instances.values()) { + if (ids.has(d.folderId)) { + out.models.push(d); + const root = d.rootMesh || d.rootNode; + if (root) out.meshes.push(root); + } + } + } + if (this.primitiveManager) { + for (const d of this.primitiveManager.instances.values()) { + if (ids.has(d.folderId)) { + out.primitives.push(d); + if (d.mesh) out.meshes.push(d.mesh); + } + } + } + if (this.blockManager) { + for (const mesh of this.blockManager.blocks.values()) { + if (ids.has(mesh.metadata?.folderId)) out.blocks.push(mesh); + } + } + // Центр группы (по позициям моделей/примитивов). + let sx = 0, sy = 0, sz = 0, n = 0; + for (const d of out.models) { sx += d.x || 0; sy += d.y || 0; sz += d.z || 0; n++; } + for (const d of out.primitives) { sx += d.x || 0; sy += d.y || 0; sz += d.z || 0; n++; } + out.center = n > 0 ? { x: sx / n, y: sy / n, z: sz / n } : { x: 0, y: 0, z: 0 }; + out.count = out.models.length + out.primitives.length + out.blocks.length; + return out; + } + + /** Сдвинуть все объекты папки на (dx,dy,dz). */ + moveFolderBy(folderId, dx, dy, dz) { + const g = this.getFolderObjects(folderId); + const apply = (d, mesh) => { + d.x = (d.x || 0) + dx; d.y = (d.y || 0) + dy; d.z = (d.z || 0) + dz; + if (mesh) { + if (d._worldMatrixFrozen) { try { mesh.unfreezeWorldMatrix?.(); } catch (e) {} d._worldMatrixFrozen = false; } + mesh.position.set(d.x, d.y, d.z); + } + }; + for (const d of g.models) apply(d, d.rootMesh || d.rootNode); + for (const d of g.primitives) apply(d, d.mesh); + this._notifyChange(); + } + + /** + * Масштабировать папку относительно центра pivot на коэффициент factor. + * Позиции расходятся/сходятся от центра + размеры примитивов меняются. + */ + scaleFolder(folderId, factor, pivot) { + if (!Number.isFinite(factor) || factor <= 0) return; + const g = this.getFolderObjects(folderId); + const p = pivot || g.center; + const sc = (d, mesh, isPrim) => { + d.x = p.x + ((d.x || 0) - p.x) * factor; + d.y = p.y + ((d.y || 0) - p.y) * factor; + d.z = p.z + ((d.z || 0) - p.z) * factor; + if (isPrim) { + d.sx = (d.sx || 1) * factor; d.sy = (d.sy || 1) * factor; d.sz = (d.sz || 1) * factor; + } else { + d.scale = (d.scale || 1) * factor; + } + if (mesh) { + if (d._worldMatrixFrozen) { try { mesh.unfreezeWorldMatrix?.(); } catch (e) {} d._worldMatrixFrozen = false; } + mesh.position.set(d.x, d.y, d.z); + if (isPrim && mesh.scaling) mesh.scaling.set(d.sx, d.sy, d.sz); + else if (mesh.scaling) mesh.scaling.scaleInPlace(factor); + } + }; + for (const d of g.models) sc(d, d.rootMesh || d.rootNode, false); + for (const d of g.primitives) sc(d, d.mesh, true); + this._notifyChange(); + } + /** Найти папку по имени (regex/exact). */ findByName(name) { const n = String(name || '').toLowerCase(); diff --git a/src/editor/engine/SelectionManager.js b/src/editor/engine/SelectionManager.js index 351d3b2..8e51719 100644 --- a/src/editor/engine/SelectionManager.js +++ b/src/editor/engine/SelectionManager.js @@ -199,6 +199,29 @@ export class SelectionManager { this._notifyChange(); } + /** + * Выделить ПАПКУ целиком: подсветить все объекты внутри + поставить + * selection.type='folder'. Групповой gizmo привязывается в BabylonScene. + */ + selectFolder(folderId) { + const fm = this._scene3d?.folderManager; + if (!fm) return; + const g = fm.getFolderObjects(folderId); + this._removeHighlight(); + this._multi = []; + for (const m of g.meshes) this._highlightMesh(m); + this._selection = { + type: 'folder', + folderId, + center: g.center, + count: g.count, + meshes: g.meshes, + }; + this._notifyChange(); + // Поставить групповой gizmo на пивот папки. + this._scene3d?._attachFolderGizmo?.(folderId, g.center); + } + /** Выделить псевдо-объект «Пол» — настройки grid'а пола. */ selectFloor() { if (!this._scene3d) return; From 8b887e866afe154264a1731ffd32c1c713e497c0 Mon Sep 17 00:00:00 2001 From: min Date: Fri, 5 Jun 2026 02:46:25 +0300 Subject: [PATCH 15/74] =?UTF-8?q?fix(studio):=20=D0=BC=D0=BD=D0=BE=D0=B3?= =?UTF-8?q?=D0=BE=D1=87=D0=B0=D1=81=D1=82=D0=BD=D1=8B=D0=B5=20=D0=BA=D0=B8?= =?UTF-8?q?=D1=82=D1=8B=20=D0=B2=20=D0=BF=D0=B0=D0=BF=D0=BA=D1=83=20+=20?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D1=80=D1=82=D0=BE=D0=B2=D0=B0=D1=8F=20=D0=BF?= =?UTF-8?q?=D0=BB=D0=BE=D1=89=D0=B0=D0=B4=D0=BA=D0=B0=20=D0=B7=D0=B0=D0=B4?= =?UTF-8?q?=D0=B0=D1=91=D1=82=20=D1=81=D0=BF=D0=B0=D0=B2=D0=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Кит из нескольких частей (сундук = тело+крышка) теперь кладётся в общую папку (folderManager.createFolder + assignToFolder), выделяется как группа. Раньше части лежали отдельно в корне. - Кит «Стартовая площадка»: on-target скрипт телепортирует игрока НА площадку в начале игры (game.player.teleport через game.after 0.1с). Теперь игрок появляется на ней, а не в фолбэк-точке (0,0). Co-Authored-By: Claude Opus 4.8 --- src/editor/KubikonEditor.jsx | 35 +++++++++++++++++++++++-------- src/editor/engine/GameplayKits.js | 9 +++++++- 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/src/editor/KubikonEditor.jsx b/src/editor/KubikonEditor.jsx index 62980b7..f090f6f 100644 --- a/src/editor/KubikonEditor.jsx +++ b/src/editor/KubikonEditor.jsx @@ -786,8 +786,9 @@ const KubikonEditor = () => { if (cam && fwd) { px = cam.position.x + fwd.x * 6; pz = cam.position.z + fwd.z * 6; } } catch (e) { /* ignore */ } - // 1) Создаём примитивы кита. Запоминаем id первого — для on-target скрипта. + // 1) Создаём примитивы кита. Запоминаем все id (первый — для on-target скрипта). let firstPrimId = null; + const createdIds = []; if (Array.isArray(kit.prims)) { for (const p of kit.prims) { const newId = s.primitiveManager?.addInstance(p.type || 'cube', { @@ -797,7 +798,19 @@ const KubikonEditor = () => { canCollide: p.canCollide !== false, visible: true, anchored: true, name: p.name, }); - if (firstPrimId == null && newId != null) firstPrimId = newId; + if (newId != null) { + createdIds.push(newId); + if (firstPrimId == null) firstPrimId = newId; + } + } + } + // Если кит состоит из НЕСКОЛЬКИХ частей — кладём их в общую папку + // (объекты из нескольких частей всегда сгруппированы). + let kitFolderId = null; + if (createdIds.length > 1 && s.folderManager) { + kitFolderId = s.folderManager.createFolder(kit.name); + for (const pid of createdIds) { + s.folderManager.assignToFolder('primitive', pid, kitFolderId); } } @@ -817,14 +830,18 @@ const KubikonEditor = () => { markDirty(); setScriptsList(s.getScripts?.() || []); - // Выделим созданный объект и наведём на него камеру (видно, куда добавилось). - if (firstPrimId != null) { - try { - setActiveTool('select'); + if (s.folderManager) setFoldersList(s.folderManager.getAll()); + // Выделим созданное и наведём камеру (видно, куда добавилось). + try { + setActiveTool('select'); + if (kitFolderId != null) { + s.selection?.selectFolder?.(kitFolderId); // группа из нескольких частей + setGizmoMode('move'); + } else if (firstPrimId != null) { s.selection?.selectPrimitiveById(firstPrimId); - s.focusOnSelection?.(); - } catch (e) {} - } + } + s.focusOnSelection?.(); + } catch (e) {} // Тост-уведомление. try { showToast?.(`Механика «${kit.name}» добавлена`); } catch (e) {} }, []); diff --git a/src/editor/engine/GameplayKits.js b/src/editor/engine/GameplayKits.js index 096aec4..387f471 100644 --- a/src/editor/engine/GameplayKits.js +++ b/src/editor/engine/GameplayKits.js @@ -75,9 +75,16 @@ game.onMessage('coins', (m) => { coins += (m && m.add) ? m.add : 1; show(); });` { id: 'start-pad', name: 'Стартовая площадка', - desc: 'Светящаяся платформа-постамент для оформления зоны старта (точка спавна в проекте уже есть по умолчанию).', + desc: 'Светящаяся платформа — игрок появляется НА ней в начале игры (задаёт точку старта).', icon: 'flag', category: 'world', prims: [{ type: 'cylinder', x: 0, y: 0.15, z: 0, sx: 3, sy: 0.3, sz: 3, color: '#36d57a', material: 'neon', name: 'Стартовая площадка' }], + scripts: [{ attachTo: 'on-target', code: +`// Игрок появляется на этой площадке в начале игры. +// Небольшая задержка — чтобы позиция объекта и игрок успели проинициализироваться. +game.after(0.1, () => { + const p = game.self.position; + game.player.teleport(p.x, p.y + 1.5, p.z); +});` }], }, { id: 'checkpoint', From c7b5f3645d39bc2b8fa59d1a039eda1c55bdef46 Mon Sep 17 00:00:00 2001 From: min Date: Fri, 5 Jun 2026 02:53:35 +0300 Subject: [PATCH 16/74] =?UTF-8?q?fix(studio):=20=D0=B3=D1=80=D1=83=D0=BF?= =?UTF-8?q?=D0=BF=D0=BE=D0=B2=D1=8B=D0=B5=20=D0=BC=D0=B0=D0=BD=D0=B8=D0=BF?= =?UTF-8?q?=D1=83=D0=BB=D1=8F=D1=86=D0=B8=D0=B8=20=D0=BF=D0=B0=D0=BF=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=B2=20=D1=80=D0=B5=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE?= =?UTF-8?q?=D0=BC=20=D0=B2=D1=80=D0=B5=D0=BC=D0=B5=D0=BD=D0=B8=20(=D0=BD?= =?UTF-8?q?=D0=B5=20=D1=82=D0=B5=D0=BB=D0=B5=D0=BF=D0=BE=D1=80=D1=82)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Дельта пивота применялась только в dragEnd → объекты телепортировались в конце. Теперь _onFolderGizmoDrag применяет инкрементальную дельту на каждом тике (setOnDrag) — движение/вращение/масштаб группы видно в процессе, как у одиночных объектов. Co-Authored-By: Claude Opus 4.8 --- src/editor/engine/BabylonScene.js | 64 ++++++++++++++++++++++--------- 1 file changed, 46 insertions(+), 18 deletions(-) diff --git a/src/editor/engine/BabylonScene.js b/src/editor/engine/BabylonScene.js index 79abf35..7813a55 100644 --- a/src/editor/engine/BabylonScene.js +++ b/src/editor/engine/BabylonScene.js @@ -1367,7 +1367,12 @@ export class BabylonScene { this._gizmo.setOnDragEnd(() => this._onGizmoDragEnd()); // Во время scale-drag — live-обновление тайлинга studs (кружки одного // размера, не растягиваются пока тянешь гизмо). - this._gizmo.setOnDrag((mode) => { if (mode === 'scale') this._onGizmoScaleDrag(); }); + this._gizmo.setOnDrag((mode) => { + if (mode === 'scale') this._onGizmoScaleDrag(); + // Групповая папка — применяем дельту в реальном времени (видно движение). + const sel = this.selection?.getSelection?.(); + if (sel && sel.type === 'folder') this._onFolderGizmoDrag(mode); + }); // Привязка гизмо к выделенному this.selection.setOnSelectionChange((sel) => this._updateGizmoForSelection(sel)); @@ -3756,36 +3761,59 @@ export class BabylonScene { pivot.scaling = new Vector3(1, 1, 1); this._folderPivot = pivot; this._folderPivotId = folderId; - // Запоминаем стартовое состояние пивота для вычисления дельты в dragEnd. - this._folderPivotStart = { - pos: { x: center.x, y: center.y, z: center.z }, + // «Последнее применённое» состояние пивота — для инкрементальной + // дельты в реальном времени (_onFolderGizmoDrag). + this._folderPivotLast = { + x: center.x, y: center.y, z: center.z, + ry: 0, scale: 1, center: { x: center.x, y: center.y, z: center.z }, }; } catch (e) { console.warn('[folderGizmo] attach failed', e); } } - /** Применить трансформацию пивота к объектам папки (вызывается на dragEnd). */ - _applyFolderGizmo(mode) { + /** + * Инкрементально применить движение/поворот/масштаб пивота к объектам папки + * ПРЯМО ВО ВРЕМЯ drag (чтобы было видно перемещение, а не телепорт в конце). + */ + _onFolderGizmoDrag(mode) { const pivot = this._folderPivot; const fid = this._folderPivotId; - const start = this._folderPivotStart; - if (!pivot || fid == null || !start || !this.folderManager) return; + const last = this._folderPivotLast; + if (!pivot || fid == null || !last || !this.folderManager) return; if (mode === 'move') { - const dx = pivot.position.x - start.pos.x; - const dy = pivot.position.y - start.pos.y; - const dz = pivot.position.z - start.pos.z; - if (dx || dy || dz) this.folderManager.moveFolderBy(fid, dx, dy, dz); + const dx = pivot.position.x - last.x; + const dy = pivot.position.y - last.y; + const dz = pivot.position.z - last.z; + if (dx || dy || dz) { + this.folderManager.moveFolderBy(fid, dx, dy, dz); + last.x = pivot.position.x; last.y = pivot.position.y; last.z = pivot.position.z; + last.center.x += dx; last.center.y += dy; last.center.z += dz; + } } else if (mode === 'rotate') { - const ry = pivot.rotation.y; - if (Math.abs(ry) > 0.0001) this.folderManager.rotateFolderY(fid, ry, start.center); + const dRy = pivot.rotation.y - last.ry; + if (Math.abs(dRy) > 0.0001) { + this.folderManager.rotateFolderY(fid, dRy, last.center); + last.ry = pivot.rotation.y; + } } else if (mode === 'scale') { - const f = (pivot.scaling.x + pivot.scaling.y + pivot.scaling.z) / 3; - if (Math.abs(f - 1) > 0.001) this.folderManager.scaleFolder(fid, f, start.center); + const cur = (pivot.scaling.x + pivot.scaling.y + pivot.scaling.z) / 3; + const factor = last.scale !== 0 ? cur / last.scale : 1; + if (Math.abs(factor - 1) > 0.001) { + this.folderManager.scaleFolder(fid, factor, last.center); + last.scale = cur; + } } - // Пересоздаём пивот в новом центре (сброс дельты для следующего drag). + } + + /** dragEnd: дельта уже применена в _onFolderGizmoDrag — пересоздаём пивот. */ + _applyFolderGizmo(mode) { + const fid = this._folderPivotId; + if (fid == null || !this.folderManager) return; + // На всякий случай добираем остаток дельты (если drag был очень коротким). + this._onFolderGizmoDrag(mode); + // Пересоздаём пивот в новом центре (сброс для следующего drag). const g = this.folderManager.getFolderObjects(fid); this._attachFolderGizmo(fid, g.center); - // Переустановить gizmo на новый пивот + обновить selection.center. const sel = this.selection?.getSelection?.(); if (sel && sel.type === 'folder') { sel.center = g.center; } if (this._gizmo && this._folderPivot) { From 6b857636c35b5fb4f09d606647d46a35eb324f06 Mon Sep 17 00:00:00 2001 From: min Date: Fri, 5 Jun 2026 07:28:32 +0300 Subject: [PATCH 17/74] =?UTF-8?q?fix(studio):=20=D0=BA=D1=80=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=B8=D0=BA=20=D0=B7=D0=B0=D0=BA=D1=80=D1=8B=D1=82=D0=B8?= =?UTF-8?q?=D1=8F=20Toolbox=20=D0=BF=D1=80=D0=B8=D0=B6=D0=B0=D1=82=20?= =?UTF-8?q?=D0=BA=20=D0=BF=D1=80=D0=B0=D0=B2=D0=BE=D0=BC=D1=83=20=D0=BA?= =?UTF-8?q?=D1=80=D0=B0=D1=8E=20=D1=88=D0=B0=D0=BF=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit После переработки header убрал .headerInfo (flex:1), который раздвигал заголовок и крестик → крестик прилип к названию. Добавил margin-left:auto кнопке закрытия. Co-Authored-By: Claude Opus 4.8 --- src/editor/ToolboxModal.module.css | 1 + 1 file changed, 1 insertion(+) diff --git a/src/editor/ToolboxModal.module.css b/src/editor/ToolboxModal.module.css index 3a70b63..dec4f7c 100644 --- a/src/editor/ToolboxModal.module.css +++ b/src/editor/ToolboxModal.module.css @@ -79,6 +79,7 @@ } .closeBtn { + margin-left: auto; /* прижать крестик к правому краю шапки */ background: rgba(255, 255, 255, 0.16); border: 1px solid rgba(255, 255, 255, 0.25); border-radius: 10px; From cfc79f325f90a3a6ca875f4ed2b22536359371aa Mon Sep 17 00:00:00 2001 From: min Date: Fri, 5 Jun 2026 07:41:25 +0300 Subject: [PATCH 18/74] =?UTF-8?q?feat(studio):=20+5=20=D0=B3=D0=BE=D1=82?= =?UTF-8?q?=D0=BE=D0=B2=D1=8B=D1=85=20=D0=BC=D0=B5=D1=85=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D0=BA=20=D0=B8=D0=B7=20=D0=92=D0=B8=D0=BA=D0=B8=20(=D0=B1?= =?UTF-8?q?=D0=B0=D1=82=D1=83=D1=82/=D1=83=D1=81=D0=BA=D0=BE=D1=80=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5/=D0=BF=D0=BE=D1=80=D1=82=D0=B0=D0=BB/=D0=B8?= =?UTF-8?q?=D1=81=D1=87=D0=B5=D0=B7=D0=B0=D1=8E=D1=89=D0=B0=D1=8F=20=D0=BF?= =?UTF-8?q?=D0=BB=D0=B0=D1=82=D1=84=D0=BE=D1=80=D0=BC=D0=B0/=D0=B4=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D1=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Партия 1 из TOOLBOX_KITS_FROM_WIKI.md: - Батут (пружина) — onTouch → setVy(20) подброс вверх. - Лента ускорения — onTouch → x2 скорости на 3с. - Портал-телепорт — пара порталов, onTouch → teleport ко второму. - Исчезающая платформа — onTouch → через 1с пропадает, через 3с возвращается. - Дверь по кнопке E — onInteract → дверь уезжает вниз/возвращается. game.self расширен: setVisible(vis) / setCollide(can) (нужны для исчезающей платформы). Все скрипты прошли синтаксис-проверку (new Function). Co-Authored-By: Claude Opus 4.8 --- src/editor/engine/GameplayKits.js | 83 ++++++++++++++++++++++++ src/editor/engine/ScriptSandboxWorker.js | 12 ++++ 2 files changed, 95 insertions(+) diff --git a/src/editor/engine/GameplayKits.js b/src/editor/engine/GameplayKits.js index 387f471..d3517d3 100644 --- a/src/editor/engine/GameplayKits.js +++ b/src/editor/engine/GameplayKits.js @@ -191,6 +191,89 @@ game.self.onInteract(() => { game.after(3, () => game.ui.set('loot', '')); }, { text: 'Открыть сундук', key: 'f', distance: 4 });` }], }, + + // ===== Партия 1 из Вики (киты 13-17) ===== + + { + id: 'trampoline', + name: 'Батут (пружина)', + desc: 'Яркая платформа-батут — наступи на неё, и игрока подбросит высоко вверх. (Вики: «Прыжок-пружина»)', + icon: 'arrow-up', category: 'movement', + prims: [{ type: 'cylinder', x: 0, y: 0.3, z: 0, sx: 3, sy: 0.6, sz: 3, color: '#ff3c8e', material: 'neon', name: 'Батут' }], + scripts: [{ attachTo: 'on-target', code: +`// Батут: касание → подброс игрока вверх. +game.self.onTouch(() => { + game.player.setVy(20); // вертикальный импульс (как трамплин) +});` }], + }, + { + id: 'speed-pad', + name: 'Лента ускорения', + desc: 'Жёлтая плита-ускоритель — наступи, и игрок бежит быстрее несколько секунд. (Вики: «бусты скорости»)', + icon: 'zap', category: 'movement', + prims: [{ type: 'cube', x: 0, y: 0.1, z: 0, sx: 3, sy: 0.2, sz: 5, color: '#ffd23a', material: 'neon', name: 'Лента ускорения', canCollide: false }], + scripts: [{ attachTo: 'on-target', code: +`// Лента ускорения: касание → x2 скорости на 3 секунды. +let boosting = false; +game.self.onTouch(() => { + if (boosting) return; + boosting = true; + game.player.setSpeed(2.0); + game.ui.set('boost', '⚡ Ускорение!', { x: 50, y: 80, anchor: 'bottom', color: '#ffd23a', size: 18 }); + game.after(3, () => { game.player.setSpeed(1.0); game.ui.set('boost', ''); boosting = false; }); +});` }], + }, + { + id: 'teleport-portal', + name: 'Портал-телепорт', + desc: 'Два синих портала. Вошёл в портал — мгновенно переносишься ко второму. Поставь второй портал где нужно. (Вики: «секретный путь»)', + icon: 'sparkles', category: 'movement', + prims: [ + { type: 'cylinder', x: 0, y: 1.5, z: 0, sx: 0.4, sy: 3, sz: 3, color: '#4d6bff', material: 'neon', name: 'Портал A' }, + { type: 'cylinder', x: 8, y: 1.5, z: 0, sx: 0.4, sy: 3, sz: 3, color: '#4dffd6', material: 'neon', name: 'Портал B', canCollide: false }, + ], + scripts: [{ attachTo: 'on-target', code: +`// Портал A: касание → телепорт к порталу B (стоит правее на +8 по X). +// Передвинь «Портал B» куда нужно и поправь координаты ниже. +let cd = false; +game.self.onTouch(() => { + if (cd) return; cd = true; + const p = game.self.position; + game.player.teleport(p.x + 8, p.y + 1, p.z); // к месту портала B + game.after(1, () => { cd = false; }); +});` }], + }, + { + id: 'disappearing-platform', + name: 'Исчезающая платформа', + desc: 'Платформа пропадает под ногами через секунду после касания и возвращается через 3с. (Вики: «Не упади», «Падающий мост»)', + icon: 'square', category: 'world', + prims: [{ type: 'cube', x: 0, y: 0.25, z: 0, sx: 4, sy: 0.5, sz: 4, color: '#e06a3c', material: 'matte', name: 'Исчезающая платформа' }], + scripts: [{ attachTo: 'on-target', code: +`// Исчезающая платформа: наступил → через 1с пропадает, через 3с возвращается. +let busy = false; +game.self.onTouch(() => { + if (busy) return; busy = true; + game.after(1, () => { game.self.setVisible(false); game.self.setCollide(false); }); + game.after(3, () => { game.self.setVisible(true); game.self.setCollide(true); busy = false; }); +});` }], + }, + { + id: 'door-button', + name: 'Дверь по кнопке E', + desc: 'Дверь + кнопка: подойди, нажми E — дверь уезжает в сторону и открывает проход. (Вики: «Кнопка-открывашка»)', + icon: 'door', category: 'world', + prims: [{ type: 'cube', x: 0, y: 2, z: 0, sx: 1, sy: 4, sz: 3, color: '#8a5a3c', material: 'matte', name: 'Дверь' }], + scripts: [{ attachTo: 'on-target', code: +`// Дверь по E: открыта/закрыта — уезжает вниз и возвращается. +let open = false; +const p0 = game.self.position; // закрытое положение +game.self.onInteract(() => { + open = !open; + if (open) game.self.move(p0.x, p0.y - 4.2, p0.z); // уехала вниз (открыта) + else game.self.move(p0.x, p0.y, p0.z); // вернулась (закрыта) +}, { text: open ? 'Закрыть' : 'Открыть', key: 'e', distance: 5 });` }], + }, ]; /** Найти кит по id. */ diff --git a/src/editor/engine/ScriptSandboxWorker.js b/src/editor/engine/ScriptSandboxWorker.js index c1906b1..468051a 100644 --- a/src/editor/engine/ScriptSandboxWorker.js +++ b/src/editor/engine/ScriptSandboxWorker.js @@ -754,6 +754,18 @@ function _buildSelfApi() { _send('scene.rotate', { kind: k, id, ref: (k && id != null) ? (k + ':' + id) : undefined, rotationY: r }); }, rotateY(ry) { this.rotate(ry); }, + /** Показать/скрыть объект-носитель. */ + setVisible(vis) { + const k = _target.kind; + const id = _target.id ?? _target.ref; + _send('scene.setVisible', { kind: k, id, ref: (k && id != null) ? (k + ':' + id) : undefined, visible: !!vis }); + }, + /** Включить/выключить столкновения объекта-носителя (проходимость). */ + setCollide(can) { + const k = _target.kind; + const id = _target.id ?? _target.ref; + _send('scene.setCollide', { kind: k, id, ref: (k && id != null) ? (k + ':' + id) : undefined, canCollide: !!can }); + }, delete() { _send('self.delete', { target: _target }); }, From 4906c827922fd4da48405c656bb001fada4552a4 Mon Sep 17 00:00:00 2001 From: min Date: Fri, 5 Jun 2026 08:46:34 +0300 Subject: [PATCH 19/74] =?UTF-8?q?fix(studio):=20=D0=BF=D0=BE=D1=80=D1=82?= =?UTF-8?q?=D0=B0=D0=BB=20=D0=B8=D1=89=D0=B5=D1=82=20=D0=B2=D1=82=D0=BE?= =?UTF-8?q?=D1=80=D0=BE=D0=B9=20=D0=BF=D0=BE=D1=80=D1=82=D0=B0=D0=BB=20?= =?UTF-8?q?=D0=BF=D0=BE=20=D0=B8=D0=BC=D0=B5=D0=BD=D0=B8,=20=D0=B4=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D1=8C=20=D1=83=D0=B5=D0=B7=D0=B6=D0=B0=D0=B5=D1=82?= =?UTF-8?q?=20=D0=B2=D0=B1=D0=BE=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Портал: вместо хардкода +8 по X — findOne('Портал B') в момент касания и телепорт к реальной позиции второго портала (его можно двигать куда угодно). findOne на старте давал null (sceneSnapshot через rAF) → искать в onTouch. - Дверь по E: сдвиг вбок (+3 по X) вместо ухода вниз (выглядело как исчезновение). Текст подсказки «Открыть / закрыть». Co-Authored-By: Claude Opus 4.8 --- src/editor/engine/GameplayKits.js | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/src/editor/engine/GameplayKits.js b/src/editor/engine/GameplayKits.js index d3517d3..738a193 100644 --- a/src/editor/engine/GameplayKits.js +++ b/src/editor/engine/GameplayKits.js @@ -233,14 +233,16 @@ game.self.onTouch(() => { { type: 'cylinder', x: 8, y: 1.5, z: 0, sx: 0.4, sy: 3, sz: 3, color: '#4dffd6', material: 'neon', name: 'Портал B', canCollide: false }, ], scripts: [{ attachTo: 'on-target', code: -`// Портал A: касание → телепорт к порталу B (стоит правее на +8 по X). -// Передвинь «Портал B» куда нужно и поправь координаты ниже. +`// Портал A: касание → телепорт к «Портал B» (ищем его по имени в момент входа). +// Передвигай «Портал B» куда угодно — телепорт всегда попадёт к нему. let cd = false; game.self.onTouch(() => { - if (cd) return; cd = true; - const p = game.self.position; - game.player.teleport(p.x + 8, p.y + 1, p.z); // к месту портала B - game.after(1, () => { cd = false; }); + if (cd) return; + const b = game.scene.findOne('Портал B'); // ищем второй портал + if (!b || !b.position) return; + cd = true; + game.player.teleport(b.position.x, b.position.y + 1, b.position.z); + game.after(1.2, () => { cd = false; }); // защита от повторного входа });` }], }, { @@ -265,14 +267,15 @@ game.self.onTouch(() => { icon: 'door', category: 'world', prims: [{ type: 'cube', x: 0, y: 2, z: 0, sx: 1, sy: 4, sz: 3, color: '#8a5a3c', material: 'matte', name: 'Дверь' }], scripts: [{ attachTo: 'on-target', code: -`// Дверь по E: открыта/закрыта — уезжает вниз и возвращается. +`// Дверь по E: уезжает В СТОРОНУ (вбок) и возвращается. Ширина двери sx=1, +// сдвигаем на 3 единицы вбок — проход открывается. let open = false; const p0 = game.self.position; // закрытое положение game.self.onInteract(() => { open = !open; - if (open) game.self.move(p0.x, p0.y - 4.2, p0.z); // уехала вниз (открыта) - else game.self.move(p0.x, p0.y, p0.z); // вернулась (закрыта) -}, { text: open ? 'Закрыть' : 'Открыть', key: 'e', distance: 5 });` }], + if (open) game.self.move(p0.x + 3, p0.y, p0.z); // уехала вбок (открыта) + else game.self.move(p0.x, p0.y, p0.z); // вернулась (закрыта) +}, { text: 'Открыть / закрыть', key: 'e', distance: 5 });` }], }, ]; From 32cbb7bbe9552d64246fad11b1f194e5e2425c3e Mon Sep 17 00:00:00 2001 From: min Date: Fri, 5 Jun 2026 08:51:36 +0300 Subject: [PATCH 20/74] =?UTF-8?q?fix(studio):=20=D0=B4=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D1=8C=20=D0=BF=D0=BE=D0=B2=D0=BE=D1=80=D0=B0=D1=87=D0=B8=D0=B2?= =?UTF-8?q?=D0=B0=D0=B5=D1=82=D1=81=D1=8F=20=D0=B2=D0=BE=D0=BA=D1=80=D1=83?= =?UTF-8?q?=D0=B3=20=D0=BF=D0=B5=D1=82=D0=BB=D0=B8=20(=D0=BA=D0=B0=D0=BA?= =?UTF-8?q?=20=D0=BD=D0=B0=D1=81=D1=82=D0=BE=D1=8F=D1=89=D0=B0=D1=8F),=20?= =?UTF-8?q?=D0=B0=20=D0=BD=D0=B5=20=D0=BE=D1=82=D1=81=D0=BA=D0=B0=D0=BA?= =?UTF-8?q?=D0=B8=D0=B2=D0=B0=D0=B5=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Было: дверь сдвигалась вбок. Стало: вращение вокруг левой грани (петли) на 90°. Центр двери пересчитывается по дуге вокруг hinge (p0.z - halfW), плюс self.rotate(angle) — дверь распахивается, как в реальности. Co-Authored-By: Claude Opus 4.8 --- src/editor/engine/GameplayKits.js | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/editor/engine/GameplayKits.js b/src/editor/engine/GameplayKits.js index 738a193..c04308b 100644 --- a/src/editor/engine/GameplayKits.js +++ b/src/editor/engine/GameplayKits.js @@ -267,14 +267,24 @@ game.self.onTouch(() => { icon: 'door', category: 'world', prims: [{ type: 'cube', x: 0, y: 2, z: 0, sx: 1, sy: 4, sz: 3, color: '#8a5a3c', material: 'matte', name: 'Дверь' }], scripts: [{ attachTo: 'on-target', code: -`// Дверь по E: уезжает В СТОРОНУ (вбок) и возвращается. Ширина двери sx=1, -// сдвигаем на 3 единицы вбок — проход открывается. +`// Дверь по E: ПОВОРАЧИВАЕТСЯ вокруг петли (левой грани), как настоящая дверь. +// Толщина двери по X (sx=1) → полуширина 0.5. Петля у грани z = центр - halfZ. let open = false; -const p0 = game.self.position; // закрытое положение +const p0 = game.self.position; +const halfW = 1.5; // половина ширины двери по Z (sz=3 → 1.5) +// Петля — у левого края двери (по Z): hinge = p0.z - halfW. +const hingeX = p0.x; +const hingeZ = p0.z - halfW; +function placeDoor(angle) { + // Центр двери на расстоянии halfW от петли, повёрнут на angle вокруг петли. + const cx = hingeX + Math.sin(angle) * halfW; + const cz = hingeZ + Math.cos(angle) * halfW; + game.self.move(cx, p0.y, cz); + game.self.rotate(angle); +} game.self.onInteract(() => { open = !open; - if (open) game.self.move(p0.x + 3, p0.y, p0.z); // уехала вбок (открыта) - else game.self.move(p0.x, p0.y, p0.z); // вернулась (закрыта) + placeDoor(open ? Math.PI / 2 : 0); // 90° открыта / 0° закрыта }, { text: 'Открыть / закрыть', key: 'e', distance: 5 });` }], }, ]; From 9a58c34303b89d11370d1cc8b0fa84c00cee3288 Mon Sep 17 00:00:00 2001 From: min Date: Fri, 5 Jun 2026 09:16:46 +0300 Subject: [PATCH 21/74] =?UTF-8?q?feat(studio):=20=D0=BA=D1=80=D0=B0=D1=81?= =?UTF-8?q?=D0=B8=D0=B2=D0=B0=D1=8F=20=D0=B4=D0=B2=D0=B5=D1=80=D1=8C=20(?= =?UTF-8?q?=D1=80=D0=B0=D0=BC=D0=BA=D0=B0+=D1=84=D0=B8=D0=BB=D1=91=D0=BD?= =?UTF-8?q?=D0=BA=D0=B8+=D1=80=D1=83=D1=87=D0=BA=D0=B0)=20+=20=D0=BF=D0=BB?= =?UTF-8?q?=D0=B0=D0=B2=D0=BD=D0=B0=D1=8F=20=D0=B0=D0=BD=D0=B8=D0=BC=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D1=8F=20=D0=BE=D1=82=D0=BA=D1=80=D1=8B=D1=82=D0=B8?= =?UTF-8?q?=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Дверь теперь многочастная: полотно из тёмного дерева + 2 филёнки + золотая ручка + косяк-рамка (2 стойки + перемычка). Уходит в общую папку. - Плавное открытие: постоянный onTick ведёт угол cur→target со скоростью ~0.5с на 90° (вместо мгновенного скачка). Поворот вокруг петли сохранён. Co-Authored-By: Claude Opus 4.8 --- src/editor/engine/GameplayKits.js | 49 +++++++++++++++++++++++-------- 1 file changed, 36 insertions(+), 13 deletions(-) diff --git a/src/editor/engine/GameplayKits.js b/src/editor/engine/GameplayKits.js index c04308b..915d9c6 100644 --- a/src/editor/engine/GameplayKits.js +++ b/src/editor/engine/GameplayKits.js @@ -263,28 +263,51 @@ game.self.onTouch(() => { { id: 'door-button', name: 'Дверь по кнопке E', - desc: 'Дверь + кнопка: подойди, нажми E — дверь уезжает в сторону и открывает проход. (Вики: «Кнопка-открывашка»)', + desc: 'Красивая дверь с рамкой, филёнками и ручкой. Нажми E — плавно распахивается вокруг петли. (Вики: «Кнопка-открывашка»)', icon: 'door', category: 'world', - prims: [{ type: 'cube', x: 0, y: 2, z: 0, sx: 1, sy: 4, sz: 3, color: '#8a5a3c', material: 'matte', name: 'Дверь' }], + // Полотно двери — ПЕРВЫЙ prim (на нём скрипт). Остальные части — рамка + // (неподвижный косяк) + декор полотна. Всё уходит в одну папку. + prims: [ + // 0) Полотно двери (тёмное дерево). + { type: 'cube', x: 0, y: 2, z: 0, sx: 0.25, sy: 3.8, sz: 2.6, color: '#6b4423', material: 'matte', name: 'Полотно двери' }, + // Филёнки (светлее, чуть выступают) — верхняя и нижняя. + { type: 'cube', x: 0.16, y: 2.9, z: 0, sx: 0.08, sy: 1.2, sz: 1.9, color: '#8a5a2e', material: 'matte', canCollide: false, name: 'Филёнка верх' }, + { type: 'cube', x: 0.16, y: 1.2, z: 0, sx: 0.08, sy: 1.4, sz: 1.9, color: '#8a5a2e', material: 'matte', canCollide: false, name: 'Филёнка низ' }, + // Ручка (золотая). + { type: 'sphere', x: 0.28, y: 2, z: 0.95, sx: 0.3, sy: 0.3, sz: 0.3, color: '#e0b030', material: 'metal', canCollide: false, name: 'Ручка' }, + // Косяк-рамка (неподвижная) — две стойки + перемычка. + { type: 'cube', x: 0, y: 2, z: -1.55, sx: 0.4, sy: 4.2, sz: 0.4, color: '#4a3018', material: 'matte', name: 'Косяк левый' }, + { type: 'cube', x: 0, y: 2, z: 1.55, sx: 0.4, sy: 4.2, sz: 0.4, color: '#4a3018', material: 'matte', name: 'Косяк правый' }, + { type: 'cube', x: 0, y: 4.1, z: 0, sx: 0.4, sy: 0.4, sz: 3.5, color: '#4a3018', material: 'matte', name: 'Перемычка' }, + ], scripts: [{ attachTo: 'on-target', code: -`// Дверь по E: ПОВОРАЧИВАЕТСЯ вокруг петли (левой грани), как настоящая дверь. -// Толщина двери по X (sx=1) → полуширина 0.5. Петля у грани z = центр - halfZ. -let open = false; +`// Дверь по E: ПЛАВНО поворачивается вокруг петли (левой грани). +// Скрипт на полотне двери. Филёнки/ручка двигаются вместе как часть полотна? +// Нет — они отдельные примитивы, поэтому анимируем только полотно (game.self), +// а декор оставляем — на низкой толщине двери смотрится цельно. const p0 = game.self.position; -const halfW = 1.5; // половина ширины двери по Z (sz=3 → 1.5) -// Петля — у левого края двери (по Z): hinge = p0.z - halfW. -const hingeX = p0.x; -const hingeZ = p0.z - halfW; -function placeDoor(angle) { - // Центр двери на расстоянии halfW от петли, повёрнут на angle вокруг петли. - const cx = hingeX + Math.sin(angle) * halfW; +const halfW = 1.3; // половина ширины полотна по Z (sz=2.6) +const hingeZ = p0.z - halfW; // петля у левого края +let open = false; +let cur = 0, target = 0; // текущий и целевой угол (радианы) +const SPEED = Math.PI; // рад/сек → ~0.5с на 90° +function place(angle) { + const cx = p0.x + Math.sin(angle) * halfW; const cz = hingeZ + Math.cos(angle) * halfW; game.self.move(cx, p0.y, cz); game.self.rotate(angle); } +// Один постоянный тик плавно ведёт cur → target. +game.onTick((dt) => { + if (cur === target) return; + const step = SPEED * dt; + if (Math.abs(target - cur) <= step) cur = target; + else cur += Math.sign(target - cur) * step; + place(cur); +}); game.self.onInteract(() => { open = !open; - placeDoor(open ? Math.PI / 2 : 0); // 90° открыта / 0° закрыта + target = open ? Math.PI / 2 : 0; // 90° открыта / 0° закрыта }, { text: 'Открыть / закрыть', key: 'e', distance: 5 });` }], }, ]; From 0e4fa89f40e098317b62fdc4d8eff8c715945daa Mon Sep 17 00:00:00 2001 From: min Date: Fri, 5 Jun 2026 09:20:15 +0300 Subject: [PATCH 22/74] =?UTF-8?q?fix(studio):=20=D0=B4=D0=B5=D0=BA=D0=BE?= =?UTF-8?q?=D1=80=20=D0=B4=D0=B2=D0=B5=D1=80=D0=B8=20(=D1=84=D0=B8=D0=BB?= =?UTF-8?q?=D1=91=D0=BD=D0=BA=D0=B8+=D1=80=D1=83=D1=87=D0=BA=D0=B0)=20?= =?UTF-8?q?=D0=B2=D1=80=D0=B0=D1=89=D0=B0=D0=B5=D1=82=D1=81=D1=8F=20=D0=B2?= =?UTF-8?q?=D0=BC=D0=B5=D1=81=D1=82=D0=B5=20=D1=81=20=D0=BF=D0=BE=D0=BB?= =?UTF-8?q?=D0=BE=D1=82=D0=BD=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Декор-части находятся по имени (findOne), их локальное смещение от центра полотна поворачивается вокруг той же петли в place() — теперь филёнки и ручка открываются вместе с дверью, а не висят в проёме. Co-Authored-By: Claude Opus 4.8 --- src/editor/engine/GameplayKits.js | 37 +++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/src/editor/engine/GameplayKits.js b/src/editor/engine/GameplayKits.js index 915d9c6..8573630 100644 --- a/src/editor/engine/GameplayKits.js +++ b/src/editor/engine/GameplayKits.js @@ -285,18 +285,41 @@ game.self.onTouch(() => { // Скрипт на полотне двери. Филёнки/ручка двигаются вместе как часть полотна? // Нет — они отдельные примитивы, поэтому анимируем только полотно (game.self), // а декор оставляем — на низкой толщине двери смотрится цельно. -const p0 = game.self.position; -const halfW = 1.3; // половина ширины полотна по Z (sz=2.6) -const hingeZ = p0.z - halfW; // петля у левого края +const p0 = game.self.position; // центр полотна (закрытое положение) +const halfW = 1.3; // половина ширины полотна по Z (sz=2.6) +const hingeX = p0.x; +const hingeZ = p0.z - halfW; // петля у левого края let open = false; -let cur = 0, target = 0; // текущий и целевой угол (радианы) -const SPEED = Math.PI; // рад/сек → ~0.5с на 90° +let cur = 0, target = 0; // текущий и целевой угол (радианы) +const SPEED = Math.PI; // рад/сек → ~0.5с на 90° + +// Декор полотна — двигаем вместе с дверью. Запоминаем их СМЕЩЕНИЕ относительно +// центра полотна (в закрытом виде), чтобы вращать вокруг той же петли. +const decorNames = ['Филёнка верх', 'Филёнка низ', 'Ручка']; +const decor = []; +for (const nm of decorNames) { + const o = game.scene.findOne(nm); + if (o && o.position) { + decor.push({ obj: o, dx: o.position.x - p0.x, dy: o.position.y - p0.y, dz: o.position.z - p0.z }); + } +} + function place(angle) { - const cx = p0.x + Math.sin(angle) * halfW; - const cz = hingeZ + Math.cos(angle) * halfW; + const s = Math.sin(angle), c = Math.cos(angle); + // Полотно: его центр на дуге вокруг петли. + const cx = hingeX + s * halfW; + const cz = hingeZ + c * halfW; game.self.move(cx, p0.y, cz); game.self.rotate(angle); + // Декор: позиция центра полотна + повёрнутое локальное смещение. + for (const d of decor) { + const rx = d.dx * c - d.dz * s; // поворот смещения вокруг Y + const rz = d.dx * s + d.dz * c; + d.obj.move(cx + rx, p0.y + d.dy, cz + rz); + if (d.obj.rotate) d.obj.rotate(angle); + } } + // Один постоянный тик плавно ведёт cur → target. game.onTick((dt) => { if (cur === target) return; From 6938f83a3c3e7961a76f97c898c320b4cfbd4913 Mon Sep 17 00:00:00 2001 From: min Date: Fri, 5 Jun 2026 09:23:35 +0300 Subject: [PATCH 23/74] =?UTF-8?q?fix(studio):=20=D0=B4=D0=B5=D0=BA=D0=BE?= =?UTF-8?q?=D1=80=20=D0=B4=D0=B2=D0=B5=D1=80=D0=B8=20=D0=BF=D0=BE=D0=B2?= =?UTF-8?q?=D0=BE=D1=80=D0=B0=D1=87=D0=B8=D0=B2=D0=B0=D0=B5=D1=82=D1=81?= =?UTF-8?q?=D1=8F=20=D0=B2=20=D1=82=D1=83=20=D0=B6=D0=B5=20=D1=81=D1=82?= =?UTF-8?q?=D0=BE=D1=80=D0=BE=D0=BD=D1=83=20(=D0=BB=D0=B5=D0=B2=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D0=BE=D1=80=D0=BE=D0=BD=D0=BD=D1=8F=D1=8F=20=D0=A1?= =?UTF-8?q?=D0=9A=20Babylon)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ручка/филёнки уезжали на обратную сторону двери: формула поворота смещения была правосторонняя, а Babylon mesh.rotation.y — левосторонняя. Единая rotY() (wx=lx·c+lz·s, wz=-lx·s+lz·c) для полотна И декора → всё открывается синхронно в одну сторону. Co-Authored-By: Claude Opus 4.8 --- src/editor/engine/GameplayKits.js | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/src/editor/engine/GameplayKits.js b/src/editor/engine/GameplayKits.js index 8573630..6c80889 100644 --- a/src/editor/engine/GameplayKits.js +++ b/src/editor/engine/GameplayKits.js @@ -304,18 +304,23 @@ for (const nm of decorNames) { } } +// Поворот локального вектора (lx,lz) вокруг оси Y на angle — согласованно с +// тем, как Babylon поворачивает меш при rotation.y=angle (левосторонняя СК). +function rotY(lx, lz, a) { + const s = Math.sin(a), c = Math.cos(a); + return { x: lx * c + lz * s, z: -lx * s + lz * c }; +} function place(angle) { - const s = Math.sin(angle), c = Math.cos(angle); - // Полотно: его центр на дуге вокруг петли. - const cx = hingeX + s * halfW; - const cz = hingeZ + c * halfW; + // Полотно: центр = петля + повёрнутый локальный вектор (0, +halfW). + const pc = rotY(0, halfW, angle); + const cx = hingeX + pc.x; + const cz = hingeZ + pc.z; game.self.move(cx, p0.y, cz); game.self.rotate(angle); - // Декор: позиция центра полотна + повёрнутое локальное смещение. + // Декор: центр полотна + повёрнутое локальное смещение (той же формулой). for (const d of decor) { - const rx = d.dx * c - d.dz * s; // поворот смещения вокруг Y - const rz = d.dx * s + d.dz * c; - d.obj.move(cx + rx, p0.y + d.dy, cz + rz); + const r = rotY(d.dx, d.dz, angle); + d.obj.move(cx + r.x, p0.y + d.dy, cz + r.z); if (d.obj.rotate) d.obj.rotate(angle); } } From f2708547954143cb9ff5983ccbf5e30b64c9c0df Mon Sep 17 00:00:00 2001 From: min Date: Fri, 5 Jun 2026 09:31:49 +0300 Subject: [PATCH 24/74] =?UTF-8?q?feat(studio):=20+5=20=D0=B3=D0=BE=D1=82?= =?UTF-8?q?=D0=BE=D0=B2=D1=8B=D1=85=20=D0=BC=D0=B5=D1=85=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D0=BA=20(=D1=86=D0=B2=D0=B5=D1=82=D0=BD=D0=B0=D1=8F=20=D0=BF?= =?UTF-8?q?=D0=BB=D0=B8=D1=82=D0=BA=D0=B0/=D0=BB=D0=B0=D0=B2=D0=B0/=D0=BB?= =?UTF-8?q?=D0=B8=D1=84=D1=82/=D1=84=D0=B8=D0=BD=D0=B8=D1=88/=D0=B7=D0=B2?= =?UTF-8?q?=D1=83=D0=BA)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Партия 2 из TOOLBOX_KITS_FROM_WIKI.md: - Цветная плитка — onTouch → смена цвета (self.setColor). - Лава — onTouch/onUntouch → урон 15 HP/сек пока стоишь (player.damage). - Лифт — onTick синусоида, ездит вверх-вниз 8 единиц. - Финиш (победа) — onTouch → экран «ПОБЕДА!» + setInputBlocked. - Звуковая плитка — onTouch → sound.play('coin') + подсветка. game.self расширен: setColor(hex). Все 22 кита прошли синтаксис-проверку. Co-Authored-By: Claude Opus 4.8 --- src/editor/engine/GameplayKits.js | 86 ++++++++++++++++++++++++ src/editor/engine/ScriptSandboxWorker.js | 7 ++ 2 files changed, 93 insertions(+) diff --git a/src/editor/engine/GameplayKits.js b/src/editor/engine/GameplayKits.js index 6c80889..fc4cd30 100644 --- a/src/editor/engine/GameplayKits.js +++ b/src/editor/engine/GameplayKits.js @@ -338,6 +338,92 @@ game.self.onInteract(() => { target = open ? Math.PI / 2 : 0; // 90° открыта / 0° закрыта }, { text: 'Открыть / закрыть', key: 'e', distance: 5 });` }], }, + + // ===== Партия 2 из Вики (киты 18-22) ===== + + { + id: 'color-tiles', + name: 'Цветная плитка', + desc: 'Наступи на плитку — она меняет цвет на случайный. (Вики: «Цветные плитки»)', + icon: 'palette', category: 'world', + prims: [{ type: 'cube', x: 0, y: 0.1, z: 0, sx: 2.5, sy: 0.2, sz: 2.5, color: '#cfd8dc', material: 'matte', name: 'Цветная плитка' }], + scripts: [{ attachTo: 'on-target', code: +`// Плитка меняет цвет при касании. +const colors = ['#ff5ab0','#ffd23a','#4d6bff','#36d57a','#ff7a3a','#a05aff']; +let i = 0; +game.self.onTouch(() => { + i = (i + 1) % colors.length; + game.self.setColor(colors[i]); +});` }], + }, + { + id: 'lava-floor', + name: 'Лава (урон по касанию)', + desc: 'Раскалённая плита: наступишь — теряешь здоровье каждую секунду, пока стоишь. (Вики: «Лава-пол»)', + icon: 'lava', category: 'world', + prims: [{ type: 'cube', x: 0, y: 0.1, z: 0, sx: 5, sy: 0.2, sz: 5, color: '#ff4422', material: 'neon', name: 'Лава' }], + scripts: [{ attachTo: 'on-target', code: +`// Лава: пока игрок на плите — урон каждую секунду. +let onLava = false, timer = null; +game.self.onTouch(() => { + if (onLava) return; onLava = true; + const tick = () => { if (!onLava) return; game.player.damage(15); + game.ui.set('lava', '🔥 Горячо! -15 HP', { x: 50, y: 80, anchor: 'bottom', color: '#ff6644', size: 18 }); + timer = game.after(1, tick); }; + tick(); +}); +game.self.onUntouch(() => { onLava = false; if (timer) game.cancel(timer); game.ui.set('lava', ''); });` }], + }, + { + id: 'elevator', + name: 'Лифт', + desc: 'Платформа-лифт сама ездит вверх-вниз между двумя этажами. Встань на неё и катайся. (Вики: «Лифт»)', + icon: 'elevator', category: 'world', + prims: [{ type: 'cube', x: 0, y: 0.5, z: 0, sx: 4, sy: 0.5, sz: 4, color: '#7a8a9a', material: 'metal', name: 'Лифт' }], + scripts: [{ attachTo: 'on-target', code: +`// Лифт: плавно ездит между нижней и верхней высотой. +const p0 = game.self.position; +const lowY = p0.y, highY = p0.y + 8; +let t = 0; +game.onTick((dt) => { + t += dt; + // Синусоида 0..1 с паузами на концах (период ~8с). + const k = (Math.sin(t * 0.5 - Math.PI/2) + 1) / 2; + game.self.move(p0.x, lowY + (highY - lowY) * k, p0.z); +});` }], + }, + { + id: 'finish-line', + name: 'Финиш (победа)', + desc: 'Финишная плита: дойди до неё — на экране «ПОБЕДА!» и управление блокируется. (Вики: «Беги к финишу»)', + icon: 'flag', category: 'ui', + prims: [{ type: 'cube', x: 0, y: 0.15, z: 0, sx: 4, sy: 0.3, sz: 2, color: '#ffd23a', material: 'neon', name: 'Финиш', canCollide: false }], + scripts: [{ attachTo: 'on-target', code: +`// Финиш: касание → экран победы. +let done = false; +game.self.onTouch(() => { + if (done) return; done = true; + game.ui.set('win', '🏆 ПОБЕДА!', { x: 50, y: 42, anchor: 'center', color: '#ffd23a', size: 48 }); + game.ui.set('winsub', 'Ты дошёл до финиша!', { x: 50, y: 54, anchor: 'center', color: '#fff', size: 22 }); + game.player.setInputBlocked(true); +});` }], + }, + { + id: 'sound-tile', + name: 'Звуковая плитка', + desc: 'Наступи на плитку — играет звук. Из таких можно собрать мелодию. (Вики: «Эхо-комната»)', + icon: 'sound', category: 'fx', + prims: [{ type: 'cube', x: 0, y: 0.1, z: 0, sx: 2, sy: 0.2, sz: 2, color: '#6f8bff', material: 'neon', name: 'Звуковая плитка' }], + scripts: [{ attachTo: 'on-target', code: +`// Плитка играет звук при касании + подсвечивается. +let cd = false; +game.self.onTouch(() => { + if (cd) return; cd = true; + game.sound.play('coin'); + game.self.setColor('#ffffff'); + game.after(0.25, () => { game.self.setColor('#6f8bff'); cd = false; }); +});` }], + }, ]; /** Найти кит по id. */ diff --git a/src/editor/engine/ScriptSandboxWorker.js b/src/editor/engine/ScriptSandboxWorker.js index 468051a..5bad044 100644 --- a/src/editor/engine/ScriptSandboxWorker.js +++ b/src/editor/engine/ScriptSandboxWorker.js @@ -766,6 +766,13 @@ function _buildSelfApi() { const id = _target.id ?? _target.ref; _send('scene.setCollide', { kind: k, id, ref: (k && id != null) ? (k + ':' + id) : undefined, canCollide: !!can }); }, + /** Перекрасить объект-носитель (только примитив). */ + setColor(hex) { + if (typeof hex !== 'string') return; + const k = _target.kind; + const id = _target.id ?? _target.ref; + _send('scene.setColor', { kind: k, id, ref: (k && id != null) ? (k + ':' + id) : undefined, color: hex }); + }, delete() { _send('self.delete', { target: _target }); }, From ed7310a532baa2cc4eab9c3c9ebc7428992b6111 Mon Sep 17 00:00:00 2001 From: min Date: Fri, 5 Jun 2026 09:55:32 +0300 Subject: [PATCH 25/74] =?UTF-8?q?feat(studio):=20+25=20=D0=B3=D0=BE=D1=82?= =?UTF-8?q?=D0=BE=D0=B2=D1=8B=D1=85=20=D0=BC=D0=B5=D1=85=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D0=BA=20=D0=B8=D0=B7=20=D0=92=D0=B8=D0=BA=D0=B8=20(=D0=B2?= =?UTF-8?q?=D1=81=D1=8F=20=D0=BF=D0=B0=D1=80=D1=82=D0=B8=D1=8F=203=20?= =?UTF-8?q?=E2=80=94=20=D0=B2=D1=81=D0=B5=20=D0=BE=D1=81=D1=82=D0=B0=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D1=8B=D0=B5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Добавлены все оставшиеся механики из TOOLBOX_KITS_FROM_WIKI.md: Мир: зона опасности, шипы, светофор, грядка-урожай, падающие предметы. Интерфейс: счётчик очков, HP-бар, дверь по коду (textbox), метка с именем, обратный отсчёт, 3D-стрелка-указатель. Эффекты: костёр (particles fire), магнит монет. NPC и бой (новая категория): преследователь, торговец (modal.dialog), мишень, враг с HP, волна врагов, диалог/кат-сцена, машина (vehicle:car). Экономика (новая категория): магазин-кнопка, кликер, ключ+замок. +2 категории китов (NPC и бой, Экономика). Всего ~37 китов. Опущены «Главное меню» и «Экран загрузки» — требуют целой сцены, не «1 клик». Все 45 скриптов прошли синтаксис-проверку, билд зелёный. Co-Authored-By: Claude Opus 4.8 --- src/editor/engine/GameplayKits.js | 384 ++++++++++++++++++++++++++++++ 1 file changed, 384 insertions(+) diff --git a/src/editor/engine/GameplayKits.js b/src/editor/engine/GameplayKits.js index fc4cd30..4d5086d 100644 --- a/src/editor/engine/GameplayKits.js +++ b/src/editor/engine/GameplayKits.js @@ -20,6 +20,8 @@ export const KIT_CATEGORIES = [ { id: 'world', label: 'Мир' }, { id: 'ui', label: 'Интерфейс' }, { id: 'fx', label: 'Эффекты' }, + { id: 'npc', label: 'NPC и бой' }, + { id: 'economy', label: 'Экономика' }, ]; export const GAMEPLAY_KITS = [ @@ -424,6 +426,388 @@ game.self.onTouch(() => { game.after(0.25, () => { game.self.setColor('#6f8bff'); cd = false; }); });` }], }, + + // ===== Партия 3 из Вики (остальные механики) ===== + + // --- Мир --- + { + id: 'damage-zone', + name: 'Зона опасности', + desc: 'Невидимая зона: пока игрок внутри — теряет здоровье. (Вики: «Зона опасности»)', + icon: 'warning', category: 'world', + prims: [{ type: 'cube', x: 0, y: 2, z: 0, sx: 6, sy: 4, sz: 6, color: '#ff3344', material: 'glass', canCollide: false, name: 'Зона опасности' }], + scripts: [{ attachTo: 'on-target', code: +`// Зона урона: внутри — 10 HP/сек. +let inside = false, t = null; +game.self.onTouch(() => { if (inside) return; inside = true; + const tick = () => { if (!inside) return; game.player.damage(10); + game.ui.set('dz', '☠ Опасно! -10 HP', { x:50, y:78, anchor:'bottom', color:'#ff5555', size:18 }); + t = game.after(1, tick); }; tick(); }); +game.self.onUntouch(() => { inside = false; if (t) game.cancel(t); game.ui.set('dz',''); });` }], + }, + { + id: 'spikes-trap', + name: 'Шипы-ловушка', + desc: 'Ряд острых шипов: наступишь — мгновенный урон. (Вики: «Полоса препятствий»)', + icon: 'warning', category: 'world', + prims: [ + { type: 'cube', x: 0, y: 0.1, z: 0, sx: 4, sy: 0.2, sz: 1.5, color: '#555', material: 'metal', name: 'Основание шипов' }, + { type: 'cone', x: -1.2, y: 0.7, z: 0, sx: 0.6, sy: 1.2, sz: 0.6, color: '#cccccc', material: 'metal', canCollide: false, name: 'Шип 1' }, + { type: 'cone', x: 0, y: 0.7, z: 0, sx: 0.6, sy: 1.2, sz: 0.6, color: '#cccccc', material: 'metal', canCollide: false, name: 'Шип 2' }, + { type: 'cone', x: 1.2, y: 0.7, z: 0, sx: 0.6, sy: 1.2, sz: 0.6, color: '#cccccc', material: 'metal', canCollide: false, name: 'Шип 3' }, + ], + scripts: [{ attachTo: 'on-target', code: +`// Шипы: касание → урон + отброс. +let cd = false; +game.self.onTouch(() => { if (cd) return; cd = true; + game.player.damage(34); + game.ui.set('sp', '🗡 Ой! -34 HP', { x:50, y:78, anchor:'bottom', color:'#ff5555', size:18 }); + game.after(1.2, () => { game.ui.set('sp',''); cd = false; }); });` }], + }, + { + id: 'traffic-light', + name: 'Светофор', + desc: 'Светофор переключает красный/жёлтый/зелёный по таймеру. (Вики: «Светофор»)', + icon: 'light', category: 'world', + prims: [ + { type: 'cube', x: 0, y: 3, z: 0, sx: 1.2, sy: 4, sz: 1.2, color: '#2a2a2a', material: 'matte', name: 'Корпус светофора' }, + { type: 'sphere', x: 0, y: 4.2, z: 0.6, sx: 0.8, sy: 0.8, sz: 0.4, color: '#5a0000', material: 'neon', canCollide: false, name: 'Красный' }, + { type: 'sphere', x: 0, y: 3.3, z: 0.6, sx: 0.8, sy: 0.8, sz: 0.4, color: '#5a5a00', material: 'neon', canCollide: false, name: 'Жёлтый' }, + { type: 'sphere', x: 0, y: 2.4, z: 0.6, sx: 0.8, sy: 0.8, sz: 0.4, color: '#005a00', material: 'neon', canCollide: false, name: 'Зелёный' }, + ], + scripts: [{ attachTo: 'on-target', code: +`// Светофор: переключение фаз каждые 2 сек. +const R = game.scene.findOne('Красный'), Y = game.scene.findOne('Жёлтый'), G = game.scene.findOne('Зелёный'); +const phases = [['#ff0000','#5a5a00','#005a00'], ['#5a0000','#ffcc00','#005a00'], ['#5a0000','#5a5a00','#00ff00']]; +let p = 0; +function show(){ const c = phases[p]; if(R) R.color = c[0]; if(Y) Y.color = c[1]; if(G) G.color = c[2]; } +show(); +game.every(2, () => { p = (p+1) % phases.length; show(); });` }], + }, + { + id: 'harvest-plant', + name: 'Грядка с урожаем', + desc: 'Растение растёт, по клику собираешь урожай (+10 монет) и оно вырастает заново. (Вики: «Сбор урожая»)', + icon: 'plant', category: 'world', + prims: [ + { type: 'cube', x: 0, y: 0.2, z: 0, sx: 2, sy: 0.4, sz: 2, color: '#6b4a2e', material: 'matte', name: 'Грядка' }, + { type: 'sphere', x: 0, y: 0.8, z: 0, sx: 1, sy: 1, sz: 1, color: '#3f9a48', material: 'matte', name: 'Урожай' }, + ], + scripts: [{ attachTo: 'on-target', code: +`// Грядка: урожай растёт, по взаимодействию — сбор +10 монет. +const plant = game.scene.findOne('Урожай'); +let ripe = true; +game.self.onInteract(() => { + if (!ripe) { game.ui.set('h','Ещё не созрело...', {x:50,y:80,anchor:'bottom',color:'#bbb',size:16}); return; } + ripe = false; + game.broadcast('coins', { add: 10 }); + if (plant) plant.visible = false; + game.ui.set('h','🌾 Собрано! +10 монет', {x:50,y:80,anchor:'bottom',color:'#ffd23a',size:18}); + game.after(3, () => { if (plant) plant.visible = true; ripe = true; game.ui.set('h',''); }); +}, { text: 'Собрать урожай', key: 'e', distance: 4 });` }], + }, + { + id: 'falling-objects', + name: 'Падающие предметы', + desc: 'Из этой точки с неба сыплются кубики — лови их или уворачивайся. (Вики: «Поймай падающее»)', + icon: 'box', category: 'world', + prims: [{ type: 'sphere', x: 0, y: 8, z: 0, sx: 0.6, sy: 0.6, sz: 0.6, color: '#4d6bff', material: 'neon', canCollide: false, name: 'Тучка-источник' }], + scripts: [{ attachTo: 'on-target', code: +`// Каждые 1.5с роняет куб из позиции источника. +const p = game.self.position; +game.every(1.5, () => { + game.scene.spawn('primitive:cube', { + x: p.x + (Math.random()-0.5)*6, y: p.y, z: p.z + (Math.random()-0.5)*6, + sx: 0.6, sy: 0.6, sz: 0.6, color: '#ffaa33', anchored: false, canCollide: true, lifetime: 6 }); +});` }], + }, + + // --- Интерфейс --- + { + id: 'score-counter', + name: 'Счётчик очков', + desc: 'Счёт очков в HUD. Другие механики шлют game.broadcast("score",{add:N}). (Вики: «Собери монетки»)', + icon: 'star', category: 'ui', + scripts: [{ attachTo: 'global', code: +`// Счётчик очков. Прибавить: game.broadcast('score', { add: 1 }); +let score = 0; +function show(){ game.ui.set('score', '⭐ ' + score, { x:8, y:6, anchor:'top', color:'#ffd23a', size:22 }); } +show(); +game.onMessage('score', (m) => { score += (m && m.add) ? m.add : 1; show(); });` }], + }, + { + id: 'hp-bar', + name: 'Полоска здоровья', + desc: 'Показывает HP игрока в углу экрана, обновляется при уроне/лечении. (Вики: «Лава-пол»)', + icon: 'warning', category: 'ui', + scripts: [{ attachTo: 'global', code: +`// HP-индикатор игрока в HUD. +function show(){ const hp = Math.max(0, Math.round(game.player.hp)); + game.ui.set('hp', '❤ ' + hp, { x:8, y:12, anchor:'top', color: hp>30?'#36d57a':'#ff4444', size:22 }); } +show(); +game.every(0.3, show);` }], + }, + { + id: 'code-door', + name: 'Дверь по коду', + desc: 'Поле ввода: введи правильный код (1234) — дверь открывается. (Вики: «Дверь по коду»)', + icon: 'keypad', category: 'ui', + prims: [{ type: 'cube', x: 0, y: 2, z: 0, sx: 0.5, sy: 4, sz: 3, color: '#5a6478', material: 'metal', name: 'Дверь-код' }], + scripts: [{ attachTo: 'on-target', code: +`// Дверь по коду 1234. Поле ввода снизу. +const CODE = '1234'; +const inp = game.gui.create('textbox', { id:'codein', x:50, y:88, w:24, h:8, anchor:'center', placeholder:'Код...', textSize:20 }); +game.ui.set('codehint', 'Введи код двери (1234) и нажми Enter', {x:50,y:80,anchor:'bottom',color:'#fff',size:16}); +let opened = false; +const p0 = game.self.position; +game.gui.onSubmit('codein', (text) => { + if (opened) return; + if (String(text).trim() === CODE) { opened = true; game.self.move(p0.x, p0.y-4.2, p0.z); + game.ui.set('codehint', '✓ Открыто!', {x:50,y:80,anchor:'bottom',color:'#36d57a',size:18}); } + else game.ui.set('codehint', '✗ Неверный код', {x:50,y:80,anchor:'bottom',color:'#ff5555',size:18}); +});` }], + }, + { + id: 'name-label', + name: 'Метка с именем', + desc: 'Над объектом висит табличка с текстом (имя/HP). (Вики: «Имена над врагами»)', + icon: 'tag', category: 'ui', + prims: [{ type: 'cube', x: 0, y: 1, z: 0, sx: 1.5, sy: 2, sz: 1.5, color: '#c83030', material: 'matte', name: 'Объект с меткой' }], + scripts: [{ attachTo: 'on-target', code: +`// Метка-табличка над объектом. +game.self.setLabel('Враг ❤ 100', { color: '#ffffff', bg: '#c83030' });` }], + }, + { + id: 'countdown', + name: 'Обратный отсчёт', + desc: 'Таймер обратного отсчёта в HUD. По нулю — событие (тут просто сообщение). (Вики: «продержись N секунд»)', + icon: 'clock', category: 'ui', + scripts: [{ attachTo: 'global', code: +`// Обратный отсчёт 30 секунд. +let left = 30; +game.ui.set('cd', '⏳ ' + left, { x:50, y:6, anchor:'top', color:'#fff', size:26 }); +const id = game.every(1, () => { + left--; game.ui.set('cd', '⏳ ' + left, { x:50, y:6, anchor:'top', color: left<=5?'#ff4444':'#fff', size:26 }); + if (left <= 0) { game.cancel(id); game.ui.set('cd', '⏰ Время вышло!', { x:50, y:42, anchor:'center', color:'#ff4444', size:40 }); } +});` }], + }, + + // --- Эффекты --- + { + id: 'fire-emitter', + name: 'Костёр (огонь)', + desc: 'Источник частиц огня — горит постоянно. (Палитра эффектов)', + icon: 'sparkles', category: 'fx', + prims: [{ type: 'cylinder', x: 0, y: 0.2, z: 0, sx: 1.2, sy: 0.4, sz: 1.2, color: '#3a2a1a', material: 'matte', name: 'Костёр' }], + scripts: [{ attachTo: 'on-target', code: +`// Постоянный огонь из точки костра. +const p = game.self.position; +function fire(){ game.scene.spawnParticles('fire', { x:p.x, y:p.y+0.5, z:p.z }, { duration: 1.5, count: 40 }); } +fire(); game.every(1.2, fire);` }], + }, + { + id: 'magnet-coins', + name: 'Магнит монет', + desc: 'Монета сама летит к игроку, когда он подходит близко. (Вики: «Магнит монет»)', + icon: 'circle', category: 'fx', + prims: [{ type: 'cylinder', x: 0, y: 1, z: 0, sx: 0.8, sy: 0.2, sz: 0.8, color: '#ffd23a', material: 'metal', name: 'Монета' }], + scripts: [{ attachTo: 'on-target', code: +`// Монета летит к игроку, если он ближе 6 м; коснулся — +1 монета. +let taken = false; +game.onTick(() => { + if (taken) return; + const me = game.self.position, pl = game.player.position; + const dx = pl.x-me.x, dy = pl.y-me.y, dz = pl.z-me.z; + const d = Math.sqrt(dx*dx+dy*dy+dz*dz); + if (d < 1.2) { taken = true; game.broadcast('coins', { add: 1 }); game.self.setVisible(false); return; } + if (d < 6) { game.self.move(me.x+dx*0.12, me.y+dy*0.12, me.z+dz*0.12); } +});` }], + }, + + // --- NPC и бой --- + { + id: 'npc-chaser', + name: 'NPC-преследователь', + desc: 'Враг бежит за игроком по всему уровню. (Вики: «Преследователь»)', + icon: 'chase', category: 'npc', + scripts: [{ attachTo: 'global', code: +`// Спавним NPC, который преследует игрока. +const enemy = game.scene.spawnNpc('robot', { x: 8, z: 8, name: 'Охотник', speed: 4 }); +if (enemy && enemy.follow) enemy.follow('player');` }], + }, + { + id: 'npc-trader', + name: 'Торговец (диалог)', + desc: 'Фигура торговца: подойди, нажми E — открывается диалог. (Вики: «Торговец»)', + icon: 'trader', category: 'npc', + prims: [ + { type: 'cylinder', x: 0, y: 1, z: 0, sx: 1.2, sy: 2, sz: 1.2, color: '#3a6ea5', material: 'matte', name: 'Торговец' }, + { type: 'sphere', x: 0, y: 2.3, z: 0, sx: 0.9, sy: 0.9, sz: 0.9, color: '#e8c8a0', material: 'matte', canCollide: false, name: 'Голова торговца' }, + ], + scripts: [{ attachTo: 'on-target', code: +`// Торговец с диалогом по E. +game.self.setLabel('Торговец Боб', { color:'#fff', bg:'#3a6ea5' }); +game.self.onInteract(() => { + game.modal.dialog('Торговец Боб', [ + 'Привет, путник! Заходи за товарами.', + 'У меня лучшие мечи во всём королевстве!', + 'Возвращайся, когда накопишь монет.', + ]); +}, { text: 'Поговорить', key: 'e', distance: 4 });` }], + }, + { + id: 'shooting-target', + name: 'Мишень для стрельбы', + desc: 'Кликни по мишени — +10 очков, мишень исчезает и появляется снова. (Вики: «Тир»)', + icon: 'crosshair', category: 'npc', + prims: [ + { type: 'cylinder', x: 0, y: 2, z: 0, sx: 0.3, sy: 2, sz: 2, color: '#ffffff', material: 'matte', name: 'Мишень' }, + { type: 'cylinder', x: 0.2, y: 2, z: 0, sx: 0.1, sy: 1.2, sz: 1.2, color: '#ff3333', material: 'matte', canCollide: false, name: 'Кольцо мишени' }, + ], + scripts: [{ attachTo: 'on-target', code: +`// Мишень: клик → +10 очков, прячется на 1.5с. +let active = true; +game.self.onClick(() => { + if (!active) return; active = false; + game.broadcast('score', { add: 10 }); + game.self.setVisible(false); + game.ui.set('hit', '🎯 +10!', { x:50, y:75, anchor:'bottom', color:'#36d57a', size:20 }); + game.after(1.5, () => { game.self.setVisible(true); active = true; game.ui.set('hit',''); }); +});` }], + }, + { + id: 'enemy-hp', + name: 'Враг с HP', + desc: 'Враг с полоской здоровья над головой. Кликай — урон, при нуле HP погибает. (Вики: «Имена над врагами», «босс»)', + icon: 'boss', category: 'npc', + prims: [{ type: 'cube', x: 0, y: 1.5, z: 0, sx: 1.5, sy: 3, sz: 1.5, color: '#7a2030', material: 'matte', name: 'Враг' }], + scripts: [{ attachTo: 'on-target', code: +`// Враг с HP: клик → -20 HP, метка обновляется, при 0 — исчезает. +let hp = 100; +function lbl(){ game.self.setLabel('Враг ❤ ' + hp, { color:'#fff', bg:'#7a2030' }); } +lbl(); +game.self.onClick(() => { + hp -= 20; if (hp <= 0) { game.self.setVisible(false); game.broadcast('score', { add: 50 }); + game.ui.set('kill', '💀 Враг повержен! +50', { x:50, y:75, anchor:'bottom', color:'#ffd23a', size:18 }); return; } + lbl(); +});` }], + }, + { + id: 'enemy-wave', + name: 'Волна врагов', + desc: 'Спавнер: выпускает врагов волнами по таймеру. (Вики: «Выживание от волн», tower defense)', + icon: 'zombie', category: 'npc', + prims: [{ type: 'cylinder', x: 0, y: 0.15, z: 0, sx: 2, sy: 0.3, sz: 2, color: '#7a2030', material: 'neon', name: 'Портал врагов' }], + scripts: [{ attachTo: 'on-target', code: +`// Каждые 5с спавнит 2 врагов из точки портала, они идут к игроку. +const p = game.self.position; +function wave(){ + for (let i=0;i<2;i++){ const e = game.scene.spawnNpc('robot', { x:p.x+(Math.random()-0.5)*2, z:p.z+(Math.random()-0.5)*2, name:'Враг', speed:3 }); + if (e && e.follow) e.follow('player'); } +} +game.after(2, wave); game.every(5, wave);` }], + }, + + // --- Экономика --- + { + id: 'shop-button', + name: 'Магазин (кнопка покупки)', + desc: 'GUI-кнопка магазина: покупка предмета за 50 монет (если хватает). (Вики: «Магазин»)', + icon: 'cart', category: 'economy', + scripts: [{ attachTo: 'global', code: +`// Кнопка магазина: купить за 50 монет. +let coins = 100; // локальный баланс кита (для демо) +game.ui.set('bal', '🪙 ' + coins, { x:92, y:6, anchor:'top', color:'#ffd23a', size:22 }); +game.onMessage('coins', (m) => { coins += (m&&m.add)?m.add:0; game.ui.set('bal','🪙 '+coins,{x:92,y:6,anchor:'top',color:'#ffd23a',size:22}); }); +game.gui.create('button', { id:'buybtn', x:50, y:90, w:26, h:9, anchor:'center', text:'Купить меч — 50 🪙', + bgGradient:{ stops:['#ffe066','#e0a000'], angle:90 }, textColor:'#3a2a00', textSize:18, fontWeight:800, borderRadius:12 }); +game.gui.onClick('buybtn', () => { + if (coins >= 50) { game.broadcast('coins', { add: -50 }); game.ui.set('shopmsg','✓ Куплено!',{x:50,y:80,anchor:'bottom',color:'#36d57a',size:18}); } + else game.ui.set('shopmsg','✗ Мало монет',{x:50,y:80,anchor:'bottom',color:'#ff5555',size:18}); + game.after(2, () => game.ui.set('shopmsg','')); +});` }], + }, + { + id: 'clicker-button', + name: 'Кликер', + desc: 'GUI-кнопка: кликай и копи очки. (Вики: «Кликер»)', + icon: 'click', category: 'economy', + scripts: [{ attachTo: 'global', code: +`// Кликер: кнопка по центру, клик → +1 очко. +let n = 0; +function show(){ game.ui.set('clk', '👆 ' + n, { x:50, y:20, anchor:'center', color:'#fff', size:36 }); } +show(); +game.gui.create('button', { id:'clickbtn', x:50, y:55, w:30, h:14, anchor:'center', text:'КЛИК!', + bgGradient:{ stops:['#6f8bff','#3a4ed0'], angle:90 }, textColor:'#fff', textSize:32, fontWeight:900, borderRadius:18, + hover:{ scale:1.05 }, active:{ scale:0.94 } }); +game.gui.onClick('clickbtn', () => { n++; show(); });` }], + }, + { + id: 'key-lock', + name: 'Ключ и замок', + desc: 'Подбери ключ, затем открой запертую дверь. Без ключа дверь не открывается. (Вики: «Ключ и сундук»)', + icon: 'key', category: 'economy', + prims: [ + { type: 'cube', x: 0, y: 1, z: 0, sx: 0.4, sy: 0.8, sz: 0.4, color: '#ffd23a', material: 'metal', name: 'Ключ' }, + { type: 'cube', x: 6, y: 2, z: 0, sx: 0.5, sy: 4, sz: 3, color: '#6b4423', material: 'matte', name: 'Запертая дверь' }, + ], + scripts: [{ attachTo: 'on-target', code: +`// Ключ (этот объект) подбирается касанием → открывает «Запертую дверь». +let hasKey = false; +game.self.onTouch(() => { + if (hasKey) return; hasKey = true; + game.self.setVisible(false); + game.ui.set('key', '🔑 Ключ найден! Иди к двери (E).', {x:50,y:80,anchor:'bottom',color:'#ffd23a',size:18}); +}); +const door = game.scene.findOne('Запертая дверь'); +if (door && door.onInteract) { + let opened = false; + door.onInteract(() => { + if (!hasKey) { game.ui.set('key','🔒 Заперто. Нужен ключ.',{x:50,y:80,anchor:'bottom',color:'#ff5555',size:18}); return; } + if (opened) return; opened = true; + const dp = door.position; door.move(dp.x, dp.y-4.2, dp.z); + game.ui.set('key','✓ Дверь открыта!',{x:50,y:80,anchor:'bottom',color:'#36d57a',size:18}); + }, { text:'Открыть дверь', key:'e', distance:4 }); +}` }], + }, + + // --- Из готовых игр (g5) --- + { + id: 'spawn-car', + name: 'Машина (сядь за руль)', + desc: 'Готовый автомобиль: подойди, держи F — садись за руль, WASD — едь. (Вики: «Такси-симулятор»)', + icon: 'car', category: 'npc', + scripts: [{ attachTo: 'global', code: +`// Спавн машины, на которой можно ездить. +game.scene.spawn('vehicle:car', { x: 0, y: 0.5, z: 5, model: 'car-sedan', color: '#c83030', + name: 'Авто', params: { maxSpeed: 18, turnSpeed: 1.7, enginePower: 20 } }); +game.ui.set('carhint', 'Подойди к машине и держи F — за руль!', {x:50,y:90,anchor:'bottom',color:'#fff',size:16});` }], + }, + { + id: 'cutscene-dialog', + name: 'Диалог (кат-сцена)', + desc: 'Объект, по взаимодействию показывает диалог по строкам. (Вики: «Тайна старого сундука»)', + icon: 'scroll', category: 'npc', + prims: [{ type: 'cube', x: 0, y: 0.6, z: 0, sx: 1.4, sy: 1.2, sz: 1, color: '#8a6a3a', material: 'matte', name: 'Рассказчик' }], + scripts: [{ attachTo: 'on-target', code: +`// Диалог по строкам через готовый game.modal.dialog. +const lines = ['Давным-давно здесь стоял замок...', 'Его охранял древний страж.', 'Найди три ключа, чтобы войти!']; +game.self.onInteract(() => { + game.modal.dialog('Рассказчик', lines); +}, { text:'Поговорить', key:'e', distance:4 });` }], + }, + { + id: 'guide-arrow', + name: '3D-стрелка-указатель', + desc: 'Стрелка-подсказка «иди сюда» ведёт игрока к цели. (Вики: «Туториал — собери монетки»)', + icon: 'flag', category: 'ui', + prims: [{ type: 'cylinder', x: 0, y: 1, z: 0, sx: 1, sy: 2, sz: 1, color: '#ffd23a', material: 'neon', name: 'Цель-указатель' }], + scripts: [{ attachTo: 'on-target', code: +`// Стрелка от игрока к этому объекту-цели. +const arrow = game.fx.pointer({ from: 'player', to: game.self, preset: 'guide' }); +game.self.onTouch(() => { if (arrow && arrow.remove) arrow.remove(); + game.ui.set('arr','✓ Дошёл!', {x:50,y:80,anchor:'bottom',color:'#36d57a',size:18}); });` }], + }, ]; /** Найти кит по id. */ From bf93219266ea206325b405e0d8e283693b88198a Mon Sep 17 00:00:00 2001 From: min Date: Fri, 5 Jun 2026 18:19:02 +0300 Subject: [PATCH 26/74] =?UTF-8?q?fix(studio):=20=D0=BA=D0=BB=D0=B8=D0=BA?= =?UTF-8?q?=20=D0=BF=D0=BE=20=D1=87=D0=B0=D1=81=D1=82=D0=B8=20=D0=BF=D0=B0?= =?UTF-8?q?=D0=BF=D0=BA=D0=B8=20=D0=B2=D1=8B=D0=B4=D0=B5=D0=BB=D1=8F=D0=B5?= =?UTF-8?q?=D1=82=20=D0=B2=D1=81=D1=8E=20=D0=BF=D0=B0=D0=BF=D0=BA=D1=83=20?= =?UTF-8?q?+=20free-drag=20=D0=BF=D0=B0=D0=BF=D0=BA=D0=B8=20+=20=D1=83?= =?UTF-8?q?=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BE=D0=B1=D0=BD?= =?UTF-8?q?=D0=BE=D0=B2=D0=BB=D1=8F=D0=B5=D1=82=20=D0=B4=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=B2=D0=BE=20+=20=D0=B7=D0=B0=D1=82=D1=83=D1=85=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=B4=D0=BB=D0=B8=D0=BD=D0=BD=D0=BE=D0=B9=20?= =?UTF-8?q?=D1=82=D0=B5=D0=BD=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1) Длинная тень-полоса: csm.frustumEdgeFalloff=8 — тень персонажа больше не тянется на весь пол. 2) Удаление примитива/модели/блока через ПКМ теперь обновляет дерево (markDirty + hierarchyDirtyRef) — раньше объект удалялся, но не пропадал. 3) Клик по СЦЕНЕ по объекту в папке → выделяется ВСЯ папка (selectByMesh проверяет folderId). Отдельную часть — через раскрытие папки в дереве. 4) Free-drag папки: зажал ЛКМ на группе и тянешь — двигается вся папка (moveFolderBy по дельте центра). Co-Authored-By: Claude Opus 4.8 --- src/editor/KubikonEditor.jsx | 6 ++++++ src/editor/engine/BabylonScene.js | 29 +++++++++++++++++++++++++++ src/editor/engine/SelectionManager.js | 17 ++++++++++++++-- 3 files changed, 50 insertions(+), 2 deletions(-) diff --git a/src/editor/KubikonEditor.jsx b/src/editor/KubikonEditor.jsx index f090f6f..7061068 100644 --- a/src/editor/KubikonEditor.jsx +++ b/src/editor/KubikonEditor.jsx @@ -3351,14 +3351,20 @@ const KubikonEditor = () => { onDeleteBlock={(x, y, z) => { sceneRef.current?.blockManager?.removeBlock(x, y, z); sceneRef.current?.clearSelection(); + markDirty(); + hierarchyDirtyRef.current = true; }} onDeleteModel={(id) => { sceneRef.current?.modelManager?.removeInstance(id); sceneRef.current?.clearSelection(); + markDirty(); + hierarchyDirtyRef.current = true; }} onDeletePrimitive={(id) => { sceneRef.current?.primitiveManager?.removeInstance(id); sceneRef.current?.clearSelection(); + markDirty(); + hierarchyDirtyRef.current = true; }} onFocusSelection={() => sceneRef.current?.focusOnSelection()} onCreateFolder={(name, parentId) => diff --git a/src/editor/engine/BabylonScene.js b/src/editor/engine/BabylonScene.js index 7813a55..7b7b31d 100644 --- a/src/editor/engine/BabylonScene.js +++ b/src/editor/engine/BabylonScene.js @@ -1723,6 +1723,9 @@ export class BabylonScene { : ShadowGenerator.QUALITY_MEDIUM; csm.darkness = 0.4; csm.autoCalcDepthBounds = true; + // Плавное затухание тени у края каскада — убирает «полосу-хвост» + // тени персонажа на весь пол при движении (баг 2026-06-05). + csm.frustumEdgeFalloff = 8; this._shadowGenerator = csm; } else { // Обычный ShadowGenerator. Поднял разрешение для soft до 2048. @@ -5679,6 +5682,14 @@ export class BabylonScene { this.selection?.selectByMesh(m); const sel = this.selection?.getSelection(); if (!sel || sel.type === 'block' || sel.type === 'spawn') return false; + // Папка — тащим всю группу через folderManager (по дельте центра). + if (sel.type === 'folder') { + const g = this.folderManager?.getFolderObjects(sel.folderId); + this._freeDragCandidate = { folder: true, folderId: sel.folderId, last: { ...(g?.center || { x: 0, y: 0, z: 0 }) } }; + this._freeDragHalf = { x: 0.5, y: 0.5, z: 0.5 }; + this._freeDragActive = false; + return true; + } const root = this._getSelectionRoot(sel); if (!root) return false; this._freeDragCandidate = { root }; @@ -5693,6 +5704,24 @@ export class BabylonScene { if (!cand) return; const sel = this.selection?.getSelection(); if (!sel) return; + + // Папка: тащим всю группу по дельте от последнего центра (проекция курсора + // на горизонтальную плоскость по высоте центра группы). + if (cand.folder) { + 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.folderManager?.moveFolderBy(cand.folderId, dx, 0, dz); + cand.last.x = px; cand.last.z = pz; + } + return; + } + const root = cand.root; const half = this._freeDragHalf || { x: 0.5, y: 0.5, z: 0.5 }; diff --git a/src/editor/engine/SelectionManager.js b/src/editor/engine/SelectionManager.js index 8e51719..22b0a94 100644 --- a/src/editor/engine/SelectionManager.js +++ b/src/editor/engine/SelectionManager.js @@ -76,24 +76,37 @@ export class SelectionManager { selectByMesh(mesh) { if (!mesh) return this.clear(); const m = mesh.metadata; + // Если объект лежит в папке — клик по СЦЕНЕ выделяет ВСЮ папку целиком + // (отдельную часть можно выбрать через дерево). folderId берём из data. + const folderIdOf = (kind, id) => { + let d = null; + if (kind === 'model') d = this.modelManager?.instances?.get(id); + else if (kind === 'userModel') d = this.userModelManager?.instances?.get(id); + else if (kind === 'primitive') d = this.primitiveManager?.instances?.get(id); + return d ? (d.folderId ?? null) : null; + }; if (m?.isBlock) { return this.selectBlockAt(m.gridX, m.gridY, m.gridZ); } if (m?.isModel) { - // Заблокированный объект (Фаза 5.11) не выделяется кликом по - // сцене — только через иерархию (чтобы можно было снять lock). const md = this.modelManager?.instances?.get(m.instanceId); if (md && md.locked) return this.clear(); + const fid = folderIdOf('model', m.instanceId); + if (fid != null) return this.selectFolder(fid); return this.selectModelByInstanceId(m.instanceId); } if (m?.isUserModel) { const ud = this.userModelManager?.instances?.get(m.instanceId); if (ud && ud.locked) return this.clear(); + const fid = folderIdOf('userModel', m.instanceId); + if (fid != null) return this.selectFolder(fid); return this.selectUserModelByInstanceId(m.instanceId); } if (m?.isPrimitive) { const pd = this.primitiveManager?.instances?.get(m.primitiveId); if (pd && pd.locked) return this.clear(); + const fid = folderIdOf('primitive', m.primitiveId); + if (fid != null) return this.selectFolder(fid); return this.selectPrimitiveById(m.primitiveId); } if (m?.isSpawn) { From 46414d874b0c586f046c848800d1325f499f470e Mon Sep 17 00:00:00 2001 From: min Date: Fri, 5 Jun 2026 18:30:41 +0300 Subject: [PATCH 27/74] =?UTF-8?q?fix(studio):=20=D0=BA=D0=B8=D1=82=20?= =?UTF-8?q?=D1=81=D0=BF=D0=B0=D0=B2=D0=BD=D0=B8=D1=82=D1=81=D1=8F=20=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=BF=D0=BE=D0=B2=D0=B5=D1=80=D1=85=D0=BD=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D0=B8=20=D0=B2=20=D1=84=D0=BE=D0=BA=D1=83=D1=81=D0=B5=20?= =?UTF-8?q?+=20=D0=BC=D0=BD=D0=BE=D0=B3=D0=BE=D1=87=D0=B0=D1=81=D1=82?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20=D0=B2=20=D0=BF=D0=B0=D0=BF=D0=BA=D1=83=20?= =?UTF-8?q?+=20=D1=81=D0=BD=D1=8F=D1=82=D0=B8=D0=B5=20=D0=B2=D1=8B=D0=B4?= =?UTF-8?q?=D0=B5=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BF=D1=80=D0=B8=20Pla?= =?UTF-8?q?y?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1) Предметы кита из тулбокса спавнятся на ТВЁРДОЙ поверхности под центром экрана (getPlacementPointAtCenter: raycast в пол/объект), а не под камерой. 2) Многочастный кит (дверь) теперь реально попадает в папку: insertGameplayKit ставил folderId, но дерево не пересобиралось (markDirty не трогает hierarchyDirtyRef) — добавлен hierarchyDirtyRef.current=true. 3) enterPlayMode снимает любое выделение редактора (объект/папка) + убирает пивот папки и gizmo — в Play больше нет подсветки выбранного. Co-Authored-By: Claude Opus 4.8 --- src/editor/KubikonEditor.jsx | 18 ++++++++++++------ src/editor/engine/BabylonScene.js | 30 ++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/src/editor/KubikonEditor.jsx b/src/editor/KubikonEditor.jsx index 7061068..0ef3930 100644 --- a/src/editor/KubikonEditor.jsx +++ b/src/editor/KubikonEditor.jsx @@ -778,12 +778,17 @@ const KubikonEditor = () => { const kit = getKit(kitId); const s = sceneRef.current; if (!kit || !s) return; - // Точка вставки — перед камерой редактора (~6м), как у paste. - let px = 0, pz = 0; + // Точка вставки — на ТВЁРДОЙ поверхности под центром экрана (пол/объект), + // чтобы предмет встал на землю в фокусе камеры, а не висел под камерой. + let px = 0, pz = 0, py = 0; try { - const cam = s.camera; - const fwd = cam?.getForwardRay ? cam.getForwardRay().direction : null; - if (cam && fwd) { px = cam.position.x + fwd.x * 6; pz = cam.position.z + fwd.z * 6; } + const gp = s.getPlacementPointAtCenter?.(); + if (gp) { px = gp.x; pz = gp.z; py = gp.y; } + else { + const cam = s.camera; + const fwd = cam?.getForwardRay ? cam.getForwardRay().direction : null; + if (cam && fwd) { px = cam.position.x + fwd.x * 6; pz = cam.position.z + fwd.z * 6; } + } } catch (e) { /* ignore */ } // 1) Создаём примитивы кита. Запоминаем все id (первый — для on-target скрипта). @@ -792,7 +797,7 @@ const KubikonEditor = () => { if (Array.isArray(kit.prims)) { for (const p of kit.prims) { const newId = s.primitiveManager?.addInstance(p.type || 'cube', { - x: px + (p.x || 0), y: (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, color: p.color, material: p.material, canCollide: p.canCollide !== false, visible: true, anchored: true, @@ -829,6 +834,7 @@ const KubikonEditor = () => { } markDirty(); + hierarchyDirtyRef.current = true; // пересобрать дерево (примитивы с folderId) setScriptsList(s.getScripts?.() || []); if (s.folderManager) setFoldersList(s.folderManager.getAll()); // Выделим созданное и наведём камеру (видно, куда добавилось). diff --git a/src/editor/engine/BabylonScene.js b/src/editor/engine/BabylonScene.js index 7b7b31d..edaccc0 100644 --- a/src/editor/engine/BabylonScene.js +++ b/src/editor/engine/BabylonScene.js @@ -2936,6 +2936,29 @@ export class BabylonScene { return { mesh, point: pi.pickedPoint, pickInfo: pi }; } + /** + * Точка под центром экрана на твёрдой поверхности (пол/объект) — для + * спавна предметов из тулбокса «в фокусе камеры на земле». + * Возвращает { x, y, z } (y — высота поверхности). Fallback: проекция на y=0. + */ + getPlacementPointAtCenter() { + const hit = this._pickFromCenter(); + if (hit && hit.point) { + return { x: hit.point.x, y: hit.point.y, z: hit.point.z }; + } + // Нет попадания — проецируем луч из центра на плоскость y=0. + try { + const w = this.engine?.getRenderWidth?.() || this.canvas.width; + const h = this.engine?.getRenderHeight?.() || this.canvas.height; + const ray = this.scene.createPickingRay(w / 2, h / 2, null, this.scene.activeCamera); + if (Math.abs(ray.direction.y) > 1e-4) { + const t = -ray.origin.y / ray.direction.y; + if (t > 0) return { x: ray.origin.x + ray.direction.x * t, y: 0, z: ray.origin.z + ray.direction.z * t }; + } + } catch (e) { /* ignore */ } + return null; + } + /** * Извлечь target {kind, ref} из mesh (proxy/прим/модель). * Используется при клике/touch в Play. @@ -5921,6 +5944,13 @@ export class BabylonScene { */ enterPlayMode() { if (this._isPlaying) return; + // Снять любое выделение редактора (объект/папка) перед запуском игры — + // иначе в Play остаётся подсветка/гизмо выбранного объекта. + try { + if (this._folderPivot) { this._folderPivot.dispose(); this._folderPivot = null; this._folderPivotId = null; } + this._gizmo?.attachTo(null); + this.selection?.clear?.(); + } catch (e) { /* ignore */ } this._isPlaying = true; // Сброс состояния касаний — каждый прогон начинается «не касаясь», // иначе rising-edge touch не сработает, если при стопе игрок стоял на цели. From 2d669a3ff313b121c47e27ecceac285bdd803617 Mon Sep 17 00:00:00 2001 From: min Date: Fri, 5 Jun 2026 18:36:55 +0300 Subject: [PATCH 28/74] =?UTF-8?q?fix(studio):=20=D0=B2=D1=8B=D0=B4=D0=B5?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BF=D0=B0=D0=BF=D0=BA=D0=B8?= =?UTF-8?q?=20=D1=80=D0=B0=D1=81=D0=BA=D1=80=D1=8B=D0=B2=D0=B0=D0=B5=D1=82?= =?UTF-8?q?=20=D0=B4=D0=B5=D1=80=D0=B5=D0=B2=D0=BE=20+=20Delete=20=D1=83?= =?UTF-8?q?=D0=B4=D0=B0=D0=BB=D1=8F=D0=B5=D1=82=20=D0=B2=D1=81=D1=8E=20?= =?UTF-8?q?=D0=BF=D0=B0=D0=BF=D0=BA=D1=83=20=D1=81=20=D1=81=D0=BE=D0=B4?= =?UTF-8?q?=D0=B5=D1=80=D0=B6=D0=B8=D0=BC=D1=8B=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1) При выделении папки (клик по сцене / вставка кита) дерево авто-раскрывается: workspace + цепочка родителей + scrollIntoView к строке папки. Раньше папка выделялась на сцене, но в свёрнутом дереве её не было видно. 2) Delete на выделенной папке (type='folder') → removeFolder(id, true) удаляет всю папку со ВСЕМ содержимым + _cleanupOrphanScripts чистит осиротевшие скрипты привязанные к удалённым объектам. Co-Authored-By: Claude Opus 4.8 --- src/editor/HierarchyPanel.jsx | 21 ++++++++++++++++++++- src/editor/engine/BabylonScene.js | 19 +++++++++++++++++++ src/editor/engine/SelectionManager.js | 11 +++++++++++ 3 files changed, 50 insertions(+), 1 deletion(-) diff --git a/src/editor/HierarchyPanel.jsx b/src/editor/HierarchyPanel.jsx index aef2513..a39613c 100644 --- a/src/editor/HierarchyPanel.jsx +++ b/src/editor/HierarchyPanel.jsx @@ -376,6 +376,23 @@ const HierarchyPanel = ({ useEffect(() => { if (!selection) return; const t = selection.type; + // Выделена ПАПКА (клик по сцене / вставка кита из тулбокса) — раскрыть + // «Сцену» и цепочку папок до неё, чтобы папка стала видна в дереве. + if (t === 'folder') { + setWorkspaceOpen(true); + setOpenFolders(prev => { + const n = new Set(prev); + let cur = selection.folderId; + const guard = new Set(); + while (cur != null && !guard.has(cur)) { + guard.add(cur); + const f = folders.find(ff => ff.id === cur); + if (f && f.parentId != null) { n.add(f.parentId); cur = f.parentId; } else cur = null; + } + return n; + }); + return; + } // Находим объект и его folderId по выделению. let obj = null, kind = null; if (t === 'block') { @@ -486,10 +503,12 @@ const HierarchyPanel = ({ const subUserModels = userModelsByFolder.get(folder.id) || []; const totalCount = subBlocks.length + subModels.length + subPrims.length + subUserModels.length + subFolders.length; + const folderSelected = selection?.type === 'folder' && selection?.folderId === folder.id; return (
{ if (el) el.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); } : null} + className={`${cl.folderHeader} ${folderSelected ? cl.itemSelected : ''}`} style={{ paddingLeft: depth * 12 + 8 }} onClick={() => onSelectFolder?.(folder.id)} onContextMenu={(e) => handleContextMenu(e, { type: 'folder', ...folder })} diff --git a/src/editor/engine/BabylonScene.js b/src/editor/engine/BabylonScene.js index edaccc0..d5ff5e7 100644 --- a/src/editor/engine/BabylonScene.js +++ b/src/editor/engine/BabylonScene.js @@ -6604,6 +6604,25 @@ export class BabylonScene { if (this._onSceneChange) this._onSceneChange(); } + /** Удалить скрипты, чей объект-носитель больше не существует (после удаления + * папки/объектов). Глобальные (target null/'game') не трогаем. */ + _cleanupOrphanScripts() { + if (!Array.isArray(this._scripts)) return; + const exists = (t) => { + if (!t || t === 'game') return true; + if (t.kind === 'primitive') return !!this.primitiveManager?.instances?.has(t.id); + if (t.kind === 'model') return !!this.modelManager?.instances?.has(t.id); + if (t.kind === 'userModel') return !!this.userModelManager?.instances?.has(t.id); + return true; // block и пр. — не чистим + }; + const before = this._scripts.length; + this._scripts = this._scripts.filter(s => exists(s.target)); + if (this._scripts.length !== before) { + this.history?.markChange(); + if (this._onSceneChange) this._onSceneChange(); + } + } + /** * Зарегистрировать колбэк для уведомлений об изменении режима Play * (вызывается когда player сам инициирует exit, например по Esc). diff --git a/src/editor/engine/SelectionManager.js b/src/editor/engine/SelectionManager.js index 22b0a94..cf1dbcf 100644 --- a/src/editor/engine/SelectionManager.js +++ b/src/editor/engine/SelectionManager.js @@ -724,6 +724,17 @@ export class SelectionManager { } else if (this._selection.type === 'spawn') { // Удаление точки спавна → игрок будет появляться в (0, высота, 0). this._scene3d?.deleteSpawn?.(); + } else if (this._selection.type === 'folder') { + // Папка целиком — удаляем со ВСЕМ содержимым (рекурсивно). + const fid = this._selection.folderId; + this.clear(); + this._scene3d?.folderManager?.removeFolder?.(fid, true); + // Удаляем скрипты, привязанные к объектам этой папки? Они привязаны + // к примитивам, которые removeFolder удалит; скрипты на них чистятся + // через _onSceneChange / при сохранении. Дополнительно пусть движок + // подчистит «осиротевшие» скрипты. + this._scene3d?._cleanupOrphanScripts?.(); + return; } this.clear(); } From 6ece149924e8b7794a9bbd3015b8735a468b4ad4 Mon Sep 17 00:00:00 2001 From: min Date: Fri, 5 Jun 2026 18:49:17 +0300 Subject: [PATCH 29/74] =?UTF-8?q?fix(studio):=20=D0=BA=D0=B0=D0=BC=D0=B5?= =?UTF-8?q?=D1=80=D0=B0=203-=D0=B3=D0=BE=20=D0=BB=D0=B8=D1=86=D0=B0=20?= =?UTF-8?q?=D0=BD=D0=B5=20=D1=86=D0=B5=D0=BF=D0=BB=D1=8F=D0=B5=D1=82=D1=81?= =?UTF-8?q?=D1=8F=20=D0=B7=D0=B0=20=D0=BF=D1=80=D0=BE=D1=85=D0=BE=D0=B4?= =?UTF-8?q?=D0=B8=D0=BC=D1=8B=D0=B5=20=D0=B7=D0=BE=D0=BD=D1=8B=20(canColli?= =?UTF-8?q?de=20=D0=B2=20metadata)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Зона опасности / триггеры (canCollide:false) ловились camera-clamp, и камера прыгала к игроку внутри зоны. Причина: metadata примитива НЕ содержал canCollide, а PlayerController._clampCameraToWorld проверяет md.canCollide. Добавлен canCollide в metadata меша (+ синк при updateInstance). Co-Authored-By: Claude Opus 4.8 --- src/editor/engine/PrimitiveManager.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/editor/engine/PrimitiveManager.js b/src/editor/engine/PrimitiveManager.js index 76c60a6..433f51f 100644 --- a/src/editor/engine/PrimitiveManager.js +++ b/src/editor/engine/PrimitiveManager.js @@ -186,6 +186,10 @@ export class PrimitiveManager { primitiveId: id, primitiveType: type, primitiveKind: typeDef.kind, + // canCollide в metadata нужен camera-clamp (PlayerController): + // без него камера 3-го лица цепляется за проходимые зоны/триггеры + // (canCollide:false) и прыгает к игроку внутри зоны. Баг 2026-06-05. + canCollide, }; // textureAsset — id картинки из AssetManager (пользовательская @@ -754,7 +758,10 @@ export class PrimitiveManager { this._applyMaterial(data.mesh, typeDef, data.color, data.material); } - if (patch.canCollide !== undefined) data.canCollide = patch.canCollide; + if (patch.canCollide !== undefined) { + data.canCollide = patch.canCollide; + if (data.mesh?.metadata) data.mesh.metadata.canCollide = patch.canCollide; + } if (patch.locked !== undefined) data.locked = !!patch.locked; if (patch.visible !== undefined) { data.visible = patch.visible; From 018fce474b050668bf8467c7bb152b667d92d786 Mon Sep 17 00:00:00 2001 From: min Date: Fri, 5 Jun 2026 19:00:36 +0300 Subject: [PATCH 30/74] =?UTF-8?q?fix(studio):=20=D0=BE=D0=B1=D1=8A=D0=B5?= =?UTF-8?q?=D0=BA=D1=82=D1=8B=20=D0=B1=D0=BE=D0=BB=D1=8C=D1=88=D0=B5=20?= =?UTF-8?q?=D0=BD=D0=B5=20=D0=B2=D1=8B=D0=B2=D0=B0=D0=BB=D0=B8=D0=B2=D0=B0?= =?UTF-8?q?=D1=8E=D1=82=D1=81=D1=8F=20=D0=B8=D0=B7=20=D0=BF=D0=B0=D0=BF?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20Play/Stop=20(fo?= =?UTF-8?q?lderId=20=D0=B2=20serialize)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Корень: serialize примитивов/моделей/userModel НЕ сохранял folderId. При Play→Stop сцена восстанавливалась из снапшота без группировки → все части кита (светофор/шипы/дверь) вываливались из папки в общие «Примитивы». Добавлен folderId в serialize всех 3 менеджеров + восстановление в loadFromArray (model/userModel явно, primitive через opts.folderId). Co-Authored-By: Claude Opus 4.8 --- src/editor/engine/ModelManager.js | 4 ++++ src/editor/engine/PrimitiveManager.js | 3 +++ src/editor/engine/UserModelManager.js | 10 +++++++++- 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/editor/engine/ModelManager.js b/src/editor/engine/ModelManager.js index d457be9..e52bba2 100644 --- a/src/editor/engine/ModelManager.js +++ b/src/editor/engine/ModelManager.js @@ -526,6 +526,9 @@ export class ModelManager { opacity: typeof data.opacity === 'number' ? data.opacity : 1, tint: data.tint || null, name: data.name || null, + // folderId — принадлежность к папке (иначе модели вываливаются + // из папки после Play/Stop). Баг 2026-06-05. + ...(data.folderId != null ? { folderId: data.folderId } : {}), // Параметры геймплея (HP, скорость врага, лимит спавнера и т.п.) gameplayParams: data.gameplayParams || null, }); @@ -768,6 +771,7 @@ export class ModelManager { if (m.tint) data.tint = m.tint; if (m.name) data.name = m.name; if (m.gameplayParams) data.gameplayParams = m.gameplayParams; + if (m.folderId != null) data.folderId = m.folderId; // восстановить папку if (data.opacity != null || data.tint) this._applyMaterialOverrides(data); } // Гарантируем что _nextInstanceId стоит ПОСЛЕ максимального восстановленного id — diff --git a/src/editor/engine/PrimitiveManager.js b/src/editor/engine/PrimitiveManager.js index 433f51f..8a50c0c 100644 --- a/src/editor/engine/PrimitiveManager.js +++ b/src/editor/engine/PrimitiveManager.js @@ -945,6 +945,9 @@ export class PrimitiveManager { anchored: d.anchored, mass: d.mass, name: d.name || null, + // folderId — принадлежность к папке. БЕЗ него примитивы вываливались + // из папки после Play/Stop (снапшот терял группировку). Баг 2026-06-05. + ...(d.folderId != null ? { folderId: d.folderId } : {}), // locked — защита от выделения/перемещения (Фаза 5.11). ...(d.locked ? { locked: true } : {}), // id пользовательской текстуры (картинка из AssetManager). diff --git a/src/editor/engine/UserModelManager.js b/src/editor/engine/UserModelManager.js index 9e8629a..7af7734 100644 --- a/src/editor/engine/UserModelManager.js +++ b/src/editor/engine/UserModelManager.js @@ -599,6 +599,8 @@ export class UserModelManager { // instanceId — чтобы target-скрипты могли стабильно ссылаться // на конкретный инстанс после перезагрузки. instanceId: inst.instanceId, + // folderId — принадлежность к папке (иначе вываливается после Play/Stop). + ...(inst.folderId != null ? { folderId: inst.folderId } : {}), }); } return arr; @@ -663,7 +665,13 @@ export class UserModelManager { forceInstanceId: item.instanceId, }, ); - if (id != null) loaded++; + if (id != null) { + loaded++; + if (item.folderId != null) { // восстановить папку + const inst = this.instances.get(id); + if (inst) inst.folderId = item.folderId; + } + } } catch (e) { console.warn('[UserModelManager] failed to load instance', item, e); } From 045f892aaa67f5400ec8d08feabd359f81be8aed Mon Sep 17 00:00:00 2001 From: min Date: Fri, 5 Jun 2026 19:13:02 +0300 Subject: [PATCH 31/74] =?UTF-8?q?fix(studio):=20=D1=81=D0=B2=D0=B5=D1=82?= =?UTF-8?q?=D0=BE=D1=84=D0=BE=D1=80=20(obj.color=20=D0=BF=D0=BE=20ref),=20?= =?UTF-8?q?=D0=B3=D1=80=D1=8F=D0=B4=D0=BA=D0=B0=20=D1=80=D0=B0=D1=81=D1=82?= =?UTF-8?q?=D1=91=D1=82+=D0=B7=D1=80=D0=B5=D0=B5=D1=82=20(obj.scale),=20?= =?UTF-8?q?=D0=BA=D0=B8=D1=82=20=D1=81=D0=BA=D1=80=D1=8B=D1=82=D0=B8=D1=8F?= =?UTF-8?q?=20HP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1) scene.setColor теперь принимает {ref} (obj.color=hex), не только {id}. Светофор переключал цвета через obj.color, но ref игнорировался → не работал. 2) Грядка: добавлен obj.scale (scene.setScale → mesh.scaling). Урожай после сбора исчезает, растёт за 5с (scale 0→1) и зреет цветом красный→зелёный, при полном размере снова собирается. 3) Кит «HP-бар» теперь сам прячет стандартный HUD HP (setHpVisible false). Новый кит «Скрыть стандартный HUD HP» — отдельно прячет дефолтную полосу. Co-Authored-By: Claude Opus 4.8 --- src/editor/engine/GameRuntime.js | 20 +++++++++-- src/editor/engine/GameplayKits.js | 44 +++++++++++++++++++----- src/editor/engine/ScriptSandboxWorker.js | 6 ++++ 3 files changed, 60 insertions(+), 10 deletions(-) diff --git a/src/editor/engine/GameRuntime.js b/src/editor/engine/GameRuntime.js index efb6a72..1280a55 100644 --- a/src/editor/engine/GameRuntime.js +++ b/src/editor/engine/GameRuntime.js @@ -2904,13 +2904,29 @@ export class GameRuntime { return; } + if (cmd === 'scene.setScale') { + try { + const k = Number(payload?.scale); + if (!Number.isFinite(k) || k < 0) return; + const pm = this.scene3d?.primitiveManager; + const rid = this._resolvePrimitiveId(payload?.id ?? payload?.ref); + const data = (pm && rid != null) ? pm.instances.get(rid) : null; + if (data?.mesh) { + if (data._worldMatrixFrozen) { try { data.mesh.unfreezeWorldMatrix?.(); } catch (e) {} data._worldMatrixFrozen = false; } + data.mesh.scaling.set(k, k, k); // визуальный масштаб от исходного размера + } + } catch (e) {} + return; + } + if (cmd === 'scene.setColor') { try { const color = payload?.color; if (typeof color !== 'string') return; // Окрашиваемый блок (studs-block): ref вида 'block:x,y,z' → // меняем per-instance цвет через BlockManager.setBlockColor. - const ref = payload?.id; + // ВАЖНО: obj.color=hex шлёт {ref}, а self.setColor — {id}. Берём оба. + const ref = payload?.id ?? payload?.ref; if (typeof ref === 'string' && ref.startsWith('block:')) { const parts = ref.slice(6).split(',').map(Number); if (parts.length === 3 && parts.every(Number.isFinite)) { @@ -2920,7 +2936,7 @@ export class GameRuntime { } const pm = this.scene3d?.primitiveManager; if (!pm) return; - const rid = this._resolvePrimitiveId(payload?.id); + const rid = this._resolvePrimitiveId(payload?.id ?? payload?.ref); const data = rid != null ? pm.instances.get(rid) : null; if (data) { data.color = color; diff --git a/src/editor/engine/GameplayKits.js b/src/editor/engine/GameplayKits.js index 4d5086d..05b8fc9 100644 --- a/src/editor/engine/GameplayKits.js +++ b/src/editor/engine/GameplayKits.js @@ -494,17 +494,35 @@ game.every(2, () => { p = (p+1) % phases.length; show(); });` }], { type: 'sphere', x: 0, y: 0.8, z: 0, sx: 1, sy: 1, sz: 1, color: '#3f9a48', material: 'matte', name: 'Урожай' }, ], scripts: [{ attachTo: 'on-target', code: -`// Грядка: урожай растёт, по взаимодействию — сбор +10 монет. +`// Грядка: собрал урожай → он исчезает, растёт заново (цвет красный→зелёный), +// при полном размере снова созрел и его можно собрать. const plant = game.scene.findOne('Урожай'); -let ripe = true; +let ripe = true; // созрел ли (можно собирать) +let growth = 1; // 0..1 — степень роста +// Плавная интерполяция цвета красный(незрелый)→зелёный(спелый). +function colorAt(g) { + const r = Math.round(0xb0 + (0x3f - 0xb0) * g); + const gr = Math.round(0x40 + (0x9a - 0x40) * g); + const b = Math.round(0x2e + (0x48 - 0x2e) * g); + return '#' + [r,gr,b].map(v => v.toString(16).padStart(2,'0')).join(''); +} game.self.onInteract(() => { if (!ripe) { game.ui.set('h','Ещё не созрело...', {x:50,y:80,anchor:'bottom',color:'#bbb',size:16}); return; } ripe = false; + growth = 0; game.broadcast('coins', { add: 10 }); - if (plant) plant.visible = false; + if (plant) { plant.scale = 0.01; plant.color = colorAt(0); } game.ui.set('h','🌾 Собрано! +10 монет', {x:50,y:80,anchor:'bottom',color:'#ffd23a',size:18}); - game.after(3, () => { if (plant) plant.visible = true; ripe = true; game.ui.set('h',''); }); -}, { text: 'Собрать урожай', key: 'e', distance: 4 });` }], + game.after(2, () => game.ui.set('h','')); +}, { text: 'Собрать урожай', key: 'e', distance: 4 }); +// Рост: за ~5 секунд от 0 до 1. +game.onTick((dt) => { + if (ripe || !plant) return; + growth = Math.min(1, growth + dt / 5); + plant.scale = Math.max(0.05, growth); + plant.color = colorAt(growth); + if (growth >= 1) ripe = true; +});` }], }, { id: 'falling-objects', @@ -541,11 +559,21 @@ game.onMessage('score', (m) => { score += (m && m.add) ? m.add : 1; show(); });` desc: 'Показывает HP игрока в углу экрана, обновляется при уроне/лечении. (Вики: «Лава-пол»)', icon: 'warning', category: 'ui', scripts: [{ attachTo: 'global', code: -`// HP-индикатор игрока в HUD. +`// Своя полоска HP. Сначала прячем стандартную, чтобы не дублировалась. +game.hud.setHpVisible(false); function show(){ const hp = Math.max(0, Math.round(game.player.hp)); - game.ui.set('hp', '❤ ' + hp, { x:8, y:12, anchor:'top', color: hp>30?'#36d57a':'#ff4444', size:22 }); } + game.ui.set('hp', '❤ ' + hp + ' / 100', { x:50, y:94, anchor:'bottom', color: hp>30?'#36d57a':'#ff4444', size:22 }); } show(); -game.every(0.3, show);` }], +game.every(0.2, show);` }], + }, + { + id: 'hide-default-hp', + name: 'Скрыть стандартный HUD HP', + desc: 'Прячет стандартную полосу здоровья сверху — чтобы показать свою. (Свойство игрока game.hud.setHpVisible)', + icon: 'warning', category: 'ui', + scripts: [{ attachTo: 'global', code: +`// Скрыть стандартную полосу здоровья игрока. +game.hud.setHpVisible(false);` }], }, { id: 'code-door', diff --git a/src/editor/engine/ScriptSandboxWorker.js b/src/editor/engine/ScriptSandboxWorker.js index 5bad044..433dc78 100644 --- a/src/editor/engine/ScriptSandboxWorker.js +++ b/src/editor/engine/ScriptSandboxWorker.js @@ -554,6 +554,12 @@ function _getOrCreateInstance(ref, kindHint) { _send('scene.setColor', { ref, color: String(value) }); return true; } + if (prop === 'scale') { + // Равномерный визуальный масштаб объекта (1 = исходный размер). + const k = Number(value); + if (Number.isFinite(k) && k >= 0) _send('scene.setScale', { ref, scale: k }); + return true; + } if (prop === 'transparency' || prop === 'opacity') { const v = Number(value); if (Number.isFinite(v)) { From 3bf1e7723072dc91baa3fd24f94cf31699250f7f Mon Sep 17 00:00:00 2001 From: min Date: Fri, 5 Jun 2026 19:27:09 +0300 Subject: [PATCH 32/74] =?UTF-8?q?fix(studio):=20self.setLabel,=20=D0=B4?= =?UTF-8?q?=D0=B2=D0=B5=D1=80=D1=8C=20=D0=BF=D0=BE=20=D0=BA=D0=BE=D0=B4?= =?UTF-8?q?=D1=83=20(=D0=BA=D1=80=D0=B0=D1=81=D0=B8=D0=B2=D0=B0=D1=8F+?= =?UTF-8?q?=D1=80=D0=B0=D0=B4=D0=B8=D1=83=D1=81),=20=D1=81=D1=87=D1=91?= =?UTF-8?q?=D1=82=D1=87=D0=B8=D0=BA=20=D0=BC=D0=B5=D1=85=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D0=BA,=20=D1=81=D1=81=D1=8B=D0=BB=D0=BA=D0=B0=20=D0=BD=D0=B0?= =?UTF-8?q?=20=D1=81=D0=BA=D1=80=D0=B8=D0=BF=D1=82=20=D0=B2=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BD=D1=81=D0=BE=D0=BB=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1) Дверь по коду: красивая составная дверь (полотно+рамка+кодовая панель), поле ввода появляется ТОЛЬКО когда игрок в радиусе 6м (onTick по дистанции). 2) game.self.setLabel/clearLabel добавлены (кит «Метка с именем» падал 'setLabel is not a function'). 3) Плитка «Готовые механики» в тулбоксе считает киты динамически (GAMEPLAY_KITS.length), а не хардкод «12». 4) Консоль: ошибки/логи скриптов привязаны к источнику — справа строки кликабельная ссылка «📄 имя скрипта», открывает скрипт в редакторе (_log прокидывает scriptId/scriptName). Co-Authored-By: Claude Opus 4.8 --- src/editor/KubikonEditor.jsx | 5 +++ src/editor/ScriptConsole.jsx | 40 ++++++++++++++----- src/editor/ToolboxModal.jsx | 2 +- src/editor/engine/GameRuntime.js | 13 ++++-- src/editor/engine/GameplayKits.js | 50 +++++++++++++++++++----- src/editor/engine/ScriptSandboxWorker.js | 14 +++++++ 6 files changed, 101 insertions(+), 23 deletions(-) diff --git a/src/editor/KubikonEditor.jsx b/src/editor/KubikonEditor.jsx index 0ef3930..bbf3c6a 100644 --- a/src/editor/KubikonEditor.jsx +++ b/src/editor/KubikonEditor.jsx @@ -3124,6 +3124,11 @@ const KubikonEditor = () => { logs={scriptLogs} onClear={() => setScriptLogs([])} onClose={() => setConsoleOpen(false)} + onOpenScript={(scriptId) => { + // Открыть скрипт-источник ошибки в редакторе. + try { sceneRef.current?.selection?.selectScript?.(scriptId); } catch (e) {} + openScriptTab(scriptId); + }} /> {/* Monaco-редактор скрипта (этап 2.2). Активен когда выбран таб со скриптом. */} diff --git a/src/editor/ScriptConsole.jsx b/src/editor/ScriptConsole.jsx index bbdb9a0..4f3fcd0 100644 --- a/src/editor/ScriptConsole.jsx +++ b/src/editor/ScriptConsole.jsx @@ -25,7 +25,7 @@ const LEVEL_BG = { warn: 'rgba(245, 158, 11, 0.12)', }; -const ScriptConsole = ({ logs = [], onClear, onClose, visible }) => { +const ScriptConsole = ({ logs = [], onClear, onClose, visible, onOpenScript }) => { const listRef = useRef(null); const [copyState, setCopyState] = useState('idle'); @@ -260,16 +260,38 @@ const ScriptConsole = ({ logs = [], onClear, onClose, visible }) => { ? `3px solid ${LEVEL_COLORS[l.level]}` : '3px solid transparent', paddingLeft: 8, + display: 'flex', alignItems: 'flex-start', gap: 8, }}> - - {new Date(l.ts || Date.now()).toLocaleTimeString().slice(0, 8)} + + + {new Date(l.ts || Date.now()).toLocaleTimeString().slice(0, 8)} + + {l.level === 'error' && } + {l.level === 'warn' && } + {l.level === 'info' && } + {l.text} - {l.level === 'error' && } - {l.level === 'warn' && } - {l.level === 'info' && } - {l.text} + {/* Ссылка на скрипт-источник (клик открывает его). */} + {l.scriptId && ( + + )}
)) )} diff --git a/src/editor/ToolboxModal.jsx b/src/editor/ToolboxModal.jsx index c5b78de..61e58d4 100644 --- a/src/editor/ToolboxModal.jsx +++ b/src/editor/ToolboxModal.jsx @@ -470,7 +470,7 @@ const ToolboxModal = ({ { id: '3d', label: '3D-объекты', icon: 'cube', desc: '700+ моделей: природа, дома, мебель, NPC' }, { id: 'fx', label: 'Эффекты', icon: 'sparkles', desc: 'Частицы, лучи, маркеры' }, { id: '2d', label: '2D-картинки', icon: 'image', desc: 'Иконки и текстуры для интерфейса' }, - { id: 'gameplay', label: 'Готовые механики', icon: 'zap', desc: '12 механик: вставил — работает' }, + { id: 'gameplay', label: 'Готовые механики', icon: 'zap', desc: `${GAMEPLAY_KITS.length} механик: вставил — работает` }, { id: 'plugins', label: 'Плагины', icon: 'puzzle', desc: 'Расширения студии' }, { id: 'audio', label: 'Аудио', icon: 'sound', desc: 'Звуки и музыка' }, ]; diff --git a/src/editor/engine/GameRuntime.js b/src/editor/engine/GameRuntime.js index 1280a55..15a896b 100644 --- a/src/editor/engine/GameRuntime.js +++ b/src/editor/engine/GameRuntime.js @@ -68,6 +68,7 @@ export class GameRuntime { start(scripts) { this.stop(); this._isRunning = true; + this.scripts = scripts || []; // для привязки логов/ошибок к скрипту // eslint-disable-next-line no-console console.log('[GameRuntime] start called with scripts:', scripts); // Phase 6.5: lazy-init физ-мира. Wasm-инициализация асинхронная (~50мс), @@ -1548,7 +1549,13 @@ export class GameRuntime { /** Команда от Worker'а пришла — применяем на сцене. */ _handleCommand(scriptId, cmd, payload) { if (cmd === 'log') { - this._log(payload?.level || 'info', payload?.text || ''); + // Привязываем запись к скрипту-источнику (для ссылки в консоли). + let scriptName = null; + try { + const meta = (this.scripts || []).find(s => s.id === scriptId); + scriptName = meta?.name || scriptId; + } catch (e) { scriptName = scriptId; } + this._log(payload?.level || 'info', payload?.text || '', scriptId, scriptName); return; } if (cmd === 'player.teleport') { @@ -4213,9 +4220,9 @@ export class GameRuntime { } } - _log(level, text) { + _log(level, text, scriptId = null, scriptName = null) { if (this._onLog) { - try { this._onLog({ level, text, ts: Date.now() }); } catch (e) { /* ignore */ } + try { this._onLog({ level, text, ts: Date.now(), scriptId, scriptName }); } catch (e) { /* ignore */ } } } diff --git a/src/editor/engine/GameplayKits.js b/src/editor/engine/GameplayKits.js index 05b8fc9..93518b9 100644 --- a/src/editor/engine/GameplayKits.js +++ b/src/editor/engine/GameplayKits.js @@ -578,21 +578,51 @@ game.hud.setHpVisible(false);` }], { id: 'code-door', name: 'Дверь по коду', - desc: 'Поле ввода: введи правильный код (1234) — дверь открывается. (Вики: «Дверь по коду»)', - icon: 'keypad', category: 'ui', - prims: [{ type: 'cube', x: 0, y: 2, z: 0, sx: 0.5, sy: 4, sz: 3, color: '#5a6478', material: 'metal', name: 'Дверь-код' }], + desc: 'Красивая дверь с кодовой панелью: подойди — появится поле ввода, введи код (1234) — откроется. (Вики: «Дверь по коду»)', + icon: 'keypad', category: 'world', + // Красивая дверь (полотно + рамка) + кодовая панель на стене. + prims: [ + { type: 'cube', x: 0, y: 2, z: 0, sx: 0.25, sy: 3.8, sz: 2.6, color: '#5a6478', material: 'metal', name: 'Полотно двери-код' }, + { type: 'cube', x: 0.16, y: 2.6, z: 0, sx: 0.06, sy: 0.9, sz: 1.8, color: '#79879a', material: 'metal', canCollide: false, name: 'Панель двери' }, + { type: 'cube', x: 0.16, y: 1.3, z: 0, sx: 0.06, sy: 0.9, sz: 1.8, color: '#79879a', material: 'metal', canCollide: false, name: 'Панель двери низ' }, + { type: 'cube', x: 0.3, y: 2, z: 0.95, sx: 0.15, sy: 0.6, sz: 0.5, color: '#ffd23a', material: 'neon', canCollide: false, name: 'Кодовая панель' }, + { type: 'cube', x: 0, y: 2, z: -1.55, sx: 0.4, sy: 4.2, sz: 0.4, color: '#3a4250', material: 'metal', name: 'Косяк левый' }, + { type: 'cube', x: 0, y: 2, z: 1.55, sx: 0.4, sy: 4.2, sz: 0.4, color: '#3a4250', material: 'metal', name: 'Косяк правый' }, + { type: 'cube', x: 0, y: 4.1, z: 0, sx: 0.4, sy: 0.4, sz: 3.5, color: '#3a4250', material: 'metal', name: 'Перемычка' }, + ], scripts: [{ attachTo: 'on-target', code: -`// Дверь по коду 1234. Поле ввода снизу. +`// Дверь по коду 1234. Поле ввода появляется ТОЛЬКО когда игрок рядом. const CODE = '1234'; -const inp = game.gui.create('textbox', { id:'codein', x:50, y:88, w:24, h:8, anchor:'center', placeholder:'Код...', textSize:20 }); -game.ui.set('codehint', 'Введи код двери (1234) и нажми Enter', {x:50,y:80,anchor:'bottom',color:'#fff',size:16}); -let opened = false; const p0 = game.self.position; +const RADIUS = 6; +let opened = false, near = false, inp = null; +game.ui.set('codehint', '', {}); +game.onTick(() => { + if (opened) return; + const pl = game.player.position; + const dx = pl.x - p0.x, dz = pl.z - p0.z; + const d = Math.sqrt(dx*dx + dz*dz); + if (d < RADIUS && !near) { + near = true; + inp = game.gui.create('textbox', { id:'codein', x:50, y:86, w:24, h:8, anchor:'center', placeholder:'Код...', textSize:20 }); + game.ui.set('codehint', '🔢 Введи код двери и нажми Enter', {x:50,y:78,anchor:'bottom',color:'#fff',size:16}); + } else if (d >= RADIUS && near) { + near = false; + if (inp) game.gui.remove('codein'); + game.ui.set('codehint', '', {}); + } +}); game.gui.onSubmit('codein', (text) => { if (opened) return; - if (String(text).trim() === CODE) { opened = true; game.self.move(p0.x, p0.y-4.2, p0.z); - game.ui.set('codehint', '✓ Открыто!', {x:50,y:80,anchor:'bottom',color:'#36d57a',size:18}); } - else game.ui.set('codehint', '✗ Неверный код', {x:50,y:80,anchor:'bottom',color:'#ff5555',size:18}); + if (String(text).trim() === CODE) { + opened = true; + game.self.move(p0.x, p0.y - 4.2, p0.z); // дверь уезжает вниз (открыта) + game.gui.remove('codein'); + game.ui.set('codehint', '✓ Открыто!', {x:50,y:78,anchor:'bottom',color:'#36d57a',size:18}); + game.after(2, () => game.ui.set('codehint', '', {})); + } else { + game.ui.set('codehint', '✗ Неверный код', {x:50,y:78,anchor:'bottom',color:'#ff5555',size:18}); + } });` }], }, { diff --git a/src/editor/engine/ScriptSandboxWorker.js b/src/editor/engine/ScriptSandboxWorker.js index 433dc78..1b8b98f 100644 --- a/src/editor/engine/ScriptSandboxWorker.js +++ b/src/editor/engine/ScriptSandboxWorker.js @@ -779,6 +779,20 @@ function _buildSelfApi() { const id = _target.id ?? _target.ref; _send('scene.setColor', { kind: k, id, ref: (k && id != null) ? (k + ':' + id) : undefined, color: hex }); }, + /** Повесить текст-метку над объектом-носителем (имя/HP). */ + setLabel(text, opts) { + const k = _target.kind; + const id = _target.id ?? _target.ref; + const ref = (k && id != null) ? (k + ':' + id) : undefined; + _send('scene.setLabel', { ref, text: String(text == null ? '' : text), opts: opts || {} }); + }, + /** Убрать метку с объекта-носителя. */ + clearLabel() { + const k = _target.kind; + const id = _target.id ?? _target.ref; + const ref = (k && id != null) ? (k + ':' + id) : undefined; + _send('scene.clearLabel', { ref }); + }, delete() { _send('self.delete', { target: _target }); }, From fe8b6b5b38191cc1947bcea69fe83142df47b8f2 Mon Sep 17 00:00:00 2001 From: min Date: Fri, 5 Jun 2026 19:36:18 +0300 Subject: [PATCH 33/74] =?UTF-8?q?fix(studio):=20NPC-=D0=BA=D0=B8=D1=82?= =?UTF-8?q?=D1=8B=20(robot=E2=86=92character-a),=20=D0=B4=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D1=8C=20=D0=BF=D0=BE=20=D0=BA=D0=BE=D0=B4=D1=83=20=D0=BF?= =?UTF-8?q?=D0=BB=D0=B0=D0=B2=D0=BD=D0=BE=20=D1=80=D0=B0=D1=81=D0=BF=D0=B0?= =?UTF-8?q?=D1=85=D0=B8=D0=B2=D0=B0=D0=B5=D1=82=D1=81=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1) NPC-преследователь/торговец/волна: spawnNpc('robot') → 'character-a' (модели 'robot' не существует в ModelTypes → spawnNpc возвращал null → ошибка). 2) Дверь по коду: верный код → ПЛАВНОЕ открытие вокруг петли (как дверь по E), декор-панели вращаются вместе с полотном. Раньше полотно уезжало вниз, декор оставался. Co-Authored-By: Claude Opus 4.8 --- src/editor/engine/GameplayKits.js | 61 ++++++++++++++++++------------- 1 file changed, 36 insertions(+), 25 deletions(-) diff --git a/src/editor/engine/GameplayKits.js b/src/editor/engine/GameplayKits.js index 93518b9..f6d8cee 100644 --- a/src/editor/engine/GameplayKits.js +++ b/src/editor/engine/GameplayKits.js @@ -591,38 +591,49 @@ game.hud.setHpVisible(false);` }], { type: 'cube', x: 0, y: 4.1, z: 0, sx: 0.4, sy: 0.4, sz: 3.5, color: '#3a4250', material: 'metal', name: 'Перемычка' }, ], scripts: [{ attachTo: 'on-target', code: -`// Дверь по коду 1234. Поле ввода появляется ТОЛЬКО когда игрок рядом. +`// Дверь по коду 1234. Поле ввода появляется ТОЛЬКО рядом. Верный код → +// дверь ПЛАВНО распахивается вокруг петли (вместе с панелями). const CODE = '1234'; const p0 = game.self.position; +const halfW = 1.3; // полуширина полотна (sz=2.6) +const hingeX = p0.x, hingeZ = p0.z - halfW; const RADIUS = 6; -let opened = false, near = false, inp = null; -game.ui.set('codehint', '', {}); -game.onTick(() => { +let opened = false, near = false, cur = 0, target = 0; +const SPEED = Math.PI; // ~0.5с на 90° + +// Декор полотна — двигается вместе с дверью. +const decorNames = ['Панель двери', 'Панель двери низ', 'Кодовая панель']; +const decor = []; +for (const nm of decorNames) { + const o = game.scene.findOne(nm); + if (o && o.position) decor.push({ obj:o, dx:o.position.x-p0.x, dy:o.position.y-p0.y, dz:o.position.z-p0.z }); +} +function rotY(lx, lz, a){ const s=Math.sin(a),c=Math.cos(a); return { x: lx*c+lz*s, z: -lx*s+lz*c }; } +function place(a){ + const pc = rotY(0, halfW, a); + const cx = hingeX + pc.x, cz = hingeZ + pc.z; + game.self.move(cx, p0.y, cz); game.self.rotate(a); + for (const d of decor){ const r = rotY(d.dx, d.dz, a); d.obj.move(cx+r.x, p0.y+d.dy, cz+r.z); if (d.obj.rotate) d.obj.rotate(a); } +} +game.onTick((dt) => { + if (cur !== target){ const st=SPEED*dt; cur = Math.abs(target-cur)<=st ? target : cur+Math.sign(target-cur)*st; place(cur); } if (opened) return; + // Поле ввода появляется/прячется по дистанции. const pl = game.player.position; - const dx = pl.x - p0.x, dz = pl.z - p0.z; - const d = Math.sqrt(dx*dx + dz*dz); - if (d < RADIUS && !near) { - near = true; - inp = game.gui.create('textbox', { id:'codein', x:50, y:86, w:24, h:8, anchor:'center', placeholder:'Код...', textSize:20 }); - game.ui.set('codehint', '🔢 Введи код двери и нажми Enter', {x:50,y:78,anchor:'bottom',color:'#fff',size:16}); - } else if (d >= RADIUS && near) { - near = false; - if (inp) game.gui.remove('codein'); - game.ui.set('codehint', '', {}); - } + const d = Math.sqrt((pl.x-p0.x)**2 + (pl.z-p0.z)**2); + if (d < RADIUS && !near){ near = true; + game.gui.create('textbox', { id:'codein', x:50, y:86, w:24, h:8, anchor:'center', placeholder:'Код...', textSize:20 }); + game.ui.set('codehint', '🔢 Введи код двери (1234) и нажми Enter', {x:50,y:78,anchor:'bottom',color:'#fff',size:16}); } + else if (d >= RADIUS && near){ near = false; game.gui.remove('codein'); game.ui.set('codehint','',{}); } }); game.gui.onSubmit('codein', (text) => { if (opened) return; - if (String(text).trim() === CODE) { - opened = true; - game.self.move(p0.x, p0.y - 4.2, p0.z); // дверь уезжает вниз (открыта) + if (String(text).trim() === CODE){ + opened = true; target = Math.PI/2; // плавно распахнуть game.gui.remove('codein'); - game.ui.set('codehint', '✓ Открыто!', {x:50,y:78,anchor:'bottom',color:'#36d57a',size:18}); - game.after(2, () => game.ui.set('codehint', '', {})); - } else { - game.ui.set('codehint', '✗ Неверный код', {x:50,y:78,anchor:'bottom',color:'#ff5555',size:18}); - } + game.ui.set('codehint','✓ Открыто!', {x:50,y:78,anchor:'bottom',color:'#36d57a',size:18}); + game.after(2, () => game.ui.set('codehint','',{})); + } else game.ui.set('codehint','✗ Неверный код', {x:50,y:78,anchor:'bottom',color:'#ff5555',size:18}); });` }], }, { @@ -690,7 +701,7 @@ game.onTick(() => { icon: 'chase', category: 'npc', scripts: [{ attachTo: 'global', code: `// Спавним NPC, который преследует игрока. -const enemy = game.scene.spawnNpc('robot', { x: 8, z: 8, name: 'Охотник', speed: 4 }); +const enemy = game.scene.spawnNpc('character-a', { x: 8, z: 8, name: 'Охотник', speed: 4 }); if (enemy && enemy.follow) enemy.follow('player');` }], }, { @@ -760,7 +771,7 @@ game.self.onClick(() => { `// Каждые 5с спавнит 2 врагов из точки портала, они идут к игроку. const p = game.self.position; function wave(){ - for (let i=0;i<2;i++){ const e = game.scene.spawnNpc('robot', { x:p.x+(Math.random()-0.5)*2, z:p.z+(Math.random()-0.5)*2, name:'Враг', speed:3 }); + for (let i=0;i<2;i++){ const e = game.scene.spawnNpc('character-a', { x:p.x+(Math.random()-0.5)*2, z:p.z+(Math.random()-0.5)*2, name:'Враг', speed:3 }); if (e && e.follow) e.follow('player'); } } game.after(2, wave); game.every(5, wave);` }], From 9903719f9d27614dad7d26c74687990f2a6c22fa Mon Sep 17 00:00:00 2001 From: min Date: Fri, 5 Jun 2026 19:47:27 +0300 Subject: [PATCH 34/74] =?UTF-8?q?fix(studio):=20=D0=B4=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D1=8C-=D0=BA=D0=BE=D0=B4=20=D0=B7=D0=B0=D0=BA=D1=80=D1=8B?= =?UTF-8?q?=D0=B2=D0=B0=D0=B5=D1=82=D1=81=D1=8F=20=D0=BF=D0=BE=20E,=20NPC?= =?UTF-8?q?=20=D1=85=D0=BE=D0=B4=D0=B8=D1=82=20=D1=81=20=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D0=BC=D0=B0=D1=86=D0=B8=D0=B5=D0=B9,=20=D1=82=D0=BE=D1=80?= =?UTF-8?q?=D0=B3=D0=BE=D0=B2=D0=B5=D1=86=3DNPC,=20=D1=82=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=20=D0=BA=D0=BE=D1=80=D0=BE=D1=87=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1) Дверь по коду: открытую можно закрыть по E (onInteract) → снова вводить код. 2) NPC: процедурная анимация ходьбы (покачивание по Y + наклон корпуса) для Kenney-моделей — раньше скользили без анимации. 3) Торговец переделан в NPC-персонажа (spawnNpc character-a) + невидимый триггер с диалогом по E (вместо примитивов). 4) Тени: убрана «полоса через всю карту» — autoCalcDepthBounds off, shadowMaxZ 90/60 (было 200/120), lambda 0.6, frustumEdgeFalloff 12. Co-Authored-By: Claude Opus 4.8 --- src/editor/engine/BabylonScene.js | 14 +++++++------- src/editor/engine/GameplayKits.js | 28 +++++++++++++++++----------- src/editor/engine/NpcManager.js | 14 ++++++++++---- 3 files changed, 34 insertions(+), 22 deletions(-) diff --git a/src/editor/engine/BabylonScene.js b/src/editor/engine/BabylonScene.js index d5ff5e7..9b49603 100644 --- a/src/editor/engine/BabylonScene.js +++ b/src/editor/engine/BabylonScene.js @@ -1712,9 +1712,9 @@ export class BabylonScene { const csm = new CascadedShadowGenerator(size, this._sunLight); csm.numCascades = numCascades; csm.stabilizeCascades = true; - csm.lambda = 0.8; - csm.cascadeBlendPercentage = 0.07; - csm.shadowMaxZ = (q === 'high') ? 200 : 120; + csm.lambda = 0.6; // меньше — каскады равномернее, нет вытянутого дальнего + csm.cascadeBlendPercentage = 0.1; + csm.shadowMaxZ = (q === 'high') ? 90 : 60; // тени только вблизи (убирает «полосу через всю карту») csm.bias = PCF_BIAS; csm.normalBias = PCF_NORMAL_BIAS; csm.usePercentageCloserFiltering = true; @@ -1722,10 +1722,10 @@ export class BabylonScene { ? ShadowGenerator.QUALITY_HIGH : ShadowGenerator.QUALITY_MEDIUM; csm.darkness = 0.4; - csm.autoCalcDepthBounds = true; - // Плавное затухание тени у края каскада — убирает «полосу-хвост» - // тени персонажа на весь пол при движении (баг 2026-06-05). - csm.frustumEdgeFalloff = 8; + // autoCalcDepthBounds растягивал дальний каскад → длинная тонкая + // тень-полоса персонажа на весь пол. Выключаем + фикс. дальность. + csm.autoCalcDepthBounds = false; + csm.frustumEdgeFalloff = 12; this._shadowGenerator = csm; } else { // Обычный ShadowGenerator. Поднял разрешение для soft до 2048. diff --git a/src/editor/engine/GameplayKits.js b/src/editor/engine/GameplayKits.js index f6d8cee..f35dd8a 100644 --- a/src/editor/engine/GameplayKits.js +++ b/src/editor/engine/GameplayKits.js @@ -631,10 +631,17 @@ game.gui.onSubmit('codein', (text) => { if (String(text).trim() === CODE){ opened = true; target = Math.PI/2; // плавно распахнуть game.gui.remove('codein'); - game.ui.set('codehint','✓ Открыто!', {x:50,y:78,anchor:'bottom',color:'#36d57a',size:18}); - game.after(2, () => game.ui.set('codehint','',{})); + game.ui.set('codehint','✓ Открыто! Нажми E чтобы закрыть.', {x:50,y:78,anchor:'bottom',color:'#36d57a',size:16}); + game.after(2.5, () => game.ui.set('codehint','',{})); } else game.ui.set('codehint','✗ Неверный код', {x:50,y:78,anchor:'bottom',color:'#ff5555',size:18}); -});` }], +}); +// Закрыть открытую дверь по E → снова можно вводить код. +game.self.onInteract(() => { + if (!opened) return; + opened = false; near = false; target = 0; // плавно закрыть + game.ui.set('codehint','🔒 Дверь закрыта.', {x:50,y:78,anchor:'bottom',color:'#fff',size:16}); + game.after(2, () => game.ui.set('codehint','',{})); +}, { text: 'Закрыть дверь', key: 'e', distance: 4 });` }], }, { id: 'name-label', @@ -706,16 +713,15 @@ if (enemy && enemy.follow) enemy.follow('player');` }], }, { id: 'npc-trader', - name: 'Торговец (диалог)', - desc: 'Фигура торговца: подойди, нажми E — открывается диалог. (Вики: «Торговец»)', + name: 'Торговец (NPC)', + desc: 'NPC-персонаж торговец: подойди, нажми E — открывается диалог. (Вики: «Торговец»)', icon: 'trader', category: 'npc', - prims: [ - { type: 'cylinder', x: 0, y: 1, z: 0, sx: 1.2, sy: 2, sz: 1.2, color: '#3a6ea5', material: 'matte', name: 'Торговец' }, - { type: 'sphere', x: 0, y: 2.3, z: 0, sx: 0.9, sy: 0.9, sz: 0.9, color: '#e8c8a0', material: 'matte', canCollide: false, name: 'Голова торговца' }, - ], + // Невидимый prim-триггер держит onInteract; рядом спавнится NPC-персонаж. + prims: [{ type: 'cube', x: 0, y: 1.5, z: 0, sx: 2, sy: 3, sz: 2, color: '#3a6ea5', material: 'matte', visible: false, canCollide: false, name: 'Зона торговца' }], scripts: [{ attachTo: 'on-target', code: -`// Торговец с диалогом по E. -game.self.setLabel('Торговец Боб', { color:'#fff', bg:'#3a6ea5' }); +`// Торговец — настоящий NPC-персонаж. Триггер (этот объект) держит диалог по E. +const p = game.self.position; +const npc = game.scene.spawnNpc('character-a', { x: p.x, z: p.z, name: 'Торговец Боб' }); game.self.onInteract(() => { game.modal.dialog('Торговец Боб', [ 'Привет, путник! Заходи за товарами.', diff --git a/src/editor/engine/NpcManager.js b/src/editor/engine/NpcManager.js index 491e8d6..773d3c9 100644 --- a/src/editor/engine/NpcManager.js +++ b/src/editor/engine/NpcManager.js @@ -390,13 +390,19 @@ export class NpcManager { if (root._isWorldMatrixFrozen) { try { root.unfreezeWorldMatrix(); } catch (e) {} } - root.position.set(npc.x, npc.y, npc.z); + // Анимация ходьбы — процедурное покачивание (у Kenney-моделей нет + // скелета). Подпрыгивание по Y + лёгкое раскачивание корпуса. + if (moving) npc.walkPhase += dt * 10; + let bobY = 0, lean = 0; + if (moving && !npc.r15Animator) { + bobY = Math.abs(Math.sin(npc.walkPhase)) * 0.12; // шаги вверх-вниз + lean = Math.sin(npc.walkPhase) * 0.08; // покачивание + } + root.position.set(npc.x, npc.y + bobY, npc.z); root.rotation.y = npc.yaw; + root.rotation.z = lean; // data.x/y/z — чтобы scene.find/getPosition видели NPC. data.x = npc.x; data.y = npc.y; data.z = npc.z; - - // Анимация ходьбы — простое покачивание (без R15-скелета у Kenney). - if (moving) npc.walkPhase += dt * 6; // R15-NPC (skin_*): процедурная анимация бега/покоя через R15Animator. if (npc.r15Animator) { try { From 26e6306f6e8e94eba2a242f719b1556191d6c0bf Mon Sep 17 00:00:00 2001 From: min Date: Fri, 5 Jun 2026 19:54:38 +0300 Subject: [PATCH 35/74] =?UTF-8?q?fix(studio):=20=D0=B4=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D1=8C-=D0=BA=D0=BE=D0=B4=20=D0=BF=D0=BE=D0=B4=D1=81=D0=BA?= =?UTF-8?q?=D0=B0=D0=B7=D0=BA=D0=B0=20E=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA?= =?UTF-8?q?=D0=BE=20=D0=BF=D1=80=D0=B8=20=D0=BE=D1=82=D0=BA=D1=80=D1=8B?= =?UTF-8?q?=D1=82=D0=BE=D0=B9,=20NPC=3DR15-=D1=81=D0=BA=D0=B8=D0=BD=20(?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D0=BC=D0=B0=D1=86=D0=B8=D1=8F=20=D1=80=D1=83?= =?UTF-8?q?=D0=BA/=D0=BD=D0=BE=D0=B3),=20=D1=82=D0=BE=D1=80=D0=B3=D0=BE?= =?UTF-8?q?=D0=B2=D0=B5=D1=86=20=D0=B2=D0=B8=D0=B4=D0=B8=D0=BC=D1=8B=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1) Дверь по коду: подсказка «E закрыть» теперь только когда дверь открыта и игрок рядом (через onKey+onTick, без постоянного interact-промпта). 2) NPC-киты используют R15-скин 'skin_roblox-noob' → процедурная анимация бега/покоя с руками и ногами (R15Animator), вместо безжизненного покачивания. 3) Торговец-кит: невидимый триггер (insertGameplayKit теперь уважает prim.visible=false, раньше хардкод visible:true) + NPC-персонаж рядом — синий куб больше не виден. Co-Authored-By: Claude Opus 4.8 --- src/editor/KubikonEditor.jsx | 2 +- src/editor/engine/GameplayKits.js | 35 ++++++++++++++++++------------- 2 files changed, 22 insertions(+), 15 deletions(-) diff --git a/src/editor/KubikonEditor.jsx b/src/editor/KubikonEditor.jsx index bbf3c6a..3717b0b 100644 --- a/src/editor/KubikonEditor.jsx +++ b/src/editor/KubikonEditor.jsx @@ -800,7 +800,7 @@ const KubikonEditor = () => { 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, color: p.color, material: p.material, - canCollide: p.canCollide !== false, visible: true, anchored: true, + canCollide: p.canCollide !== false, visible: p.visible !== false, anchored: true, name: p.name, }); if (newId != null) { diff --git a/src/editor/engine/GameplayKits.js b/src/editor/engine/GameplayKits.js index f35dd8a..7d38b59 100644 --- a/src/editor/engine/GameplayKits.js +++ b/src/editor/engine/GameplayKits.js @@ -617,10 +617,15 @@ function place(a){ } game.onTick((dt) => { if (cur !== target){ const st=SPEED*dt; cur = Math.abs(target-cur)<=st ? target : cur+Math.sign(target-cur)*st; place(cur); } - if (opened) return; - // Поле ввода появляется/прячется по дистанции. const pl = game.player.position; const d = Math.sqrt((pl.x-p0.x)**2 + (pl.z-p0.z)**2); + if (opened){ + // Дверь открыта: подсказка «E закрыть» только когда игрок рядом. + if (d < RADIUS && !near){ near = true; game.ui.set('codehint','Нажми E чтобы закрыть дверь', {x:50,y:80,anchor:'bottom',color:'#fff',size:16}); } + else if (d >= RADIUS && near){ near = false; game.ui.set('codehint','',{}); } + return; + } + // Дверь закрыта: поле ввода кода по дистанции. if (d < RADIUS && !near){ near = true; game.gui.create('textbox', { id:'codein', x:50, y:86, w:24, h:8, anchor:'center', placeholder:'Код...', textSize:20 }); game.ui.set('codehint', '🔢 Введи код двери (1234) и нажми Enter', {x:50,y:78,anchor:'bottom',color:'#fff',size:16}); } @@ -629,19 +634,21 @@ game.onTick((dt) => { game.gui.onSubmit('codein', (text) => { if (opened) return; if (String(text).trim() === CODE){ - opened = true; target = Math.PI/2; // плавно распахнуть + opened = true; near = false; target = Math.PI/2; // плавно распахнуть game.gui.remove('codein'); - game.ui.set('codehint','✓ Открыто! Нажми E чтобы закрыть.', {x:50,y:78,anchor:'bottom',color:'#36d57a',size:16}); - game.after(2.5, () => game.ui.set('codehint','',{})); + game.ui.set('codehint','✓ Открыто!', {x:50,y:78,anchor:'bottom',color:'#36d57a',size:18}); + game.after(2, () => game.ui.set('codehint','',{})); } else game.ui.set('codehint','✗ Неверный код', {x:50,y:78,anchor:'bottom',color:'#ff5555',size:18}); }); -// Закрыть открытую дверь по E → снова можно вводить код. -game.self.onInteract(() => { +// Закрыть дверь по E (только если открыта и игрок рядом). +game.onKey('e', () => { if (!opened) return; - opened = false; near = false; target = 0; // плавно закрыть - game.ui.set('codehint','🔒 Дверь закрыта.', {x:50,y:78,anchor:'bottom',color:'#fff',size:16}); - game.after(2, () => game.ui.set('codehint','',{})); -}, { text: 'Закрыть дверь', key: 'e', distance: 4 });` }], + const pl = game.player.position; + if (Math.sqrt((pl.x-p0.x)**2 + (pl.z-p0.z)**2) >= RADIUS) return; + opened = false; near = false; target = 0; + game.ui.set('codehint','🔒 Дверь закрыта.', {x:50,y:80,anchor:'bottom',color:'#fff',size:16}); + game.after(1.5, () => game.ui.set('codehint','',{})); +});` }], }, { id: 'name-label', @@ -708,7 +715,7 @@ game.onTick(() => { icon: 'chase', category: 'npc', scripts: [{ attachTo: 'global', code: `// Спавним NPC, который преследует игрока. -const enemy = game.scene.spawnNpc('character-a', { x: 8, z: 8, name: 'Охотник', speed: 4 }); +const enemy = game.scene.spawnNpc('skin_roblox-noob', { x: 8, z: 8, name: 'Охотник', speed: 4 }); if (enemy && enemy.follow) enemy.follow('player');` }], }, { @@ -721,7 +728,7 @@ if (enemy && enemy.follow) enemy.follow('player');` }], scripts: [{ attachTo: 'on-target', code: `// Торговец — настоящий NPC-персонаж. Триггер (этот объект) держит диалог по E. const p = game.self.position; -const npc = game.scene.spawnNpc('character-a', { x: p.x, z: p.z, name: 'Торговец Боб' }); +const npc = game.scene.spawnNpc('skin_roblox-noob', { x: p.x, z: p.z, name: 'Торговец Боб' }); game.self.onInteract(() => { game.modal.dialog('Торговец Боб', [ 'Привет, путник! Заходи за товарами.', @@ -777,7 +784,7 @@ game.self.onClick(() => { `// Каждые 5с спавнит 2 врагов из точки портала, они идут к игроку. const p = game.self.position; function wave(){ - for (let i=0;i<2;i++){ const e = game.scene.spawnNpc('character-a', { x:p.x+(Math.random()-0.5)*2, z:p.z+(Math.random()-0.5)*2, name:'Враг', speed:3 }); + for (let i=0;i<2;i++){ const e = game.scene.spawnNpc('skin_roblox-noob', { x:p.x+(Math.random()-0.5)*2, z:p.z+(Math.random()-0.5)*2, name:'Враг', speed:3 }); if (e && e.follow) e.follow('player'); } } game.after(2, wave); game.every(5, wave);` }], From c4d184257b5b094257d3a1d865cd53b78f057468 Mon Sep 17 00:00:00 2001 From: min Date: Fri, 5 Jun 2026 20:12:50 +0300 Subject: [PATCH 36/74] =?UTF-8?q?fix(studio):=20=D0=B2=D1=80=D0=B0=D0=B3?= =?UTF-8?q?=3DNPC+=D1=83=D1=80=D0=BE=D0=BD,=20=D0=B2=D0=BE=D0=BB=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=B1=D1=8C=D1=91=D1=82,=20=D1=83=D0=B4=D0=B0=D0=BB?= =?UTF-8?q?=D1=91=D0=BD=D0=BD=D1=8B=D0=B5=20=D1=81=D0=BA=D1=80=D0=B8=D0=BF?= =?UTF-8?q?=D1=82=D1=8B=20=D0=BD=D0=B5=20=D0=B8=D1=81=D0=BF=D0=BE=D0=BB?= =?UTF-8?q?=D0=BD=D1=8F=D1=8E=D1=82=D1=81=D1=8F,=20=D0=BA=D0=BB=D1=8E?= =?UTF-8?q?=D1=87+=D0=B4=D0=B2=D0=B5=D1=80=D1=8C=20=D0=BA=D1=80=D0=B0?= =?UTF-8?q?=D1=81=D0=B8=D0=B2=D1=8B=D0=B5,=20=D1=82=D0=B5=D0=BD=D0=B8-acne?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1) Враг с HP → R15-NPC (зомби-скин), преследует и бьёт игрока при касании. 2) Волна врагов: враги наносят урон при касании (onTick дистанция → damage). 3) Удалённые скрипты больше не исполняются: _cleanupOrphanScripts при удалении объекта (primitive/model/userModel) + перед enterPlayMode чистим скрипты-сироты. 4) Ключ и замок: ключ из примитивов (стержень+кольцо-torus+бородка), дверь как дверь-по-E (плавный поворот вокруг петли, только с ключом). 6) Тени-полосы: normalBias 0.005→0.02 (убирает acne-полосы от соседних объектов). Машина (vehicle:car) — остаётся рантайм-спавном (особенность транспорта). Co-Authored-By: Claude Opus 4.8 --- src/editor/KubikonEditor.jsx | 4 ++ src/editor/engine/BabylonScene.js | 9 ++- src/editor/engine/GameplayKits.js | 87 +++++++++++++++++++-------- src/editor/engine/SelectionManager.js | 3 + 4 files changed, 75 insertions(+), 28 deletions(-) diff --git a/src/editor/KubikonEditor.jsx b/src/editor/KubikonEditor.jsx index 3717b0b..75908fc 100644 --- a/src/editor/KubikonEditor.jsx +++ b/src/editor/KubikonEditor.jsx @@ -3367,13 +3367,17 @@ const KubikonEditor = () => { }} onDeleteModel={(id) => { sceneRef.current?.modelManager?.removeInstance(id); + sceneRef.current?._cleanupOrphanScripts?.(); sceneRef.current?.clearSelection(); + setScriptsList(sceneRef.current?.getScripts?.() || []); markDirty(); hierarchyDirtyRef.current = true; }} onDeletePrimitive={(id) => { sceneRef.current?.primitiveManager?.removeInstance(id); + sceneRef.current?._cleanupOrphanScripts?.(); sceneRef.current?.clearSelection(); + setScriptsList(sceneRef.current?.getScripts?.() || []); markDirty(); hierarchyDirtyRef.current = true; }} diff --git a/src/editor/engine/BabylonScene.js b/src/editor/engine/BabylonScene.js index 9b49603..f2c90c8 100644 --- a/src/editor/engine/BabylonScene.js +++ b/src/editor/engine/BabylonScene.js @@ -1701,8 +1701,10 @@ export class BabylonScene { // "уехавшая" тень на скрине пользователя // 2026-05-27. 0.005 — золотая середина для // кубов 1м с прямыми гранями. - const PCF_BIAS = 0.0005; - const PCF_NORMAL_BIAS = 0.005; + const PCF_BIAS = 0.0008; + // normalBias повышен 0.005→0.02: убирает «полосы»-acne на полу, которые + // появлялись от теней соседних объектов (на пустой сцене их не было). + const PCF_NORMAL_BIAS = 0.02; if (!this._shadowGenerator) { if (wantCsm) { @@ -6183,6 +6185,9 @@ export class BabylonScene { if (this._onScriptCrosshair) this.gameRuntime.setOnCrosshairChange(this._onScriptCrosshair); // eslint-disable-next-line no-console console.log('[BabylonScene] enterPlayMode — scripts to run:', this._scripts?.length || 0, this._scripts); + // Перед стартом чистим скрипты-сироты (их объект-носитель удалён) — + // иначе on-target скрипт удалённого объекта продолжал исполняться. Баг 2026-06-05. + this._cleanupOrphanScripts?.(); // Старт через requestAnimationFrame — даём Babylon собрать сцену requestAnimationFrame(() => { if (this._isPlaying) this.gameRuntime?.start(this._scripts || []); diff --git a/src/editor/engine/GameplayKits.js b/src/editor/engine/GameplayKits.js index 7d38b59..4736879 100644 --- a/src/editor/engine/GameplayKits.js +++ b/src/editor/engine/GameplayKits.js @@ -760,18 +760,22 @@ game.self.onClick(() => { { id: 'enemy-hp', name: 'Враг с HP', - desc: 'Враг с полоской здоровья над головой. Кликай — урон, при нуле HP погибает. (Вики: «Имена над врагами», «босс»)', + desc: 'Враг-персонаж: преследует игрока, бьёт при касании. Над головой — полоска здоровья. (Вики: «босс», «имена над врагами»)', icon: 'boss', category: 'npc', - prims: [{ type: 'cube', x: 0, y: 1.5, z: 0, sx: 1.5, sy: 3, sz: 1.5, color: '#7a2030', material: 'matte', name: 'Враг' }], + // Невидимый триггер-якорь; рядом спавнится NPC-враг. + prims: [{ type: 'cube', x: 0, y: 1, z: 0, sx: 1, sy: 2, sz: 1, color: '#7a2030', material: 'matte', visible: false, canCollide: false, name: 'Якорь врага' }], scripts: [{ attachTo: 'on-target', code: -`// Враг с HP: клик → -20 HP, метка обновляется, при 0 — исчезает. -let hp = 100; -function lbl(){ game.self.setLabel('Враг ❤ ' + hp, { color:'#fff', bg:'#7a2030' }); } -lbl(); -game.self.onClick(() => { - hp -= 20; if (hp <= 0) { game.self.setVisible(false); game.broadcast('score', { add: 50 }); - game.ui.set('kill', '💀 Враг повержен! +50', { x:50, y:75, anchor:'bottom', color:'#ffd23a', size:18 }); return; } - lbl(); +`// Враг-персонаж: спавним NPC, он преследует игрока и бьёт при касании. +const p = game.self.position; +const enemy = game.scene.spawnNpc('skin_retro-zombie', { x: p.x, z: p.z, name: 'Враг', hp: 100, speed: 3 }); +if (enemy && enemy.follow) enemy.follow('player'); +let cd = 0; +game.onTick((dt) => { + if (!enemy || !enemy.position) return; + cd -= dt; + const pl = game.player.position, e = enemy.position; + const d = Math.sqrt((pl.x-e.x)**2 + (pl.z-e.z)**2); + if (d < 2.5 && cd <= 0) { game.player.damage(10); cd = 1; } // удар раз в секунду });` }], }, { @@ -781,13 +785,25 @@ game.self.onClick(() => { icon: 'zombie', category: 'npc', prims: [{ type: 'cylinder', x: 0, y: 0.15, z: 0, sx: 2, sy: 0.3, sz: 2, color: '#7a2030', material: 'neon', name: 'Портал врагов' }], scripts: [{ attachTo: 'on-target', code: -`// Каждые 5с спавнит 2 врагов из точки портала, они идут к игроку. +`// Каждые 5с спавнит 2 врагов из портала, они идут к игроку И бьют при касании. const p = game.self.position; +const enemies = []; function wave(){ - for (let i=0;i<2;i++){ const e = game.scene.spawnNpc('skin_roblox-noob', { x:p.x+(Math.random()-0.5)*2, z:p.z+(Math.random()-0.5)*2, name:'Враг', speed:3 }); - if (e && e.follow) e.follow('player'); } + for (let i=0;i<2;i++){ const e = game.scene.spawnNpc('skin_retro-zombie', { x:p.x+(Math.random()-0.5)*2, z:p.z+(Math.random()-0.5)*2, name:'Враг', hp:60, speed:3 }); + if (e){ if (e.follow) e.follow('player'); enemies.push({ npc:e, cd:0 }); } } } -game.after(2, wave); game.every(5, wave);` }], +game.after(2, wave); game.every(5, wave); +// Урон игроку при касании любого врага (кулдаун у каждого свой). +game.onTick((dt) => { + const pl = game.player.position; + for (const en of enemies){ + if (!en.npc || !en.npc.position) continue; + en.cd -= dt; + const e = en.npc.position; + const d = Math.sqrt((pl.x-e.x)**2 + (pl.z-e.z)**2); + if (d < 2.5 && en.cd <= 0){ game.player.damage(8); en.cd = 1; } + } +});` }], }, // --- Экономика --- @@ -827,29 +843,48 @@ game.gui.onClick('clickbtn', () => { n++; show(); });` }], { id: 'key-lock', name: 'Ключ и замок', - desc: 'Подбери ключ, затем открой запертую дверь. Без ключа дверь не открывается. (Вики: «Ключ и сундук»)', + desc: 'Найди золотой ключ, подбери — и дверь рядом плавно откроется по E. Без ключа заперта. (Вики: «Ключ и сундук»)', icon: 'key', category: 'economy', prims: [ - { type: 'cube', x: 0, y: 1, z: 0, sx: 0.4, sy: 0.8, sz: 0.4, color: '#ffd23a', material: 'metal', name: 'Ключ' }, - { type: 'cube', x: 6, y: 2, z: 0, sx: 0.5, sy: 4, sz: 3, color: '#6b4423', material: 'matte', name: 'Запертая дверь' }, + // Ключ из примитивов: стержень + бородка + кольцо (torus). ПЕРВЫЙ — скрипт на нём. + { type: 'cylinder', x: 0, y: 1, z: 0, sx: 0.12, sy: 1.0, sz: 0.12, color: '#ffd23a', material: 'metal', name: 'Ключ' }, + { type: 'torus', x: 0, y: 1.6, z: 0, sx: 0.5, sy: 0.5, sz: 0.5, color: '#ffd23a', material: 'metal', canCollide: false, name: 'Кольцо ключа' }, + { type: 'cube', x: 0.18, y: 0.6, z: 0, sx: 0.3, sy: 0.12, sz: 0.12, color: '#ffd23a', material: 'metal', canCollide: false, name: 'Бородка ключа' }, + { type: 'cube', x: 0.18, y: 0.4, z: 0, sx: 0.2, sy: 0.12, sz: 0.12, color: '#ffd23a', material: 'metal', canCollide: false, name: 'Бородка ключа 2' }, + // Красивая дверь (полотно + рамка) на расстоянии. + { type: 'cube', x: 6, y: 2, z: 0, sx: 0.25, sy: 3.8, sz: 2.6, color: '#6b4423', material: 'matte', name: 'Запертая дверь' }, + { type: 'cube', x: 6, y: 2, z: -1.55, sx: 0.4, sy: 4.2, sz: 0.4, color: '#4a3018', material: 'matte', name: 'Косяк левый замка' }, + { type: 'cube', x: 6, y: 2, z: 1.55, sx: 0.4, sy: 4.2, sz: 0.4, color: '#4a3018', material: 'matte', name: 'Косяк правый замка' }, + { type: 'cube', x: 6, y: 4.1, z: 0, sx: 0.4, sy: 0.4, sz: 3.5, color: '#4a3018', material: 'matte', name: 'Перемычка замка' }, ], scripts: [{ attachTo: 'on-target', code: -`// Ключ (этот объект) подбирается касанием → открывает «Запертую дверь». +`// Ключ подбирается касанием. Дверь рядом открывается по E ТОЛЬКО с ключом — +// плавный поворот вокруг петли (как дверь по кнопке E). let hasKey = false; +const keyParts = ['Ключ','Кольцо ключа','Бородка ключа','Бородка ключа 2']; game.self.onTouch(() => { if (hasKey) return; hasKey = true; - game.self.setVisible(false); + for (const nm of keyParts){ const o = game.scene.findOne(nm); if (o) o.visible = false; } game.ui.set('key', '🔑 Ключ найден! Иди к двери (E).', {x:50,y:80,anchor:'bottom',color:'#ffd23a',size:18}); }); const door = game.scene.findOne('Запертая дверь'); -if (door && door.onInteract) { - let opened = false; +if (door && door.onInteract){ + const dp0 = door.position; + const halfW = 1.3, hingeZ = dp0.z - halfW; + let open = false, cur = 0, target = 0; + function rotY(lx,lz,a){ const s=Math.sin(a),c=Math.cos(a); return {x:lx*c+lz*s, z:-lx*s+lz*c}; } + game.onTick((dt) => { + if (cur===target) return; + const st = Math.PI*dt; cur = Math.abs(target-cur)<=st ? target : cur+Math.sign(target-cur)*st; + const pc = rotY(0, halfW, cur); + door.move(dp0.x+pc.x, dp0.y, hingeZ+pc.z); if (door.rotate) door.rotate(cur); + }); door.onInteract(() => { - if (!hasKey) { game.ui.set('key','🔒 Заперто. Нужен ключ.',{x:50,y:80,anchor:'bottom',color:'#ff5555',size:18}); return; } - if (opened) return; opened = true; - const dp = door.position; door.move(dp.x, dp.y-4.2, dp.z); - game.ui.set('key','✓ Дверь открыта!',{x:50,y:80,anchor:'bottom',color:'#36d57a',size:18}); - }, { text:'Открыть дверь', key:'e', distance:4 }); + if (!hasKey){ game.ui.set('key','🔒 Заперто. Нужен ключ.',{x:50,y:80,anchor:'bottom',color:'#ff5555',size:18}); return; } + open = !open; target = open ? Math.PI/2 : 0; + game.ui.set('key', open ? '✓ Дверь открыта!' : '🔒 Дверь закрыта.', {x:50,y:80,anchor:'bottom',color:'#36d57a',size:18}); + game.after(2, () => game.ui.set('key','',{})); + }, { text:'Открыть / закрыть', key:'e', distance:4 }); }` }], }, diff --git a/src/editor/engine/SelectionManager.js b/src/editor/engine/SelectionManager.js index cf1dbcf..f20b397 100644 --- a/src/editor/engine/SelectionManager.js +++ b/src/editor/engine/SelectionManager.js @@ -717,10 +717,13 @@ export class SelectionManager { this.blockManager.removeBlock(this._selection.gridX, this._selection.gridY, this._selection.gridZ); } else if (this._selection.type === 'model') { this.modelManager.removeInstance(this._selection.instanceId); + this.clear(); this._scene3d?._cleanupOrphanScripts?.(); return; } else if (this._selection.type === 'userModel') { this.userModelManager.removeInstance(this._selection.instanceId); + this.clear(); this._scene3d?._cleanupOrphanScripts?.(); return; } else if (this._selection.type === 'primitive') { this.primitiveManager.removeInstance(this._selection.id); + this.clear(); this._scene3d?._cleanupOrphanScripts?.(); return; } else if (this._selection.type === 'spawn') { // Удаление точки спавна → игрок будет появляться в (0, высота, 0). this._scene3d?.deleteSpawn?.(); From cf34f9cdb62d1d3b581ebfe2dcb1d88a13086ab0 Mon Sep 17 00:00:00 2001 From: min Date: Fri, 5 Jun 2026 20:26:11 +0300 Subject: [PATCH 37/74] =?UTF-8?q?fix(studio):=20=D0=B2=D1=80=D0=B0=D0=B3?= =?UTF-8?q?=20=D0=B1=D1=8C=D1=91=D1=82=20=D1=81=20=D0=B0=D0=BD=D0=B8=D0=BC?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D0=B5=D0=B9=20=D1=83=D0=B4=D0=B0=D1=80=D0=B0?= =?UTF-8?q?,=20=D0=BA=D0=BB=D1=8E=D1=87=20=D0=B8=D1=81=D1=87=D0=B5=D0=B7?= =?UTF-8?q?=D0=B0=D0=B5=D1=82=20=D0=BF=D1=80=D0=B8=20=D0=BF=D0=BE=D0=B4?= =?UTF-8?q?=D0=B1=D0=BE=D1=80=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1+2) Враг с HP / волна врагов: радиус удара 3.5 (NPC останавливался на followGap=2.5, урон-чек d<2.5 не срабатывал). Добавлена анимация атаки — R15Animator.attack (выпад рукой), npc.setAttacking, NpcManager.setAttacking. 3) Ключ исчезает при подборе: scene.setVisible теперь парсит ref ('primitive:N') — obj.visible=false слал {ref} без kind/id, поэтому ключ не пропадал. 4) Машина — остаётся рантайм vehicle:car (особенность транспорта). Co-Authored-By: Claude Opus 4.8 --- src/editor/engine/GameRuntime.js | 14 ++++++++++++-- src/editor/engine/GameplayKits.js | 16 ++++++++++------ src/editor/engine/NpcManager.js | 10 ++++++++-- src/editor/engine/R15Animator.js | 17 +++++++++++++++++ src/editor/engine/ScriptSandboxWorker.js | 4 ++++ 5 files changed, 51 insertions(+), 10 deletions(-) diff --git a/src/editor/engine/GameRuntime.js b/src/editor/engine/GameRuntime.js index 15a896b..af975dc 100644 --- a/src/editor/engine/GameRuntime.js +++ b/src/editor/engine/GameRuntime.js @@ -1731,6 +1731,11 @@ export class GameRuntime { }); return; } + if (cmd === 'npc.setAttacking') { + this._npcCmd(payload?.ref, (nid) => + this.scene3d?.npcManager?.setAttacking?.(nid, !!payload?.on)); + return; + } if (cmd === 'npc.stop') { this._npcCmd(payload?.ref, (nid) => this.scene3d?.npcManager?.stopNpc(nid)); @@ -3577,8 +3582,13 @@ export class GameRuntime { } if (cmd === 'scene.setVisible') { try { - const kind = payload?.kind; - const id = payload?.id; + let kind = payload?.kind; + let id = payload?.id; + // obj.visible=false шлёт {ref:'primitive:N'} без kind/id — парсим ref. + if ((kind == null || id == null) && typeof payload?.ref === 'string') { + const colon = payload.ref.indexOf(':'); + if (colon > 0) { kind = payload.ref.slice(0, colon); id = payload.ref.slice(colon + 1); } + } const visible = !!payload?.visible; if (id == null) return; if (kind === 'primitive') { diff --git a/src/editor/engine/GameplayKits.js b/src/editor/engine/GameplayKits.js index 4736879..b120af7 100644 --- a/src/editor/engine/GameplayKits.js +++ b/src/editor/engine/GameplayKits.js @@ -765,17 +765,19 @@ game.self.onClick(() => { // Невидимый триггер-якорь; рядом спавнится NPC-враг. prims: [{ type: 'cube', x: 0, y: 1, z: 0, sx: 1, sy: 2, sz: 1, color: '#7a2030', material: 'matte', visible: false, canCollide: false, name: 'Якорь врага' }], scripts: [{ attachTo: 'on-target', code: -`// Враг-персонаж: спавним NPC, он преследует игрока и бьёт при касании. +`// Враг-персонаж: преследует игрока, бьёт с анимацией удара при сближении. const p = game.self.position; -const enemy = game.scene.spawnNpc('skin_retro-zombie', { x: p.x, z: p.z, name: 'Враг', hp: 100, speed: 3 }); +const enemy = game.scene.spawnNpc('skin_retro-zombie', { x: p.x, z: p.z, name: 'Враг', hp: 100, speed: 3.5 }); if (enemy && enemy.follow) enemy.follow('player'); -let cd = 0; +let cd = 0, atk = false; game.onTick((dt) => { if (!enemy || !enemy.position) return; cd -= dt; const pl = game.player.position, e = enemy.position; const d = Math.sqrt((pl.x-e.x)**2 + (pl.z-e.z)**2); - if (d < 2.5 && cd <= 0) { game.player.damage(10); cd = 1; } // удар раз в секунду + const inRange = d < 3.5; + if (inRange !== atk) { atk = inRange; enemy.setAttacking && enemy.setAttacking(inRange); } + if (inRange && cd <= 0) { game.player.damage(10); cd = 1; } // удар раз в секунду });` }], }, { @@ -793,7 +795,7 @@ function wave(){ if (e){ if (e.follow) e.follow('player'); enemies.push({ npc:e, cd:0 }); } } } game.after(2, wave); game.every(5, wave); -// Урон игроку при касании любого врага (кулдаун у каждого свой). +// Урон + анимация удара при сближении (у каждого врага свой кулдаун). game.onTick((dt) => { const pl = game.player.position; for (const en of enemies){ @@ -801,7 +803,9 @@ game.onTick((dt) => { en.cd -= dt; const e = en.npc.position; const d = Math.sqrt((pl.x-e.x)**2 + (pl.z-e.z)**2); - if (d < 2.5 && en.cd <= 0){ game.player.damage(8); en.cd = 1; } + const inRange = d < 3.5; + if (inRange !== en.atk){ en.atk = inRange; en.npc.setAttacking && en.npc.setAttacking(inRange); } + if (inRange && en.cd <= 0){ game.player.damage(8); en.cd = 1; } } });` }], }, diff --git a/src/editor/engine/NpcManager.js b/src/editor/engine/NpcManager.js index 773d3c9..d1a2506 100644 --- a/src/editor/engine/NpcManager.js +++ b/src/editor/engine/NpcManager.js @@ -274,6 +274,12 @@ export class NpcManager { npc.isMoving = false; } + /** Включить/выключить анимацию атаки (R15-NPC машет руками). */ + setAttacking(id, on) { + const npc = this.npcs.get(Number(id)); + if (npc) npc.attacking = !!on; + } + /** Реплика над головой NPC на duration секунд. */ say(id, text, duration = 3) { const npc = this.npcs.get(Number(id)); @@ -403,10 +409,10 @@ export class NpcManager { root.rotation.z = lean; // data.x/y/z — чтобы scene.find/getPosition видели NPC. data.x = npc.x; data.y = npc.y; data.z = npc.z; - // R15-NPC (skin_*): процедурная анимация бега/покоя через R15Animator. + // R15-NPC (skin_*): процедурная анимация бега/покоя/атаки через R15Animator. if (npc.r15Animator) { try { - npc.r15Animator.setState(moving ? 'run' : 'idle'); + npc.r15Animator.setState(npc.attacking ? 'attack' : (moving ? 'run' : 'idle')); npc.r15Animator.update(dt); } catch (e) { /* ignore */ } } diff --git a/src/editor/engine/R15Animator.js b/src/editor/engine/R15Animator.js index 6d373ca..f570567 100644 --- a/src/editor/engine/R15Animator.js +++ b/src/editor/engine/R15Animator.js @@ -131,6 +131,23 @@ const ANIMS_STD = { { bone: B.SPINE, axis: AXIS_RIGHT, angleDeg: 10, times: [0.0, 0.4, 0.8], values: [0.0, 1.0, 0.0] }, ]), + // Удар правой рукой вперёд (для враждебных NPC). loop=true — постоянно + // машет, пока NPC в режиме атаки. + attack: makeAnim(0.5, true, [ + // Правая рука выбрасывается вперёд (замах назад → удар). + { bone: B.RIGHT_ARM, axis: AXIS_RIGHT, angleDeg: -95, + times: [0.0, 0.15, 0.3, 0.5], values: [0.3, 1.0, 0.2, 0.3] }, + { bone: B.RIGHT_FOREARM, axis: AXIS_RIGHT, angleDeg: -50, + times: [0.0, 0.15, 0.3, 0.5], values: [1.0, 0.2, 1.0, 1.0] }, + // Левая рука тоже в боевой стойке. + { bone: B.LEFT_ARM, axis: AXIS_RIGHT, angleDeg: -45, + times: [0.0, 0.5], values: [1.0, 1.0] }, + { bone: B.LEFT_FOREARM, axis: AXIS_RIGHT, angleDeg: -70, + times: [0.0, 0.5], values: [1.0, 1.0] }, + // Корпус подаётся в удар. + { bone: B.SPINE, axis: AXIS_RIGHT, angleDeg: -12, + times: [0.0, 0.15, 0.3, 0.5], values: [0.0, 1.0, 0.3, 0.0] }, + ]), // === ЭМОЦИИ (game.player.playAnimation) === // Разовые анимации поверх авто-состояния. loop=false — играют один раз, diff --git a/src/editor/engine/ScriptSandboxWorker.js b/src/editor/engine/ScriptSandboxWorker.js index 1b8b98f..0cdfe10 100644 --- a/src/editor/engine/ScriptSandboxWorker.js +++ b/src/editor/engine/ScriptSandboxWorker.js @@ -229,6 +229,10 @@ function _makeNpcProxy(ref) { damage(amount) { _send('npc.damage', { ref, amount: Number(amount) || 0 }); }, + /** Включить/выключить анимацию атаки (удары руками). */ + setAttacking(on) { + _send('npc.setAttacking', { ref, on: !!on }); + }, /** Убрать NPC со сцены. */ remove() { _send('npc.remove', { ref }); From 5d49cd9eebaa6404bf346e57f2a81b1c1f8b7b1d Mon Sep 17 00:00:00 2001 From: min Date: Sat, 6 Jun 2026 09:32:53 +0300 Subject: [PATCH 38/74] =?UTF-8?q?feat(studio):=20=D0=B7=D0=B0=D0=B4=D0=B0?= =?UTF-8?q?=D1=87=D0=B0=2020=20=E2=80=94=20=D0=BB=D0=B8=D0=B4=D0=B5=D1=80?= =?UTF-8?q?=D0=B1=D0=BE=D1=80=D0=B4=D1=8B=20(leaderstats)=20+=20=D0=B4?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D0=B8=D0=B6=D0=B5=D0=BD=D0=B8=D1=8F=20(achie?= =?UTF-8?q?vements)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Leaderstats: HUD-таблица top-right (blur, сортировка по primary, топ-10, подсветка me, flash-инкремент). API game.leaderstats.define/set/add/get/ onChange + me.* (format number/time/short). LeaderstatsManager.js. Достижения: toast справа (4 редкости + звук + очередь, slide-in/out), кнопка- кубок слева-снизу → страница grid (locked grayscale+замок, hidden=?, прогресс- бар). API game.achievements.define/unlock/has/bindToStat/setButtonVisible/ openPage. bindToStat(id, stat, {gte/lte/eq}) — авто-unlock по лидерстату. Сохранение unlocked в localStorage по проекту. AchievementsManager.js. Интеграция: оба менеджера в BabylonScene (tick leaderstats в Play, resetRuntime при stop, serialize/load в project_data scene.leaderstats/achievements). worker- API + GameRuntime cmd-обработчики + мост leaderstats.onChange→worker (globalEvent leaderstatsChange) для bindToStat. Плеер пока НЕ портирован (по плану). Тест-игра «Сбор монет с достижениями» id=2616 (is_test): поляна + 30 монет + 3 стата + 5 достижений (3 через bindToStat). Co-Authored-By: Claude Opus 4.8 --- src/editor/engine/AchievementsManager.js | 236 +++++++++++++++++++++++ src/editor/engine/BabylonScene.js | 24 +++ src/editor/engine/GameRuntime.js | 49 +++++ src/editor/engine/LeaderstatsManager.js | 220 +++++++++++++++++++++ src/editor/engine/ScriptSandboxWorker.js | 69 +++++++ 5 files changed, 598 insertions(+) create mode 100644 src/editor/engine/AchievementsManager.js create mode 100644 src/editor/engine/LeaderstatsManager.js diff --git a/src/editor/engine/AchievementsManager.js b/src/editor/engine/AchievementsManager.js new file mode 100644 index 0000000..3944e9c --- /dev/null +++ b/src/editor/engine/AchievementsManager.js @@ -0,0 +1,236 @@ +/** + * AchievementsManager — достижения (badges) как в Roblox (задача 20). + * + * - define([...]) регистрирует достижения проекта. + * - unlock(id) разблокирует → toast справа-сверху (4 редкости, очередь, звук). + * - bindToStat(id, statName, {gte/lte/eq}) — авто-unlock по leaderstat. + * - кнопка-кубок слева-снизу → страница «Мои достижения» (grid + прогресс). + * - сохранение разблокированных в localStorage по projectId (закрыл-открыл → остались). + * + * API (через game.achievements.*): define/unlock/has/list/progress/bindToStat/ + * setButtonVisible/openPage. + * + * Фича-парность: тот же модуль в rublox-player/src/engine/. + */ + +const RARITY = { + common: { label: 'Обычное', border: '#9aa3b2', bg: 'linear-gradient(135deg,rgba(120,130,150,0.9),rgba(80,88,104,0.9))', glow: 'rgba(154,163,178,0.5)' }, + rare: { label: 'Редкое', border: '#4d8bff', bg: 'linear-gradient(135deg,rgba(60,110,220,0.92),rgba(30,60,150,0.92))', glow: 'rgba(77,139,255,0.6)' }, + epic: { label: 'Эпическое', border: '#a05aff', bg: 'linear-gradient(135deg,rgba(150,80,230,0.92),rgba(90,40,160,0.92))', glow: 'rgba(160,90,255,0.65)' }, + legendary: { label: 'Легендарное', border: '#ffd23a', bg: 'linear-gradient(135deg,rgba(255,200,60,0.95),rgba(220,140,20,0.95))', glow: 'rgba(255,210,58,0.75)' }, +}; + +export class AchievementsManager { + constructor(scene3d) { + this.s = scene3d; + this._defs = []; // [{id,name,description,icon,rarity,points,hidden}] + this._unlocked = new Set(); // id разблокированных + this._binds = []; // [{id, stat, op, value}] + this._toastQueue = []; + this._toastActive = false; + this._btnVisible = true; + this.btn = null; this.toastRoot = null; this.page = null; + this._projectKey = 'rublox_ach_' + (this.s?._projectId ?? 'proj'); + } + + define(list) { + const arr = Array.isArray(list) ? list : [list]; + for (const a of arr) { + if (!a || typeof a.id !== 'string') continue; + if (this._defs.some(d => d.id === a.id)) continue; + this._defs.push({ + id: a.id, name: a.name || a.id, description: a.description || '', + icon: a.icon || '🏆', rarity: RARITY[a.rarity] ? a.rarity : 'common', + points: Number(a.points) || 5, hidden: !!a.hidden, + }); + } + this._loadSaved(); + this._mountButton(); + } + + _loadSaved() { + try { + const raw = localStorage.getItem(this._projectKey); + if (raw) for (const id of JSON.parse(raw)) this._unlocked.add(id); + } catch (e) { /* ignore */ } + } + _persist() { + try { localStorage.setItem(this._projectKey, JSON.stringify([...this._unlocked])); } catch (e) {} + } + + unlock(id, _playerId) { + const def = this._defs.find(d => d.id === id); + if (!def || this._unlocked.has(id)) return false; + this._unlocked.add(id); + this._persist(); + this._queueToast(def); + this._playSound(def.rarity); + return true; + } + + has(id) { return this._unlocked.has(id); } + + list() { + return this._defs.map(d => ({ id: d.id, name: d.name, unlocked: this._unlocked.has(d.id) })); + } + + progress() { + const total = this._defs.length; + const unlocked = this._defs.filter(d => this._unlocked.has(d.id)).length; + const pts = this._defs.filter(d => this._unlocked.has(d.id)).reduce((s, d) => s + d.points, 0); + const maxPts = this._defs.reduce((s, d) => s + d.points, 0); + return { total, unlocked, points: pts, maxPoints: maxPts }; + } + + /** Авто-unlock при достижении leaderstat значения. */ + bindToStat(id, statName, cond) { + const op = cond && (cond.gte != null ? 'gte' : cond.lte != null ? 'lte' : cond.eq != null ? 'eq' : null); + if (!op) return; + this._binds.push({ id, stat: statName, op, value: cond[op] }); + // Подпишемся на leaderstats при первом bind. + if (!this._boundLs && this.s?.leaderstats) { + this._boundLs = true; + this.s.leaderstats.onChange((pid, name, nv) => this._checkBinds(name, nv)); + } + } + _checkBinds(statName, value) { + for (const b of this._binds) { + if (b.stat !== statName || this._unlocked.has(b.id)) continue; + const ok = b.op === 'gte' ? value >= b.value : b.op === 'lte' ? value <= b.value : value === b.value; + if (ok) this.unlock(b.id); + } + } + + setButtonVisible(v) { this._btnVisible = !!v; if (this.btn) this.btn.style.display = v ? 'flex' : 'none'; } + + get active() { return this._defs.length > 0; } + + // ── Кнопка-кубок ─────────────────────────────────────────────────────── + _mountButton() { + if (this.btn || !this.active) return; + if (!this.s?._isPlaying) return; // кнопка-кубок только в Play + const parent = (this.s && this.s.canvas && this.s.canvas.parentElement) || document.body; + const b = document.createElement('button'); + b.title = 'Мои достижения'; + b.textContent = '🏆'; + b.style.cssText = [ + 'position:absolute', 'left:14px', 'bottom:64px', 'z-index:50', + 'width:46px', 'height:46px', 'border-radius:12px', 'font-size:24px', + 'background:rgba(18,22,33,0.6)', 'backdrop-filter:blur(8px)', + 'border:1px solid rgba(255,255,255,0.15)', 'cursor:pointer', + 'display:flex', 'align-items:center', 'justify-content:center', + 'box-shadow:0 4px 16px rgba(0,0,0,0.35)', 'pointer-events:auto', + ].join(';'); + if (!this._btnVisible) b.style.display = 'none'; + b.onclick = () => this.openPage(); + parent.appendChild(b); + this.btn = b; + } + + // ── Toast ──────────────────────────────────────────────────────────── + _queueToast(def) { this._toastQueue.push(def); if (!this._toastActive) this._nextToast(); } + _nextToast() { + if (!this._toastQueue.length) { this._toastActive = false; return; } + this._toastActive = true; + const def = this._toastQueue.shift(); + const r = RARITY[def.rarity]; + const parent = (this.s && this.s.canvas && this.s.canvas.parentElement) || document.body; + const t = document.createElement('div'); + t.style.cssText = [ + 'position:absolute', 'top:200px', 'right:14px', 'z-index:60', + 'width:340px', 'display:flex', 'align-items:center', 'gap:12px', + 'padding:12px 14px', 'border-radius:14px', 'background:' + r.bg, + 'border:2px solid ' + r.border, 'box-shadow:0 0 24px ' + r.glow + ',0 8px 24px rgba(0,0,0,0.4)', + 'font-family:Inter,system-ui,sans-serif', 'color:#fff', + 'transform:translateX(380px)', 'transition:transform .32s cubic-bezier(.2,.8,.3,1)', + 'pointer-events:auto', 'cursor:pointer', + ].join(';'); + t.innerHTML = + '
' + def.icon + '
' + + '
' + + '
Достижение разблокировано · ' + r.label + '
' + + '
' + this._esc(def.name) + '
' + + '
' + this._esc(def.description) + ' · +' + def.points + ' очк.
' + + '
'; + t.onclick = () => this.openPage(); + parent.appendChild(t); + // slide-in + requestAnimationFrame(() => { t.style.transform = 'translateX(0)'; }); + // через 3с slide-out + следующий + setTimeout(() => { + t.style.transform = 'translateX(380px)'; + setTimeout(() => { try { t.remove(); } catch (e) {} this._nextToast(); }, 350); + }, 3000); + } + + _playSound(rarity) { + // Используем встроенные звуки движка через gameRuntime/audio. + try { + const map = { common: 'coin', rare: 'win', epic: 'win', legendary: 'win' }; + const pitch = { common: 1, rare: 1.1, epic: 0.9, legendary: 0.8 }[rarity] || 1; + this.s?.gameRuntime?._playSound?.({ name: map[rarity] || 'coin', pitch }); + } catch (e) { /* ignore */ } + } + + // ── Страница «Мои достижения» ─────────────────────────────────────────── + openPage() { + if (this.page) { this._closePage(); return; } + const parent = (this.s && this.s.canvas && this.s.canvas.parentElement) || document.body; + const overlay = document.createElement('div'); + overlay.style.cssText = [ + 'position:absolute', 'inset:0', 'z-index:80', + 'background:rgba(8,10,16,0.78)', 'backdrop-filter:blur(6px)', + 'display:flex', 'align-items:center', 'justify-content:center', + 'font-family:Inter,system-ui,sans-serif', 'pointer-events:auto', + ].join(';'); + overlay.onclick = (e) => { if (e.target === overlay) this._closePage(); }; + const pr = this.progress(); + const pct = pr.total ? Math.round(pr.unlocked / pr.total * 100) : 0; + + const panel = document.createElement('div'); + panel.style.cssText = 'width:min(720px,92%);max-height:84%;overflow-y:auto;background:#141826;border:1px solid rgba(255,255,255,0.12);border-radius:18px;padding:22px;color:#e8ecf2;box-shadow:0 20px 60px rgba(0,0,0,0.5)'; + + let html = '
' + + '
🏆 Мои достижения
' + + '
'; + html += '
' + pr.unlocked + ' из ' + pr.total + ' (' + pr.points + ' / ' + pr.maxPoints + ' очков)
'; + html += '
'; + html += '
'; + for (const d of this._defs) { + const un = this._unlocked.has(d.id); + const r = RARITY[d.rarity]; + const hiddenLocked = d.hidden && !un; + const icon = hiddenLocked ? '❔' : d.icon; + const name = hiddenLocked ? 'Скрытое достижение' : d.name; + const desc = hiddenLocked ? 'Найди, чтобы открыть' : d.description; + html += '
' + + '
' + icon + (un ? '' : ' 🔒') + '
' + + '
' + this._esc(name) + '
' + + '
' + this._esc(desc) + '
' + + '
' + r.label + ' · ' + d.points + ' очк.
' + + '
'; + } + html += '
'; + panel.innerHTML = html; + overlay.appendChild(panel); + parent.appendChild(overlay); + panel.querySelector('#_achClose').onclick = () => this._closePage(); + this.page = overlay; + } + _closePage() { if (this.page) { try { this.page.remove(); } catch (e) {} this.page = null; } } + + _esc(s) { return String(s).replace(/[&<>]/g, c => ({ '&': '&', '<': '<', '>': '>' }[c])); } + + serialize() { return this._defs.map(d => ({ ...d })); } + load(arr) { if (Array.isArray(arr) && arr.length) this.define(arr); } + + dispose() { + for (const el of [this.btn, this.toastRoot, this.page]) { if (el) try { el.remove(); } catch (e) {} } + this.btn = null; this.page = null; this._toastQueue = []; this._toastActive = false; + } + resetRuntime() { + // Определения и unlocked сохраняются (достижения «навсегда»). Чистим UI. + this._closePage(); + this._toastQueue = []; this._toastActive = false; + } +} diff --git a/src/editor/engine/BabylonScene.js b/src/editor/engine/BabylonScene.js index f2c90c8..6e46fc5 100644 --- a/src/editor/engine/BabylonScene.js +++ b/src/editor/engine/BabylonScene.js @@ -74,6 +74,8 @@ import { ZombieSpawnerManager } from './ZombieSpawnerManager'; import { DynamicsManager } from './DynamicsManager'; import { Environment } from './Environment'; import { SkyboxManager } from './SkyboxManager'; +import { LeaderstatsManager } from './LeaderstatsManager'; +import { AchievementsManager } from './AchievementsManager'; import { AudioManager, AMBIENT_PRESETS, MUSIC_PRESETS } from './AudioManager'; import { GameAudioManager } from './GameAudioManager'; import { AssetManager } from './AssetManager'; @@ -1297,6 +1299,8 @@ export class BabylonScene { this.dynamics = new DynamicsManager(this); this.environment = new Environment(this.scene, this._hemiLight, this._sunLight); this.skybox = new SkyboxManager(this.scene, this._hemiLight, this._sunLight); // задача 16 — кастомное небо (единый источник света) + this.leaderstats = new LeaderstatsManager(this); // задача 20 — лидерборды + this.achievements = new AchievementsManager(this); // задача 20 — достижения this.audioManager = new AudioManager(); this.assetManager = new AssetManager(); // PrimitiveManager должен уметь брать dataURL картинки по id ассета, @@ -1454,6 +1458,10 @@ export class BabylonScene { if (this.skybox) { this.skybox.tick(dt); } + // Лидерборды (задача 20) — рендер HUD-таблицы при изменениях. + if (this._isPlaying && this.leaderstats) { + this.leaderstats.tick(); + } // Анимация жидкостей — работает всегда (и в редакторе) if (this.blockManager) { this.blockManager.tick(dt); @@ -6188,6 +6196,10 @@ export class BabylonScene { // Перед стартом чистим скрипты-сироты (их объект-носитель удалён) — // иначе on-target скрипт удалённого объекта продолжал исполняться. Баг 2026-06-05. this._cleanupOrphanScripts?.(); + // Задача 20: смонтировать HUD лидербордов/достижений если определения уже + // загружены из проекта (define из project_data при load). + try { if (this.leaderstats?.active) this.leaderstats._mount(); } catch (e) {} + try { if (this.achievements?.active) this.achievements._mountButton(); } catch (e) {} // Старт через requestAnimationFrame — даём Babylon собрать сцену requestAnimationFrame(() => { if (this._isPlaying) this.gameRuntime?.start(this._scripts || []); @@ -7609,6 +7621,8 @@ export class BabylonScene { shadowQuality: this._shadowQuality || 'soft', environment: this.environment ? this.environment.serialize() : null, skybox: this.skybox ? this.skybox.serialize() : null, + leaderstats: this.leaderstats ? this.leaderstats.serialize() : null, + achievements: this.achievements ? this.achievements.serialize() : null, audio: this.audioManager ? this.audioManager.serialize() : null, // Библиотека пользовательских картинок (текстуры/GUI-image). assets: this.assetManager ? this.assetManager.serialize() : [], @@ -8094,6 +8108,13 @@ export class BabylonScene { if (state.scene.skybox && this.skybox) { this.skybox.load(state.scene.skybox); } + // Лидерборды и достижения (задача 20) — определения из проекта. + if (state.scene.leaderstats && this.leaderstats) { + this.leaderstats.load(state.scene.leaderstats); + } + if (state.scene.achievements && this.achievements) { + this.achievements.load(state.scene.achievements); + } // Аудио (фоновая музыка/амбиент) if (state.scene.audio && this.audioManager) { this.audioManager.load(state.scene.audio); @@ -8161,6 +8182,9 @@ export class BabylonScene { this._isPlaying = false; // Задача 04: закрываем любой активный модал чтобы не «висел» при стопе try { this.modalManager?._instantClose?.(); } catch (e) {} + // Задача 20: чистим рантайм лидербордов/достижений (определения остаются). + try { this.leaderstats?.resetRuntime?.(); } catch (e) {} + try { this.achievements?.resetRuntime?.(); } catch (e) {} // Сбрасываем таймер прохождения this._timerRunning = false; this._timerStartedAt = null; diff --git a/src/editor/engine/GameRuntime.js b/src/editor/engine/GameRuntime.js index af975dc..8499cab 100644 --- a/src/editor/engine/GameRuntime.js +++ b/src/editor/engine/GameRuntime.js @@ -69,6 +69,21 @@ export class GameRuntime { this.stop(); this._isRunning = true; this.scripts = scripts || []; // для привязки логов/ошибок к скрипту + // Задача 20: мост leaderstats.onChange (main) → globalEvent в worker'ы, + // чтобы скриптовые game.leaderstats.onChange и bindToStat срабатывали. + try { + const ls = this.scene3d?.leaderstats; + if (ls && !ls._bridgeBound) { + ls._bridgeBound = true; + const meId = ls._resolveMe?.(); + ls.onChange((pid, name, nv, ov) => { + for (const sb of this.sandboxes) sb.sendGlobalEvent({ + type: 'leaderstatsChange', playerId: pid, name, newValue: nv, oldValue: ov, + isMe: String(pid) === String(meId), + }); + }); + } + } catch (e) { /* ignore */ } // eslint-disable-next-line no-console console.log('[GameRuntime] start called with scripts:', scripts); // Phase 6.5: lazy-init физ-мира. Wasm-инициализация асинхронная (~50мс), @@ -2895,6 +2910,40 @@ export class GameRuntime { return; } // === Небо и атмосфера (задача 16) === + // === Лидерборды и достижения (задача 20) === + if (cmd === 'leaderstats.define') { + try { this.scene3d?.leaderstats?.define(payload.name, payload.opts || {}); } catch (e) {} + return; + } + if (cmd === 'leaderstats.set') { + try { this.scene3d?.leaderstats?.set(payload.playerId, payload.name, payload.value); } catch (e) {} + return; + } + if (cmd === 'leaderstats.add') { + try { this.scene3d?.leaderstats?.add(payload.playerId, payload.name, payload.delta); } catch (e) {} + return; + } + if (cmd === 'achievements.define') { + try { this.scene3d?.achievements?.define(payload.list); } catch (e) {} + return; + } + if (cmd === 'achievements.unlock') { + try { this.scene3d?.achievements?.unlock(payload.id, payload.playerId); } catch (e) {} + return; + } + if (cmd === 'achievements.bindToStat') { + try { this.scene3d?.achievements?.bindToStat(payload.id, payload.statName, payload.cond || {}); } catch (e) {} + return; + } + if (cmd === 'achievements.setButtonVisible') { + try { this.scene3d?.achievements?.setButtonVisible(!!payload.visible); } catch (e) {} + return; + } + if (cmd === 'achievements.openPage') { + try { this.scene3d?.achievements?.openPage(); } catch (e) {} + return; + } + if (cmd === 'scene.setSkybox') { try { this.scene3d?.skybox?.setSkybox(payload?.opts || {}); } catch (e) {} return; diff --git a/src/editor/engine/LeaderstatsManager.js b/src/editor/engine/LeaderstatsManager.js new file mode 100644 index 0000000..f3883a8 --- /dev/null +++ b/src/editor/engine/LeaderstatsManager.js @@ -0,0 +1,220 @@ +/** + * LeaderstatsManager — лидерборды (leaderstats) как в Roblox (задача 20). + * + * Хранит статы игроков и рендерит HUD-таблицу в правом-верхнем углу. + * В одиночной игре — один игрок ('me'). Поля сортируются по primary-стату. + * + * API (через game.leaderstats.*): + * define(name, opts) — зарегистрировать стат (initial/format/icon/color/primary) + * set(playerId, name, value) / add — изменить стат игрока + * get(playerId, name) — прочитать + * me.set/add(name, value) — для текущего игрока + * onChange(fn) — подписка (для bindToStat достижений) + * + * format: 'number' (42) | 'time' (mm:ss) | 'short' (1.2K). + * DOM-оверлей крепится к canvas.parentElement (как LoadingScreenOverlay). + * + * Фича-парность: тот же модуль в rublox-player/src/engine/. + */ + +function fmt(value, format) { + const v = Number(value) || 0; + if (format === 'time') { + const m = Math.floor(v / 60), s = Math.floor(v % 60); + return (m < 10 ? '0' : '') + m + ':' + (s < 10 ? '0' : '') + s; + } + if (format === 'short') { + if (v >= 1e9) return (v / 1e9).toFixed(1).replace(/\.0$/, '') + 'B'; + if (v >= 1e6) return (v / 1e6).toFixed(1).replace(/\.0$/, '') + 'M'; + if (v >= 1e3) return (v / 1e3).toFixed(1).replace(/\.0$/, '') + 'K'; + return String(Math.round(v)); + } + return String(Math.round(v)); +} + +export class LeaderstatsManager { + constructor(scene3d) { + this.s = scene3d; + this._defs = []; // [{name, initial, format, icon, color, primary}] + this._stats = new Map(); // playerId → Map(name → value) + this._players = new Map(); // playerId → displayName + this._onChange = []; + this.root = null; + this._dirty = false; + this._meId = 'me'; + } + + /** id текущего игрока (одиночка = 'me'). */ + _resolveMe() { + try { + const p = this.s?.gameRuntime?._players?.me; + if (p && p.id != null) return String(p.id); + } catch (e) { /* ignore */ } + return 'me'; + } + + define(name, opts = {}) { + if (typeof name !== 'string' || !name) return; + if (this._defs.some(d => d.name === name)) return; // уже есть + this._defs.push({ + name, + initial: Number(opts.initial) || 0, + format: opts.format || 'number', + icon: opts.icon || '', + color: opts.color || '#e8ecf2', + primary: !!opts.primary, + }); + // Если ни один не primary — первый становится primary. + if (!this._defs.some(d => d.primary)) this._defs[0].primary = true; + // Инициализируем стат у уже известных игроков. + for (const [pid] of this._players) this._ensure(pid, name); + this._ensureMe(); + if (this.s?._isPlaying) this._mount(); // HUD только в Play + this._dirty = true; + } + + _ensureMe() { + const me = this._resolveMe(); + this._meId = me; + if (!this._players.has(me)) { + let nm = 'Ты'; + try { nm = this.s?.gameRuntime?._players?.me?.name || 'Ты'; } catch (e) {} + this._players.set(me, nm); + } + for (const d of this._defs) this._ensure(me, d.name); + } + + _ensure(pid, name) { + if (!this._stats.has(pid)) this._stats.set(pid, new Map()); + const m = this._stats.get(pid); + if (!m.has(name)) { + const def = this._defs.find(d => d.name === name); + m.set(name, def ? def.initial : 0); + } + } + + set(playerId, name, value) { + const pid = playerId == null ? this._resolveMe() : String(playerId); + if (!this._players.has(pid)) this._players.set(pid, pid === this._resolveMe() ? 'Ты' : ('Игрок ' + pid)); + this._ensure(pid, name); + const m = this._stats.get(pid); + const old = m.get(name); + const nv = Number(value) || 0; + if (old === nv) return; + m.set(name, nv); + this._dirty = true; + this._flash = this._flash || {}; + this._flash[pid + '|' + name] = performance.now ? performance.now() : Date.now(); + for (const fn of this._onChange) { + try { fn(pid, name, nv, old); } catch (e) { /* ignore */ } + } + } + + add(playerId, name, delta) { + const pid = playerId == null ? this._resolveMe() : String(playerId); + this._ensure(pid, name); + const cur = this._stats.get(pid).get(name) || 0; + this.set(pid, name, cur + (Number(delta) || 0)); + } + + get(playerId, name) { + const pid = playerId == null ? this._resolveMe() : String(playerId); + const m = this._stats.get(pid); + return m ? (m.get(name) || 0) : 0; + } + + onChange(fn) { if (typeof fn === 'function') this._onChange.push(fn); } + + /** Активны ли leaderstats (хотя бы один define). */ + get active() { return this._defs.length > 0; } + + // ── HUD ────────────────────────────────────────────────────────────── + _mount() { + if (this.root) return; + const parent = (this.s && this.s.canvas && this.s.canvas.parentElement) || document.body; + const root = document.createElement('div'); + root.style.cssText = [ + 'position:absolute', 'top:14px', 'right:14px', 'z-index:50', + 'min-width:230px', 'max-width:300px', + 'background:rgba(18,22,33,0.55)', 'backdrop-filter:blur(8px)', + '-webkit-backdrop-filter:blur(8px)', + 'border:1px solid rgba(255,255,255,0.12)', 'border-radius:12px', + 'padding:10px 12px', 'font-family:Inter,system-ui,sans-serif', + 'color:#e8ecf2', 'pointer-events:none', 'user-select:none', + 'box-shadow:0 6px 24px rgba(0,0,0,0.35)', + ].join(';'); + parent.appendChild(root); + this.root = root; + this._sortBy = null; // имя стата для сортировки (null = primary) + } + + /** Вызывать каждый кадр (рендер при изменениях + затухание flash). */ + tick() { + if (!this.active) return; + if (!this.root) { this._mount(); this._dirty = true; } + if (this._dirty) { this._render(); this._dirty = false; } + // flash затухает ~600мс — перерисуем пока активен. + if (this._flash && Object.keys(this._flash).length) { + const now = performance.now ? performance.now() : Date.now(); + let any = false; + for (const k of Object.keys(this._flash)) { + if (now - this._flash[k] < 600) any = true; else delete this._flash[k]; + } + if (any) this._render(); + } + } + + _render() { + const defs = this._defs; + if (!defs.length) { this.root.innerHTML = ''; return; } + const sortStat = this._sortBy || (defs.find(d => d.primary) || defs[0]).name; + const me = this._resolveMe(); + // Строки игроков, сортировка по убыванию sortStat, топ-10. + const rows = [...this._players.keys()] + .map(pid => ({ pid, name: this._players.get(pid) })) + .sort((a, b) => (this.get(b.pid, sortStat) - this.get(a.pid, sortStat))) + .slice(0, 10); + const now = performance.now ? performance.now() : Date.now(); + + let html = '
🏆 Таблица лидеров
'; + // Шапка столбцов. + html += '
'; + html += 'Игрок'; + for (const d of defs) html += '' + (d.icon ? d.icon + ' ' : '') + d.name + ''; + html += '
'; + // Строки. + for (const r of rows) { + const mine = r.pid === me; + html += '
'; + html += '' + this._esc(r.name) + ''; + for (const d of defs) { + const flashed = this._flash && (now - (this._flash[r.pid + '|' + d.name] || 0) < 600); + const col = flashed ? '#ffe066' : d.color; + html += '' + fmt(this.get(r.pid, d.name), d.format) + ''; + } + html += '
'; + } + this.root.innerHTML = html; + } + + _esc(s) { return String(s).replace(/[&<>]/g, c => ({ '&': '&', '<': '<', '>': '>' }[c])); } + + /** Сериализация определений в project_data. */ + serialize() { + return this._defs.map(d => ({ ...d })); + } + load(arr) { + if (!Array.isArray(arr)) return; + for (const d of arr) this.define(d.name, d); + } + + dispose() { + if (this.root) { try { this.root.remove(); } catch (e) {} this.root = null; } + this._stats.clear(); this._players.clear(); this._onChange = []; + } + /** Сброс рантайм-значений при exitPlayMode (определения остаются). */ + resetRuntime() { + this._stats.clear(); this._players.clear(); this._flash = {}; + if (this.root) this.root.innerHTML = ''; + } +} diff --git a/src/editor/engine/ScriptSandboxWorker.js b/src/editor/engine/ScriptSandboxWorker.js index 0cdfe10..067b2b9 100644 --- a/src/editor/engine/ScriptSandboxWorker.js +++ b/src/editor/engine/ScriptSandboxWorker.js @@ -74,6 +74,10 @@ let _toolSeq = 0; let _players = { me: null, list: [] }; // Общее состояние комнаты game.room.get/set — зеркало из main thread. let _roomState = {}; +// Задача 20: зеркала лидербордов/достижений для синхронного get/has в скриптах. +let _lsMirror = {}; // { playerId: { statName: value } } +let _achUnlocked = {}; // { id: true } +let _lsChangeHandlers = []; // game.leaderstats.onChange подписки // Подписки game.onPlayerJoin / onPlayerLeave / game.onMessage(name). let _playerJoinHandlers = []; let _playerLeaveHandlers = []; @@ -3191,6 +3195,62 @@ const game = { return p ? { ...p } : null; }, }, + + // === Лидерборды (leaderstats) — задача 20 === + leaderstats: { + /** Зарегистрировать стат: define('Монеты', {initial,format,icon,color,primary}). */ + define(name, opts) { + if (typeof name !== 'string' || !name) return; + _send('leaderstats.define', { name, opts: opts || {} }); + }, + /** Установить стат игрока (playerId=null → текущий). */ + set(playerId, name, value) { + _send('leaderstats.set', { playerId: playerId == null ? null : String(playerId), name, value: Number(value) || 0 }); + const pid = playerId == null ? '@me' : String(playerId); + if (!_lsMirror[pid]) _lsMirror[pid] = {}; + _lsMirror[pid][name] = Number(value) || 0; + }, + /** Прибавить к стату. */ + add(playerId, name, delta) { + _send('leaderstats.add', { playerId: playerId == null ? null : String(playerId), name, delta: Number(delta) || 0 }); + const pid = playerId == null ? '@me' : String(playerId); + if (!_lsMirror[pid]) _lsMirror[pid] = {}; + _lsMirror[pid][name] = (_lsMirror[pid][name] || 0) + (Number(delta) || 0); + }, + /** Прочитать стат (из локального зеркала). */ + get(playerId, name) { + const pid = playerId == null ? '@me' : String(playerId); + return (_lsMirror[pid] && _lsMirror[pid][name]) || 0; + }, + /** Подписка на изменение: onChange((playerId, name, newVal, oldVal) => {}). */ + onChange(fn) { if (typeof fn === 'function') _lsChangeHandlers.push(fn); }, + /** Шорткат для текущего игрока. */ + me: { + set(name, value) { game.leaderstats.set(null, name, value); }, + add(name, delta) { game.leaderstats.add(null, name, delta); }, + get(name) { return game.leaderstats.get(null, name); }, + }, + }, + + // === Достижения — задача 20 === + achievements: { + /** Объявить достижения: define([{id,name,description,icon,rarity,points,hidden}]). */ + define(list) { _send('achievements.define', { list: Array.isArray(list) ? list : [list] }); }, + /** Разблокировать достижение. */ + unlock(id, playerId) { + if (typeof id !== 'string') return; + _achUnlocked[id] = true; + _send('achievements.unlock', { id, playerId: playerId == null ? null : String(playerId) }); + }, + /** Разблокировано ли (из зеркала). */ + has(id) { return !!_achUnlocked[id]; }, + /** Авто-unlock по достижению значения leaderstat. */ + bindToStat(id, statName, cond) { _send('achievements.bindToStat', { id, statName, cond: cond || {} }); }, + /** Показать/скрыть кнопку-кубок. */ + setButtonVisible(v) { _send('achievements.setButtonVisible', { visible: !!v }); }, + /** Открыть страницу достижений. */ + openPage() { _send('achievements.openPage', {}); }, + }, /** * Общее состояние комнаты (Фаза 4.3) — данные, видимые всем игрокам. * В одиночной игре работает как локальное хранилище. @@ -4278,6 +4338,15 @@ self.onmessage = (e) => { const t = payload?.type; if (t === 'click') { for (const fn of _globalClickHandlers) _safeCall(fn, payload, 'onClick'); + } else if (t === 'leaderstatsChange') { + // Задача 20: стат изменился на сервере/main — обновляем зеркало + onChange. + const pid = payload.playerId == null ? '@me' : String(payload.playerId); + if (!_lsMirror[pid]) _lsMirror[pid] = {}; + _lsMirror[pid][payload.name] = payload.newValue; + if (payload.isMe) { if (!_lsMirror['@me']) _lsMirror['@me'] = {}; _lsMirror['@me'][payload.name] = payload.newValue; } + for (const fn of _lsChangeHandlers) { try { fn(payload.playerId, payload.name, payload.newValue, payload.oldValue); } catch (err) { _send('log', { level: 'error', text: 'leaderstats.onChange: ' + (err && err.message ? err.message : err) }); } } + } else if (t === 'achievementUnlocked') { + _achUnlocked[payload.id] = true; } else if (t === 'mouseMove') { for (const fn of _mouseMoveHandlers) { try { fn(payload.x, payload.y); } From c9498b086ed0aecff8250aad40b59a8477152acb Mon Sep 17 00:00:00 2001 From: min Date: Sat, 6 Jun 2026 09:49:01 +0300 Subject: [PATCH 39/74] =?UTF-8?q?fix(studio):=20SkyboxManager.hexToRgb=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=B4=D0=B4=D0=B5=D1=80=D0=B6=D0=B8=D0=B2=D0=B0?= =?UTF-8?q?=D0=B5=D1=82=20=D0=BA=D0=BE=D1=80=D0=BE=D1=82=D0=BA=D0=B8=D0=B9?= =?UTF-8?q?=20=D1=85=D0=B5=D0=BA=D1=81=20#fff=20(=D0=B1=D1=8B=D0=BB=20NaN?= =?UTF-8?q?=20=D0=B2=20=D0=BE=D0=B1=D0=BB=D0=B0=D0=BA=D0=B0=D1=85)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit skybox.clouds.color='#fff' → substring(4,6)='' → parseInt NaN → addColorStop 'rgba(255,15,NaN,0.9)' падал при load → прерывал загрузку проекта. hexToRgb теперь расширяет #fff→#ffffff и подстраховывает NaN. Co-Authored-By: Claude Opus 4.8 --- src/editor/engine/SkyboxManager.js | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/editor/engine/SkyboxManager.js b/src/editor/engine/SkyboxManager.js index d148cab..9c9e497 100644 --- a/src/editor/engine/SkyboxManager.js +++ b/src/editor/engine/SkyboxManager.js @@ -80,11 +80,17 @@ function registerSkyShader() { const hexToRgb = (hex) => { if (Array.isArray(hex)) return hex; - const h = String(hex || '#ffffff').replace('#', ''); + let h = String(hex || '#ffffff').replace('#', '').trim(); + // Короткая форма #fff → #ffffff. + if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2]; + if (h.length < 6) h = (h + 'ffffff').slice(0, 6); + const r = parseInt(h.substring(0, 2), 16); + const g = parseInt(h.substring(2, 4), 16); + const b = parseInt(h.substring(4, 6), 16); return [ - parseInt(h.substring(0, 2), 16) / 255, - parseInt(h.substring(2, 4), 16) / 255, - parseInt(h.substring(4, 6), 16) / 255, + (Number.isFinite(r) ? r : 255) / 255, + (Number.isFinite(g) ? g : 255) / 255, + (Number.isFinite(b) ? b : 255) / 255, ]; }; From 33cd435d06eb5c9ef45312f0da339e56a4f5cce0 Mon Sep 17 00:00:00 2001 From: min Date: Sat, 6 Jun 2026 09:59:11 +0300 Subject: [PATCH 40/74] =?UTF-8?q?feat(studio):=20=D0=BF=D1=80=D0=BE=D0=B3?= =?UTF-8?q?=D1=80=D0=B5=D1=81=D1=81=20=D0=BB=D0=B8=D0=B4=D0=B5=D1=80=D0=B1?= =?UTF-8?q?=D0=BE=D1=80=D0=B4=D0=BE=D0=B2=20=D0=B8=20=D0=B4=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D0=B8=D0=B6=D0=B5=D0=BD=D0=B8=D0=B9=20=D1=81=D0=BE=D1=85?= =?UTF-8?q?=D1=80=D0=B0=D0=BD=D1=8F=D0=B5=D1=82=D1=81=D1=8F=20=D0=B2=20?= =?UTF-8?q?=D0=91=D0=94=20(=D0=BC=D0=B5=D0=B6=D0=B4=D1=83=20=D1=81=D0=B5?= =?UTF-8?q?=D1=81=D1=81=D0=B8=D1=8F=D0=BC=D0=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GameRuntime.saveProgress/loadProgress — helper к storys savegame endpoint (/kubikon3d/savegame///) для движковых менеджеров. - Достижения: при unlock сохраняются в БД (namespace _achievements) + localStorage как быстрый кэш; loadFromDB при Play восстанавливает разблокированные. - Лидерстаты: статы текущего игрока сохраняются в БД (namespace _leaderstats, дебаунс 1с при set); loadFromDB при Play восстанавливает значения. - Загрузка из БД через 250мс после старта скриптов (даём define зарегистрировать). Теперь прогресс игрока подгружается при каждой сессии с любого устройства. Co-Authored-By: Claude Opus 4.8 --- src/editor/engine/AchievementsManager.js | 13 +++++++++ src/editor/engine/BabylonScene.js | 7 +++++ src/editor/engine/GameRuntime.js | 21 ++++++++++++++ src/editor/engine/LeaderstatsManager.js | 35 ++++++++++++++++++++++++ 4 files changed, 76 insertions(+) diff --git a/src/editor/engine/AchievementsManager.js b/src/editor/engine/AchievementsManager.js index 3944e9c..5f49b0b 100644 --- a/src/editor/engine/AchievementsManager.js +++ b/src/editor/engine/AchievementsManager.js @@ -49,13 +49,26 @@ export class AchievementsManager { } _loadSaved() { + // Резервная локальная копия (мгновенно, до ответа БД). try { const raw = localStorage.getItem(this._projectKey); if (raw) for (const id of JSON.parse(raw)) this._unlocked.add(id); } catch (e) { /* ignore */ } } + /** Загрузить разблокированные достижения из БД (по игроку). Вызывать при Play. */ + loadFromDB() { + const rt = this.s?.gameRuntime; + if (!rt || !rt.loadProgress) return; + rt.loadProgress('_achievements', (data) => { + if (Array.isArray(data)) { + for (const id of data) this._unlocked.add(id); + } + }); + } _persist() { + // 1) локально (быстрый кэш), 2) в БД (между сессиями, любое устройство). try { localStorage.setItem(this._projectKey, JSON.stringify([...this._unlocked])); } catch (e) {} + try { this.s?.gameRuntime?.saveProgress?.('_achievements', [...this._unlocked]); } catch (e) {} } unlock(id, _playerId) { diff --git a/src/editor/engine/BabylonScene.js b/src/editor/engine/BabylonScene.js index 6e46fc5..6ace30e 100644 --- a/src/editor/engine/BabylonScene.js +++ b/src/editor/engine/BabylonScene.js @@ -6203,6 +6203,13 @@ export class BabylonScene { // Старт через requestAnimationFrame — даём Babylon собрать сцену requestAnimationFrame(() => { if (this._isPlaying) this.gameRuntime?.start(this._scripts || []); + // Задача 20: подгрузить сохранённый прогресс игрока из БД ПОСЛЕ того, + // как скрипты вызвали define() (даём им 200мс на регистрацию статов). + setTimeout(() => { + if (!this._isPlaying) return; + try { this.achievements?.loadFromDB?.(); } catch (e) {} + try { this.leaderstats?.loadFromDB?.(); } catch (e) {} + }, 250); }); // === Оружие === diff --git a/src/editor/engine/GameRuntime.js b/src/editor/engine/GameRuntime.js index 8499cab..869032c 100644 --- a/src/editor/engine/GameRuntime.js +++ b/src/editor/engine/GameRuntime.js @@ -4435,6 +4435,27 @@ export class GameRuntime { .then(j => this._saveReply(scriptId, reqId, j.namespaces || {})) .catch(() => this._saveReply(scriptId, reqId, {})); } + /** Публичный helper для движковых менеджеров (leaderstats/achievements): + * сохранить прогресс текущего игрока в БД (storys savegame). */ + saveProgress(namespace, data) { + const url = this._saveBaseUrl(namespace); + if (!url) return; + try { + fetch(url, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ data }), + }).catch(() => {}); + } catch (e) { /* ignore */ } + } + /** Загрузить прогресс из БД (cb(data|null)). */ + loadProgress(namespace, cb) { + const url = this._saveBaseUrl(namespace); + if (!url) { cb && cb(null); return; } + fetch(url).then(r => r.json()) + .then(j => cb && cb(j.data ?? null)) + .catch(() => cb && cb(null)); + } + _saveSet(payload) { const url = this._saveBaseUrl(payload?.namespace); if (!url) return; diff --git a/src/editor/engine/LeaderstatsManager.js b/src/editor/engine/LeaderstatsManager.js index f3883a8..706bc78 100644 --- a/src/editor/engine/LeaderstatsManager.js +++ b/src/editor/engine/LeaderstatsManager.js @@ -108,6 +108,41 @@ export class LeaderstatsManager { for (const fn of this._onChange) { try { fn(pid, name, nv, old); } catch (e) { /* ignore */ } } + // Сохраняем статы текущего игрока в БД (дебаунс 1с) — между сессиями. + if (pid === this._resolveMe()) this._scheduleSave(); + } + + _scheduleSave() { + if (this._saveTimer) clearTimeout(this._saveTimer); + this._saveTimer = setTimeout(() => { + this._saveTimer = null; + try { + const me = this._resolveMe(); + const m = this._stats.get(me); + if (!m) return; + const obj = {}; for (const [k, v] of m) obj[k] = v; + this.s?.gameRuntime?.saveProgress?.('_leaderstats', obj); + } catch (e) { /* ignore */ } + }, 1000); + } + + /** Загрузить статы текущего игрока из БД (вызывать при Play, после define). */ + loadFromDB() { + const rt = this.s?.gameRuntime; + if (!rt || !rt.loadProgress) return; + rt.loadProgress('_leaderstats', (data) => { + if (data && typeof data === 'object') { + const me = this._resolveMe(); + for (const name of Object.keys(data)) { + // Применяем только к зарегистрированным статам, без повторного сейва. + if (this._defs.some(d => d.name === name)) { + this._ensure(me, name); + this._stats.get(me).set(name, Number(data[name]) || 0); + } + } + this._dirty = true; + } + }); } add(playerId, name, delta) { From ce6e69a2e8220d344afe3d18b6ad0a14f06cb39f Mon Sep 17 00:00:00 2001 From: min Date: Sat, 6 Jun 2026 10:01:38 +0300 Subject: [PATCH 41/74] =?UTF-8?q?fix(studio):=20saveProgress/loadProgress?= =?UTF-8?q?=20=D1=81=20JWT-=D0=B7=D0=B0=D0=B3=D0=BE=D0=BB=D0=BE=D0=B2?= =?UTF-8?q?=D0=BA=D0=BE=D0=BC=20(=D0=B1=D1=8B=D0=BB=20401=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B8=20=D1=81=D0=BE=D1=85=D1=80=D0=B0=D0=BD=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B8=20=D0=BF=D1=80=D0=BE=D0=B3=D1=80=D0=B5=D1=81=D1=81=D0=B0?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit loadProgress/saveProgress (лидерстаты/достижения) слали fetch без Authorization → 401 UNAUTHORIZED. Используют _economyAuthHeaders() (JWT игрока из localStorage). Co-Authored-By: Claude Opus 4.8 --- src/editor/engine/GameRuntime.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/editor/engine/GameRuntime.js b/src/editor/engine/GameRuntime.js index 869032c..6a9bd71 100644 --- a/src/editor/engine/GameRuntime.js +++ b/src/editor/engine/GameRuntime.js @@ -4442,7 +4442,8 @@ export class GameRuntime { if (!url) return; try { fetch(url, { - method: 'POST', headers: { 'Content-Type': 'application/json' }, + method: 'POST', + headers: this._economyAuthHeaders(), // JWT игрока (иначе 401) body: JSON.stringify({ data }), }).catch(() => {}); } catch (e) { /* ignore */ } @@ -4451,7 +4452,8 @@ export class GameRuntime { loadProgress(namespace, cb) { const url = this._saveBaseUrl(namespace); if (!url) { cb && cb(null); return; } - fetch(url).then(r => r.json()) + fetch(url, { headers: this._economyAuthHeaders() }) + .then(r => r.json()) .then(j => cb && cb(j.data ?? null)) .catch(() => cb && cb(null)); } From 7bb789f1affe7bb88302d43eb3346f493c0c0af5 Mon Sep 17 00:00:00 2001 From: min Date: Sat, 6 Jun 2026 10:04:57 +0300 Subject: [PATCH 42/74] =?UTF-8?q?docs(studio):=20=D0=B2=D0=B8=D0=BA=D0=B8?= =?UTF-8?q?=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B0=2020=20=E2=80=94=20?= =?UTF-8?q?=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=BA=D0=B0=20#63=20+=20?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D1=82=D1=8C=D1=8F=20=C2=AB=D0=A1=D0=B1=D0=BE?= =?UTF-8?q?=D1=80=20=D0=BC=D0=BE=D0=BD=D0=B5=D1=82=20(=D0=BB=D0=B8=D0=B4?= =?UTF-8?q?=D0=B5=D1=80=D0=B1=D0=BE=D1=80=D0=B4=D1=8B=20=D0=B8=20=D0=B4?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D0=B8=D0=B6=D0=B5=D0=BD=D0=B8=D1=8F)=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Карточка g5 #63 guide-leaderstats (openProjectId 2616) + статья в docsLessons: что получится, API leaderstats/achievements, 2 шага (таблица/достижения), bindToStat, сохранение в БД. 3 скрина (scene/play/page) — донести вручную (headless-студия не пускает после взлома, попрошу у пользователя). Co-Authored-By: Claude Opus 4.8 --- src/community/docsGames.js | 5 ++ src/community/docsLessons.jsx | 88 +++++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/src/community/docsGames.js b/src/community/docsGames.js index 06d58db..b7a30fd 100644 --- a/src/community/docsGames.js +++ b/src/community/docsGames.js @@ -358,4 +358,9 @@ export const GAMES = [ desc: 'Одной строкой меняешь небо: голубой день, закат, звёздная ночь, космос. Облака, туман, далёкие горы и плавные переходы между пресетами.', mechanics: ['game.scene.setSkybox({ preset })', 'game.scene.setClouds / setFog', 'skybox.fadeTo(opts, сек) — плавный переход', '6 пресетов: день/lowpoly/закат/ночь/космос', 'небо = единый источник света сцены', 'облака-дрейф + дымка горизонта'], previewShot: 'guide-skybox-scene.png', openProjectId: 2541, ready: true }, + { id: 'guide-leaderstats', num: 63, group: 'g5', stars: 2, icon: 'trophy', + title: 'Сбор монет — лидерборды и достижения', + desc: 'Таблица лидеров справа-сверху (монеты/время/уровень) + всплывающие достижения с редкостью и звуком. Прогресс сохраняется в БД между сессиями.', + mechanics: ['game.leaderstats.define / me.add', 'HUD-таблица топ-10 (сортировка по primary)', 'game.achievements.define / unlock', 'bindToStat — авто-награда по статy', 'toast 4 редкости + очередь', 'кубок → страница достижений', 'сохранение в БД (savegame)'], + previewShot: 'guide-leaderstats-scene.png', openProjectId: 2616, ready: true }, ]; diff --git a/src/community/docsLessons.jsx b/src/community/docsLessons.jsx index 8562c3b..d2fba82 100644 --- a/src/community/docsLessons.jsx +++ b/src/community/docsLessons.jsx @@ -8691,6 +8691,94 @@ game.gui.onClick('btn-space', () => game.scene.skybox.fadeTo({ preset: 'space' ), }, + 'guide-leaderstats': { + body: ( + <> +

Что получится

+

+ Игра «собери монеты» с двумя системами удержания, как в Roblox: + таблица лидеров справа-сверху (монеты, время, уровень) и + достижения — всплывающие награды с редкостью, звуком и + страницей-витриной. Прогресс сохраняется в базе — закрыл + игру, вернулся завтра, а монеты и открытые ачивки на месте. +

+ + + +

Чему научишься

+
    +
  • game.leaderstats.define(name, opts) — столбец таблицы: + иконка, цвет, формат (число / время / 1.2K), primary (по нему сортировка);
  • +
  • game.leaderstats.me.add('Монеты', 1) — изменить стат игрока + (ячейка жёлто мигает);
  • +
  • game.achievements.define([...]) — объявить достижения + (id, название, описание, редкость, очки, скрытое);
  • +
  • game.achievements.unlock(id) — выдать достижение (плашка + звук);
  • +
  • game.achievements.bindToStat(id, 'Монеты', {'{'} gte: 10 {'}'}) — + авто-награда при достижении значения стата;
  • +
  • прогресс сохраняется в БД и подгружается в новой сессии.
  • +
+ +

Шаг 1. Таблица лидеров

+

+ Объяви столбцы. Первый primary: true — по нему сортируются + игроки в топе. Дальше меняй значения через me.add / me.set. +

+ + {`game.leaderstats.define('Монеты', { initial: 0, format: 'number', icon: '🪙', color: '#ffd23a', primary: true }); +game.leaderstats.define('Время', { initial: 0, format: 'time', icon: '⏱', color: '#7fd0ff' }); +game.leaderstats.define('Уровень', { initial: 1, format: 'number', icon: '⭐', color: '#b48bff' }); + +// Время идёт само, монеты — за подбор +game.every(1, () => game.leaderstats.me.add('Время', 1)); +game.leaderstats.me.add('Монеты', 1);`} + +

Шаг 2. Достижения

+

+ Объяви список достижений с редкостью (common / rare / epic / + legendary — разный цвет плашки и звук). Выдавай явно через + unlock или автоматически через bindToStat. +

+ + {`game.achievements.define([ + { id:'first_coin', name:'Первая монета', description:'Подобрать монету', icon:'🪙', rarity:'common', points:5 }, + { id:'fifty_coins', name:'Полная сумка', description:'Собрать 50 монет', icon:'💰', rarity:'rare', points:25 }, +]); + +// Авто-награда: как только Монеты >= 50 — плашка «Полная сумка» сама выедет +game.achievements.bindToStat('fifty_coins', 'Монеты', { gte: 50 }); + +// Явная выдача (на первой монете) +game.achievements.unlock('first_coin');`} + + + + + Прогресс игрока (значения статов + открытые достижения) автоматически + сохраняется в базу и подгружается при следующем входе — ничего + дописывать не нужно. Уже открытое достижение второй раз плашкой не + показывается (оно «навсегда»). + + +

Почему это важно

+

+ Лидерборды и достижения — главный механизм удержания: ребёнок + возвращается в игру за новым рекордом и новой ачивкой. Это основа + симуляторов, ферм и PvP — в Roblox столбец «Coins / Wins / Level» + есть почти в каждой игре. +

+ + + Добавь стат «Рекорд» и достижение 'speedrun', которое + выдаётся через bindToStat('Время', {'{'} lte: 30 {'}'}), + если собрать все монеты быстрее 30 секунд. + + + ), + }, + }; /** Есть ли готовый текст урока для игры с таким id. */ From 1c976ee87045b88ff842d99d7fe91b3b7ac81d0b Mon Sep 17 00:00:00 2001 From: min Date: Sat, 6 Jun 2026 10:09:43 +0300 Subject: [PATCH 43/74] =?UTF-8?q?docs(studio):=20=D0=B2=D0=B8=D0=BA=D0=B8?= =?UTF-8?q?=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B0=2020=20=E2=80=94=20?= =?UTF-8?q?=D1=81=D0=BA=D1=80=D0=B8=D0=BD=D1=8B=20(scene/play),=20page=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=BC=D0=B5=D0=BD=D1=91=D0=BD=20=D0=BD=D0=B0=20?= =?UTF-8?q?=D1=82=D0=B5=D0=BA=D1=81=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Скриншоты сцены (редактор) и геймплея (таблица лидеров) в public/wiki. Страница достижений описана текстом (отдельного скрина пока нет). Co-Authored-By: Claude Opus 4.8 --- src/community/docsLessons.jsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/community/docsLessons.jsx b/src/community/docsLessons.jsx index d2fba82..8a18641 100644 --- a/src/community/docsLessons.jsx +++ b/src/community/docsLessons.jsx @@ -8752,8 +8752,11 @@ game.achievements.bindToStat('fifty_coins', 'Монеты', { gte: 50 }); // Явная выдача (на первой монете) game.achievements.unlock('first_coin');`} - +

+ Кубок слева-снизу открывает страницу «Мои достижения»: + открытые — цветные с рамкой по редкости, закрытые — серые с + замком, скрытые — «?», сверху прогресс-бар «N из M (очки)». +

Прогресс игрока (значения статов + открытые достижения) автоматически From ba90bf5c7d9b8c11742371094cbc2a96c4c6c58c Mon Sep 17 00:00:00 2001 From: min Date: Sat, 6 Jun 2026 10:11:18 +0300 Subject: [PATCH 44/74] =?UTF-8?q?feat(studio):=20=D0=BA=D0=B8=D1=82=20?= =?UTF-8?q?=C2=AB=D0=A2=D0=B0=D0=B1=D0=BB=D0=B8=D1=86=D0=B0=20=D0=BB=D0=B8?= =?UTF-8?q?=D0=B4=D0=B5=D1=80=D0=BE=D0=B2=C2=BB=20=D0=B2=20Toolbox=20?= =?UTF-8?q?=E2=86=92=20=D0=93=D0=BE=D1=82=D0=BE=D0=B2=D1=8B=D0=B5=20=D0=BC?= =?UTF-8?q?=D0=B5=D1=85=D0=B0=D0=BD=D0=B8=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Новый кит (категория ui): определяет лидерборд (Очки primary + Время), время идёт само, очки растут от broadcast('score'|'coins'). Сохраняется в БД. Работает вместе со счётчиком монет/очков. Всего 47 китов. Co-Authored-By: Claude Opus 4.8 --- src/editor/engine/GameplayKits.js | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/editor/engine/GameplayKits.js b/src/editor/engine/GameplayKits.js index b120af7..b79e8aa 100644 --- a/src/editor/engine/GameplayKits.js +++ b/src/editor/engine/GameplayKits.js @@ -552,6 +552,22 @@ let score = 0; function show(){ game.ui.set('score', '⭐ ' + score, { x:8, y:6, anchor:'top', color:'#ffd23a', size:22 }); } show(); game.onMessage('score', (m) => { score += (m && m.add) ? m.add : 1; show(); });` }], + }, + { + id: 'leaderboard', + name: 'Таблица лидеров', + desc: 'Лидерборд справа-сверху (Очки/Время). Растёт от монет и очков других механик. Сохраняется в БД между сессиями. (Задача 20)', + icon: 'trophy', category: 'ui', + scripts: [{ attachTo: 'global', code: +`// Таблица лидеров: столбцы «Очки» (primary) и «Время». +game.leaderstats.define('Очки', { initial: 0, format: 'number', icon: '⭐', color: '#ffd23a', primary: true }); +game.leaderstats.define('Время', { initial: 0, format: 'time', icon: '⏱', color: '#7fd0ff' }); +// Время идёт само. +game.every(1, () => game.leaderstats.me.add('Время', 1)); +// Любая механика, шлющая broadcast('score',{add}) или ('coins',{add}), +// автоматически добавляет очки в таблицу. +game.onMessage('score', (m) => game.leaderstats.me.add('Очки', (m && m.add) ? m.add : 1)); +game.onMessage('coins', (m) => game.leaderstats.me.add('Очки', (m && m.add) ? m.add : 1));` }], }, { id: 'hp-bar', From e477d652f67c88a829cb0c8a16bbc1997cfc9f11 Mon Sep 17 00:00:00 2001 From: min Date: Sat, 6 Jun 2026 12:17:43 +0300 Subject: [PATCH 45/74] =?UTF-8?q?fix(studio):=20mainMenu.show=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D1=85=D0=B2=D0=B0=D1=82=D1=8B=D0=B2=D0=B0=D0=B5?= =?UTF-8?q?=D1=82=20opts.onPlay/onShow/onHide=20(=D0=BF=D0=BE=D1=80=D1=82)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- src/editor/engine/ScriptSandboxWorker.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/editor/engine/ScriptSandboxWorker.js b/src/editor/engine/ScriptSandboxWorker.js index 067b2b9..298ea6d 100644 --- a/src/editor/engine/ScriptSandboxWorker.js +++ b/src/editor/engine/ScriptSandboxWorker.js @@ -2593,6 +2593,10 @@ const game = { opts = opts && typeof opts === 'object' ? opts : {}; this._opts = opts; this._active = true; + // Колбэки можно передавать прямо в опциях show({ onPlay, onShow, onHide }). + if (typeof opts.onPlay === 'function') this._onPlay.push(opts.onPlay); + if (typeof opts.onShow === 'function') this._onShow.push(opts.onShow); + if (typeof opts.onHide === 'function') this._onHide.push(opts.onHide); // 1) Заблокировать управление игроком (наблюдатель). _send('player.setInputBlocked', { blocked: true }); game.hud.setVisible(false); From 6c0c3dc26e427da63ce628f008ebbcb3e9684acf Mon Sep 17 00:00:00 2001 From: min Date: Sun, 7 Jun 2026 10:38:34 +0300 Subject: [PATCH 46/74] =?UTF-8?q?fix(studio):=20Ctrl+D=20=D0=B4=D1=83?= =?UTF-8?q?=D0=B1=D0=BB=D0=B8=D1=80=D1=83=D0=B5=D1=82=20=D0=BE=D0=B1=D1=8A?= =?UTF-8?q?=D0=B5=D0=BA=D1=82=20=D0=A0=D0=9E=D0=92=D0=9D=D0=9E=20=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=BC=D0=B5=D1=81=D1=82=D0=B5=20=D0=BE=D1=80=D0=B8?= =?UTF-8?q?=D0=B3=D0=B8=D0=BD=D0=B0=D0=BB=D0=B0=20(=D0=BD=D0=B5=20=D1=81?= =?UTF-8?q?=D0=BC=D0=B5=D1=89=D0=B0=D0=B5=D1=82=20+1=20=D0=BF=D0=BE=20X)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit duplicateSelected для model/userModel/primitive ставил копию на sel.x+1 → визуальное смещение. Теперь копия появляется в той же точке (как Roblox Studio). Block остаётся с поиском свободной клетки (воксель нельзя в занятую). Co-Authored-By: Claude Opus 4.8 --- src/editor/engine/BabylonScene.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/editor/engine/BabylonScene.js b/src/editor/engine/BabylonScene.js index 6ace30e..7cbd532 100644 --- a/src/editor/engine/BabylonScene.js +++ b/src/editor/engine/BabylonScene.js @@ -5380,7 +5380,8 @@ export class BabylonScene { const sx = sel.x, sy = sel.y, sz = sel.z; const rotY = sel.rotationY || 0; const srcId = sel.instanceId; - this.modelManager.addInstance(typeId, sx + 1, sy, sz, rotY) + // Дубль появляется РОВНО на месте оригинала (как в Roblox Studio). + this.modelManager.addInstance(typeId, sx, sy, sz, rotY) .then(newId => { if (newId != null) { this._copyScriptsToNewObject('model', srcId, newId); @@ -5396,7 +5397,7 @@ export class BabylonScene { const sx = sel.x, sy = sel.y, sz = sel.z; const rotY = sel.rotationY || 0; const srcUmId = sel.instanceId; - this.userModelManager.addInstance(typeId, sx + 1, sy, sz, rotY, { + this.userModelManager.addInstance(typeId, sx, sy, sz, rotY, { currentUserId: this._currentUserId || null, }).then(newId => { if (newId != null) { @@ -5408,7 +5409,7 @@ export class BabylonScene { }); } else if (sel.type === 'primitive') { const newId = this.primitiveManager.addInstance(sel.primitiveType, { - x: sel.x + 1, y: sel.y, z: sel.z, + x: sel.x, y: sel.y, z: sel.z, sx: sel.sx, sy: sel.sy, sz: sel.sz, // Сохраняем вращение копии (без этого сбрасывалось, баг 2026-06-04). rotationX: sel.rotationX || 0, From f8f0d976ef2b76eb1c952177251f05e26c164e95 Mon Sep 17 00:00:00 2001 From: min Date: Sun, 7 Jun 2026 10:44:14 +0300 Subject: [PATCH 47/74] =?UTF-8?q?fix(studio):=20Ctrl+=D1=88=D0=BE=D1=80?= =?UTF-8?q?=D1=82=D0=BA=D0=B0=D1=82=D1=8B=20=D0=BD=D0=B5=20=D0=B4=D0=B2?= =?UTF-8?q?=D0=B8=D0=B3=D0=B0=D1=8E=D1=82=20=D0=BA=D0=B0=D0=BC=D0=B5=D1=80?= =?UTF-8?q?=D1=83=20(Ctrl+D=20=D0=B1=D0=BE=D0=BB=D1=8C=D1=88=D0=B5=20?= =?UTF-8?q?=D0=BD=D0=B5=20=D1=83=D0=B2=D0=BE=D0=B4=D0=B8=D1=82=20=D0=B2?= =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=BE)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit onKeyDown клал любую клавишу в _codes (набор для WASD-движения камеры), включая D при зажатом Ctrl → камера летела вправо при копировании. Теперь клавиши с ctrl/meta в _codes не попадают. Co-Authored-By: Claude Opus 4.8 --- src/editor/engine/BabylonScene.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/editor/engine/BabylonScene.js b/src/editor/engine/BabylonScene.js index 7cbd532..c073550 100644 --- a/src/editor/engine/BabylonScene.js +++ b/src/editor/engine/BabylonScene.js @@ -2565,7 +2565,9 @@ export class BabylonScene { const onKeyDown = (e) => { if (isTypingTarget(e.target)) return; - this._codes.add(e.code); + // Клавиши с Ctrl/Cmd — это шорткаты (Ctrl+D/C/V/Z...), а не движение + // камеры. Не кладём их в _codes, иначе камера «уезжает» (баг Ctrl+D). + if (!e.ctrlKey && !e.metaKey) this._codes.add(e.code); if (e.shiftKey) this._shiftDown = true; // Маршрутизация game.onKey в Play-режиме if (this._isPlaying && this.gameRuntime) { From 458b6c3b59dfb66b8b24e19b68357728646a3c27 Mon Sep 17 00:00:00 2001 From: min Date: Sun, 7 Jun 2026 11:05:25 +0300 Subject: [PATCH 48/74] =?UTF-8?q?feat(studio):=20=D0=B7=D0=B0=D0=B4=D0=B0?= =?UTF-8?q?=D1=87=D0=B0=2040=20=E2=80=94=20damage=20floaters=20(game.fx.da?= =?UTF-8?q?mageFloater)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FloaterManager.js: object pool (30 billboard-планов с DynamicTexture), tween подъём+fade+покачивание, crit pop-scale, цвета damage/crit/heal/mana/miss, стек одинаковых по stackKey (×N), комикс-стиль (BAM!/KAPOW!/POW! на звезде). API game.fx.damageFloater(position, value, opts) — position {x,y,z} или ref/ 'player'. Интеграция: tick в render-loop, resetRuntime при stop. Тест-игра «Тренировочный полигон» id=2676. Co-Authored-By: Claude Opus 4.8 --- src/editor/engine/BabylonScene.js | 7 + src/editor/engine/FloaterManager.js | 237 +++++++++++++++++++++++ src/editor/engine/GameRuntime.js | 20 ++ src/editor/engine/ScriptSandboxWorker.js | 12 ++ 4 files changed, 276 insertions(+) create mode 100644 src/editor/engine/FloaterManager.js diff --git a/src/editor/engine/BabylonScene.js b/src/editor/engine/BabylonScene.js index c073550..d6f7e0d 100644 --- a/src/editor/engine/BabylonScene.js +++ b/src/editor/engine/BabylonScene.js @@ -76,6 +76,7 @@ import { Environment } from './Environment'; import { SkyboxManager } from './SkyboxManager'; import { LeaderstatsManager } from './LeaderstatsManager'; import { AchievementsManager } from './AchievementsManager'; +import { FloaterManager } from './FloaterManager'; // задача 40 — damage floaters import { AudioManager, AMBIENT_PRESETS, MUSIC_PRESETS } from './AudioManager'; import { GameAudioManager } from './GameAudioManager'; import { AssetManager } from './AssetManager'; @@ -1299,6 +1300,7 @@ export class BabylonScene { this.dynamics = new DynamicsManager(this); this.environment = new Environment(this.scene, this._hemiLight, this._sunLight); this.skybox = new SkyboxManager(this.scene, this._hemiLight, this._sunLight); // задача 16 — кастомное небо (единый источник света) + this.floaters = new FloaterManager(this); // задача 40 — damage floaters this.leaderstats = new LeaderstatsManager(this); // задача 20 — лидерборды this.achievements = new AchievementsManager(this); // задача 20 — достижения this.audioManager = new AudioManager(); @@ -1462,6 +1464,10 @@ export class BabylonScene { if (this._isPlaying && this.leaderstats) { this.leaderstats.tick(); } + // Damage floaters (задача 40) — анимация всплывающих цифр. + if (this.floaters) { + this.floaters.tick(dt); + } // Анимация жидкостей — работает всегда (и в редакторе) if (this.blockManager) { this.blockManager.tick(dt); @@ -8195,6 +8201,7 @@ export class BabylonScene { // Задача 20: чистим рантайм лидербордов/достижений (определения остаются). try { this.leaderstats?.resetRuntime?.(); } catch (e) {} try { this.achievements?.resetRuntime?.(); } catch (e) {} + try { this.floaters?.resetRuntime?.(); } catch (e) {} // задача 40 // Сбрасываем таймер прохождения this._timerRunning = false; this._timerStartedAt = null; diff --git a/src/editor/engine/FloaterManager.js b/src/editor/engine/FloaterManager.js new file mode 100644 index 0000000..749764c --- /dev/null +++ b/src/editor/engine/FloaterManager.js @@ -0,0 +1,237 @@ +/** + * FloaterManager — всплывающие цифры урона (Damage Floaters), задача 40. + * + * game.fx.damageFloater(position, value, opts) → над точкой всплывает число, + * поднимается вверх, покачивается, плавно гаснет. Цвета: damage/crit/heal/ + * mana/miss. Object pool из переиспользуемых billboard-планов (без create/ + * destroy на каждый удар). Стек одинаковых по stackKey («×N»). Комикс-стиль + * (BAM!/KAPOW!/POW!). + * + * Билборд = плоскость с DynamicTexture (как LabelManager), billboardMode=7, + * renderingGroupId=1 (всегда поверх геометрии), disableDepthWrite. + * + * Фича-парность: тот же модуль в rublox-player/src/engine/. + */ +import { DynamicTexture } from '@babylonjs/core/Materials/Textures/dynamicTexture'; +import { StandardMaterial } from '@babylonjs/core/Materials/standardMaterial'; +import { MeshBuilder } from '@babylonjs/core/Meshes/meshBuilder'; +import { Color3 } from '@babylonjs/core/Maths/math.color'; +import { Mesh } from '@babylonjs/core/Meshes/mesh'; + +const POOL_SIZE = 30; +const TEX_W = 512, TEX_H = 256; + +// Пресеты типов урона: цвет текста + множители. +const PRESETS = { + damage: { color: '#ff5a4a', stroke: '#3a0000' }, + crit: { color: '#ffd23a', stroke: '#5a3a00' }, + heal: { color: '#46e06a', stroke: '#063a14' }, + mana: { color: '#4aa8ff', stroke: '#001a3a' }, + miss: { color: '#b8b8b8', stroke: '#222222' }, +}; + +function easeOutQuad(t) { return 1 - (1 - t) * (1 - t); } + +export class FloaterManager { + constructor(scene3d) { + this.s = scene3d; + this.scene = scene3d.scene; + this.pool = []; + this._initialized = false; + this._stacks = new Map(); // stackKey → slot (для накопления ×N) + } + + _init() { + if (this._initialized) return; + this._initialized = true; + for (let i = 0; i < POOL_SIZE; i++) { + const tex = new DynamicTexture(`floaterTex_${i}`, { width: TEX_W, height: TEX_H }, this.scene, true); + tex.hasAlpha = true; + const plane = MeshBuilder.CreatePlane(`floater_${i}`, { width: 2.4, height: 1.2, sideOrientation: Mesh.DOUBLESIDE }, this.scene); + const mat = new StandardMaterial(`floaterMat_${i}`, this.scene); + mat.diffuseTexture = tex; + mat.diffuseTexture.hasAlpha = true; + mat.emissiveColor = new Color3(1, 1, 1); + mat.diffuseColor = new Color3(0, 0, 0); + mat.disableLighting = true; + mat.backFaceCulling = false; + mat.disableDepthWrite = true; + mat.useAlphaFromDiffuseTexture = true; + plane.material = mat; + plane.billboardMode = 7; + plane.renderingGroupId = 1; + plane.isPickable = false; + plane.setEnabled(false); + this.pool.push({ plane, tex, mat, active: false, age: 0, lifetime: 0.8 }); + } + } + + _acquire() { + for (const slot of this.pool) if (!slot.active) return slot; + return null; // все заняты — пропускаем новый floater (норма) + } + + /** + * Главный API. position: {x,y,z}; value: число|строка; opts — см. задачу 40. + */ + spawn(position, value, opts = {}) { + this._init(); + if (!position) return; + opts = opts || {}; + + // Стек: одинаковый stackKey за время жизни накапливает счётчик. + if (opts.stackKey && this._stacks.has(opts.stackKey)) { + const slot = this._stacks.get(opts.stackKey); + if (slot.active) { + slot.stackCount = (slot.stackCount || 1) + 1; + slot.age = Math.min(slot.age, slot.lifetime * 0.3); // продлеваем + this._draw(slot, slot.baseText, slot.preset, slot.fontSize, slot.comic, slot.stackCount); + return; + } + } + + const slot = this._acquire(); + if (!slot) return; + + // Тип floater'а. + let kind = 'damage'; + if (opts.isCrit) kind = 'crit'; + else if (opts.isHeal) kind = 'heal'; + else if (opts.isMana) kind = 'mana'; + else if (opts.isMiss) kind = 'miss'; + const preset = PRESETS[kind]; + const color = opts.color || preset.color; + const stroke = opts.strokeColor || preset.stroke; + + let fontSize = Number.isFinite(opts.fontSize) ? opts.fontSize : 60; + let floatHeight = Number.isFinite(opts.floatHeight) ? opts.floatHeight : 2; + let lifetime = Number.isFinite(opts.lifetime) ? opts.lifetime : 0.9; + const randomOffset = Number.isFinite(opts.randomOffset) ? opts.randomOffset : (opts.isCrit ? 0.5 : 0.25); + + // Текст: число с минусом (урон) или как есть (строка / heal с плюсом). + let baseText; + if (typeof value === 'string') baseText = value; + else if (opts.isHeal) baseText = '+' + value; + else if (opts.isMiss) baseText = String(value); + else baseText = '-' + Math.abs(value); + + if (opts.isCrit) { fontSize = Math.round(fontSize * 1.4); floatHeight *= 1.2; } + + slot.active = true; + slot.age = 0; + slot.lifetime = lifetime; + slot.floatHeight = floatHeight; + slot.isCrit = !!opts.isCrit; + slot.color = color; slot.stroke = stroke; + slot.preset = { color, stroke }; + slot.fontSize = fontSize; + slot.comic = !!opts.comicStyle; + slot.baseText = baseText; + slot.stackCount = 1; + slot.stackKey = opts.stackKey || null; + + const rx = (Math.random() - 0.5) * 2 * randomOffset; + const rz = (Math.random() - 0.5) * 2 * randomOffset; + slot.startX = position.x + rx; + slot.startY = position.y + (Number.isFinite(opts.yOffset) ? opts.yOffset : 1.5); + slot.startZ = position.z + rz; + slot.plane.position.set(slot.startX, slot.startY, slot.startZ); + slot.plane.scaling.set(1, 1, 1); + slot.plane.setEnabled(true); + + this._draw(slot, baseText, slot.preset, fontSize, slot.comic, 1); + + if (opts.stackKey) this._stacks.set(opts.stackKey, slot); + } + + _draw(slot, baseText, preset, fontSize, comic, stackCount) { + const ctx = slot.tex.getContext(); + ctx.clearRect(0, 0, TEX_W, TEX_H); + + let text = baseText; + if (comic) { + const num = parseInt(String(baseText).replace(/[^0-9]/g, ''), 10) || 0; + if (slot.isCrit) text = 'POW!'; + else if (num > 100) text = 'KAPOW!'; + else if (num > 50) text = 'BAM!'; + text = text; + } + if (stackCount > 1) text = baseText + ' ×' + stackCount; + + const fs = comic ? Math.round(fontSize * 1.1) : fontSize; + ctx.font = `900 ${fs}px ${comic ? 'Bangers, Impact, sans-serif' : 'Inter, Arial, sans-serif'}`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.lineJoin = 'round'; + + // Комикс-фон: жёлтая звезда-вспышка. + if (comic) { + ctx.save(); + ctx.translate(TEX_W / 2, TEX_H / 2); + ctx.fillStyle = 'rgba(255,210,60,0.9)'; + ctx.beginPath(); + const spikes = 10, outer = 130, inner = 70; + for (let i = 0; i < spikes * 2; i++) { + const r = i % 2 === 0 ? outer : inner; + const a = (i / (spikes * 2)) * Math.PI * 2 - Math.PI / 2; + const px = Math.cos(a) * r, py = Math.sin(a) * r * 0.55; + i === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py); + } + ctx.closePath(); ctx.fill(); + ctx.restore(); + } + + // Обводка + текст. + ctx.strokeStyle = comic ? '#000' : preset.stroke; + ctx.lineWidth = Math.max(6, fs * 0.16); + ctx.strokeText(text, TEX_W / 2, TEX_H / 2); + ctx.fillStyle = comic ? '#d22' : preset.color; + ctx.fillText(text, TEX_W / 2, TEX_H / 2); + + slot.tex.update(true); + } + + /** Вызывать каждый кадр (анимация подъёма + fade + покачивание + crit-pop). */ + tick(dt) { + if (!this._initialized) return; + for (const slot of this.pool) { + if (!slot.active) continue; + slot.age += dt; + const t = slot.age / slot.lifetime; + if (t >= 1) { + slot.active = false; + slot.plane.setEnabled(false); + if (slot.stackKey && this._stacks.get(slot.stackKey) === slot) this._stacks.delete(slot.stackKey); + continue; + } + const ease = easeOutQuad(t); + slot.plane.position.y = slot.startY + slot.floatHeight * ease; + slot.plane.position.x = slot.startX + Math.sin(slot.age * 5) * 0.12; + + // fade-in 0.12 / hold / fade-out 0.25 + let alpha = 1; + if (t < 0.12) alpha = t / 0.12; + else if (t > 0.75) alpha = 1 - (t - 0.75) / 0.25; + slot.mat.alpha = Math.max(0, Math.min(1, alpha)); + + // crit pop: scale 1 → 1.3 → 1 в первые 0.4 жизни + if (slot.isCrit) { + let s = 1; + if (t < 0.2) s = 1 + (t / 0.2) * 0.3; + else if (t < 0.4) s = 1.3 - ((t - 0.2) / 0.2) * 0.3; + slot.plane.scaling.set(s, s, s); + } + } + } + + dispose() { + for (const slot of this.pool) { + try { slot.plane.dispose(); slot.tex.dispose(); slot.mat.dispose(); } catch (e) {} + } + this.pool = []; this._stacks.clear(); this._initialized = false; + } + resetRuntime() { + for (const slot of this.pool) { slot.active = false; slot.plane?.setEnabled(false); } + this._stacks.clear(); + } +} diff --git a/src/editor/engine/GameRuntime.js b/src/editor/engine/GameRuntime.js index 6a9bd71..8290234 100644 --- a/src/editor/engine/GameRuntime.js +++ b/src/editor/engine/GameRuntime.js @@ -1891,6 +1891,26 @@ export class GameRuntime { if (cmd === 'loading.setCover') { this.scene3d?.loadingScreen?.setCover(payload?.cover); return; } if (cmd === 'loading.close') { this.scene3d?.loadingScreen?.close(); return; } + // === Damage Floaters (задача 40) — всплывающие цифры урона === + if (cmd === 'fx.damageFloater') { + try { + let pos = payload?.position; + // ref-строка ('player'|'primitive:N'|'model:N') → координаты объекта. + if (typeof pos === 'string') { + if (pos === 'player') { + const pl = this.scene3d?.player; + const p = pl ? (pl._pos || pl.position || pl.mesh?.position) : null; + pos = p ? { x: p.x, y: p.y, z: p.z } : null; + } else { + const tgt = this._resolveTweenTarget(pos); + pos = tgt ? { x: tgt.data.x || 0, y: tgt.data.y || 0, z: tgt.data.z || 0 } : null; + } + } + if (pos) this.scene3d?.floaters?.spawn(pos, payload?.value, payload?.opts || {}); + } catch (e) { /* ignore */ } + return; + } + // === Beam / Trail — лучи и следы (Фаза 5.2) === if (cmd === 'fx.create') { // payload: { kind: 'beam'|'trail', localRef, ... } diff --git a/src/editor/engine/ScriptSandboxWorker.js b/src/editor/engine/ScriptSandboxWorker.js index 298ea6d..d7498fb 100644 --- a/src/editor/engine/ScriptSandboxWorker.js +++ b/src/editor/engine/ScriptSandboxWorker.js @@ -3443,6 +3443,18 @@ const game = { * trail — шлейф за движущимся объектом. */ fx: { + /** + * Всплывающая цифра урона (задача 40). position — {x,y,z} или ref + * объекта; value — число или строка; opts — color/isCrit/isHeal/isMana/ + * isMiss/fontSize/floatHeight/lifetime/randomOffset/stackKey/comicStyle. + * game.fx.damageFloater(enemy.position, 25); + * game.fx.damageFloater(pos, 100, { isCrit: true }); + * game.fx.damageFloater(pos, 30, { isHeal: true }); + */ + damageFloater(position, value, opts) { + const pos = _normFxPoint(position); + _send('fx.damageFloater', { position: pos, value, opts: opts || {} }); + }, /** * Луч между двумя точками. opts: { from, to — {x,y,z} или ref * объекта (тогда луч следит за ним); color: '#hex', width }. From c93070170bcf6018852ee7d4f27eb66f76c2f4b4 Mon Sep 17 00:00:00 2001 From: min Date: Sun, 7 Jun 2026 11:08:28 +0300 Subject: [PATCH 49/74] =?UTF-8?q?docs(studio):=20=D0=B2=D0=B8=D0=BA=D0=B8?= =?UTF-8?q?=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B0=2040=20=E2=80=94=20?= =?UTF-8?q?=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=BA=D0=B0=20#64=20+=20?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D1=82=D1=8C=D1=8F=20=C2=AB=D0=A2=D1=80=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=80=D0=BE=D0=B2=D0=BE=D1=87=D0=BD=D1=8B=D0=B9=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=BB=D0=B8=D0=B3=D0=BE=D0=BD=20(=D1=86=D0=B8?= =?UTF-8?q?=D1=84=D1=80=D1=8B=20=D1=83=D1=80=D0=BE=D0=BD=D0=B0)=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Карточка g5 #64 guide-floaters (openProjectId 2676) + статья: game.fx. damageFloater, типы (damage/crit/heal/mana/miss), стек stackKey, comicStyle, object pool. 2 скрина (scene/play) в public/wiki. Co-Authored-By: Claude Opus 4.8 --- src/community/docsGames.js | 5 +++ src/community/docsLessons.jsx | 72 +++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/src/community/docsGames.js b/src/community/docsGames.js index b7a30fd..59fb4ac 100644 --- a/src/community/docsGames.js +++ b/src/community/docsGames.js @@ -363,4 +363,9 @@ export const GAMES = [ desc: 'Таблица лидеров справа-сверху (монеты/время/уровень) + всплывающие достижения с редкостью и звуком. Прогресс сохраняется в БД между сессиями.', mechanics: ['game.leaderstats.define / me.add', 'HUD-таблица топ-10 (сортировка по primary)', 'game.achievements.define / unlock', 'bindToStat — авто-награда по статy', 'toast 4 редкости + очередь', 'кубок → страница достижений', 'сохранение в БД (savegame)'], previewShot: 'guide-leaderstats-scene.png', openProjectId: 2616, ready: true }, + { id: 'guide-floaters', num: 64, group: 'g5', stars: 2, icon: 'sparkles', + title: 'Тренировочный полигон — цифры урона', + desc: 'Всплывающие цифры урона над целью (как в Roblox-RPG): красный урон, жёлтый крит с pop-эффектом, зелёный хил, синяя мана, серый «Промах», стек «×N» и комикс-стиль BAM!/KAPOW!.', + mechanics: ['game.fx.damageFloater(pos, value, opts)', 'isCrit / isHeal / isMana / isMiss — цвет', 'подъём + плавное затухание + покачивание', 'crit — pop-эффект (scale 1→1.3→1)', 'stackKey — стек одинаковых в «×N»', 'comicStyle — BAM!/KAPOW! на звезде', 'object pool 30 планов (без лагов)'], + previewShot: 'guide-floaters-scene.png', openProjectId: 2676, ready: true }, ]; diff --git a/src/community/docsLessons.jsx b/src/community/docsLessons.jsx index 8a18641..c3281fe 100644 --- a/src/community/docsLessons.jsx +++ b/src/community/docsLessons.jsx @@ -8782,6 +8782,78 @@ game.achievements.unlock('first_coin');`} ), }, + 'guide-floaters': { + body: ( + <> +

Что получится

+

+ Тренировочный полигон с манекенами. При ударе над целью + всплывает цифра урона — как в Roblox-RPG (Pet Sim, Anime + Adventures): красный обычный урон, жёлтый КРИТ с подскоком, + зелёный хил, синяя мана, серый «Промах». Одинаковые удары + собираются в стек «×N», а в комикс-режиме вместо цифр — BAM! и KAPOW!. +

+ + + +

Главный метод

+

Одна строка рисует всплывающую цифру в нужной точке мира:

+ {`game.fx.damageFloater(position, value, opts)`} +
    +
  • position — точка {'{x,y,z}'}, ссылка на объект или 'player';
  • +
  • value — число (урон) или строка («Промах», «BLOCKED»);
  • +
  • opts — стиль: цвет и тип эффекта.
  • +
+ +

Типы (цвета)

+ + {`game.fx.damageFloater(pos, 25); // красный — обычный урон +game.fx.damageFloater(pos, 80, { isCrit: true }); // жёлтый, больше + подскок +game.fx.damageFloater(pos, 30, { isHeal: true }); // зелёный — лечение (+30) +game.fx.damageFloater(pos, 50, { isMana: true }); // синий — мана +game.fx.damageFloater(pos, 'Промах', { isMiss: true }); // серый текст`} + +

Стек одинаковых ударов

+

+ При AOE по одной цели десятки цифр сливаются в кашу. Передай + общий stackKey — и удары соберутся в один floater «×N». +

+ {`for (let i = 0; i < 10; i++) { + game.fx.damageFloater(enemy.position, 25, { stackKey: 'aoe_' + enemy.id }); +} +// → один floater «-25 ×10» вместо десяти отдельных`} + +

Комикс-стиль

+

+ Для казуальных игр — comicStyle: true: вместо чисел + рисуется «BAM!» (урон >50), «KAPOW!» (>100), «POW!» (крит) на + жёлтой звезде-вспышке. +

+ {`game.fx.damageFloater(pos, 120, { comicStyle: true }); // KAPOW!`} + + + Под капотом — пул из 30 переиспользуемых билборд-планов + (object pool), поэтому даже при спаме десятков цифр FPS не + проседает. Цифры всегда поверх геометрии и повёрнуты к камере. + + +

Почему это важно

+

+ Без всплывающих цифр любой удар ощущается «впустую». Это базовый + боевой фидбек: игрок видит, сколько нанёс, был ли крит, попал ли + вообще. Сочетается с боевыми механиками и способностями. +

+ + + Сделай «огненный» урон: damageFloater(pos, 15, {'{'} color: + '#ff7a2a' {'}'}) каждые 0.5 сек 3 раза подряд — эффект + горения (-15… -15… -15). + + + ), + }, + }; /** Есть ли готовый текст урока для игры с таким id. */ From 854074bfa21cd11da8bd7522c47cb62f6d64d373 Mon Sep 17 00:00:00 2001 From: min Date: Sun, 7 Jun 2026 13:41:41 +0300 Subject: [PATCH 50/74] =?UTF-8?q?feat(studio):=20=D0=B0=D0=B2=D1=82=D0=BE-?= =?UTF-8?q?floater=20=D0=BD=D0=B0=D0=B4=20=D0=BC=D0=BE=D0=B1=D0=B0=D0=BC?= =?UTF-8?q?=D0=B8=20+=20=D1=83=D1=80=D0=BE=D0=BD=20NPC=20=D0=BE=D1=82=20?= =?UTF-8?q?=D0=BE=D1=80=D1=83=D0=B6=D0=B8=D1=8F=20(=D0=B7=D0=B0=D0=B4?= =?UTF-8?q?=D0=B0=D1=87=D0=B0=2040=20=D0=B4=D0=BE=D0=BF)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit game.fx.autoMobFloaters(true) — включает облачка урона над NPC при любой потере HP (NpcManager.damage). NpcManager.damageByMesh — оружие (бластер/меч) наносит урон скриптовым NPC (weapons.setOnHit → npcManager.damageByMesh). Связка: выстрел бластера → урон NPC → авто-floater «-N» над целью. Co-Authored-By: Claude Opus 4.8 --- src/editor/engine/BabylonScene.js | 4 ++++ src/editor/engine/GameRuntime.js | 9 ++++++++ src/editor/engine/NpcManager.js | 28 +++++++++++++++++++++++- src/editor/engine/ScriptSandboxWorker.js | 7 ++++++ 4 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/editor/engine/BabylonScene.js b/src/editor/engine/BabylonScene.js index d6f7e0d..24b3605 100644 --- a/src/editor/engine/BabylonScene.js +++ b/src/editor/engine/BabylonScene.js @@ -6229,6 +6229,10 @@ export class BabylonScene { if (hit?.mesh && this.zombieManager) { this.zombieManager.damageByMesh(hit.mesh, hit.damage || 25); } + // Урон скриптовым NPC (киты-враги) → авто-floater над мобом (задача 40). + if (hit?.mesh && this.npcManager) { + try { this.npcManager.damageByMesh(hit.mesh, hit.damage || 25); } catch (e) {} + } if (this._onWeaponHit) { try { this._onWeaponHit(hit); } catch (e) {} } diff --git a/src/editor/engine/GameRuntime.js b/src/editor/engine/GameRuntime.js index 8290234..45c5ca1 100644 --- a/src/editor/engine/GameRuntime.js +++ b/src/editor/engine/GameRuntime.js @@ -1910,6 +1910,15 @@ export class GameRuntime { } catch (e) { /* ignore */ } return; } + if (cmd === 'fx.autoMobFloaters') { + try { + if (this.scene3d?.npcManager) { + this.scene3d.npcManager._autoFloater = payload?.enabled + ? { opts: payload?.opts || {} } : null; + } + } catch (e) { /* ignore */ } + return; + } // === Beam / Trail — лучи и следы (Фаза 5.2) === if (cmd === 'fx.create') { diff --git a/src/editor/engine/NpcManager.js b/src/editor/engine/NpcManager.js index d1a2506..b7e9ff7 100644 --- a/src/editor/engine/NpcManager.js +++ b/src/editor/engine/NpcManager.js @@ -292,10 +292,36 @@ export class NpcManager { damage(id, amount) { const npc = this.npcs.get(Number(id)); if (!npc || npc.dead) return; - npc.hp = Math.max(0, npc.hp - (Number(amount) || 0)); + const amt = Number(amount) || 0; + npc.hp = Math.max(0, npc.hp - amt); + // Авто-floater над мобом (задача 40 доп): game.fx.autoMobFloaters(true). + if (this._autoFloater && amt > 0 && this.scene3d?.floaters) { + try { + this.scene3d.floaters.spawn( + { x: npc.x, y: (npc.y || 0) + 2.2, z: npc.z }, amt, this._autoFloater.opts || {}); + } catch (e) { /* ignore */ } + } if (npc.hp <= 0) this._killNpc(npc); } + /** Нанести урон NPC по мешу-попаданию (бластер/оружие). Ищет NPC, чьи меши + * содержат hit-меш (или предка). Вызывает damage() → авто-floater. */ + damageByMesh(mesh, amount) { + if (!mesh) return false; + for (const npc of this.npcs.values()) { + if (npc.dead) continue; + const root = npc.data && npc.data.rootMesh; + if (!root) continue; + let m = mesh, hit = false; + for (let i = 0; i < 8 && m; i++) { + if (m === root) { hit = true; break; } + m = m.parent; + } + if (hit) { this.damage(npc.id, amount); return true; } + } + return false; + } + /** Удалить NPC по id (без эффекта смерти — просто убрать). */ removeNpc(id) { const npc = this.npcs.get(Number(id)); diff --git a/src/editor/engine/ScriptSandboxWorker.js b/src/editor/engine/ScriptSandboxWorker.js index d7498fb..c810d54 100644 --- a/src/editor/engine/ScriptSandboxWorker.js +++ b/src/editor/engine/ScriptSandboxWorker.js @@ -3455,6 +3455,13 @@ const game = { const pos = _normFxPoint(position); _send('fx.damageFloater', { position: pos, value, opts: opts || {} }); }, + /** + * Авто-floater'ы над мобами (NPC) при потере HP. Включил один раз — любой + * урон по NPC сам показывает облачко «-N». game.fx.autoMobFloaters(true). + */ + autoMobFloaters(enabled, opts) { + _send('fx.autoMobFloaters', { enabled: enabled !== false, opts: opts || {} }); + }, /** * Луч между двумя точками. opts: { from, to — {x,y,z} или ref * объекта (тогда луч следит за ним); color: '#hex', width }. From e4fdd91b12a46da80ab76d0da2bec31d3074ffa4 Mon Sep 17 00:00:00 2001 From: min Date: Sun, 7 Jun 2026 13:49:06 +0300 Subject: [PATCH 51/74] =?UTF-8?q?fix(studio):=20=D0=BE=D1=80=D1=83=D0=B6?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=BF=D0=BE=D0=BF=D0=B0=D0=B4=D0=B0=D0=B5=D1=82?= =?UTF-8?q?=20=D0=BF=D0=BE=20NPC=20(pickable+npcId)=20=E2=86=92=20=D0=B0?= =?UTF-8?q?=D0=B2=D1=82=D0=BE-floater=20=D1=83=D1=80=D0=BE=D0=BD=D0=B0=20?= =?UTF-8?q?=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=B0=D0=B5=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Меши NPC ставились isPickable=false → raycast бластера/меча проходил сквозь них, урон и авто-floater не срабатывали. Теперь меши NPC pickable + npcId в metadata; damageByMesh находит NPC по metadata (быстро) или по rootMesh. Co-Authored-By: Claude Opus 4.8 --- src/editor/engine/NpcManager.js | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/src/editor/engine/NpcManager.js b/src/editor/engine/NpcManager.js index b7e9ff7..f452f81 100644 --- a/src/editor/engine/NpcManager.js +++ b/src/editor/engine/NpcManager.js @@ -161,6 +161,20 @@ export class NpcManager { r15Animator, }; this.npcs.set(id, npc); + // Пометить меши NPC для попаданий оружия (бластер/меч): pickable + npcId + // в metadata. Без pickable raycast оружия проходит сквозь NPC и урон/ + // floater'ы не срабатывают (задача 40). + try { + const root = npc.data && npc.data.rootMesh; + if (root) { + root.isPickable = true; + root.metadata = Object.assign({}, root.metadata, { npcId: id }); + for (const m of root.getChildMeshes(false)) { + m.isPickable = true; + m.metadata = Object.assign({}, m.metadata, { npcId: id }); + } + } + } catch (e) { /* ignore */ } return id; } @@ -308,16 +322,23 @@ export class NpcManager { * содержат hit-меш (или предка). Вызывает damage() → авто-floater. */ damageByMesh(mesh, amount) { if (!mesh) return false; + // 1) Быстрый путь: npcId в metadata меша (или предка). + let m = mesh; + for (let i = 0; i < 8 && m; i++) { + const nid = m.metadata && m.metadata.npcId; + if (nid != null && this.npcs.has(nid)) { this.damage(nid, amount); return true; } + m = m.parent; + } + // 2) Fallback: сравнение с rootMesh по иерархии. for (const npc of this.npcs.values()) { if (npc.dead) continue; const root = npc.data && npc.data.rootMesh; if (!root) continue; - let m = mesh, hit = false; - for (let i = 0; i < 8 && m; i++) { - if (m === root) { hit = true; break; } - m = m.parent; + let mm = mesh; + for (let i = 0; i < 8 && mm; i++) { + if (mm === root) { this.damage(npc.id, amount); return true; } + mm = mm.parent; } - if (hit) { this.damage(npc.id, amount); return true; } } return false; } From 931d53b4d91fec5bf4be1822783844a6996b17ab Mon Sep 17 00:00:00 2001 From: min Date: Sun, 7 Jun 2026 14:01:14 +0300 Subject: [PATCH 52/74] =?UTF-8?q?fix(studio):=20=D0=B1=D0=BB=D0=B0=D1=81?= =?UTF-8?q?=D1=82=D0=B5=D1=80=20=D0=BE=D1=82=203-=D0=B3=D0=BE=20=D0=BB?= =?UTF-8?q?=D0=B8=D1=86=D0=B0=20=D1=81=D1=82=D1=80=D0=B5=D0=BB=D1=8F=D0=B5?= =?UTF-8?q?=D1=82=20=D0=B2=20=D1=82=D0=BE=D1=87=D0=BA=D1=83=20=D0=BA=D0=BB?= =?UTF-8?q?=D0=B8=D0=BA=D0=B0,=20=D0=B0=20=D0=BD=D0=B5=20=D0=B2=20=D1=86?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=D1=80=20=D0=BA=D0=B0=D0=BC=D0=B5=D1=80=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit При свободном курсоре (нет pointer-lock, 3-е лицо) выстрел шёл из getForwardRay (фокус камеры). Теперь onDown берёт координаты клика → setAimScreenPoint → луч через точку клика; onMove обновляет _holdAim для авто-огня при удержании. При pointer-lock (1-е лицо, курсор в центре) — прежнее поведение (центр). Co-Authored-By: Claude Opus 4.8 --- src/editor/engine/WeaponSystem.js | 33 ++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/src/editor/engine/WeaponSystem.js b/src/editor/engine/WeaponSystem.js index a4bf657..048d848 100644 --- a/src/editor/engine/WeaponSystem.js +++ b/src/editor/engine/WeaponSystem.js @@ -90,6 +90,18 @@ export class WeaponSystem { if (e.button !== 0) return; // Если UI-режим курсора — не стреляем (мышь работает по GUI) if (this.scene3d?.player?.isUiCursorMode?.()) return; + // Если курсор СВОБОДЕН (нет pointer-lock — обычно 3-е лицо) — стреляем + // ТУДА, КУДА КЛИКНУЛИ, а не в центр камеры. При pointer-lock курсор в + // центре экрана → используем прицел камеры (aim не задаём). + if (document.pointerLockElement !== canvas) { + const rect = canvas.getBoundingClientRect(); + const cx = (e.clientX != null ? e.clientX : 0) - rect.left; + const cy = (e.clientY != null ? e.clientY : 0) - rect.top; + if (cx >= 0 && cy >= 0 && cx <= rect.width && cy <= rect.height) { + this.setAimScreenPoint(cx * (canvas.width / rect.width), + cy * (canvas.height / rect.height)); + } + } this._mouseDown = true; this._tryFire(); }; @@ -97,14 +109,28 @@ export class WeaponSystem { if (e.button !== 0) return; this._mouseDown = false; }; + // При свободном курсоре (3-е лицо) запоминаем позицию мыши — чтобы + // авто-огонь при удержании ЛКМ продолжал стрелять в точку курсора. + const onMove = (e) => { + if (!this._mouseDown) return; + if (document.pointerLockElement === canvas) return; + const rect = canvas.getBoundingClientRect(); + const cx = (e.clientX != null ? e.clientX : 0) - rect.left; + const cy = (e.clientY != null ? e.clientY : 0) - rect.top; + if (cx >= 0 && cy >= 0 && cx <= rect.width && cy <= rect.height) { + this._holdAim = { x: cx * (canvas.width / rect.width), y: cy * (canvas.height / rect.height) }; + } + }; const onKey = (e) => { if (e.code === 'KeyR') this.reload(); }; canvas.addEventListener('mousedown', onDown); window.addEventListener('mouseup', onUp); + window.addEventListener('mousemove', onMove); window.addEventListener('keydown', onKey); this._listeners.push({ target: canvas, type: 'mousedown', fn: onDown }); this._listeners.push({ target: window, type: 'mouseup', fn: onUp }); + this._listeners.push({ target: window, type: 'mousemove', fn: onMove }); this._listeners.push({ target: window, type: 'keydown', fn: onKey }); // Регистрируем перед-кадровый хук для авто-стрельбы (если auto=true) @@ -583,7 +609,12 @@ export class WeaponSystem { // (для tap-to-shoot на мобиле). Точка применяется один раз. let hit = null; let ray; - const aim = this._aimScreenPoint; + // aim: разовый клик (_aimScreenPoint) или удержание по курсору (_holdAim, + // только когда курсор свободен — нет pointer-lock). + let aim = this._aimScreenPoint; + if (!aim && this._holdAim && document.pointerLockElement !== this.scene3d?.canvas) { + aim = this._holdAim; + } try { if (aim) { ray = this.scene.createPickingRay(aim.x, aim.y, null, camera); From c20ac56895acf56504470fc9df007202ca032ba1 Mon Sep 17 00:00:00 2001 From: min Date: Sun, 7 Jun 2026 14:18:53 +0300 Subject: [PATCH 53/74] =?UTF-8?q?docs(studio):=20=D0=B2=D0=B8=D0=BA=D0=B8?= =?UTF-8?q?=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B0=2040=20=E2=80=94=20?= =?UTF-8?q?=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D0=B0=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=20=D0=B7=D0=BE=D0=BC=D0=B1=D0=B8-=D0=B0=D1=80?= =?UTF-8?q?=D0=B5=D0=BD=D1=83=20(=D0=B1=D0=BB=D0=B0=D1=81=D1=82=D0=B5?= =?UTF-8?q?=D1=80=20+=20autoMobFloaters=20+=20=D0=B2=D0=BE=D0=BB=D0=BD?= =?UTF-8?q?=D1=8B)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Карточка #64 «Зомби-арена — бластер и цифры урона» + статья переписана: giveTool бластер, autoMobFloaters (авто-облачко над мобами), spawnNpc+follow волны зомби, прицел в точку клика, ручной damageFloater (типы/стек/комикс). Новые скрины scene/play (зомби-шутер). Co-Authored-By: Claude Opus 4.8 --- src/community/docsGames.js | 6 +-- src/community/docsLessons.jsx | 89 ++++++++++++++++++++--------------- 2 files changed, 54 insertions(+), 41 deletions(-) diff --git a/src/community/docsGames.js b/src/community/docsGames.js index 59fb4ac..40a4d65 100644 --- a/src/community/docsGames.js +++ b/src/community/docsGames.js @@ -364,8 +364,8 @@ export const GAMES = [ mechanics: ['game.leaderstats.define / me.add', 'HUD-таблица топ-10 (сортировка по primary)', 'game.achievements.define / unlock', 'bindToStat — авто-награда по статy', 'toast 4 редкости + очередь', 'кубок → страница достижений', 'сохранение в БД (savegame)'], previewShot: 'guide-leaderstats-scene.png', openProjectId: 2616, ready: true }, { id: 'guide-floaters', num: 64, group: 'g5', stars: 2, icon: 'sparkles', - title: 'Тренировочный полигон — цифры урона', - desc: 'Всплывающие цифры урона над целью (как в Roblox-RPG): красный урон, жёлтый крит с pop-эффектом, зелёный хил, синяя мана, серый «Промах», стек «×N» и комикс-стиль BAM!/KAPOW!.', - mechanics: ['game.fx.damageFloater(pos, value, opts)', 'isCrit / isHeal / isMana / isMiss — цвет', 'подъём + плавное затухание + покачивание', 'crit — pop-эффект (scale 1→1.3→1)', 'stackKey — стек одинаковых в «×N»', 'comicStyle — BAM!/KAPOW! на звезде', 'object pool 30 планов (без лагов)'], + title: 'Зомби-арена — бластер и цифры урона', + desc: 'Шутер: волны зомби бегут к игроку, бластер их отстреливает, над целью всплывают облачка урона. Авто-floater над любым мобом одной строкой + ручной game.fx.damageFloater (крит/хил/мана/промах/стек/комикс).', + mechanics: ['game.fx.damageFloater(pos, value, opts)', 'game.fx.autoMobFloaters(true) — облачко над NPC при уроне', 'game.player.giveTool(\'blaster-...\') — бластер', 'бластер от 3-го лица — в точку клика', 'spawnNpc + follow(\'player\') — зомби-волны', 'isCrit/isHeal/isMana/isMiss, стек ×N, комикс', 'object pool 30 планов (без лагов)'], previewShot: 'guide-floaters-scene.png', openProjectId: 2676, ready: true }, ]; diff --git a/src/community/docsLessons.jsx b/src/community/docsLessons.jsx index c3281fe..8ef5032 100644 --- a/src/community/docsLessons.jsx +++ b/src/community/docsLessons.jsx @@ -8787,68 +8787,81 @@ game.achievements.unlock('first_coin');`} <>

Что получится

- Тренировочный полигон с манекенами. При ударе над целью - всплывает цифра урона — как в Roblox-RPG (Pet Sim, Anime - Adventures): красный обычный урон, жёлтый КРИТ с подскоком, - зелёный хил, синяя мана, серый «Промах». Одинаковые удары - собираются в стек «×N», а в комикс-режиме вместо цифр — BAM! и KAPOW!. + Мини-шутер: волны зомби бегут к игроку, ты отстреливаешь + их из бластера, а над каждой целью всплывает облачко + урона — как в Roblox-RPG (Pet Sim, Anime Adventures). Зомби + гибнут, счётчик растёт, волны усиливаются.

- + -

Главный метод

-

Одна строка рисует всплывающую цифру в нужной точке мира:

- {`game.fx.damageFloater(position, value, opts)`} -
    -
  • position — точка {'{x,y,z}'}, ссылка на объект или 'player';
  • -
  • value — число (урон) или строка («Промах», «BLOCKED»);
  • -
  • opts — стиль: цвет и тип эффекта.
  • -
- -

Типы (цвета)

+

Шаг 1. Бластер + авто-облачка над мобами

+

+ Две строки превращают игру в шутер с фидбеком урона: выдаём + бластер и включаем авто-floater — теперь любой урон + по NPC сам рисует «-N» над целью, вручную вызывать ничего не надо. +

+ {`game.player.giveTool('blaster-blaster-a', { equip: true }); // бластер в руки +game.fx.autoMobFloaters(true); // облачко урона над любым мобом при попадании`} + +

Шаг 2. Волны зомби, идущих к игроку

+ {`function spawnWave(n){ + const pl = game.player.position; + for (let i = 0; i < n; i++){ + const a = (i / n) * Math.PI * 2; + const e = game.scene.spawnNpc('skin_retro-zombie', { + x: pl.x + Math.cos(a)*18, z: pl.z + Math.sin(a)*18, + name: 'Зомби', hp: 100, speed: 2.6, + }); + if (e && e.follow) e.follow('player'); // зомби преследует игрока + } +} +game.after(1.5, () => spawnWave(5)); +game.every(14, () => spawnWave(8));`} +

+ Стрелять из бластера — ЛКМ. В режиме от 3-го лица пуля летит + туда, куда кликнул курсором. Попал по зомби → облачко + урона (благодаря autoMobFloaters), убил → засчитан. +

+ +

Ручной floater — все типы

+

Когда нужен полный контроль — рисуй цифру сам:

{`game.fx.damageFloater(pos, 25); // красный — обычный урон game.fx.damageFloater(pos, 80, { isCrit: true }); // жёлтый, больше + подскок game.fx.damageFloater(pos, 30, { isHeal: true }); // зелёный — лечение (+30) game.fx.damageFloater(pos, 50, { isMana: true }); // синий — мана game.fx.damageFloater(pos, 'Промах', { isMiss: true }); // серый текст`} - -

Стек одинаковых ударов

- При AOE по одной цели десятки цифр сливаются в кашу. Передай - общий stackKey — и удары соберутся в один floater «×N». + position{'{x,y,z}'}, ссылка на объект или + 'player'; value — число или строка.

- {`for (let i = 0; i < 10; i++) { - game.fx.damageFloater(enemy.position, 25, { stackKey: 'aoe_' + enemy.id }); -} -// → один floater «-25 ×10» вместо десяти отдельных`} -

Комикс-стиль

-

- Для казуальных игр — comicStyle: true: вместо чисел - рисуется «BAM!» (урон >50), «KAPOW!» (>100), «POW!» (крит) на - жёлтой звезде-вспышке. -

- {`game.fx.damageFloater(pos, 120, { comicStyle: true }); // KAPOW!`} +

Стек и комикс-стиль

+ {`// общий stackKey → удары сливаются в «-25 ×N» вместо кучи цифр +game.fx.damageFloater(enemy.position, 25, { stackKey: 'aoe_' + enemy.id }); +// comicStyle → BAM! (>50), KAPOW! (>100), POW! (крит) на жёлтой звезде +game.fx.damageFloater(pos, 120, { comicStyle: true });`} Под капотом — пул из 30 переиспользуемых билборд-планов - (object pool), поэтому даже при спаме десятков цифр FPS не + (object pool), поэтому даже при толпе зомби и спаме цифр FPS не проседает. Цифры всегда поверх геометрии и повёрнуты к камере.

Почему это важно

- Без всплывающих цифр любой удар ощущается «впустую». Это базовый - боевой фидбек: игрок видит, сколько нанёс, был ли крит, попал ли - вообще. Сочетается с боевыми механиками и способностями. + Без облачек урона стрельба ощущается «впустую». Это базовый + боевой фидбек: игрок видит, сколько нанёс, был ли крит, попал ли. + Связка бластер + autoMobFloaters + волны NPC — готовый + каркас любого шутера/выживания.

Сделай «огненный» урон: damageFloater(pos, 15, {'{'} color: - '#ff7a2a' {'}'}) каждые 0.5 сек 3 раза подряд — эффект - горения (-15… -15… -15). + '#ff7a2a' {'}'}) каждые 0.5 сек 3 раза — эффект горения. + Или увеличь HP зомби и добавь крит каждый 5-й выстрел. ), From 48e2e83ef7ea9fc7a1edeff2a5fa4310db0f9ebd Mon Sep 17 00:00:00 2001 From: min Date: Sun, 7 Jun 2026 14:22:11 +0300 Subject: [PATCH 54/74] =?UTF-8?q?docs(studio):=20=D0=B2=D0=B8=D0=BA=D0=B8?= =?UTF-8?q?=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B0=2040=20=E2=80=94=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B5=D0=B2=D1=8C=D1=8E/=D1=81=D0=BA=D1=80=D0=B8?= =?UTF-8?q?=D0=BD=20=3D=20=D0=BA=D0=B0=D0=B4=D1=80=20=D1=81=20=D0=BE=D0=B1?= =?UTF-8?q?=D0=BB=D0=B0=D1=87=D0=BA=D0=B0=D0=BC=D0=B8=20=D1=83=D1=80=D0=BE?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=BD=D0=B0=D0=B4=20=D0=B7=D0=BE=D0=BC=D0=B1?= =?UTF-8?q?=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- src/community/docsLessons.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/community/docsLessons.jsx b/src/community/docsLessons.jsx index 8ef5032..9aa1c79 100644 --- a/src/community/docsLessons.jsx +++ b/src/community/docsLessons.jsx @@ -8794,7 +8794,7 @@ game.achievements.unlock('first_coin');`}

+ caption="Зомби сбегаются к игроку, бластер стреляет — над целью всплывают красные облачка урона «-25»." />

Шаг 1. Бластер + авто-облачка над мобами

From 2645337bdda4f3f46c2b031e0062babe79a6f284 Mon Sep 17 00:00:00 2001 From: min Date: Sun, 7 Jun 2026 14:36:52 +0300 Subject: [PATCH 55/74] =?UTF-8?q?chore:=20e2e-=D1=82=D0=B5=D1=81=D1=82=20w?= =?UTF-8?q?orkflow=20=D1=80=D0=B0=D0=B7=D1=80=D0=B0=D0=B1=D0=BE=D1=82?= =?UTF-8?q?=D1=87=D0=B8=D0=BA=D0=BE=D0=B2=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5?= =?UTF-8?q?=20=D0=B2=D0=BE=D1=81=D1=81=D1=82=D0=B0=D0=BD=D0=BE=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index cffa6b9..1591aa7 100644 --- a/README.md +++ b/README.md @@ -168,3 +168,4 @@ git push origin feature/моя-фича - Issues и PR: https://git.rublox.pro/rublox/studio - Безопасность: [SECURITY.md](./SECURITY.md) + From b1fbc3790e39d0abaaeb724bd6b80991e7b6563b Mon Sep 17 00:00:00 2001 From: min Date: Sun, 7 Jun 2026 14:43:27 +0300 Subject: [PATCH 56/74] chore: re-trigger CI after runner restart From 42f13349081dd507b4a6ba35688016818a0ba98c Mon Sep 17 00:00:00 2001 From: min Date: Sun, 7 Jun 2026 14:45:24 +0300 Subject: [PATCH 57/74] =?UTF-8?q?feat(studio):=20=D0=B7=D0=B0=D0=B4=D0=B0?= =?UTF-8?q?=D1=87=D0=B0=2044=20=E2=80=94=20drag-drop=20=D0=B8=D0=BD=D0=B2?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=D0=B0=D1=80=D1=8C=20(=D1=81=D0=B5=D1=82?= =?UTF-8?q?=D0=BA=D0=B0=208=C3=975=20+=20hotbar=209=20+=20=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D0=BA=D0=B8=20+=20=D1=80=D0=B5=D0=B4=D0=BA=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D0=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit InventoryUI.js: DOM-оверлей — окно инвентаря по I (сетка 8×5), постоянный hotbar 9 (клавиши 1-9), drag-drop между слотами (HTML5), стаки с maxStack, 5 редкостей (цвет рамки), tooltip на hover, ПКМ-меню (использовать/разделить/ выбросить), сортировка по редкости. API: game.items.define([...]), game.inventory.give/take/open/toggle/sort/setActiveHotbar. onUseEffect heal/speed. Сериализация scene.inventory2. Co-Authored-By: Claude Opus 4.8 --- src/editor/engine/BabylonScene.js | 27 ++ src/editor/engine/GameRuntime.js | 13 + src/editor/engine/InventoryUI.js | 370 +++++++++++++++++++++++ src/editor/engine/ScriptSandboxWorker.js | 22 ++ 4 files changed, 432 insertions(+) create mode 100644 src/editor/engine/InventoryUI.js diff --git a/src/editor/engine/BabylonScene.js b/src/editor/engine/BabylonScene.js index 24b3605..1c241cf 100644 --- a/src/editor/engine/BabylonScene.js +++ b/src/editor/engine/BabylonScene.js @@ -77,6 +77,7 @@ import { SkyboxManager } from './SkyboxManager'; import { LeaderstatsManager } from './LeaderstatsManager'; import { AchievementsManager } from './AchievementsManager'; import { FloaterManager } from './FloaterManager'; // задача 40 — damage floaters +import { InventoryUI } from './InventoryUI'; // задача 44 — drag-drop инвентарь import { AudioManager, AMBIENT_PRESETS, MUSIC_PRESETS } from './AudioManager'; import { GameAudioManager } from './GameAudioManager'; import { AssetManager } from './AssetManager'; @@ -1301,6 +1302,7 @@ export class BabylonScene { this.environment = new Environment(this.scene, this._hemiLight, this._sunLight); this.skybox = new SkyboxManager(this.scene, this._hemiLight, this._sunLight); // задача 16 — кастомное небо (единый источник света) this.floaters = new FloaterManager(this); // задача 40 — damage floaters + this.invUI = new InventoryUI(this); // задача 44 — drag-drop инвентарь this.leaderstats = new LeaderstatsManager(this); // задача 20 — лидерборды this.achievements = new AchievementsManager(this); // задача 20 — достижения this.audioManager = new AudioManager(); @@ -2580,6 +2582,19 @@ export class BabylonScene { const key = this._normalizeKey(e); this.gameRuntime.routeGlobalEvent('keydown', { key, code: e.code }); } + // Задача 44: клавиша I — открыть/закрыть инвентарь (в Play, если он активен). + if (this._isPlaying && e.code === 'KeyI' && this.invUI && + (this.invUI.defs.size > 0 || this.invUI.grid.some(Boolean) || this.invUI.hotbar.some(Boolean))) { + e.preventDefault(); this.invUI.toggle(); return; + } + if (this._isPlaying && e.code === 'Escape' && this.invUI?.isOpen()) { + e.preventDefault(); this.invUI.close(); return; + } + // Цифры 1-9 → активный hotbar-слот инвентаря (задача 44). + if (this._isPlaying && this.invUI && /^Digit[1-9]$/.test(e.code) && + (this.invUI.hotbar.some(Boolean) || this.invUI.defs.size > 0)) { + this.invUI.setActiveHotbar(parseInt(e.code.slice(5), 10) - 1); + } // Placement mode (задача 11): R — повернуть preview, Esc — отмена. if (this._isPlaying && this.placementManager && this.placementManager.isActive()) { if (e.code === 'KeyR') { e.preventDefault(); this.placementManager.rotate(); return; } @@ -6209,6 +6224,12 @@ export class BabylonScene { // загружены из проекта (define из project_data при load). try { if (this.leaderstats?.active) this.leaderstats._mount(); } catch (e) {} try { if (this.achievements?.active) this.achievements._mountButton(); } catch (e) {} + // Задача 44: хотбар инвентаря показываем если есть определения/предметы. + try { + if (this.invUI && (this.invUI.defs.size > 0 || this.invUI.hotbar.some(Boolean) || this.invUI.grid.some(Boolean))) { + this.invUI.mountHotbar(); + } + } catch (e) {} // Старт через requestAnimationFrame — даём Babylon собрать сцену requestAnimationFrame(() => { if (this._isPlaying) this.gameRuntime?.start(this._scripts || []); @@ -7614,6 +7635,7 @@ export class BabylonScene { folders: this.folderManager ? this.folderManager.serialize() : [], gui: this.guiManager ? this.guiManager.serialize() : [], inventory: this.inventory ? this.inventory.serialize() : null, + inventory2: this.invUI ? this.invUI.serialize() : null, // задача 44 spawnPoint: { ...this._spawnPoint }, spawnEnabled: this._spawnEnabled !== false, playerModelType: this._playerModelType, @@ -7995,6 +8017,10 @@ export class BabylonScene { if (this.inventory) { this.inventory.loadFromArray(state.scene.inventory || null); } + // Задача 44: drag-drop инвентарь (определения предметов + слоты). + if (this.invUI && state.scene.inventory2) { + this.invUI.load(state.scene.inventory2); + } // Простановка folderId на блоках (loadFromArray BlockManager не знает про это поле) if (this.blockManager && Array.isArray(state.scene.blocks)) { for (const b of state.scene.blocks) { @@ -8206,6 +8232,7 @@ export class BabylonScene { try { this.leaderstats?.resetRuntime?.(); } catch (e) {} try { this.achievements?.resetRuntime?.(); } catch (e) {} try { this.floaters?.resetRuntime?.(); } catch (e) {} // задача 40 + try { this.invUI?.resetRuntime?.(); } catch (e) {} // задача 44 // Сбрасываем таймер прохождения this._timerRunning = false; this._timerStartedAt = null; diff --git a/src/editor/engine/GameRuntime.js b/src/editor/engine/GameRuntime.js index 45c5ca1..c329e0a 100644 --- a/src/editor/engine/GameRuntime.js +++ b/src/editor/engine/GameRuntime.js @@ -2087,6 +2087,19 @@ export class GameRuntime { } return; } + // === Задача 44: drag-drop инвентарь (invUI) === + if (cmd === 'items.define') { try { this.scene3d?.invUI?.defineItem(payload.def); } catch (e) {} return; } + if (cmd === 'inv2.add') { + try { this.scene3d?.invUI?.add(payload.itemId, payload.count); this.scene3d?.invUI?.mountHotbar(); } catch (e) {} + return; + } + if (cmd === 'inv2.remove') { try { this.scene3d?.invUI?.remove(payload.itemId, payload.count); } catch (e) {} return; } + if (cmd === 'inv2.open') { try { this.scene3d?.invUI?.open(); } catch (e) {} return; } + if (cmd === 'inv2.close') { try { this.scene3d?.invUI?.close(); } catch (e) {} return; } + if (cmd === 'inv2.toggle') { try { this.scene3d?.invUI?.toggle(); } catch (e) {} return; } + if (cmd === 'inv2.sort') { try { this.scene3d?.invUI?.sort(payload.by); } catch (e) {} return; } + if (cmd === 'inv2.setActive') { try { this.scene3d?.invUI?.setActiveHotbar(payload.i); } catch (e) {} return; } + if (cmd === 'inventory.remove') { // payload: { modelTypeId? , name? } — убрать первый совпавший слот. const inv = this.scene3d?.inventory; diff --git a/src/editor/engine/InventoryUI.js b/src/editor/engine/InventoryUI.js new file mode 100644 index 0000000..0a49359 --- /dev/null +++ b/src/editor/engine/InventoryUI.js @@ -0,0 +1,370 @@ +/** + * InventoryUI — drag-drop инвентарь (задача 44): сетка 8×5 + hotbar 9 + стаки + + * редкости + ПКМ-меню + tooltip. Самодостаточный DOM-оверлей (как + * LoadingScreenOverlay) — крепится к canvas.parentElement, работает в студии и + * плеере одинаково. + * + * Хранит: item-defs (game.items.define), слоты основного инвентаря (GRID), + * слоты hotbar (HOTBAR), активный hotbar-слот. Постоянный hotbar внизу HUD; + * окно инвентаря по клавише I (toggle). + * + * API (через game.inventory.* / game.items.*): + * game.items.define({id,name,icon,rarity,maxStack,description,value,onUse,tags}) + * game.inventory.add(itemId, count) / remove / has / count + * game.inventory.open() / close() / toggle() / isOpen() + * game.inventory.move(from, to) / split(slot, n) / sort(by) / use(slot) + * game.inventory.setActiveHotbar(i) / getActiveItem() + * + * Фича-парность: тот же модуль в rublox-player/src/engine/. + */ + +const GRID = 40; // 8×5 основной инвентарь +const COLS = 8; +const HOTBAR = 9; + +const RARITY = { + common: { color: '#bbbbbb', label: 'Обычное' }, + uncommon: { color: '#5cb85c', label: 'Необычное' }, + rare: { color: '#5bc0de', label: 'Редкое' }, + epic: { color: '#9b59b6', label: 'Эпическое' }, + legendary: { color: '#f0ad4e', label: 'Легендарное' }, +}; + +export class InventoryUI { + constructor(scene3d) { + this.s = scene3d; + this.defs = new Map(); // itemId → def + this.grid = new Array(GRID).fill(null); // {itemId,count}|null + this.hotbar = new Array(HOTBAR).fill(null); + this.active = 0; + this._open = false; + this.root = null; this.hotbarRoot = null; this.tooltip = null; this.ctxMenu = null; + this._drag = null; // {from:'grid'|'hotbar', idx} + this._onChange = []; + this._events = { added: [], removed: [], used: [], slot: [] }; + this._opts = { allowDrop: true, allowSplit: true, showRarity: true }; + } + + // ── Определения предметов ─────────────────────────────────────────────── + defineItem(def) { + if (!def || typeof def.id !== 'string') return; + this.defs.set(def.id, { + id: def.id, name: def.name || def.id, + icon: def.icon || null, emoji: def.emoji || null, + rarity: RARITY[def.rarity] ? def.rarity : 'common', + maxStack: Number(def.maxStack) > 0 ? Number(def.maxStack) : 1, + description: def.description || '', value: Number(def.value) || 0, + tags: Array.isArray(def.tags) ? def.tags : [], + onUseEffect: def.onUseEffect || null, // 'heal:50' | 'speed:1.5:5' | null + }); + } + _def(id) { return this.defs.get(id) || { id, name: id, rarity: 'common', maxStack: 99, emoji: '📦', icon: null, description: '', value: 0, tags: [] }; } + + // ── Операции ──────────────────────────────────────────────────────────── + add(itemId, count = 1) { + const def = this._def(itemId); + let left = count; + // 1) долить в существующие стаки (grid, потом hotbar) + const fill = (arr) => { + for (let i = 0; i < arr.length && left > 0; i++) { + const s = arr[i]; + if (s && s.itemId === itemId && s.count < def.maxStack) { + const room = def.maxStack - s.count; + const take = Math.min(room, left); + s.count += take; left -= take; + } + } + }; + fill(this.grid); fill(this.hotbar); + // 2) в пустые слоты (grid, потом hotbar) + const place = (arr) => { + for (let i = 0; i < arr.length && left > 0; i++) { + if (!arr[i]) { const take = Math.min(def.maxStack, left); arr[i] = { itemId, count: take }; left -= take; } + } + }; + place(this.grid); place(this.hotbar); + const added = count - left; + if (added > 0) { this._emit('added', { itemId, count: added }); this._changed(); } + return { added, overflow: left }; + } + + remove(itemId, count = 1) { + let left = count; + const drain = (arr) => { + for (let i = arr.length - 1; i >= 0 && left > 0; i--) { + const s = arr[i]; + if (s && s.itemId === itemId) { + const take = Math.min(s.count, left); + s.count -= take; left -= take; + if (s.count <= 0) arr[i] = null; + } + } + }; + drain(this.hotbar); drain(this.grid); + const removed = count - left; + if (removed > 0) { this._emit('removed', { itemId, count: removed }); this._changed(); } + return removed; + } + + count(itemId) { + let n = 0; + for (const s of this.grid) if (s && s.itemId === itemId) n += s.count; + for (const s of this.hotbar) if (s && s.itemId === itemId) n += s.count; + return n; + } + has(itemId, n = 1) { return this.count(itemId) >= n; } + + /** slot-ref: число 0..39 = grid; строка 'h0'..'h8' = hotbar. */ + _arrIdx(ref) { + if (typeof ref === 'string' && ref[0] === 'h') return { arr: this.hotbar, idx: parseInt(ref.slice(1), 10) }; + return { arr: this.grid, idx: Number(ref) }; + } + move(from, to) { + const a = this._arrIdx(from), b = this._arrIdx(to); + if (!a.arr || !b.arr || a.idx == null || b.idx == null) return; + if (a.arr === b.arr && a.idx === b.idx) return; + const src = a.arr[a.idx], dst = b.arr[b.idx]; + // merge одинаковых стаков + if (src && dst && src.itemId === dst.itemId) { + const def = this._def(src.itemId); + const room = def.maxStack - dst.count; + if (room > 0) { + const take = Math.min(room, src.count); + dst.count += take; src.count -= take; + if (src.count <= 0) a.arr[a.idx] = null; + this._changed(); return; + } + } + // swap + a.arr[a.idx] = dst; b.arr[b.idx] = src; + this._changed(); + } + split(ref, n) { + if (!this._opts.allowSplit) return; + const { arr, idx } = this._arrIdx(ref); + const s = arr[idx]; if (!s || s.count <= 1) return; + const take = Math.max(1, Math.min(s.count - 1, n || Math.floor(s.count / 2))); + const empty = this.grid.indexOf(null); + if (empty < 0) return; + s.count -= take; this.grid[empty] = { itemId: s.itemId, count: take }; + this._changed(); + } + sort(by = 'rarity') { + const order = { legendary: 0, epic: 1, rare: 2, uncommon: 3, common: 4 }; + const all = this.grid.filter(Boolean); + all.sort((x, y) => { + const dx = this._def(x.itemId), dy = this._def(y.itemId); + if (by === 'rarity') return (order[dx.rarity] - order[dy.rarity]) || dx.name.localeCompare(dy.name); + if (by === 'name') return dx.name.localeCompare(dy.name); + return dx.id.localeCompare(dy.id); + }); + this.grid = all.concat(new Array(GRID - all.length).fill(null)); + this._changed(); + } + use(ref) { + const { arr, idx } = this._arrIdx(ref); + const s = arr[idx]; if (!s) return; + const def = this._def(s.itemId); + let consume = false; + if (def.onUseEffect) { + const [eff, a, b] = String(def.onUseEffect).split(':'); + try { + if (eff === 'heal') { this.s?.player?.heal?.(Number(a) || 25); consume = true; } + else if (eff === 'speed') { this.s?.player?.setSpeed?.(Number(a) || 1.5); consume = true; } + } catch (e) { /* ignore */ } + } + this._emit('used', { itemId: s.itemId }); + if (consume) { s.count -= 1; if (s.count <= 0) arr[idx] = null; this._changed(); } + } + setActiveHotbar(i) { this.active = Math.max(0, Math.min(HOTBAR - 1, i | 0)); this._renderHotbar(); } + getActiveItem() { const s = this.hotbar[this.active]; return s ? { ...s, def: this._def(s.itemId) } : null; } + + onChange(fn) { if (typeof fn === 'function') this._onChange.push(fn); } + on(evt, fn) { if (this._events[evt] && typeof fn === 'function') this._events[evt].push(fn); } + _emit(evt, data) { for (const fn of (this._events[evt] || [])) { try { fn(data); } catch (e) {} } } + _changed() { + for (const fn of this._onChange) { try { fn(); } catch (e) {} } + this._emit('slot', {}); + if (this._open) this._renderGrid(); + this._renderHotbar(); + } + + // ── DOM: hotbar (постоянный) ─────────────────────────────────────────── + _parent() { return (this.s && this.s.canvas && this.s.canvas.parentElement) || document.body; } + mountHotbar() { + if (this.hotbarRoot) return; + const r = document.createElement('div'); + r.style.cssText = 'position:absolute;left:50%;bottom:14px;transform:translateX(-50%);z-index:48;display:flex;gap:6px;pointer-events:auto;font-family:Inter,system-ui,sans-serif'; + this._parent().appendChild(r); this.hotbarRoot = r; + this._renderHotbar(); + } + _slotInner(s) { + if (!s) return ''; + const def = this._def(s.itemId); + const icon = def.icon ? `` + : `${def.emoji || '📦'}`; + const cnt = s.count > 1 ? `${s.count}` : ''; + return icon + cnt; + } + _slotStyle(s, activeBorder) { + const rc = (s && this._opts.showRarity) ? RARITY[this._def(s.itemId).rarity].color : 'rgba(255,255,255,0.15)'; + const border = activeBorder ? '#ffd23a' : rc; + return `position:relative;width:52px;height:52px;border-radius:10px;border:2px solid ${border};background:rgba(20,26,40,0.7);display:flex;align-items:center;justify-content:center;cursor:pointer;box-shadow:0 2px 8px rgba(0,0,0,0.3)` + (activeBorder ? ';box-shadow:0 0 12px #ffd23a' : ''); + } + _renderHotbar() { + if (!this.hotbarRoot) return; + this.hotbarRoot.innerHTML = ''; + for (let i = 0; i < HOTBAR; i++) { + const s = this.hotbar[i]; + const cell = document.createElement('div'); + cell.style.cssText = this._slotStyle(s, i === this.active); + cell.innerHTML = `${i + 1}` + this._slotInner(s); + cell.onmouseenter = (e) => this._showTooltip(s, e); + cell.onmouseleave = () => this._hideTooltip(); + cell.onclick = () => { this.setActiveHotbar(i); }; + cell.oncontextmenu = (e) => { e.preventDefault(); this._openCtx('h' + i, e); }; + this._wireDrag(cell, 'h' + i); + this.hotbarRoot.appendChild(cell); + } + } + + // ── DOM: окно инвентаря ───────────────────────────────────────────────── + open() { if (this._open) return; this._open = true; this._mountWindow(); } + close() { this._open = false; if (this.root) { try { this.root.remove(); } catch (e) {} this.root = null; } this._hideTooltip(); this._closeCtx(); } + toggle() { this._open ? this.close() : this.open(); } + isOpen() { return this._open; } + + _mountWindow() { + const overlay = document.createElement('div'); + overlay.style.cssText = 'position:absolute;inset:0;z-index:70;background:rgba(8,10,16,0.6);backdrop-filter:blur(4px);display:flex;align-items:center;justify-content:center;font-family:Inter,system-ui,sans-serif;pointer-events:auto'; + overlay.onclick = (e) => { if (e.target === overlay) this.close(); }; + const panel = document.createElement('div'); + panel.style.cssText = 'width:min(640px,94%);background:#141826;border:1px solid rgba(255,255,255,0.12);border-radius:18px;padding:20px;color:#e8ecf2;box-shadow:0 20px 60px rgba(0,0,0,0.5)'; + panel.onclick = (e) => e.stopPropagation(); + panel.innerHTML = + '

' + + '
🎒 Инвентарь
' + + '
' + + '' + + '' + + '
' + + '
' + + '
Панель быстрого доступа (1-9)
' + + '
'; + overlay.appendChild(panel); this._parent().appendChild(overlay); this.root = overlay; + panel.querySelector('#_inv_close').onclick = () => this.close(); + panel.querySelector('#_inv_sort').onclick = () => this.sort('rarity'); + this._gridEl = panel.querySelector('#_inv_grid'); + this._hbEl = panel.querySelector('#_inv_hb'); + this._renderGrid(); + } + _renderGrid() { + if (!this._gridEl) return; + const build = (el, arr, prefix) => { + el.innerHTML = ''; + for (let i = 0; i < arr.length; i++) { + const ref = prefix === 'h' ? 'h' + i : i; + const s = arr[i]; + const cell = document.createElement('div'); + cell.style.cssText = this._slotStyle(s, prefix === 'h' && i === this.active).replace('52px', '56px'); + cell.innerHTML = (prefix === 'h' ? `${i + 1}` : '') + this._slotInner(s); + cell.onmouseenter = (e) => this._showTooltip(s, e); + cell.onmouseleave = () => this._hideTooltip(); + cell.oncontextmenu = (e) => { e.preventDefault(); this._openCtx(ref, e); }; + if (prefix === 'h') cell.onclick = () => this.setActiveHotbar(i); + this._wireDrag(cell, ref); + el.appendChild(cell); + } + }; + build(this._gridEl, this.grid, 'g'); + if (this._hbEl) build(this._hbEl, this.hotbar, 'h'); + } + + // ── Drag-drop (HTML5 native) ──────────────────────────────────────────── + _wireDrag(cell, ref) { + cell.draggable = true; + cell.addEventListener('dragstart', (e) => { + this._drag = ref; cell.style.opacity = '0.4'; + try { e.dataTransfer.setData('text/plain', String(ref)); e.dataTransfer.effectAllowed = 'move'; } catch (er) {} + }); + cell.addEventListener('dragend', () => { cell.style.opacity = '1'; this._drag = null; }); + cell.addEventListener('dragover', (e) => { e.preventDefault(); }); + cell.addEventListener('drop', (e) => { + e.preventDefault(); + const from = this._drag; + if (from != null && String(from) !== String(ref)) this.move(from, ref); + }); + } + + // ── Tooltip ────────────────────────────────────────────────────────────── + _showTooltip(s, e) { + if (!s) return; + this._hideTooltip(); + const def = this._def(s.itemId), rc = RARITY[def.rarity]; + const t = document.createElement('div'); + t.style.cssText = 'position:absolute;z-index:90;max-width:240px;padding:10px 12px;background:rgba(12,16,26,0.96);border:1px solid ' + rc.color + ';border-radius:10px;color:#e8ecf2;font-family:Inter,system-ui,sans-serif;font-size:13px;pointer-events:none;box-shadow:0 6px 20px rgba(0,0,0,0.5)'; + t.innerHTML = + '
' + this._esc(def.name) + '
' + + '
' + rc.label + (def.tags.length ? ' · ' + def.tags.join(', ') : '') + '
' + + (def.description ? '
' + this._esc(def.description) + '
' : '') + + (def.value ? '
💰 ' + def.value + '
' : ''); + document.body.appendChild(t); + const x = (e && e.clientX) || 0, y = (e && e.clientY) || 0; + t.style.left = Math.min(x + 14, window.innerWidth - 250) + 'px'; + t.style.top = (y + 14) + 'px'; + this.tooltip = t; + } + _hideTooltip() { if (this.tooltip) { try { this.tooltip.remove(); } catch (e) {} this.tooltip = null; } } + + // ── ПКМ-меню (Use/Split/Drop) ───────────────────────────────────────────── + _openCtx(ref, e) { + this._closeCtx(); + const { arr, idx } = this._arrIdx(ref); + const s = arr[idx]; if (!s) return; + const m = document.createElement('div'); + m.style.cssText = 'position:absolute;z-index:95;background:#1a2030;border:1px solid rgba(255,255,255,0.15);border-radius:10px;padding:5px;min-width:140px;font-family:Inter,system-ui,sans-serif;box-shadow:0 8px 24px rgba(0,0,0,0.5)'; + const item = (label, fn) => { + const b = document.createElement('div'); + b.textContent = label; + b.style.cssText = 'padding:8px 12px;border-radius:7px;cursor:pointer;color:#e8ecf2;font-size:14px'; + b.onmouseenter = () => b.style.background = 'rgba(255,255,255,0.08)'; + b.onmouseleave = () => b.style.background = 'transparent'; + b.onclick = () => { fn(); this._closeCtx(); }; + m.appendChild(b); + }; + item('Использовать', () => this.use(ref)); + if (this._opts.allowSplit && s.count > 1) item('Разделить', () => this.split(ref, Math.floor(s.count / 2))); + if (this._opts.allowDrop && !this._def(s.itemId).tags.includes('quest')) item('Выбросить', () => { arr[idx] = null; this._changed(); }); + item('Отмена', () => {}); + document.body.appendChild(m); + m.style.left = Math.min((e.clientX || 0), window.innerWidth - 150) + 'px'; + m.style.top = (e.clientY || 0) + 'px'; + this.ctxMenu = m; + setTimeout(() => { this._ctxCloser = () => this._closeCtx(); window.addEventListener('click', this._ctxCloser, { once: true }); }, 0); + } + _closeCtx() { if (this.ctxMenu) { try { this.ctxMenu.remove(); } catch (e) {} this.ctxMenu = null; } } + + _esc(str) { return String(str).replace(/[&<>]/g, c => ({ '&': '&', '<': '<', '>': '>' }[c])); } + + // ── Сериализация ────────────────────────────────────────────────────────── + serialize() { + return { defs: [...this.defs.values()], grid: this.grid, hotbar: this.hotbar, active: this.active, opts: this._opts }; + } + load(data) { + if (!data) return; + if (Array.isArray(data.defs)) for (const d of data.defs) this.defineItem(d); + if (Array.isArray(data.grid)) this.grid = data.grid.slice(0, GRID).concat(new Array(Math.max(0, GRID - data.grid.length)).fill(null)); + if (Array.isArray(data.hotbar)) this.hotbar = data.hotbar.slice(0, HOTBAR).concat(new Array(Math.max(0, HOTBAR - data.hotbar.length)).fill(null)); + if (typeof data.active === 'number') this.active = data.active; + if (data.opts) this._opts = { ...this._opts, ...data.opts }; + } + + dispose() { + this.close(); + if (this.hotbarRoot) { try { this.hotbarRoot.remove(); } catch (e) {} this.hotbarRoot = null; } + } + resetRuntime() { + this.close(); + if (this.hotbarRoot) { try { this.hotbarRoot.remove(); } catch (e) {} this.hotbarRoot = null; } + } +} diff --git a/src/editor/engine/ScriptSandboxWorker.js b/src/editor/engine/ScriptSandboxWorker.js index c810d54..4469ea0 100644 --- a/src/editor/engine/ScriptSandboxWorker.js +++ b/src/editor/engine/ScriptSandboxWorker.js @@ -3172,6 +3172,28 @@ const game = { clear() { _send('inventory.clear', {}); }, + // === Задача 44: drag-drop инвентарь (сетка 8×5 + hotbar 9 + стаки) === + /** Добавить предмет по itemId со стаком. game.inventory.give('berry', 5). */ + give(itemId, count) { _send('inv2.add', { itemId, count: Number(count) || 1 }); }, + /** Убрать N предметов по itemId. */ + take(itemId, count) { _send('inv2.remove', { itemId, count: Number(count) || 1 }); }, + /** Открыть/закрыть/тоггл окна инвентаря. */ + open() { _send('inv2.open', {}); }, + closeUi() { _send('inv2.close', {}); }, + toggle() { _send('inv2.toggle', {}); }, + /** Сортировать (by: 'rarity'|'name'). */ + sort(by) { _send('inv2.sort', { by: by || 'rarity' }); }, + /** Активный слот хотбара (0..8). */ + setActiveHotbar(i) { _send('inv2.setActive', { i: Number(i) || 0 }); }, + }, + + // === Определения предметов (задача 44) === + items: { + /** Зарегистрировать предмет: {id,name,emoji|icon,rarity,maxStack,description,value,tags,onUseEffect}. */ + define(def) { + if (Array.isArray(def)) { for (const d of def) _send('items.define', { def: d }); return; } + _send('items.define', { def: def || {} }); + }, }, /** * Игроки комнаты (Фаза 4.3 — мультиплеер). From 661ff60bdf1ea836993545b9d36825a0e2aadbe2 Mon Sep 17 00:00:00 2001 From: min Date: Sun, 7 Jun 2026 14:51:27 +0300 Subject: [PATCH 58/74] =?UTF-8?q?fix(studio):=20=D0=B8=D0=BD=D0=B2=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D0=B0=D1=80=D1=8C=20=E2=80=94=20=D1=81=D0=BE=D0=B1?= =?UTF-8?q?=D1=80=D0=B0=D0=BD=D0=BD=D0=BE=D0=B5=20=D0=B8=D0=B4=D1=91=D1=82?= =?UTF-8?q?=20=D1=81=D0=BD=D0=B0=D1=87=D0=B0=D0=BB=D0=B0=20=D0=B2=20hotbar?= =?UTF-8?q?=20(=D0=B2=D0=B8=D0=B4=D0=B5=D0=BD),=20hotbar=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=B4=D0=BD=D1=8F=D1=82=20=D0=BD=D0=B0=D0=B4=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=B4=D1=81=D0=BA=D0=B0=D0=B7=D0=BA=D0=BE=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1) add() заполняет сначала hotbar, потом grid → собранные предметы сразу видны в постоянном хотбаре (раньше уходили в скрытую сетку — хотбар казался пустым). 2) Хотбар поднят bottom 14→64px, не перекрывает подсказку внизу экрана. Co-Authored-By: Claude Opus 4.8 --- src/editor/engine/InventoryUI.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/editor/engine/InventoryUI.js b/src/editor/engine/InventoryUI.js index 0a49359..86620cf 100644 --- a/src/editor/engine/InventoryUI.js +++ b/src/editor/engine/InventoryUI.js @@ -64,7 +64,7 @@ export class InventoryUI { add(itemId, count = 1) { const def = this._def(itemId); let left = count; - // 1) долить в существующие стаки (grid, потом hotbar) + // 1) долить в существующие стаки (сначала hotbar — он на виду, потом grid) const fill = (arr) => { for (let i = 0; i < arr.length && left > 0; i++) { const s = arr[i]; @@ -75,14 +75,14 @@ export class InventoryUI { } } }; - fill(this.grid); fill(this.hotbar); - // 2) в пустые слоты (grid, потом hotbar) + fill(this.hotbar); fill(this.grid); + // 2) в пустые слоты (сначала hotbar — собранное видно сразу, потом grid) const place = (arr) => { for (let i = 0; i < arr.length && left > 0; i++) { if (!arr[i]) { const take = Math.min(def.maxStack, left); arr[i] = { itemId, count: take }; left -= take; } } }; - place(this.grid); place(this.hotbar); + place(this.hotbar); place(this.grid); const added = count - left; if (added > 0) { this._emit('added', { itemId, count: added }); this._changed(); } return { added, overflow: left }; @@ -194,7 +194,7 @@ export class InventoryUI { mountHotbar() { if (this.hotbarRoot) return; const r = document.createElement('div'); - r.style.cssText = 'position:absolute;left:50%;bottom:14px;transform:translateX(-50%);z-index:48;display:flex;gap:6px;pointer-events:auto;font-family:Inter,system-ui,sans-serif'; + r.style.cssText = 'position:absolute;left:50%;bottom:64px;transform:translateX(-50%);z-index:48;display:flex;gap:6px;pointer-events:auto;font-family:Inter,system-ui,sans-serif'; this._parent().appendChild(r); this.hotbarRoot = r; this._renderHotbar(); } From c62073f7f88934a65c9968ef7892b29a38ff5038 Mon Sep 17 00:00:00 2001 From: min Date: Sun, 7 Jun 2026 14:54:25 +0300 Subject: [PATCH 59/74] =?UTF-8?q?chore:=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D0=BA=D0=B0=20CI=20=D1=81=20=D0=B2=D0=BE=D1=81=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D0=BD=D1=8B?= =?UTF-8?q?=D0=BC=20runner=20(ubuntu-latest=20label)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From e15dc56de3de8c8715326ac8140682887bbd55d6 Mon Sep 17 00:00:00 2001 From: min Date: Sun, 7 Jun 2026 14:59:25 +0300 Subject: [PATCH 60/74] =?UTF-8?q?fix(studio):=20self.delete=20=D1=81=D0=BD?= =?UTF-8?q?=D0=B8=D0=BC=D0=B0=D0=B5=D1=82=20interact-=D0=BF=D0=BE=D0=B4?= =?UTF-8?q?=D1=81=D0=BA=D0=B0=D0=B7=D0=BA=D1=83=20(E=20=D0=B1=D0=BE=D0=BB?= =?UTF-8?q?=D1=8C=D1=88=D0=B5=20=D0=BD=D0=B5=20=D0=B2=D0=B8=D1=81=D0=B8?= =?UTF-8?q?=D1=82=20=D0=BD=D0=B0=20=D0=BF=D1=83=D1=81=D1=82=D0=BE=D1=82?= =?UTF-8?q?=D0=B5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _applySelfDelete удалял меш, но запись в _interactables оставалась → промпт «E Собрать» висел на месте собранного предмета. Теперь запись чистится по ref. Co-Authored-By: Claude Opus 4.8 --- src/editor/engine/GameRuntime.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/editor/engine/GameRuntime.js b/src/editor/engine/GameRuntime.js index c329e0a..58ec24b 100644 --- a/src/editor/engine/GameRuntime.js +++ b/src/editor/engine/GameRuntime.js @@ -4314,6 +4314,11 @@ export class GameRuntime { const id = t.id ?? t.ref; this.scene3d?.primitiveManager?.removeInstance(id); } + // Снять interact-подсказку удалённого объекта (иначе «E» висит на пустоте). + if (t.kind && (t.ref ?? t.id) != null && Array.isArray(this._interactables)) { + const ref = t.kind + ':' + (t.ref ?? t.id); + this._interactables = this._interactables.filter(it => it.ref !== ref); + } this.scheduleSceneSnapshot(); } catch (e) { // eslint-disable-next-line no-console From f46e6f01020dd6b1e3b6108d4afa38816102b788 Mon Sep 17 00:00:00 2001 From: min Date: Sun, 7 Jun 2026 15:06:28 +0300 Subject: [PATCH 61/74] =?UTF-8?q?fix(studio):=20=D1=83=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D0=BB=202=20eslint-=D0=BE=D1=88=D0=B8=D0=B1=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=B2=20main=20(showToast=20no-undef=20+=20text=20self-assign)?= =?UTF-8?q?=20=E2=80=94=20CI=20=D0=B1=D1=8B=D0=BB=20=D0=BA=D1=80=D0=B0?= =?UTF-8?q?=D1=81=D0=BD=D1=8B=D0=BC=20=D0=B4=D0=BB=D1=8F=20=D0=B2=D1=81?= =?UTF-8?q?=D0=B5=D1=85=20PR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/editor/KubikonEditor.jsx | 9 +++++++-- src/editor/engine/FloaterManager.js | 1 - 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/editor/KubikonEditor.jsx b/src/editor/KubikonEditor.jsx index 75908fc..7a5a7ff 100644 --- a/src/editor/KubikonEditor.jsx +++ b/src/editor/KubikonEditor.jsx @@ -848,8 +848,13 @@ const KubikonEditor = () => { } s.focusOnSelection?.(); } catch (e) {} - // Тост-уведомление. - try { showToast?.(`Механика «${kit.name}» добавлена`); } catch (e) {} + // Тост-уведомление (showToast будет подключён позже — заглушка, + // чтобы не падал eslint no-undef и CI оставался зелёным). + try { + if (typeof window !== 'undefined' && typeof window.showToast === 'function') { + window.showToast(`Механика «${kit.name}» добавлена`); + } + } catch (e) {} }, []); const [paletteTab, setPaletteTab] = useState('blocks'); // 'blocks' | 'models' | 'primitives' diff --git a/src/editor/engine/FloaterManager.js b/src/editor/engine/FloaterManager.js index 749764c..b6a538e 100644 --- a/src/editor/engine/FloaterManager.js +++ b/src/editor/engine/FloaterManager.js @@ -154,7 +154,6 @@ export class FloaterManager { if (slot.isCrit) text = 'POW!'; else if (num > 100) text = 'KAPOW!'; else if (num > 50) text = 'BAM!'; - text = text; } if (stackCount > 1) text = baseText + ' ×' + stackCount; From 9f2cca1a49fbcf6fa4721ae454f357faa2e9fd80 Mon Sep 17 00:00:00 2001 From: min Date: Sun, 7 Jun 2026 15:07:42 +0300 Subject: [PATCH 62/74] =?UTF-8?q?docs(studio):=20=D0=B2=D0=B8=D0=BA=D0=B8?= =?UTF-8?q?=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B0=2044=20=E2=80=94=20?= =?UTF-8?q?=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=BA=D0=B0=20#65=20+=20?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D1=82=D1=8C=D1=8F=20=C2=AB=D0=A1=D0=B1=D0=BE?= =?UTF-8?q?=D1=80=20=D0=B8=20=D1=81=D0=BE=D1=80=D1=82=D0=B8=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=D0=BA=D0=B0=20(=D0=B8=D0=BD=D0=B2=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=D0=B0=D1=80=D1=8C=20drag-drop)=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Карточка g5 #65 guide-inventory (openProjectId 2685) + статья: items.define, inventory.give/take, окно по I (сетка 8×5 + хотбар 9), drag-drop, стаки, редкости, ПКМ-меню, tooltip, сортировка. 2 скрина (scene окно / play сбор). Co-Authored-By: Claude Opus 4.8 --- src/community/docsGames.js | 5 +++ src/community/docsLessons.jsx | 83 +++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/src/community/docsGames.js b/src/community/docsGames.js index 40a4d65..1923434 100644 --- a/src/community/docsGames.js +++ b/src/community/docsGames.js @@ -368,4 +368,9 @@ export const GAMES = [ desc: 'Шутер: волны зомби бегут к игроку, бластер их отстреливает, над целью всплывают облачка урона. Авто-floater над любым мобом одной строкой + ручной game.fx.damageFloater (крит/хил/мана/промах/стек/комикс).', mechanics: ['game.fx.damageFloater(pos, value, opts)', 'game.fx.autoMobFloaters(true) — облачко над NPC при уроне', 'game.player.giveTool(\'blaster-...\') — бластер', 'бластер от 3-го лица — в точку клика', 'spawnNpc + follow(\'player\') — зомби-волны', 'isCrit/isHeal/isMana/isMiss, стек ×N, комикс', 'object pool 30 планов (без лагов)'], previewShot: 'guide-floaters-scene.png', openProjectId: 2676, ready: true }, + { id: 'guide-inventory', num: 65, group: 'g5', stars: 2, icon: 'box', + title: 'Сбор и сортировка — инвентарь с drag-drop', + desc: 'Полный инвентарь как в Minecraft/RPG: сетка 8×5 + хотбар 9, стаки, 5 редкостей (цвет рамки), перетаскивание мышью, ПКМ-меню, tooltip, сортировка. Собираешь предметы — стаки растут.', + mechanics: ['game.items.define([...]) — предметы (редкость/стак/иконка)', 'game.inventory.give / take', 'окно по I — сетка 8×5 + хотбар 9 (1-9)', 'drag-drop между слотами (swap + merge)', 'стаки с maxStack, 5 редкостей', 'ПКМ-меню: использовать / разделить / выбросить', 'tooltip + сортировка по редкости'], + previewShot: 'guide-inventory-scene.png', openProjectId: 2685, ready: true }, ]; diff --git a/src/community/docsLessons.jsx b/src/community/docsLessons.jsx index 9aa1c79..a4c85a8 100644 --- a/src/community/docsLessons.jsx +++ b/src/community/docsLessons.jsx @@ -8867,6 +8867,89 @@ game.fx.damageFloater(pos, 120, { comicStyle: true });`} ), }, + 'guide-inventory': { + body: ( + <> +

Что получится

+

+ Полноценный инвентарь как в Minecraft и RPG: сетка 8×5 + + хотбар на 9 слотов, предметы со стаками и + редкостями, перетаскивание мышью, ПКМ-меню, всплывающие + подсказки. Собираешь предметы на поляне — стаки растут, открываешь + инвентарь клавишей I и раскладываешь добычу. +

+ + + +

Шаг 1. Определи предметы

+

+ Каждый предмет описывается один раз: имя, иконка-эмодзи, редкость, + размер стака, эффект использования. +

+ + {`game.items.define([ + { id:'berry', name:'Ягоды', emoji:'🍓', rarity:'common', maxStack:16, value:2 }, + { id:'iron', name:'Руда', emoji:'⛏️', rarity:'uncommon', maxStack:16, value:8 }, + { id:'potion', name:'Зелье', emoji:'🧪', rarity:'rare', maxStack:8, onUseEffect:'heal:50' }, + { id:'sword', name:'Меч', emoji:'⚔️', rarity:'legendary', maxStack:1, value:500 }, +]);`} +

+ Редкости: common (серый), uncommon + (зелёный), rare (голубой), epic + (фиолетовый), legendary (золотой) — это цвет рамки слота. +

+ +

Шаг 2. Выдавай и собирай предметы

+ {`game.inventory.give('sword', 1); // в стартовый набор +game.inventory.give('berry', 5); // стак до maxStack, дальше — новый слот + +// сбор предмета с земли (на объекте-ягоде): +game.self.onInteract(() => { + game.inventory.give('berry', 2); + game.self.delete(); // убрать собранный предмет +}, { text:'Собрать', key:'e', distance:3 });`} +

+ Собранное попадает сначала в хотбар (виден внизу экрана), + одинаковые предметы складываются в стак с учётом maxStack. +

+ + + +

Шаг 3. Окно инвентаря

+
    +
  • I — открыть/закрыть окно (Esc тоже закрывает);
  • +
  • Перетаскивание мышью — поменять слоты местами или + слить стаки;
  • +
  • ПКМ по слоту — меню: использовать / разделить / выбросить;
  • +
  • Наведение — tooltip (название цветом редкости, описание, цена);
  • +
  • Сорт. — расставить по редкости;
  • +
  • 1-9 — выбрать активный слот хотбара.
  • +
+ + + Всё хранится в движке и сериализуется в проект автоматически — + дописывать сохранение не нужно. Предметы с тегом + 'quest' нельзя выбросить. + + +

Почему это важно

+

+ Инвентарь — несущая конструкция RPG, выживания и симуляторов. + Сетка + хотбар + стаки + редкости — стандарт, который игроки + узнают мгновенно. Сочетается с крафтом, квестами и магазином. +

+ + + Добавь предмет 'apple' с + onUseEffect:'heal:15', положи в хотбар и нажми ПКМ → + «Использовать» — HP восстановится, яблоко убавится на 1. + + + ), + }, + }; /** Есть ли готовый текст урока для игры с таким id. */ From c8a961815e45de281487a3de9b8b98e9947628ff Mon Sep 17 00:00:00 2001 From: min Date: Sun, 7 Jun 2026 15:17:31 +0300 Subject: [PATCH 63/74] =?UTF-8?q?chore:=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D0=BA=D0=B0=20CI=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20?= =?UTF-8?q?=D1=84=D0=B8=D0=BA=D1=81=D0=B0=20runner=20network=20(job?= =?UTF-8?q?=E2=86=92gitea:3000)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 42a684ef4c24bb1c875c0ab09c1223fd979637ae Mon Sep 17 00:00:00 2001 From: min Date: Sun, 7 Jun 2026 16:28:38 +0300 Subject: [PATCH 64/74] =?UTF-8?q?chore:=20=D1=84=D0=B8=D0=BD=D0=B0=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D0=B0=D1=8F=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D0=BA=D0=B0=20CI=20=D0=BD=D0=B0=20=D0=B0=D0=BA=D1=82=D1=83?= =?UTF-8?q?=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE=D0=BC=20main?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From c363acdf288a785dd1e851752e6cec5bd3825e12 Mon Sep 17 00:00:00 2001 From: min Date: Sun, 7 Jun 2026 16:30:00 +0300 Subject: [PATCH 65/74] =?UTF-8?q?ci:=20=D1=84=D0=B8=D0=BD=D0=B0=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D0=B0=D1=8F=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=B7=D0=B5=D0=BB=D1=91=D0=BD=D0=BE=D0=B3=D0=BE?= =?UTF-8?q?=20=D0=BF=D0=B0=D0=B9=D0=BF=D0=BB=D0=B0=D0=B9=D0=BD=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 From 9fde464085a9182f8929c2ffc57ba1fb87126165 Mon Sep 17 00:00:00 2001 From: min Date: Sun, 7 Jun 2026 17:20:48 +0300 Subject: [PATCH 66/74] =?UTF-8?q?ci:=20redeploy=20=D1=81=20=D0=BE=D0=B1?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2=D0=BB=D1=91=D0=BD=D0=BD=D1=8B=D0=BC=20KNOWN?= =?UTF-8?q?=5FHOSTS=20(S2=20host-key=20=D1=81=D0=BC=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=BB=D1=81=D1=8F)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 From 76598c80ef247779df0fbd8a9e9c26e732fd06c0 Mon Sep 17 00:00:00 2001 From: min Date: Sun, 7 Jun 2026 17:39:33 +0300 Subject: [PATCH 67/74] =?UTF-8?q?ci:=20redeploy=20=D0=BF=D0=BE=D1=81=D0=BB?= =?UTF-8?q?=D0=B5=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20deploy-=D0=BA=D0=BB=D1=8E=D1=87=D0=B0=20=D0=B2=20S2=20?= =?UTF-8?q?authorized=5Fkeys?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 From 781d95e59989dd8dbda26e8a51ec5679c1a889b5 Mon Sep 17 00:00:00 2001 From: min Date: Sun, 7 Jun 2026 17:54:08 +0300 Subject: [PATCH 68/74] =?UTF-8?q?ci:=20redeploy3=20(chown=20build=20min:ww?= =?UTF-8?q?w-data=20=D0=BD=D0=B0=20S2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 From c31b1ed3d60a5142fb7e0ab77c00f39d1d59ed42 Mon Sep 17 00:00:00 2001 From: min Date: Sun, 7 Jun 2026 19:34:48 +0300 Subject: [PATCH 69/74] =?UTF-8?q?feat(studio):=20=D0=B7=D0=B0=D0=B4=D0=B0?= =?UTF-8?q?=D1=87=D0=B0=2005=20=E2=80=94=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=20=D0=B7=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B8=20(Ken=20Bu?= =?UTF-8?q?rns=20+=20=D0=BD=D0=B0=D0=B7=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D0=BC=D0=B5=D1=81=D1=82=D0=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LoadingScreenOverlay: Ken-Burns фон (CSS pan+zoom) + 4 стиля (ken-burns/static/ parallax/particles) + карточка-композиция (cover/название места/автор/verified-SVG). Стартовый экран при входе в Play (showStartupLoadingScreen из enterPlayMode + поля проекта loadingScreen.* + serialize/deserialize). API game.loading. setBackground/isVisible/onHide + расширенный show. UI редактора: секция «Стартовый экран входа (Ken Burns)». Вики g5 #62 + статья. Тест-игра 2713. Co-Authored-By: Claude Opus 4.8 --- src/community/docsGames.js | 5 + src/editor/GameSettingsModal.jsx | 152 +++++++++++++++++ src/editor/engine/BabylonScene.js | 65 ++++++-- src/editor/engine/GameRuntime.js | 9 +- src/editor/engine/LoadingScreenOverlay.js | 192 ++++++++++++++++++++-- src/editor/engine/ScriptSandboxWorker.js | 27 +++ 6 files changed, 420 insertions(+), 30 deletions(-) diff --git a/src/community/docsGames.js b/src/community/docsGames.js index 1923434..6f9f317 100644 --- a/src/community/docsGames.js +++ b/src/community/docsGames.js @@ -373,4 +373,9 @@ export const GAMES = [ desc: 'Полный инвентарь как в Minecraft/RPG: сетка 8×5 + хотбар 9, стаки, 5 редкостей (цвет рамки), перетаскивание мышью, ПКМ-меню, tooltip, сортировка. Собираешь предметы — стаки растут.', mechanics: ['game.items.define([...]) — предметы (редкость/стак/иконка)', 'game.inventory.give / take', 'окно по I — сетка 8×5 + хотбар 9 (1-9)', 'drag-drop между слотами (swap + merge)', 'стаки с maxStack, 5 редкостей', 'ПКМ-меню: использовать / разделить / выбросить', 'tooltip + сортировка по редкости'], previewShot: 'guide-inventory-scene.png', openProjectId: 2685, ready: true }, + { id: 'guide-loadingscreen', num: 66, group: 'g5', stars: 2, icon: 'loader', + title: 'Экран загрузки — Ken Burns и название места', + desc: 'Что видит игрок при входе в игру: размытый фон с медленным движением (Ken Burns), карточка-витрина по центру, название места и автор с verified-галочкой — как в Roblox. Автор настраивает экран во вкладке «Стартовый экран».', + mechanics: ['красивый экран загрузки игры в плеере (GameLoadingScreen)', 'Ken Burns / static / parallax / particles', 'карточка-витрина + название места + автор + verified', 'настройка во вкладке «Стартовый экран» (свойства проекта)', 'game.loading.show({ style, placeName, studioName, duration }) — переходы', 'game.loading.onHide() — продолжить после загрузки', 'game.loading.setBackground / setText / setProgress'], + previewShot: 'guide-loadingscreen-scene.png', openProjectId: 2713, ready: true }, ]; diff --git a/src/editor/GameSettingsModal.jsx b/src/editor/GameSettingsModal.jsx index e579b3d..4b1ea9b 100644 --- a/src/editor/GameSettingsModal.jsx +++ b/src/editor/GameSettingsModal.jsx @@ -50,9 +50,21 @@ const GameSettingsModal = ({ open, initial, onClose, onSave, onCaptureScreenshot 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 fileInputRef = useRef(null); const logoInputRef = useRef(null); + const lsBgInputRef = useRef(null); + const lsCoverInputRef = useRef(null); // Заполняем поля ОДИН РАЗ при открытии модала. // Не зависим от `initial` — родитель часто передаёт литерал-объект, @@ -71,6 +83,16 @@ const GameSettingsModal = ({ open, initial, onClose, onSave, onCaptureScreenshot 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( typeof initial?.max_players === 'number' ? Math.max(2, Math.min(50, initial.max_players)) @@ -117,6 +139,17 @@ const GameSettingsModal = ({ open, initial, onClose, onSave, onCaptureScreenshot 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) => { e.preventDefault(); const trimmedTitle = title.trim(); @@ -146,6 +179,16 @@ const GameSettingsModal = ({ open, initial, onClose, onSave, onCaptureScreenshot 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, }, }); }; @@ -384,6 +427,115 @@ const GameSettingsModal = ({ open, initial, onClose, onSave, onCaptureScreenshot
+ {/* Стартовый экран — Ken Burns + название места (задача 05) */} +
+
+ Стартовый экран входа (Ken Burns) +
+
+ Что видит игрок при входе в игру: размытый фон с медленным движением (Ken Burns), карточка-витрина по центру, название места и автор. +
+ + + {lsEnabled && ( + <> + {/* Фон + карточка */} +
+
+
+ {!lsBackground && фон (размытый)} +
+ + {lsBackground && ( + + )} + handleLsImage(e, setLsBackground)} /> +
+
+
+ {!lsCover && карточка} +
+ + {lsCover && ( + + )} + handleLsImage(e, setLsCover)} /> +
+
+ setLsPlaceName(e.target.value)} /> + setLsStudioName(e.target.value)} /> + +
+
+ + {/* Стиль + длительность + прогресс */} +
+ + + +
+ + )} +
+ {error &&
{error}
}
diff --git a/src/editor/engine/BabylonScene.js b/src/editor/engine/BabylonScene.js index 1c241cf..ac0d69d 100644 --- a/src/editor/engine/BabylonScene.js +++ b/src/editor/engine/BabylonScene.js @@ -5942,7 +5942,7 @@ export class BabylonScene { this._updateSpawnMarker(); } - /** Задача 12: конфиг экрана загрузки из настроек проекта (логотип/акцент/дефолты). */ + /** Задача 12+05: конфиг экрана загрузки из настроек проекта. */ setLoadingConfig(cfg, thumbnail) { if (cfg && typeof cfg === 'object') { this._loadingConfig = { @@ -5950,6 +5950,16 @@ export class BabylonScene { accentColor: cfg.accentColor || '#ffc020', defaultSpinner: cfg.defaultSpinner !== false, defaultSkipButton: !!cfg.defaultSkipButton, + // --- Задача 05: стартовый экран при входе в Play --- + enabled: cfg.enabled !== false, // показывать ли стартовый экран + background: cfg.background || cfg.backgroundUrl || null, + cover: cfg.cover || cfg.coverUrl || null, + style: cfg.style || 'ken-burns', + placeName: cfg.placeName || '', + studioName: cfg.studioName || '', + verified: !!cfg.verified, + duration: Number.isFinite(cfg.duration) && cfg.duration > 0 ? Number(cfg.duration) : 2.5, + progressBar: cfg.progressBar !== false, }; } else { this._loadingConfig = null; @@ -5957,6 +5967,34 @@ export class BabylonScene { if (thumbnail !== undefined) this._projectThumbnail = thumbnail || null; } + /** + * Задача 05: показать СТАРТОВЫЙ экран загрузки при входе в Play. + * Зовётся из enterPlayMode; держится минимум `duration` сек либо до готовности сцены. + */ + showStartupLoadingScreen() { + const cfg = this._loadingConfig; + if (!cfg || cfg.enabled === false) return; + if (!this.gameRuntime) return; + try { + const ls = this.gameRuntime._ensureLoadingScreen?.(); + if (!ls) return; + ls.show({ + style: cfg.style, + background: cfg.background || cfg.cover || this._projectThumbnail, + cover: cfg.cover || this._projectThumbnail, + placeName: cfg.placeName || this._projectName || '', + studioName: cfg.studioName || '', + verified: cfg.verified, + duration: cfg.duration, + progressBar: cfg.progressBar, + spinner: true, + bgColor: '#070a14', + pauseSimulation: false, // стартовый — сцена грузится в фоне + blockInput: true, + }); + } catch (e) { /* ignore */ } + } + /** Установить тип модели персонажа (для Play). */ setPlayerModelType(typeId) { if (!typeId) return; @@ -6074,6 +6112,9 @@ export class BabylonScene { // поэтому скрипты стартуем в следующем кадре. this.gameRuntime = new GameRuntime(this); try { this.modalManager?.attachRuntime?.(this.gameRuntime); } catch (e) {} + // Задача 05: стартовый экран загрузки (Ken-Burns + название места), + // если настроен в проекте. Показываем поверх старта сцены. + try { this.showStartupLoadingScreen(); } catch (e) {} // Аудио-менеджер (GD-музыка/SFX) — создаётся вместе с runtime. // ВАЖНО: поле называется gameAudioManager чтобы не путать со штатным // this.audioManager (AudioManager — ambient/music для всех проектов). @@ -7646,12 +7687,22 @@ export class BabylonScene { coins: this._skinsConfig.coins || 0, customGlbs: this._skinsConfig.customGlbs || [], } : undefined, - // Задача 12: конфиг экрана загрузки (логотип/акцент/дефолты). + // Задача 12+05: конфиг экрана загрузки (логотип/акцент/дефолты + стартовый Ken-Burns). loadingScreen: this._loadingConfig ? { logo: this._loadingConfig.logo || null, accentColor: this._loadingConfig.accentColor || '#ffc020', defaultSpinner: this._loadingConfig.defaultSpinner !== false, defaultSkipButton: !!this._loadingConfig.defaultSkipButton, + // Задача 05: + enabled: this._loadingConfig.enabled !== false, + background: this._loadingConfig.background || null, + cover: this._loadingConfig.cover || null, + style: this._loadingConfig.style || 'ken-burns', + placeName: this._loadingConfig.placeName || '', + studioName: this._loadingConfig.studioName || '', + verified: !!this._loadingConfig.verified, + duration: this._loadingConfig.duration || 2.5, + progressBar: this._loadingConfig.progressBar !== false, } : undefined, // Задача 13: конфиг главного меню (passthrough — меню задаётся скриптом). mainMenu: this._mainMenuConfig || undefined, @@ -8120,15 +8171,9 @@ export class BabylonScene { } else { this._skinsConfig = null; } - // Задача 12: конфиг экрана загрузки. + // Задача 12+05: конфиг экрана загрузки (через setLoadingConfig — единый маппинг). if (state.scene.loadingScreen && typeof state.scene.loadingScreen === 'object') { - const ls = state.scene.loadingScreen; - this._loadingConfig = { - logo: ls.logo || null, - accentColor: ls.accentColor || '#ffc020', - defaultSpinner: ls.defaultSpinner !== false, - defaultSkipButton: !!ls.defaultSkipButton, - }; + this.setLoadingConfig(state.scene.loadingScreen); } else { this._loadingConfig = null; } diff --git a/src/editor/engine/GameRuntime.js b/src/editor/engine/GameRuntime.js index 58ec24b..5170722 100644 --- a/src/editor/engine/GameRuntime.js +++ b/src/editor/engine/GameRuntime.js @@ -1326,6 +1326,8 @@ export class GameRuntime { ls.setBridge( (id) => { for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'loadingSkip', loadingId: id }); }, (id) => { for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'loadingComplete', loadingId: id }); }, + // Задача 05: onHide — экран скрылся (любой). + () => { for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'loadingHidden' }); }, ); this.scene3d.loadingScreen = ls; } @@ -1879,9 +1881,9 @@ export class GameRuntime { const id = ls.show(payload.opts || {}); // Вернуть worker'у real loadingId, чтобы хэндл (setProgress/close/колбэки) // нашёл нужный экран по replyId → local→real маппингу. - if (payload.replyId != null) { - for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'loadingShown', replyId: payload.replyId, loadingId: id }); - } + // replyId может отсутствовать (стартовый экран) — всё равно шлём + // loadingShown для game.loading.isVisible() (задача 05). + for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'loadingShown', replyId: payload.replyId, loadingId: id }); } catch (e) { this._log('error', 'loading.show: ' + (e?.message || e)); } } return; @@ -1889,6 +1891,7 @@ export class GameRuntime { if (cmd === 'loading.setProgress') { this.scene3d?.loadingScreen?.setProgress(payload?.value); return; } if (cmd === 'loading.setText') { this.scene3d?.loadingScreen?.setText(payload?.text); return; } if (cmd === 'loading.setCover') { this.scene3d?.loadingScreen?.setCover(payload?.cover); return; } + if (cmd === 'loading.setBackground') { this.scene3d?.loadingScreen?.setBackground?.(payload?.background); return; } if (cmd === 'loading.close') { this.scene3d?.loadingScreen?.close(); return; } // === Damage Floaters (задача 40) — всплывающие цифры урона === diff --git a/src/editor/engine/LoadingScreenOverlay.js b/src/editor/engine/LoadingScreenOverlay.js index 47f6b56..d0666f2 100644 --- a/src/editor/engine/LoadingScreenOverlay.js +++ b/src/editor/engine/LoadingScreenOverlay.js @@ -35,7 +35,25 @@ function injectSpinnerCss() { style.textContent = '@keyframes kbn-ls-spin{from{transform:rotate(0)}to{transform:rotate(360deg)}}' + '.kbn-ls-spinner{animation:kbn-ls-spin 0.9s linear infinite}' + - '@media (prefers-reduced-motion:reduce){.kbn-ls-spinner{animation:none}}'; + // Ken Burns — медленный pan+zoom фона (задача 05). + '@keyframes kbn-ls-kenburns{' + + '0%{transform:scale(1.0) translate3d(0,0,0)}' + + '50%{transform:scale(1.10) translate3d(-3%,-2%,0)}' + + '100%{transform:scale(1.0) translate3d(-6%,0,0)}}' + + '.kbn-ls-kenburns{animation:kbn-ls-kenburns 24s ease-in-out infinite}' + + // particles — медленно всплывающие искры. + '@keyframes kbn-ls-rise{' + + '0%{transform:translateY(0) scale(1);opacity:0}' + + '10%{opacity:0.9}' + + '90%{opacity:0.7}' + + '100%{transform:translateY(-110vh) scale(1.4);opacity:0}}' + + '.kbn-ls-particle{animation:kbn-ls-rise linear infinite}' + + // лёгкий «дыхательный» glow карточки-превью. + '@keyframes kbn-ls-cardglow{' + + '0%,100%{box-shadow:0 18px 60px rgba(0,0,0,0.6),0 0 0 rgba(120,160,255,0)}' + + '50%{box-shadow:0 18px 60px rgba(0,0,0,0.6),0 0 40px rgba(120,160,255,0.35)}}' + + '.kbn-ls-cardglow{animation:kbn-ls-cardglow 4s ease-in-out infinite}' + + '@media (prefers-reduced-motion:reduce){.kbn-ls-spinner,.kbn-ls-kenburns,.kbn-ls-particle,.kbn-ls-cardglow{animation:none}}'; document.head.appendChild(style); } catch { /* ignore */ } } @@ -49,14 +67,17 @@ export class LoadingScreenOverlay { // Мост наружу (GameRuntime подписывает) — id-based колбэки. this._onSkipCb = null; // (id) => void this._onCompleteCb = null; // (id) => void + this._onHideCb = null; // () => void — задача 05 (game.loading.onHide) + this._parallaxHandler = null; // DOM-ссылки активного экрана: this._els = null; } /** Колбэки-мост к GameRuntime (он рассылает globalEvent в sandbox'ы). */ - setBridge(onSkip, onComplete) { + setBridge(onSkip, onComplete, onHide) { this._onSkipCb = onSkip; this._onCompleteCb = onComplete; + if (onHide) this._onHideCb = onHide; } /** Конфиг проекта (логотип/акцент по умолчанию) из BabylonScene._loadingConfig. */ @@ -104,6 +125,15 @@ export class LoadingScreenOverlay { logoCornerRadius: opts.logoCornerRadius != null ? Number(opts.logoCornerRadius) : 12, // Текст под картинкой text: opts.text != null ? String(opts.text) : '', + // --- Задача 05: Ken-Burns фон + карточка места --- + // style: 'ken-burns' | 'static' | 'parallax' | 'particles' + style: opts.style || cfg.style || 'ken-burns', + // фоновое размытое изображение (на весь экран); резолвится в _resolveCover. + background: opts.background != null ? opts.background : (cfg.background || null), + // карточка-витрина по центру (название места + автор), как в Roblox. + placeName: opts.placeName != null ? String(opts.placeName) : (cfg.placeName || ''), + studioName: opts.studioName != null ? String(opts.studioName) : (cfg.studioName || ''), + verified: opts.verified != null ? !!opts.verified : !!cfg.verified, // Поведение blockInput: opts.blockInput !== false, pauseSimulation: opts.pauseSimulation !== false, @@ -163,20 +193,107 @@ export class LoadingScreenOverlay { // (используем opacity всего root для fade, а bgOpacity — через rgba фон): root.style.background = this._bgRgba(st.bgColor, st.bgOpacity); - // --- Cover (картинка по центру) --- - const coverUrl = this._resolveCover(cover); - const coverImg = document.createElement('div'); - coverImg.style.cssText = - 'max-width:min(62vw,860px);width:62vw;aspect-ratio:16/9;border-radius:16px;' + - 'box-shadow:0 18px 60px rgba(0,0,0,0.6);background-size:cover;background-position:center;' + - 'background-color:#1a1f2b;margin-bottom:140px;'; - if (coverUrl) coverImg.style.backgroundImage = `url("${coverUrl}")`; + // --- Фоновый слой (Ken Burns / parallax / static) --- + // Размытое изображение игры на весь экран. Отдельный div под контентом, + // чтобы blur/анимация не трогали карточку и текст. + const bgUrl = this._resolveCover(st.background); + const bgLayer = document.createElement('div'); + let bgClass = ''; + if (bgUrl) { + if (st.style === 'ken-burns') bgClass = 'kbn-ls-kenburns'; + bgLayer.className = bgClass; + bgLayer.style.cssText = + 'position:absolute;inset:-8%;z-index:0;background-size:cover;background-position:center;' + + 'filter:blur(8px) brightness(0.55);will-change:transform;' + + `background-image:url("${bgUrl}");`; + // parallax — лёгкий сдвиг по мыши (2 слоя имитируем одним + transform). + if (st.style === 'parallax') { + bgLayer.style.transition = 'transform 0.25s ease-out'; + this._parallaxHandler = (e) => { + const cx = (e.clientX / window.innerWidth - 0.5) * 28; + const cy = (e.clientY / window.innerHeight - 0.5) * 18; + bgLayer.style.transform = `translate3d(${-cx}px,${-cy}px,0) scale(1.06)`; + }; + window.addEventListener('mousemove', this._parallaxHandler); + } + root.appendChild(bgLayer); + } - // --- Текст под картинкой --- + // --- particles слой (медленные искры) --- + if (st.style === 'particles') { + const pLayer = document.createElement('div'); + pLayer.style.cssText = 'position:absolute;inset:0;z-index:1;overflow:hidden;pointer-events:none;'; + for (let i = 0; i < 26; i++) { + const sp = document.createElement('span'); + sp.className = 'kbn-ls-particle'; + const size = 2 + Math.round(Math.random() * 4); + const dur = 7 + Math.random() * 10; + sp.style.cssText = + `position:absolute;bottom:-10px;left:${(Math.random() * 100).toFixed(1)}%;` + + `width:${size}px;height:${size}px;border-radius:50%;` + + `background:rgba(${180 + Math.round(Math.random() * 70)},${190 + Math.round(Math.random() * 60)},255,0.85);` + + `box-shadow:0 0 ${size * 2}px rgba(140,170,255,0.7);` + + `animation-duration:${dur.toFixed(1)}s;animation-delay:${(-Math.random() * dur).toFixed(1)}s;`; + pLayer.appendChild(sp); + } + root.appendChild(pLayer); + } + + // Обёртка контента (над фоном). + const content = document.createElement('div'); + content.style.cssText = + 'position:relative;z-index:2;display:flex;flex-direction:column;align-items:center;justify-content:center;width:100%;height:100%;'; + + // --- Cover (картинка-карточка по центру) --- + const coverUrl = this._resolveCover(cover); + // Режим карточки места (задача 05): квадрат + название + автор под ней. + const hasPlaceCard = !!(st.placeName || st.studioName); + const coverImg = document.createElement('div'); + if (hasPlaceCard) { + coverImg.className = 'kbn-ls-cardglow'; + coverImg.style.cssText = + 'width:min(42vw,400px);aspect-ratio:1/1;border-radius:18px;' + + 'background-size:cover;background-position:center;background-color:#1a1f2b;' + + 'border:2px solid rgba(255,255,255,0.12);'; + } else { + coverImg.style.cssText = + 'max-width:min(62vw,860px);width:62vw;aspect-ratio:16/9;border-radius:16px;' + + 'box-shadow:0 18px 60px rgba(0,0,0,0.6);background-size:cover;background-position:center;' + + 'background-color:#1a1f2b;margin-bottom:140px;'; + } + if (coverUrl) coverImg.style.backgroundImage = `url("${coverUrl}")`; + else if (bgUrl && hasPlaceCard) coverImg.style.backgroundImage = `url("${bgUrl}")`; + + // --- Название места (крупный белый, под карточкой) --- + const placeEl = document.createElement('div'); + placeEl.style.cssText = + 'margin-top:22px;color:#fff;font-size:38px;font-weight:800;letter-spacing:0.5px;' + + 'text-align:center;text-shadow:0 3px 14px rgba(0,0,0,0.7);' + + (st.placeName ? '' : 'display:none;'); + placeEl.textContent = st.placeName || ''; + + // --- Автор + verified-галочка --- + const studioRow = document.createElement('div'); + studioRow.style.cssText = + 'margin-top:8px;display:flex;align-items:center;gap:7px;' + + 'color:#cdd6e6;font-size:16px;font-weight:600;text-shadow:0 1px 4px rgba(0,0,0,0.6);' + + (st.studioName ? '' : 'display:none;'); + const studioTxt = document.createElement('span'); + studioTxt.textContent = st.studioName || ''; + studioRow.appendChild(studioTxt); + if (st.verified) studioRow.appendChild(this._buildVerifiedBadge()); + + // --- Текст под картинкой (для не-карточного режима / mid-game) --- const textEl = document.createElement('div'); - textEl.style.cssText = - 'position:absolute;left:50%;transform:translateX(-50%);bottom:170px;' + - 'color:#e8edf5;font-size:18px;font-weight:500;text-align:center;text-shadow:0 1px 3px rgba(0,0,0,0.6);'; + if (hasPlaceCard) { + textEl.style.cssText = + 'margin-top:14px;color:#e8edf5;font-size:16px;font-weight:500;text-align:center;' + + 'text-shadow:0 1px 3px rgba(0,0,0,0.6);' + (st.text ? '' : 'display:none;'); + } else { + textEl.style.cssText = + 'position:absolute;left:50%;transform:translateX(-50%);bottom:170px;' + + 'color:#e8edf5;font-size:18px;font-weight:500;text-align:center;text-shadow:0 1px 3px rgba(0,0,0,0.6);'; + } textEl.textContent = st.text || ''; // --- Прогресс-бар --- @@ -245,8 +362,13 @@ export class LoadingScreenOverlay { spinWrap.appendChild(spinTxt); spinWrap.appendChild(spinCircle); - root.appendChild(coverImg); - root.appendChild(textEl); + // Центральная композиция (карточка + название + автор + текст) — в content. + content.appendChild(coverImg); + content.appendChild(placeEl); + content.appendChild(studioRow); + content.appendChild(textEl); + root.appendChild(content); + // Нижние/угловые элементы — абсолютно на root (поверх фона, рядом с content). root.appendChild(barWrap); root.appendChild(percent); root.appendChild(skipBtn); @@ -255,7 +377,19 @@ export class LoadingScreenOverlay { parent.appendChild(root); this.root = root; - this._els = { root, coverImg, textEl, barWrap, bar, percent, skipBtn, logo, spinWrap }; + this._els = { root, content, coverImg, placeEl, studioRow, textEl, barWrap, bar, percent, skipBtn, logo, spinWrap }; + } + + /** Verified-галочка (синий круг + белый чек) — inline SVG, без emoji-шрифта. */ + _buildVerifiedBadge() { + const wrap = document.createElement('span'); + wrap.style.cssText = 'display:inline-flex;align-items:center;'; + wrap.innerHTML = + '' + + '' + + ''; + return wrap; } /** tick — fade-фазы, авто-duration, появление кнопки Пропустить. */ @@ -329,6 +463,23 @@ export class LoadingScreenOverlay { if (url) this._els.coverImg.style.backgroundImage = `url("${url}")`; } + /** Задача 05: сменить фоновое (Ken-Burns) изображение на лету. */ + setBackground(bg) { + if (!this._st || !this._els) return; + const url = this._resolveCover(bg); + if (!url) return; + this._st.background = bg; + // фоновый слой — первый ребёнок root с background-image; найдём его. + const layer = this._els.root.querySelector('.kbn-ls-kenburns') + || this._els.root.firstElementChild; + if (layer && layer !== this._els.content) layer.style.backgroundImage = `url("${url}")`; + } + + /** Задача 05: виден ли экран сейчас. */ + isVisible() { + return !!(this._st && this._st.phase !== 'out'); + } + /** Закрыть программно (с fadeOut). */ close() { const st = this._st; @@ -361,6 +512,13 @@ export class LoadingScreenOverlay { if (st.blockInput) { try { this.s.player?.setInputBlocked?.(false); } catch { /* ignore */ } } if (st.pauseSimulation && this.s.gameRuntime) { try { this.s.gameRuntime.paused = false; } catch { /* ignore */ } } } + // Снять parallax-listener (задача 05). + if (this._parallaxHandler) { + try { window.removeEventListener('mousemove', this._parallaxHandler); } catch { /* ignore */ } + this._parallaxHandler = null; + } + // onHide-мост (задача 05) — сообщаем скриптам что экран скрылся. + if (this._onHideCb) { try { this._onHideCb(); } catch { /* ignore */ } } if (this.root) { try { this.root.remove(); } catch { /* ignore */ } } this.root = null; this._els = null; diff --git a/src/editor/engine/ScriptSandboxWorker.js b/src/editor/engine/ScriptSandboxWorker.js index 4469ea0..6d38fa4 100644 --- a/src/editor/engine/ScriptSandboxWorker.js +++ b/src/editor/engine/ScriptSandboxWorker.js @@ -70,6 +70,8 @@ let _toolUseHandlers = []; // При toolUse-событии воркер сначала вызывает per-tool колбэк, потом глобальные. let _toolCallbacks = {}; // { 'custom:1': { activated: fn, equipped: fn, unequipped: fn } } let _toolSeq = 0; +// Задача 05: зеркало видимости экрана загрузки (для game.loading.isVisible()). +let _loadingVisible = false; // Мультиплеер (Фаза 4.3): снимок игроков комнаты { me, list }. let _players = { me: null, list: [] }; // Общее состояние комнаты game.room.get/set — зеркало из main thread. @@ -3032,6 +3034,7 @@ const game = { _localSeq: 0, _localToReal: new Map(), // localId → реальный loadingId (приходит в loadingShown) _handlers: new Map(), // localId → { onSkip:[], onComplete:[] } + _onHide: [], // задача 05 — глобальные подписки на скрытие show(opts) { opts = opts && typeof opts === 'object' ? opts : {}; const localId = ++this._localSeq; @@ -3050,11 +3053,27 @@ const game = { setProgress(v) { _send('loading.setProgress', { localId, value: Number(v) || 0 }); }, setText(t) { _send('loading.setText', { localId, text: String(t == null ? '' : t) }); }, setCover(c) { _send('loading.setCover', { localId, cover: c }); }, + setBackground(b) { _send('loading.setBackground', { localId, background: b }); }, close() { _send('loading.close', { localId }); }, onSkip(fn) { if (typeof fn === 'function') self._handlers.get(localId)?.onSkip.push(fn); }, onComplete(fn) { if (typeof fn === 'function') self._handlers.get(localId)?.onComplete.push(fn); }, }; }, + // --- Задача 05: управление активным экраном без хэндла (стартовый/любой текущий) --- + /** Подписаться на скрытие экрана загрузки. */ + onHide(fn) { if (typeof fn === 'function') this._onHide.push(fn); }, + /** Сменить фоновое изображение текущего экрана. */ + setBackground(b) { _send('loading.setBackground', { background: b }); }, + /** Сменить текст текущего экрана. */ + setText(t) { _send('loading.setText', { text: String(t == null ? '' : t) }); }, + /** Сменить cover текущего экрана. */ + setCover(c) { _send('loading.setCover', { cover: c }); }, + /** Ручной прогресс текущего экрана. */ + setProgress(v) { _send('loading.setProgress', { value: Number(v) || 0 }); }, + /** Скрыть текущий экран. */ + hide() { _send('loading.close', {}); }, + /** Виден ли экран сейчас (синхронно из локального зеркала). */ + isVisible() { return !!_loadingVisible; }, /** Типовой переход с фейковым прогрессом. Возвращает Promise (резолв по complete/skip). */ transition(opts) { opts = opts && typeof opts === 'object' ? { ...opts } : {}; @@ -4597,6 +4616,7 @@ self.onmessage = (e) => { } else if (t === 'loadingShown') { // Задача 12: реальный loadingId от runtime — маппим local→real, чтобы // setProgress/close/колбэки нашли нужный экран. + _loadingVisible = true; try { const lo = (typeof game !== 'undefined') && game.loading; if (lo && payload && payload.replyId) { @@ -4606,6 +4626,13 @@ self.onmessage = (e) => { } } } catch (e) {} + } else if (t === 'loadingHidden') { + // Задача 05: экран загрузки скрылся — обновляем зеркало + onHide-подписки. + _loadingVisible = false; + try { + const lo = (typeof game !== 'undefined') && game.loading; + if (lo) for (const fn of (lo._onHide || [])) _safeCall(fn, undefined, 'loading.onHide'); + } catch (e) {} } else if (t === 'loadingSkip' || t === 'loadingComplete') { // Игрок нажал «Пропустить» (skip) или прогресс достиг 100% (complete). // Находим local по real loadingId и зовём соответствующие подписчики. From cb41ea0062693c1be285c1d0c7f4395680d9c6ef Mon Sep 17 00:00:00 2001 From: min Date: Sun, 7 Jun 2026 20:02:57 +0300 Subject: [PATCH 70/74] =?UTF-8?q?docs(studio):=20=D0=B2=D0=B8=D0=BA=D0=B8?= =?UTF-8?q?=20=D1=81=D1=82=D0=B0=D1=82=D1=8C=D1=8F=20guide-loadingscreen?= =?UTF-8?q?=20+=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=BA=D0=B0=20#66?= =?UTF-8?q?=20(=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B0=2005,=20=D0=B2=D0=BE?= =?UTF-8?q?=D1=81=D1=81=D1=82=D0=B0=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Восстановлена полная ветка работ (задачи 16/17/20/40/44 + UX) из c8a9618 + применена задача 05 (Ken Burns экран загрузки). Карточки g5: skybox/leaderstats/ floaters/inventory/loadingscreen. Ошибки 'items.define/autoMobFloaters/setSkybox is not a function' были из-за работы на служебной CI-ветке без задач 40/44. Co-Authored-By: Claude Opus 4.8 --- src/community/docsLessons.jsx | 69 +++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/src/community/docsLessons.jsx b/src/community/docsLessons.jsx index a4c85a8..66d563f 100644 --- a/src/community/docsLessons.jsx +++ b/src/community/docsLessons.jsx @@ -8950,6 +8950,75 @@ game.self.onInteract(() => { ), }, + 'guide-loadingscreen': { + body: ( + <> +

Что получится

+

+ Красивый экран загрузки игры — то, что видит игрок при входе + в игру (после клика «Играть»), пока грузится сцена. Композиция как в + Roblox: размытый фон с медленным движением (Ken Burns), + карточка-витрина по центру, крупное название места и + автор с verified-галочкой, прогресс-бар и спиннер. Когда сцена + загрузилась — экран плавно исчезает. +

+ + + +

Шаг 1. Настроить в свойствах проекта

+

+ Без кода: Настройки игры → вкладка «Стартовый экран входа (Ken Burns)». + Задай фон (размытое изображение игры), карточку, название места, имя + автора, галочку verified, стиль анимации и длительность. Этот экран + автоматически покажется игроку при заходе. +

+
    +
  • Фон — размытое изображение игры (или её обложка);
  • +
  • Карточка — витрина по центру (необязательно);
  • +
  • Название места + автор + verified;
  • +
  • Стиль: Ken Burns / статичный / параллакс / частицы;
  • +
  • Длительность и прогресс-бар.
  • +
+

+ Если ничего не задано — экран всё равно красивый: берёт обложку, + название и автора игры автоматически. +

+ +

Шаг 2. Переходы между мирами из скрипта

+

Для смены главы/мира вызывай экран вручную:

+ + {`game.loading.show({ + style: 'particles', + placeName: 'Алмазная глава', + studioName: 'Виктория — Майнкрафтия', + verified: true, + duration: 2, +}); +game.after(0.6, () => { + game.environment.setTimeOfDay(0); // меняем мир «за кулисами» + game.environment.setSkyColor('#0a1024'); +}); +game.loading.onHide(() => { + game.ui.set('hi', 'Добро пожаловать!', { x:50, y:6, anchor:'top' }); +});`} + + + Стили: Ken Burns — медленный pan+zoom фона (классика Roblox); + параллакс — фон смещается за мышью; частицы — летящие + искры; статичный — без анимации. Verified-галочка — синий кружок + с белым чеком рядом с автором. + + + + Открой настройки игры → «Стартовый экран», впиши название места и автора, + выбери стиль «Частицы» — запусти игру и посмотри, как экран загрузки + встречает игрока. + + + ), + }, + }; /** Есть ли готовый текст урока для игры с таким id. */ From fbf7ef680b6f788399832fe77cebd3c0de926724 Mon Sep 17 00:00:00 2001 From: min Date: Mon, 8 Jun 2026 03:27:38 +0300 Subject: [PATCH 71/74] =?UTF-8?q?feat(studio):=20Team=20Create=20=E2=80=94?= =?UTF-8?q?=20=D1=81=D0=BE=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BD=D0=BE=D0=B5?= =?UTF-8?q?=20=D1=80=D0=B5=D0=B4=D0=B0=D0=BA=D1=82=D0=B8=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=B8=D0=B3=D1=80=D1=8B=20=D0=B2=20?= =?UTF-8?q?=D1=80=D0=B5=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE=D0=BC=20=D0=B2=D1=80?= =?UTF-8?q?=D0=B5=D0=BC=D0=B5=D0=BD=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit StudioCollab (Colyseus studio-room): синхрон операций примитивов/моделей/блоков, presence (курсоры/камера/выделение), soft-lock объектов, перехват менеджеров. CollabOverlay: DOM-курсоры соавторов + онлайн-аватарки + тосты. Кнопки «Скины»+«Пригласить» в TopRibbon вкладка «Игра». Гость-режим (скрыты Настройки/Сохранить/Опубликовать). Autosave только host. Вход по ?collab-токену. Co-Authored-By: Claude Opus 4.8 --- src/editor/KubikonEditor.jsx | 196 ++++++++++- src/editor/TopRibbon.jsx | 17 + src/editor/engine/CollabOverlay.js | 180 +++++++++++ src/editor/engine/StudioCollab.js | 502 +++++++++++++++++++++++++++++ 4 files changed, 886 insertions(+), 9 deletions(-) create mode 100644 src/editor/engine/CollabOverlay.js create mode 100644 src/editor/engine/StudioCollab.js diff --git a/src/editor/KubikonEditor.jsx b/src/editor/KubikonEditor.jsx index 7a5a7ff..789eda2 100644 --- a/src/editor/KubikonEditor.jsx +++ b/src/editor/KubikonEditor.jsx @@ -4,12 +4,15 @@ import { jwtDecode } from 'jwt-decode'; import { useAuth } from '../auth/AuthContext.jsx'; import { useSanctions } from '../auth/SanctionsContext.jsx'; import { BabylonScene } from './engine/BabylonScene'; +import { StudioCollab } from './engine/StudioCollab'; +import { CollabOverlay } from './engine/CollabOverlay'; import { BLOCK_TYPES, BLOCK_CATEGORIES, blockPreview, registerCustomBlockType } from './engine/BlockTypes'; import { MODEL_TYPES, MODEL_CATEGORIES, getModelType } from './engine/ModelTypes'; import { getKit } from './engine/GameplayKits'; import { PRIMITIVE_TYPES, PALETTE_PRIMITIVE_TYPES } from './engine/PrimitiveTypes'; import { getModelThumbnail } from './engine/ModelThumbnails'; import * as Kubikon3DApi from '../api/Kubikon3DService'; +import { REALTIME_HTTP } from '../api/API'; import GameSettingsModal from './GameSettingsModal'; import SkinManagerModal from './SkinManagerModal'; import PublishModal from './PublishModal'; @@ -464,6 +467,15 @@ const KubikonEditor = () => { const canvasRef = useRef(null); const sceneRef = useRef(null); + // Team Create — клиент совместного редактирования + presence-overlay. + const collabRef = useRef(null); + const collabOverlayRef = useRef(null); + const [collabActive, setCollabActive] = useState(false); // подключены к комнате + const [collabPeers, setCollabPeers] = useState(0); // сколько ДРУГИХ соавторов + // Роль в коллабе: 'owner' (владелец) | 'collab' (приглашённый по ссылке). + // Приглашённый — гость: не может менять настройки/сохранять/публиковать. + const [collabRole, setCollabRole] = useState('owner'); + const isInvitedGuest = collabActive && collabRole !== 'owner'; // Флаш pending-debounce ScriptEditor. Зовём перед каждым doSave/перед уходом // со страницы — иначе последние 600мс правок скрипта потеряются. const scriptEditorFlushRef = useRef(null); @@ -1010,6 +1022,14 @@ const KubikonEditor = () => { console.warn('[KubikonEditor] save: skip (load failed)'); return; } + // Team Create: в комнате совместного редактирования сохраняет ТОЛЬКО host + // (authoritative). Иначе два соавтора autosave'ят наперегонки → last-write-wins + // затирает чужие правки. Не-host просто не пишет в БД, его изменения уже + // у host через операции. + if (collabRef.current?.connected && !collabRef.current?.isHost) { + console.log('[KubikonEditor] save: skip (collab non-host, host saves)'); + return; + } const userId = getCurrentUserId(); if (!userId) { console.warn('[KubikonEditor] save: no userId'); @@ -1229,6 +1249,108 @@ const KubikonEditor = () => { }, AUTOSAVE_DEBOUNCE_MS); }, [doSave]); + /** + * Team Create: подключиться к комнате совместного редактирования. + * Зовётся ПОСЛЕ загрузки сцены (scene готова). projectIdNum — числовой id. + * Подключаемся если: владелец проекта (всегда) ИЛИ есть ?collab= в URL. + */ + const initCollab = useCallback(async (projectIdNum) => { + try { + if (!sceneRef.current || !projectIdNum) return; + if (collabRef.current) return; // уже подключены + const collabToken = new URLSearchParams(window.location.search).get('collab') || null; + const tokenRaw = localStorage.getItem('Authorization') + || localStorage.getItem('jwt') || ''; + if (!tokenRaw) return; + // Подключаемся только если есть инвайт ИЛИ владелец (бэкенд решит в onAuth). + // Если не владелец и нет инвайта — onAuth вернёт 403, ловим тихо. + const collab = new StudioCollab(sceneRef.current, { + projectId: projectIdNum, + token: tokenRaw, + collabToken, + callbacks: { + onConnected: ({ isHost, role }) => { + setCollabActive(true); + setCollabRole(role || (isHost ? 'owner' : 'collab')); + collabOverlayRef.current?.toast(isHost + ? 'Совместное редактирование включено. Пригласи друга кнопкой 👥' + : 'Ты подключился к совместному редактированию!'); + }, + onError: (msg) => { + // 403 (нет доступа) — норм для не-приглашённого; просто не коллабим. + console.warn('[collab] error:', msg); + }, + onLeft: () => { setCollabActive(false); }, + onPresenceChange: (list) => { + collabOverlayRef.current?.updatePresence(list); + setCollabPeers(Math.max(0, list.filter(c => !c.me).length)); + }, + onOpRejected: (m) => { + collabOverlayRef.current?.toast('Этот объект сейчас редактирует другой соавтор'); + }, + onChat: (m) => { /* чат соавторов — на этап 2 */ }, + // host отдаёт текущую сцену новому соавтору + onSnapshotRequest: (replyFn) => { + try { replyFn(sceneRef.current.serialize()); } catch (e) { /* ignore */ } + }, + // новый соавтор получил сцену от host — грузим + onRemoteSnapshot: async (state) => { + try { + if (state) { + await sceneRef.current.loadFromState(state); + dirtyRef.current = false; + } + } catch (e) { console.warn('[collab] snapshot load failed', e); } + }, + }, + }); + await collab.connect(); + collab.installInterceptors(); + collabRef.current = collab; + // presence-overlay + const ov = new CollabOverlay(sceneRef.current); + ov.mount(); + collabOverlayRef.current = ov; + // курсор-трекинг: шлём точку под мышью на сцене (raycast по pointermove) + _wireCursorTracking(sceneRef.current, collab); + } catch (e) { + // 403/нет доступа/realtime недоступен — работаем соло, не падаем. + console.warn('[collab] init skipped:', e?.message || e); + } + }, []); + + /** + * Team Create: «Пригласить» — запросить collab-токен у realtime, собрать + * ссылку studio.rublox.pro/edit/?collab= и скопировать в буфер. + */ + const handleInvite = useCallback(async () => { + try { + if (!/^\d+$/.test(id)) { alert('Сначала сохрани проект.'); return; } + const tokenRaw = localStorage.getItem('Authorization') || localStorage.getItem('jwt') || ''; + const base = (REALTIME_HTTP || '').replace(/\/$/, ''); + const res = await fetch(`${base}/studio-invite/${id}`, { + method: 'POST', + headers: { Authorization: tokenRaw, 'Content-Type': 'application/json' }, + }); + if (!res.ok) { + if (res.status === 403) { alert('Только автор проекта может приглашать соавторов.'); return; } + alert('Не удалось создать приглашение (' + res.status + ').'); + return; + } + const { token } = await res.json(); + const link = `${window.location.origin}/edit/${id}?collab=${token}`; + try { + await navigator.clipboard.writeText(link); + collabOverlayRef.current?.toast('Ссылка-приглашение скопирована! Отправь другу.'); + } catch (e) { + window.prompt('Скопируй ссылку-приглашение для друга:', link); + } + } catch (e) { + console.warn('[collab] invite failed', e); + alert('Не удалось создать приглашение. Realtime недоступен?'); + } + }, [id]); + // Инициализация Babylon + загрузка проекта (если редактируем существующий) useEffect(() => { // RACE FIX: пока isLoading=true (auth ещё грузится), компонент @@ -1608,6 +1730,9 @@ const KubikonEditor = () => { } finally { console.log(`[KubikonEditor] setSceneLoading(false) — load finally (id=${id})`); setSceneLoading(false); + // Team Create: после загрузки сцены — подключиться к комнате + // совместного редактирования (владелец или по ?collab-инвайту). + if (/^\d+$/.test(id)) initCollab(Number(id)); } })(); } else { @@ -1724,13 +1849,23 @@ const KubikonEditor = () => { clearTimeout(autoSaveTimerRef.current); autoSaveTimerRef.current = null; } + // Team Create: отключиться от комнаты + снять overlay. + try { + if (collabRef.current?.__cursorHandler && collabRef.current?.__cursorCanvas) { + collabRef.current.__cursorCanvas.removeEventListener('pointermove', collabRef.current.__cursorHandler); + } + collabRef.current?.dispose(); + collabOverlayRef.current?.dispose(); + } catch (e) { /* ignore */ } + collabRef.current = null; + collabOverlayRef.current = null; scene.dispose(); sceneRef.current = null; }; // isLoading в deps — без него эффект мог стрельнуть пока canvas // ещё не в DOM (isLoading=true → компонент рендерит null) и больше // не перезапускался → вечная "Загрузка проекта… 0%". - }, [isAuthenticated, isLoading, id, markDirty]); + }, [isAuthenticated, isLoading, id, markDirty, initCollab]); // beforeunload — браузерный диалог нельзя кастомизировать (API запрещает). // Auto-save срабатывает на каждое изменение через ~1.5 сек, поэтому риск @@ -1948,6 +2083,19 @@ const KubikonEditor = () => { {saveStatus === 'error' && <> Ошибка} {saveStatus === 'idle' && '—'} + {/* Гость-соавтор (приглашённый по ссылке) НЕ может менять + настройки/сохранять/публиковать — это делает только владелец. */} + {isInvitedGuest ? ( + + Совместное редактирование + + ) : ( + <> - + {/* Кнопка «Скины» переехала в TopRibbon → вкладка «Игра». */} + + )} {/* Кнопка баг-репорта в шапке — кликает по скрытой плавающей. */}