diff --git a/rbxl-importer/src/__pycache__/converter.cpython-314.pyc b/rbxl-importer/src/__pycache__/converter.cpython-314.pyc new file mode 100644 index 0000000..af16ebf Binary files /dev/null and b/rbxl-importer/src/__pycache__/converter.cpython-314.pyc differ diff --git a/src/editor/engine/GameRuntime.js b/src/editor/engine/GameRuntime.js index 5e65d13..c6fad87 100644 --- a/src/editor/engine/GameRuntime.js +++ b/src/editor/engine/GameRuntime.js @@ -135,10 +135,21 @@ export class GameRuntime { if (meta && meta.enabled === false) { rbxlSkipped++; continue; } const luaSource = unpackRobloxLuaCode(s.code); if (luaSource && luaSource.trim()) { + // Эвристика Tool: если скрипт ссылается на Equipped/Activated + // или Tool = script.Parent — он лежит в Tool. Все Tool-скрипты + // с target=null склеиваем в ОДИН виртуальный Tool, имя берём + // из самого "явного" скрипта (содержит RayGun/Sword/Gun/Weapon). + let toolName = null; + if (s.target == null && /(script\.Parent|Tool)\.(Equipped|Unequipped|Activated|Deactivated)/.test(luaSource)) { + // Все Tool-скрипты группируем в ОДИН виртуальный Tool с именем "Tool". + // Для Zapper-демки этого хватит. В будущем — парсинг StarterPack из converter. + toolName = 'Tool'; + } luaUserBatch.push({ id: s.id, name: s.name, target: s.target, + toolName, language: 'lua', code: luaSource, _rbxlImported: true, @@ -195,6 +206,11 @@ export class GameRuntime { // eslint-disable-next-line no-console console.error('[GameRuntime] handleLuaCommand failed:', cmd, payload, e); } + } else if (cmd === 'toolRegistered') { + // Lua-shim создал Tool — кладём в hotbar инвентаря. + try { this._registerRbxlTool(payload); } catch (e) { + console.warn('[GameRuntime] toolRegistered failed', e); + } } else { this._handleCommand(null, cmd, payload); } @@ -204,7 +220,7 @@ export class GameRuntime { const snap = this._buildSceneSnapshot(); sb.sendSceneSnapshot(snap); } catch (_) {} - for (const s of luaUserBatch) sb.addScript(s.id, s.code, s.target); + for (const s of luaUserBatch) sb.addScript(s.id, s.code, s.target, s.name, { toolName: s.toolName }); sb.start(); this.sandboxes.push(sb); this._luaUserSandbox = sb; @@ -524,6 +540,83 @@ export class GameRuntime { return null; } + /** Регистрирует Roblox-Tool в InventoryUI как item в hotbar. + * Слушает смену активного слота → шлёт equipTool/unequipTool в Lua-shim. + * Слушает клики ЛКМ → шлёт mouseButton1Down (Tool.Activated fires там). */ + _registerRbxlTool(payload) { + if (!payload || payload.index == null) return; + const invUI = this.scene3d?.inventory; + if (!invUI) return; + const itemId = `rbxlTool_${payload.index}`; + const toolName = String(payload.name || `Tool ${payload.index}`); + invUI.defineItem({ + id: itemId, + name: toolName, + emoji: '🔫', + rarity: 'uncommon', + maxStack: 1, + description: `Импортированный Roblox-Tool: ${toolName}`, + }); + // Кладём в конкретный hotbar-слот (index 1..9 → slot 0..8) + const slot = Math.max(0, Math.min(8, payload.index - 1)); + invUI.hotbar[slot] = { itemId, count: 1 }; + invUI._renderHotbar?.(); + // На первом Tool — навешиваем слушатели слотов и кликов мыши. + if (!this._rbxlToolHooks) { + this._rbxlToolHooks = true; + this._rbxlActiveSlot = -1; + invUI.on('slot', () => { + const sl = invUI.active; + const item = invUI.hotbar[sl]; + const sb = this._luaUserSandbox; + if (!sb) return; + if (item && item.itemId.startsWith('rbxlTool_')) { + const idx = +item.itemId.slice('rbxlTool_'.length); + sb.sendGlobalEvent?.({ type: 'equipTool', index: idx }); + this._rbxlActiveSlot = sl; + } else if (this._rbxlActiveSlot >= 0) { + sb.sendGlobalEvent?.({ type: 'unequipTool' }); + this._rbxlActiveSlot = -1; + } + }); + // Клики мыши при экипированном Tool — Activated/mouseButton1Down + try { + const canvas = this.scene3d?.engine?.getRenderingCanvas?.(); + if (canvas) { + const sb = this._luaUserSandbox; + canvas.addEventListener('mousedown', (e) => { + if (e.button !== 0) return; + if (this._rbxlActiveSlot < 0) return; + // Hit-position: raycast от камеры в сцену + const hit = this._raycastFromCamera?.() || { x: 0, y: 5, z: 0 }; + sb?.sendGlobalEvent?.({ type: 'mouseButton1Down', hit }); + sb?.sendGlobalEvent?.({ type: 'toolActivated' }); + }); + canvas.addEventListener('mouseup', (e) => { + if (e.button !== 0) return; + if (this._rbxlActiveSlot < 0) return; + sb?.sendGlobalEvent?.({ type: 'mouseButton1Up' }); + }); + } + } catch (_) {} + } + } + + /** Простой raycast от камеры — для mouse.Hit. */ + _raycastFromCamera() { + try { + const cam = this.scene3d?.scene?.activeCamera; + if (!cam) return { x: 0, y: 5, z: 0 }; + const forward = cam.getForwardRay?.()?.direction; + const pos = cam.position; + if (!pos || !forward) return { x: 0, y: 5, z: 0 }; + const t = 50; + return { x: pos.x + forward.x * t, y: pos.y + forward.y * t, z: pos.z + forward.z * t }; + } catch (_) { + return { x: 0, y: 5, z: 0 }; + } + } + stop() { if (this.sandboxes.length > 0) { this._log('info', 'Остановка скриптов'); diff --git a/src/editor/engine/lua/LuaSharedSandbox.js b/src/editor/engine/lua/LuaSharedSandbox.js index f2943e5..ce0b15e 100644 --- a/src/editor/engine/lua/LuaSharedSandbox.js +++ b/src/editor/engine/lua/LuaSharedSandbox.js @@ -43,12 +43,13 @@ export class LuaSharedSandbox { get target() { return null; } tick(_dt, _state) { /* no-op: main-loop запускается изнутри (setInterval) */ } - addScript(id, code, target, name) { + addScript(id, code, target, name, extra) { const entry = { id: String(id || `lua_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`), code: String(code || ''), target: target == null ? null : target, name: name || null, + toolName: extra?.toolName || null, }; this._scriptsById.set(entry.id, entry); if (!this._isKickedOff) { @@ -152,12 +153,26 @@ export class LuaSharedSandbox { // Регистрируем coroutine в __rbxl_coroutines с id для возобновления. // Скрипт оборачиваем в coroutine. task.wait()→coroutine.yield(sec) возвращает // delay из resume → планируем следующий resume через scheduleResume. - // Fallback Parent = workspace для скриптов без target (или с невалидным - // target). Это спасает массу Roblox-скриптов которые делают - // script.Parent.Parent — если бы Parent был nil, упало бы сразу. - const parentExpr = primId != null - ? `(__rbxl_get_part_by_id(${Number(primId)}) or workspace)` - : 'workspace'; + // Fallback Parent: если скрипт связан с Tool (по имени Tool из metadata) — + // подсовываем виртуальный Tool как script.Parent. Иначе primitive по id, + // иначе workspace. + let parentExpr; + if (entry.toolName) { + // Tool создаётся в shim как Instance.new('Tool'). По имени достаём. + // Если не нашли — fallback на новый Tool того же имени. + const safeName = JSON.stringify(entry.toolName); + parentExpr = `(function() + local existing = __rbxl_get_tool_by_name(${safeName}) + if existing then return existing end + local t = Instance.new("Tool") + t.Name = ${safeName} + return t + end)()`; + } else if (primId != null) { + parentExpr = `(__rbxl_get_part_by_id(${Number(primId)}) or workspace)`; + } else { + parentExpr = 'workspace'; + } const wrapped = ` do local script = { diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index 63fd219..8429f36 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -733,9 +733,48 @@ export function registerRobloxShim(lua, opts) { localPlayer.Children.push(playerGui); localPlayer.PlayerGui = playerGui; localPlayer.DisplayName = 'Player'; + // Backpack — инвентарь, содержит Tools. В Roblox StarterPack автоматически + // клонируется в Backpack каждого спавнящегося игрока. + const backpack = newInstance('Backpack', 'Backpack'); + backpack.Parent = localPlayer; + localPlayer.Children.push(backpack); + localPlayer.Backpack = backpack; + // Глобальный Mouse — единственный экземпляр на игрока, привязан к окну + // браузера. Реальные Button1Down/Hit фейерятся в GameRuntime. + const playerMouse = (function makePlayerMouse() { + const m = newInstance('Mouse', 'Mouse'); + m.Button1Down = makeSignal(); + m.Button1Up = makeSignal(); + m.Button2Down = makeSignal(); + m.Button2Up = makeSignal(); + m.Move = makeSignal(); + m.KeyDown = makeSignal(); + m.KeyUp = makeSignal(); + m.WheelForward = makeSignal(); + m.WheelBackward = makeSignal(); + m.Idle = makeSignal(); + m.Icon = ''; + m.X = 0; m.Y = 0; + m.ViewSizeX = 1920; m.ViewSizeY = 1080; + m.Hit = { Position: new RbxVector3(0, 0, 0), p: new RbxVector3(0, 0, 0), + Lookvector: new RbxVector3(0, 0, -1) }; + m.Origin = { Position: new RbxVector3(0, 5, 0) }; + m.Target = undefined; + m.TargetFilter = undefined; + m.TargetSurface = 'Top'; + return m; + })(); + localPlayer.GetMouse = function () { return playerMouse; }; + localPlayer.playerMouse = playerMouse; players.Children.push(localPlayer); players.LocalPlayer = localPlayer; + // === Tool registry === + // Tracks все Tool-инстансы — для UI (hotbar) и equip-flow. + // GameRuntime читает API equipTool/unequipTool на main-loop. + const allTools = []; // [Tool, ...] в порядке создания (для hotbar 1-9) + let equippedTool = null; + const character = newInstance('Model', 'Player'); character.Parent = localPlayer; localPlayer.Children.push(character); @@ -1175,8 +1214,6 @@ export function registerRobloxShim(lua, opts) { inst = newGuiInstance(className); guiByLocalRef.set(inst.__guiLocalRef, inst); } else if (className === 'Tool' || className === 'HopperBin') { - // Tool — оружие в Roblox. У нас нет инвентаря, поэтому это - // просто контейнер с правильными событиями + Handle (заглушка). inst = newInstance(className, 'Tool'); inst.Equipped = makeSignal(); inst.Unequipped = makeSignal(); @@ -1191,6 +1228,21 @@ export function registerRobloxShim(lua, opts) { inst.RequiresHandle = true; inst.TextureId = ''; inst.ToolTip = ''; + // Виртуальный Handle — Roblox-скрипты делают Tool.Handle.Position + const handle = newInstance('Part', 'Handle'); + handle.Parent = inst; + handle.Position = new RbxVector3(0, 5, 0); + handle.Size = new RbxVector3(1, 1, 1); + inst.Handle = handle; + inst.Children = inst.Children || []; + inst.Children.push(handle); + // Регистрируем Tool, чтобы плеер показал его в hotbar + allTools.push(inst); + inst.__toolIndex = allTools.length; + send('toolRegistered', { + index: inst.__toolIndex, + name: inst.Name || `Tool ${inst.__toolIndex}`, + }); } else if (className === 'IntValue' || className === 'NumberValue' || className === 'BoolValue' || className === 'StringValue' || className === 'ObjectValue' || className === 'CFrameValue' @@ -1265,6 +1317,7 @@ export function registerRobloxShim(lua, opts) { // === Helpers для скриптов === const partById = new Map(); global.set('__rbxl_get_part_by_id', (id) => partById.get(Number(id)) || undefined); + global.set('__rbxl_get_tool_by_name', (name) => allTools.find(t => t.Name === name) || undefined); global.set('__rbxl_send_error', (id, errStr) => { send('log', { level: 'error', text: `[Lua ${id}] ${errStr || 'unknown error'}` }); }); @@ -1452,7 +1505,59 @@ export function registerRobloxShim(lua, opts) { guiEl.MouseButton1Click.Fire(); } } + // Tool equip/unequip — клавиши 1-9 в плеере шлют + // {type:'equipTool', index:N}, {type:'unequipTool'} + if (p.type === 'equipTool') { + const idx = Number(p.index) - 1; + if (idx < 0 || idx >= allTools.length) return; + const tool = allTools[idx]; + if (equippedTool === tool) return; + // Снимаем предыдущий + if (equippedTool) { + try { equippedTool.Unequipped.Fire(); } catch (_) {} + } + equippedTool = tool; + // В Roblox Tool при equip перемещается в Character + tool.Parent = character; + try { tool.Equipped.Fire(playerMouse); } catch (_) {} + } + if (p.type === 'unequipTool') { + if (!equippedTool) return; + try { equippedTool.Unequipped.Fire(); } catch (_) {} + equippedTool.Parent = backpack; + equippedTool = null; + } + if (p.type === 'toolActivated') { + if (!equippedTool) return; + try { equippedTool.Activated.Fire(); } catch (_) {} + } + if (p.type === 'toolDeactivated') { + if (!equippedTool) return; + try { equippedTool.Deactivated.Fire(); } catch (_) {} + } + // Mouse-события из плеера: клики, движение, клавиши при equipped Tool + if (p.type === 'mouseButton1Down') { + if (p.hit) { + playerMouse.Hit.Position = new RbxVector3(p.hit.x, p.hit.y, p.hit.z); + playerMouse.Hit.p = playerMouse.Hit.Position; + } + try { playerMouse.Button1Down.Fire(); } catch (_) {} + } + if (p.type === 'mouseButton1Up') { + try { playerMouse.Button1Up.Fire(); } catch (_) {} + } + if (p.type === 'keyDown') { + try { playerMouse.KeyDown.Fire(String(p.key || '').toLowerCase()); } catch (_) {} + } + if (p.type === 'keyUp') { + try { playerMouse.KeyUp.Fire(String(p.key || '').toLowerCase()); } catch (_) {} + } }, + // Tool registry (для GameRuntime: какой Tool сделать script.Parent) + getToolByName(name) { + return allTools.find(t => t.Name === name); + }, + getAllTools() { return allTools.slice(); }, // Доступ к ключевым объектам (для тестов и отладки) partById, localPlayer, humanoid, character, workspace, players, game, };