feat(rbxl): XML-������ ������ .rbxl + Day/Night + Tool/Mouse/Backpack flow #38

Closed
min wants to merge 39 commits from feat/rbxl-xml-parser-import into main
4 changed files with 223 additions and 10 deletions
Showing only changes of commit 1080c18ae0 - Show all commits

View File

@ -135,10 +135,21 @@ export class GameRuntime {
if (meta && meta.enabled === false) { rbxlSkipped++; continue; } if (meta && meta.enabled === false) { rbxlSkipped++; continue; }
const luaSource = unpackRobloxLuaCode(s.code); const luaSource = unpackRobloxLuaCode(s.code);
if (luaSource && luaSource.trim()) { 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({ luaUserBatch.push({
id: s.id, id: s.id,
name: s.name, name: s.name,
target: s.target, target: s.target,
toolName,
language: 'lua', language: 'lua',
code: luaSource, code: luaSource,
_rbxlImported: true, _rbxlImported: true,
@ -195,6 +206,11 @@ export class GameRuntime {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.error('[GameRuntime] handleLuaCommand failed:', cmd, payload, e); 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 { } else {
this._handleCommand(null, cmd, payload); this._handleCommand(null, cmd, payload);
} }
@ -204,7 +220,7 @@ export class GameRuntime {
const snap = this._buildSceneSnapshot(); const snap = this._buildSceneSnapshot();
sb.sendSceneSnapshot(snap); sb.sendSceneSnapshot(snap);
} catch (_) {} } 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(); sb.start();
this.sandboxes.push(sb); this.sandboxes.push(sb);
this._luaUserSandbox = sb; this._luaUserSandbox = sb;
@ -524,6 +540,83 @@ export class GameRuntime {
return null; 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() { stop() {
if (this.sandboxes.length > 0) { if (this.sandboxes.length > 0) {
this._log('info', 'Остановка скриптов'); this._log('info', 'Остановка скриптов');

View File

@ -43,12 +43,13 @@ export class LuaSharedSandbox {
get target() { return null; } get target() { return null; }
tick(_dt, _state) { /* no-op: main-loop запускается изнутри (setInterval) */ } tick(_dt, _state) { /* no-op: main-loop запускается изнутри (setInterval) */ }
addScript(id, code, target, name) { addScript(id, code, target, name, extra) {
const entry = { const entry = {
id: String(id || `lua_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`), id: String(id || `lua_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`),
code: String(code || ''), code: String(code || ''),
target: target == null ? null : target, target: target == null ? null : target,
name: name || null, name: name || null,
toolName: extra?.toolName || null,
}; };
this._scriptsById.set(entry.id, entry); this._scriptsById.set(entry.id, entry);
if (!this._isKickedOff) { if (!this._isKickedOff) {
@ -152,12 +153,26 @@ export class LuaSharedSandbox {
// Регистрируем coroutine в __rbxl_coroutines с id для возобновления. // Регистрируем coroutine в __rbxl_coroutines с id для возобновления.
// Скрипт оборачиваем в coroutine. task.wait()→coroutine.yield(sec) возвращает // Скрипт оборачиваем в coroutine. task.wait()→coroutine.yield(sec) возвращает
// delay из resume → планируем следующий resume через scheduleResume. // delay из resume → планируем следующий resume через scheduleResume.
// Fallback Parent = workspace для скриптов без target (или с невалидным // Fallback Parent: если скрипт связан с Tool (по имени Tool из metadata) —
// target). Это спасает массу Roblox-скриптов которые делают // подсовываем виртуальный Tool как script.Parent. Иначе primitive по id,
// script.Parent.Parent — если бы Parent был nil, упало бы сразу. // иначе workspace.
const parentExpr = primId != null let parentExpr;
? `(__rbxl_get_part_by_id(${Number(primId)}) or workspace)` if (entry.toolName) {
: 'workspace'; // 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 = ` const wrapped = `
do do
local script = { local script = {

View File

@ -733,9 +733,48 @@ export function registerRobloxShim(lua, opts) {
localPlayer.Children.push(playerGui); localPlayer.Children.push(playerGui);
localPlayer.PlayerGui = playerGui; localPlayer.PlayerGui = playerGui;
localPlayer.DisplayName = 'Player'; 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.Children.push(localPlayer);
players.LocalPlayer = 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'); const character = newInstance('Model', 'Player');
character.Parent = localPlayer; character.Parent = localPlayer;
localPlayer.Children.push(character); localPlayer.Children.push(character);
@ -1175,8 +1214,6 @@ export function registerRobloxShim(lua, opts) {
inst = newGuiInstance(className); inst = newGuiInstance(className);
guiByLocalRef.set(inst.__guiLocalRef, inst); guiByLocalRef.set(inst.__guiLocalRef, inst);
} else if (className === 'Tool' || className === 'HopperBin') { } else if (className === 'Tool' || className === 'HopperBin') {
// Tool — оружие в Roblox. У нас нет инвентаря, поэтому это
// просто контейнер с правильными событиями + Handle (заглушка).
inst = newInstance(className, 'Tool'); inst = newInstance(className, 'Tool');
inst.Equipped = makeSignal(); inst.Equipped = makeSignal();
inst.Unequipped = makeSignal(); inst.Unequipped = makeSignal();
@ -1191,6 +1228,21 @@ export function registerRobloxShim(lua, opts) {
inst.RequiresHandle = true; inst.RequiresHandle = true;
inst.TextureId = ''; inst.TextureId = '';
inst.ToolTip = ''; 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' } else if (className === 'IntValue' || className === 'NumberValue'
|| className === 'BoolValue' || className === 'StringValue' || className === 'BoolValue' || className === 'StringValue'
|| className === 'ObjectValue' || className === 'CFrameValue' || className === 'ObjectValue' || className === 'CFrameValue'
@ -1265,6 +1317,7 @@ export function registerRobloxShim(lua, opts) {
// === Helpers для скриптов === // === Helpers для скриптов ===
const partById = new Map(); const partById = new Map();
global.set('__rbxl_get_part_by_id', (id) => partById.get(Number(id)) || undefined); 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) => { global.set('__rbxl_send_error', (id, errStr) => {
send('log', { level: 'error', text: `[Lua ${id}] ${errStr || 'unknown error'}` }); send('log', { level: 'error', text: `[Lua ${id}] ${errStr || 'unknown error'}` });
}); });
@ -1452,7 +1505,59 @@ export function registerRobloxShim(lua, opts) {
guiEl.MouseButton1Click.Fire(); 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, partById, localPlayer, humanoid, character, workspace, players, game,
}; };