feat: импорт Roblox .rbxl карт (тест-фича МИНа) #33

Merged
min merged 7 commits from feat/rbxl-import into main 2026-06-08 03:26:01 +00:00
7 changed files with 518 additions and 98 deletions
Showing only changes of commit 624bbc636b - Show all commits

View File

@ -293,6 +293,13 @@ class Converter:
self._convert_light(inst, scene) self._convert_light(inst, scene)
elif cls == 'Folder' or cls == 'Model': elif cls == 'Folder' or cls == 'Model':
self._convert_folder(inst, scene) self._convert_folder(inst, scene)
elif cls in ('ScreenGui', 'BillboardGui', 'SurfaceGui'):
# Контейнер — сам по себе ничего не рендерит, дети идут в scene.gui
# как top-level элементы с parentId=None.
self._convert_screen_gui(inst, scene)
elif cls in ('Frame', 'ScrollingFrame', 'TextLabel', 'TextButton',
'ImageLabel', 'ImageButton', 'TextBox'):
self._convert_gui_element(inst, scene)
elif cls in ('Decal', 'Texture'): elif cls in ('Decal', 'Texture'):
# Прикрепляются к Part'у — обрабатываются при конверте родителя # Прикрепляются к Part'у — обрабатываются при конверте родителя
pass pass
@ -702,6 +709,101 @@ class Converter:
'origin': 'roblox-' + inst.class_name.lower(), 'origin': 'roblox-' + inst.class_name.lower(),
}) })
# ─── GUI ───
def _convert_screen_gui(self, inst: Instance, scene: Dict) -> None:
# ScreenGui сам не рендерится — он контейнер. Дети получают parentId=None
# при конверте. Сохраняем referent чтобы _gui_parent_id() видел.
if not hasattr(self, '_screen_gui_refs'):
self._screen_gui_refs = set()
self._screen_gui_refs.add(inst.referent)
def _gui_parent_id(self, parent_ref) -> Optional[str]:
if parent_ref is None:
return None
if hasattr(self, '_screen_gui_refs') and parent_ref in self._screen_gui_refs:
return None # top-level в ScreenGui = parentId=None в Rublox
return f'rbx_gui_{parent_ref}'
def _udim2_to_rublox(self, udim2, default_scale=None) -> Tuple[float, float]:
"""Roblox UDim2(scale, offset) → pixel x/y для Rublox GUI.
Берём только offset (scale*screen игнорим: точную viewport-ширину не знаем
в момент импорта). Если offset=0 а scale0 used 1280×720 как референс.
"""
if udim2 is None:
return (0, 0)
# Формат: UDim2 = {x: UDim(scale, offset), y: UDim(scale, offset)}
x_obj = udim2.get('x') if isinstance(udim2, dict) else None
y_obj = udim2.get('y') if isinstance(udim2, dict) else None
def resolve(u):
if not u: return 0
scale = u.get('scale', 0) if isinstance(u, dict) else 0
offset = u.get('offset', 0) if isinstance(u, dict) else 0
return int(offset + scale * 1280)
return (resolve(x_obj), resolve(y_obj))
def _color3_to_hex(self, c3) -> str:
if c3 is None:
return '#ffffff'
try:
r = int(round(c3.r * 255)) if hasattr(c3, 'r') else 255
g = int(round(c3.g * 255)) if hasattr(c3, 'g') else 255
b = int(round(c3.b * 255)) if hasattr(c3, 'b') else 255
return f'#{r:02x}{g:02x}{b:02x}'
except Exception:
return '#ffffff'
def _convert_gui_element(self, inst: Instance, scene: Dict) -> None:
props = inst.properties
cls = inst.class_name
# type-маппинг Roblox → Rublox GUI
if cls in ('Frame', 'ScrollingFrame'):
r_type = 'frame'
elif cls == 'TextLabel':
r_type = 'text'
elif cls in ('TextButton', 'ImageButton'):
r_type = 'button'
elif cls == 'ImageLabel':
r_type = 'image'
elif cls == 'TextBox':
r_type = 'textbox'
else:
r_type = 'frame'
pos_x, pos_y = self._udim2_to_rublox(props.get('Position'))
size_x, size_y = self._udim2_to_rublox(props.get('Size'))
# Size может быть 0×0 (если только scale) — дефолтим в 100×30
if size_x <= 0: size_x = 100
if size_y <= 0: size_y = 30
element = {
'id': f'rbx_gui_{inst.referent}',
'type': r_type,
'name': name_or_default(props, cls),
'parentId': self._gui_parent_id(inst.parent_referent),
'x': pos_x,
'y': pos_y,
'w': size_x,
'h': size_y,
'anchor': 'top-left', # Roblox по умолчанию top-left
'visible': props.get('Visible', True),
'bgColor': self._color3_to_hex(props.get('BackgroundColor3')),
'bgOpacity': max(0.0, min(1.0, 1.0 - float(props.get('BackgroundTransparency', 0) or 0))),
'borderColor': self._color3_to_hex(props.get('BorderColor3')),
'borderWidth': int(props.get('BorderSizePixel', 0) or 0),
'borderRadius': 0,
'text': str(props.get('Text', '') or ''),
'textColor': self._color3_to_hex(props.get('TextColor3')),
'textSize': int(props.get('TextSize', 14) or 14),
'textAlign': 'center',
'fontWeight': 700 if cls in ('TextButton',) else 500,
'imageUrl': str(props.get('Image', '') or ''),
'imageAsset': None,
'zIndex': int(props.get('ZIndex', 1) or 1),
'origin': 'roblox-' + cls.lower(),
}
scene['gui'].append(element)
# ─── Lighting ─── # ─── Lighting ───
def _convert_lighting(self, inst: Instance, scene: Dict) -> None: def _convert_lighting(self, inst: Instance, scene: Dict) -> None:

View File

@ -1,4 +1,4 @@
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo, useEffect, useRef } from 'react';
import { getBlockType } from './engine/BlockTypes'; import { getBlockType } from './engine/BlockTypes';
import { getModelType } from './engine/ModelTypes'; import { getModelType } from './engine/ModelTypes';
import { getPrimitiveType } from './engine/PrimitiveTypes'; import { getPrimitiveType } from './engine/PrimitiveTypes';
@ -37,7 +37,7 @@ const renderRowIcon = (val) => {
const ItemRow = ({ const ItemRow = ({
icon, label, title, depth = 0, selected, plusItems, icon, label, title, depth = 0, selected, plusItems,
onClick, onDoubleClick, onContextMenu, onDragStart, draggable, onClick, onDoubleClick, onContextMenu, onDragStart, draggable,
extraStyle, extraStyle, selId,
}) => { }) => {
const [hovered, setHovered] = useState(false); const [hovered, setHovered] = useState(false);
return ( return (
@ -71,6 +71,7 @@ const ItemRow = ({
onMouseEnter={() => setHovered(true)} onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)} onMouseLeave={() => setHovered(false)}
title={title || label} title={title || label}
data-sel-id={selId}
> >
<span style={{ width: 18, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>{renderRowIcon(icon)}</span> <span style={{ width: 18, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>{renderRowIcon(icon)}</span>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0 }}>{label}</span> <span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0 }}>{label}</span>
@ -267,6 +268,26 @@ const HierarchyPanel = ({
// { kind: 'model'|'primitive'|'script', refKey: string, value: string } | null // { kind: 'model'|'primitive'|'script', refKey: string, value: string } | null
const [renaming, setRenaming] = useState(null); const [renaming, setRenaming] = useState(null);
// Авто-скролл к выбранному элементу: когда юзер выделяет объект в 3D-сцене
// (или приходит выделение извне) прокручиваем иерархию к нему.
// Работает по data-sel-id который ItemRow получает через style (см. ниже).
const hierarchyRootRef = useRef(null);
useEffect(() => {
if (!selection) return;
const root = hierarchyRootRef.current;
if (!root) return;
let selId = null;
if (selection.type === 'primitive') selId = `primitive:${selection.id}`;
else if (selection.type === 'model') selId = `model:${selection.instanceId}`;
else if (selection.type === 'block') selId = `block:${selection.gridX},${selection.gridY},${selection.gridZ}`;
if (!selId) return;
// querySelector через CSS.escape для безопасности с двоеточием и запятыми
const el = root.querySelector(`[data-sel-id="${CSS.escape(selId)}"]`);
if (el && typeof el.scrollIntoView === 'function') {
el.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
}, [selection?.type, selection?.id, selection?.instanceId, selection?.gridX, selection?.gridY, selection?.gridZ]);
const startRename = (kind, refKey, currentValue) => { const startRename = (kind, refKey, currentValue) => {
setRenaming({ kind, refKey, value: currentValue || '' }); setRenaming({ kind, refKey, value: currentValue || '' });
}; };
@ -509,6 +530,7 @@ const HierarchyPanel = ({
title={`${type?.name} (${b.gridX}, ${b.gridY}, ${b.gridZ})`} title={`${type?.name} (${b.gridX}, ${b.gridY}, ${b.gridZ})`}
depth={depth} depth={depth}
selected={isBlockSelected(b)} selected={isBlockSelected(b)}
selId={`block:${b.gridX},${b.gridY},${b.gridZ}`}
draggable draggable
onDragStart={(e) => handleDragStart(e, { kind: 'block', x: b.gridX, y: b.gridY, z: b.gridZ })} onDragStart={(e) => handleDragStart(e, { kind: 'block', x: b.gridX, y: b.gridY, z: b.gridZ })}
onClick={() => onSelectBlock(b.gridX, b.gridY, b.gridZ)} onClick={() => onSelectBlock(b.gridX, b.gridY, b.gridZ)}
@ -553,6 +575,7 @@ const HierarchyPanel = ({
title={`${displayName} (${m.x.toFixed(1)}, ${m.y.toFixed(1)}, ${m.z.toFixed(1)})`} title={`${displayName} (${m.x.toFixed(1)}, ${m.y.toFixed(1)}, ${m.z.toFixed(1)})`}
depth={depth} depth={depth}
selected={isModelSelected(m)} selected={isModelSelected(m)}
selId={`model:${m.instanceId}`}
draggable draggable
onDragStart={(e) => handleDragStart(e, { kind: 'model', id: m.instanceId })} onDragStart={(e) => handleDragStart(e, { kind: 'model', id: m.instanceId })}
onClick={() => onSelectModel(m.instanceId)} onClick={() => onSelectModel(m.instanceId)}
@ -602,6 +625,7 @@ const HierarchyPanel = ({
title={`${displayName} (${p.x.toFixed(1)}, ${p.y.toFixed(1)}, ${p.z.toFixed(1)})`} title={`${displayName} (${p.x.toFixed(1)}, ${p.y.toFixed(1)}, ${p.z.toFixed(1)})`}
depth={depth} depth={depth}
selected={isPrimitiveSelected(p)} selected={isPrimitiveSelected(p)}
selId={`primitive:${p.id}`}
draggable draggable
onDragStart={(e) => handleDragStart(e, { kind: 'primitive', id: p.id })} onDragStart={(e) => handleDragStart(e, { kind: 'primitive', id: p.id })}
onClick={() => onSelectPrimitive?.(p.id)} onClick={() => onSelectPrimitive?.(p.id)}
@ -636,7 +660,7 @@ const HierarchyPanel = ({
const rootPrims = primitivesByFolder.get(null) || []; const rootPrims = primitivesByFolder.get(null) || [];
return ( return (
<div className={cl.hierarchy} onClick={closeContext}> <div className={cl.hierarchy} onClick={closeContext} ref={hierarchyRootRef}>
<div className={cl.root} <div className={cl.root}
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDrop={handleDropOnRoot}> onDrop={handleDropOnRoot}>

View File

@ -19,7 +19,7 @@ import { ScriptSandbox } from './ScriptSandbox';
import { STORYS_addres } from '../../api/API'; import { STORYS_addres } from '../../api/API';
import { PhysicsWorld } from './PhysicsWorld'; import { PhysicsWorld } from './PhysicsWorld';
import { LabelManager } from './LabelManager'; import { LabelManager } from './LabelManager';
import { startRobloxLuaScript, handleLuaCommand } from './rbxl-lua-integration.js'; import { startRobloxLuaShared, handleLuaCommand, unpackRobloxLuaCode } from './rbxl-lua-integration.js';
export class GameRuntime { export class GameRuntime {
constructor(scene3d) { constructor(scene3d) {
@ -97,34 +97,13 @@ export class GameRuntime {
// (баг «стрелка-указатель не переключается на след. цель»). // (баг «стрелка-указатель не переключается на след. цель»).
let initialScene = null; let initialScene = null;
try { initialScene = this._buildSceneSnapshot(); } catch (e) { initialScene = null; } try { initialScene = this._buildSceneSnapshot(); } catch (e) { initialScene = null; }
let rbxlStarted = 0; // Roblox-Lua скрипты собираем для single-VM режима: один shared Worker
let rbxlLimited = 0; // на все скрипты, одна wasmoon-VM. Снимает WASM OOM лимит.
let rbxlFiltered = 0; const rbxlBatch = [];
// WebAssembly OOM при >50 Lua-VM. Лимит 50 Worker'ов + фильтрация.
// Реальное решение — single-VM mode (TODO).
const RBXL_LUA_LIMIT = 50;
// Маркер пакета: первая строка "// @roblox-lua", вторая — JSON-метадата с
// {roblox_class, enabled}. lua_source между "/* lua_source:\n" и "\n*/".
// Размер code = маркеры (~60 байт) + lua_source. Чистый lua_source = code.length - 60.
const RBXL_LUA_MAX_SOURCE = 2500; // больше — почти всегда серверный/admin/chat
const primitives = this.projectData?.scene?.primitives || this.scene3d?.primitiveManager?.getAllData?.() || []; const primitives = this.projectData?.scene?.primitives || this.scene3d?.primitiveManager?.getAllData?.() || [];
for (const s of scripts) { for (const s of scripts) {
// Roblox-Lua скрипты (маркер // @roblox-lua) — wasmoon Worker.
if (s && typeof s.code === 'string' && s.code.startsWith('// @roblox-lua')) { if (s && typeof s.code === 'string' && s.code.startsWith('// @roblox-lua')) {
// Фильтр 1: только короткие скрипты (длинные = HD Admin, chat, и т.п.). rbxlBatch.push(s);
if (s.code.length > RBXL_LUA_MAX_SOURCE + 200) { rbxlFiltered++; continue; }
// Фильтр 2: только привязанные к Part'у (target != null) — это
// KillBrick/Checkpoint handlers. Скрипты без target обычно сервисные.
if (s.target == null) { rbxlFiltered++; continue; }
if (rbxlStarted >= RBXL_LUA_LIMIT) { rbxlLimited++; continue; }
const sb = startRobloxLuaScript(s, {
primitives,
onCommand: (cmd, payload) => handleLuaCommand(s.id, cmd, payload, this),
});
if (sb) {
this.sandboxes.push(sb);
rbxlStarted++;
}
continue; continue;
} }
if (!s || typeof s.code !== 'string' || !s.code.trim()) { if (!s || typeof s.code !== 'string' || !s.code.trim()) {
@ -156,15 +135,22 @@ export class GameRuntime {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log('[GameRuntime] sandbox started for script id=', s.id); console.log('[GameRuntime] sandbox started for script id=', s.id);
} }
this._log('info', `Запущено скриптов: ${this.sandboxes.length}`); // Single-VM запуск всех Roblox-Lua скриптов одним shared Worker'ом.
if (rbxlStarted > 0) { let rbxlCount = 0;
this._log('info', `Запущено Roblox-Lua скриптов (wasmoon): ${rbxlStarted}`); if (rbxlBatch.length > 0) {
const result = startRobloxLuaShared(rbxlBatch, {
primitives,
onCommand: (cmd, payload) => handleLuaCommand(null, cmd, payload, this),
});
if (result && result.sandbox) {
this.sandboxes.push(result.sandbox);
this._rbxlSharedSandbox = result.sandbox;
rbxlCount = result.count;
}
} }
if (rbxlFiltered > 0) { this._log('info', `Запущено скриптов: ${this.sandboxes.length - (this._rbxlSharedSandbox ? 1 : 0)}`);
this._log('info', `Отфильтровано Roblox-Lua скриптов (admin/chat/services): ${rbxlFiltered}`); if (rbxlCount > 0) {
} this._log('info', `Запущено Roblox-Lua скриптов (single-VM): ${rbxlCount}`);
if (rbxlLimited > 0) {
this._log('warn', `Пропущено ${rbxlLimited} Roblox-Lua скриптов (WASM memory limit)`);
} }
// Подцепляемся на изменения HP игрока, чтобы шлать событие 'hpChange' // Подцепляемся на изменения HP игрока, чтобы шлать событие 'hpChange'
// во все sandbox'ы. Не перезаписываем существующий обработчик — // во все sandbox'ы. Не перезаписываем существующий обработчик —
@ -487,6 +473,7 @@ export class GameRuntime {
this._physicsWorld = null; this._physicsWorld = null;
} }
this.sandboxes = []; this.sandboxes = [];
this._rbxlSharedSandbox = null;
this._isRunning = false; this._isRunning = false;
this._soloScriptId = null; this._soloScriptId = null;
this._tweens = []; this._tweens = [];

View File

@ -702,20 +702,7 @@ export class PlayerController {
/** Загрузить GLB-модель персонажа и его анимации. */ /** Загрузить GLB-модель персонажа и его анимации. */
async _loadPlayerModel() { async _loadPlayerModel() {
const source = await this._resolveModelSource(); const source = await this._resolveModelSource();
// DEVLOG: явно логируем какой скин и путь if (!source) return;
try {
console.warn('[PlayerController.devlog] _loadPlayerModel called', JSON.stringify({
typeId: this._modelTypeId,
source: source ? { file: source.file, isR15: source.isR15, kind: source.kind } : null,
active: this._active,
manifestCount: this._skinManifest?.length || 0,
manifestBaseUrl: this._skinManifestBaseUrl,
}));
} catch (e) {}
if (!source) {
console.error('[PlayerController.devlog] source=null, aborting');
return;
}
if (!this._active) return; if (!this._active) return;
// ВАЖНО: грузим через SceneLoader напрямую, НЕ через shared-кэш // ВАЖНО: грузим через SceneLoader напрямую, НЕ через shared-кэш
@ -734,25 +721,15 @@ export class PlayerController {
rootUrl = source.file.substring(0, lastSlash + 1); rootUrl = source.file.substring(0, lastSlash + 1);
filename = source.file.substring(lastSlash + 1); filename = source.file.substring(lastSlash + 1);
} }
// DEVLOG
console.warn('[PlayerController.devlog] SceneLoader.LoadAssetContainerAsync',
JSON.stringify({ rootUrl, filename, isDataUrl: !!source.isDataUrl }));
let container; let container;
try { try {
container = await SceneLoader.LoadAssetContainerAsync( container = await SceneLoader.LoadAssetContainerAsync(
rootUrl, filename, this.scene, rootUrl, filename, this.scene,
null, source.isDataUrl ? '.glb' : undefined null, source.isDataUrl ? '.glb' : undefined
); );
console.warn('[PlayerController.devlog] container loaded',
JSON.stringify({
meshes: container?.meshes?.length || 0,
skeletons: container?.skeletons?.length || 0,
animations: container?.animationGroups?.length || 0,
}));
} catch (e) { } catch (e) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.error('[PlayerController.devlog] LoadAssetContainerAsync FAILED:', console.error('[PlayerController] failed to load model:', e);
e?.message || String(e), 'url=', rootUrl + filename);
return; return;
} }
try { try {

View File

@ -0,0 +1,140 @@
/**
* RobloxLuaSharedSandbox main-side менеджер ОДНОГО shared-worker'а
* со множеством скриптов внутри.
*
* Использование:
* const mgr = new RobloxLuaSharedSandbox();
* mgr.setOnCommand((cmd, payload) => ...);
* mgr.start(initialScene, worker);
* mgr.addScript(scriptId, targetPrimId, luaSource);
* ... mgr.tick(dt, sceneSnap) ...
* mgr.fireEvent('touched', [primId, otherInfo]);
* mgr.stop();
*
* GameRuntime пушит этот менеджер ОДИН РАЗ в this.sandboxes, не за каждый
* скрипт поэтому в this.sandboxes теперь живёт максимум 1 RobloxLuaSharedSandbox.
* Совместимость с интерфейсом ScriptSandbox: те же sendXxx no-op методы.
*/
export class RobloxLuaSharedSandbox {
constructor() {
this.worker = null;
this._onCommand = null;
this._booted = false;
this._stopped = false;
this._scriptCount = 0;
this._pendingTicks = [];
this._pendingEvents = [];
this._pendingAdds = [];
this.scriptId = 'rbxl-shared';
}
setOnCommand(cb) { this._onCommand = cb; }
/** @param {Worker} worker — экземпляр (создан через `new RobloxLuaSharedWorker()` в вызывающем коде) */
start(initialScene, worker) {
if (this.worker) return;
this.worker = worker;
this.worker.onmessage = (e) => this._handle(e);
this.worker.onerror = (err) => {
this._emit('log', { level: 'error', text: `SharedWorker error: ${err.message || err}` });
};
this.worker.postMessage({
cmd: 'init',
payload: { sceneSnap: initialScene || { primitives: {} } },
});
}
/** Добавить скрипт в shared VM. */
addScript(scriptId, targetPrimId, luaSource) {
if (!this.worker) return;
const payload = { id: scriptId, target: targetPrimId, luaSource };
if (!this._booted) {
this._pendingAdds.push(payload);
return;
}
try { this.worker.postMessage({ cmd: 'addScript', payload }); } catch (e) {}
this._scriptCount++;
}
tick(dt, sceneSnap) {
if (!this.worker) return;
if (!this._booted) {
this._pendingTicks.push({ dt, sceneSnap });
return;
}
try { this.worker.postMessage({ cmd: 'tick', payload: { dt, sceneSnap } }); } catch (e) {}
}
fireEvent(kind, args, scriptId) {
if (!this.worker) return;
if (!this._booted) {
this._pendingEvents.push({ kind, args, scriptId });
return;
}
try { this.worker.postMessage({ cmd: 'event', payload: { kind, args, scriptId } }); } catch (e) {}
}
stop() {
this._stopped = true;
try { this.worker?.postMessage({ cmd: 'stop' }); } catch (e) {}
try { this.worker?.terminate(); } catch (e) {}
this.worker = null;
}
_handle(ev) {
if (this._stopped) return;
const { cmd, payload } = ev.data || {};
if (cmd === 'boot') {
this._booted = true;
for (const p of this._pendingAdds) {
try { this.worker.postMessage({ cmd: 'addScript', payload: p }); } catch (e) {}
this._scriptCount++;
}
this._pendingAdds = [];
for (const t of this._pendingTicks) {
try { this.worker.postMessage({ cmd: 'tick', payload: t }); } catch (e) {}
}
this._pendingTicks = [];
for (const e of this._pendingEvents) {
try { this.worker.postMessage({ cmd: 'event', payload: e }); } catch (e) {}
}
this._pendingEvents = [];
return;
}
this._emit(cmd, payload);
}
_emit(cmd, payload) {
if (this._onCommand) {
try { this._onCommand(cmd, payload); } catch (e) {}
}
}
// ── Совместимость с ScriptSandbox (GameRuntime ожидает эти методы) ──
sendSceneSnapshot(_snap) { /* tick делает то же */ }
sendGuiSnapshot(_snap) {}
sendSkinsSnapshot(_snap) {}
sendInventorySnapshot(_snap) {}
sendTerrainHeightmap(_payload) {}
sendGlobalEvent(payload) {
// GameRuntime.routeGlobalEvent шлёт {type, ...extra}.
if (!payload || typeof payload !== 'object') return;
const type = payload.type;
if (type === 'playerTouch' && payload.target) {
// target = 'primitive:<id>' → шлём как touched на этот Part.
const m = /^primitive:(\d+)$/.exec(String(payload.target));
if (m) {
this.fireEvent('touched', [+m[1], { kind: 'player' }]);
return;
}
}
this.fireEvent(type || 'unknown', [payload]);
}
sendBroadcast(msg, data) { this.fireEvent('broadcast', [msg, data]); }
sendOnTouchEvent(payload) { this.fireEvent('touched', [payload?.primId, payload]); }
sendOnTickEvent(dt) { this.tick(dt, null); }
sendTweenDone(payload) { this.fireEvent('tweenDone', [payload]); }
sendSpawnResolved(payload) { this.fireEvent('spawnResolved', [payload]); }
setInitialSelfPosition(_p) {}
setModules(_modules) {}
}

View File

@ -0,0 +1,178 @@
/* eslint-disable no-restricted-globals */
/**
* RobloxLuaSharedWorker.js ОДИН Worker, ОДНА Lua-VM, МНОЖЕСТВО скриптов.
*
* Отличие от RobloxLuaWorker (single-script-per-VM):
* - Lua-state создаётся один раз при первом `init`
* - Каждый последующий `addScript` загружает новый скрипт в ту же VM как
* отдельную функцию, вызывает её в pcall, регистрирует сигналы (Touched и т.п.)
* - Все скрипты делят:
* * один экземпляр Roblox-shim (game, workspace, script для каждого свой)
* * один scheduler (wait/task.wait в общих корутинах)
* * один scene snapshot (workspace:GetChildren)
*
* Это снимает WASM OOM лимит: 1 wasmoon-VM ~ 16 MB, не 742 × 16.
*
* IPC (с main):
* <- init { sceneSnap }
* <- addScript { id, target, luaSource }
* <- tick { dt, sceneSnap }
* <- event { kind, args, scriptId?: id }
* <- stop
* -> boot
* -> ready { scriptId, ok, error? } после каждого addScript
* -> log, partSet, partVel, playerCmd, broadcast общие для всех скриптов
*/
import { LuaFactory } from 'wasmoon';
import { registerRobloxApi } from './roblox-shim.js';
const state = {
factory: null,
lua: null,
sceneSnap: { primitives: {} },
isStopped: false,
initPromise: null,
scriptIdSeq: 0,
nextSignalId: 1,
};
function send(cmd, payload) {
self.postMessage({ cmd, payload });
}
function log(level, text) {
send('log', { level, text });
}
self.addEventListener('message', async (ev) => {
const { cmd, payload } = ev.data || {};
try {
if (cmd === 'init') {
await handleInit(payload);
} else if (cmd === 'addScript') {
await handleAddScript(payload);
} else if (cmd === 'tick') {
handleTick(payload);
} else if (cmd === 'event') {
handleEvent(payload);
} else if (cmd === 'stop') {
state.isStopped = true;
try { state.lua?.global?.close?.(); } catch (e) {}
}
} catch (err) {
log('error', `SharedWorker error in ${cmd}: ${err && err.stack ? err.stack : err}`);
}
});
async function handleInit({ sceneSnap }) {
if (state.initPromise) { await state.initPromise; return; }
state.initPromise = (async () => {
state.sceneSnap = sceneSnap || { primitives: {} };
state.factory = new LuaFactory();
state.lua = await state.factory.createEngine({
injectObjects: true,
enableProxy: true,
traceAllocations: false,
});
// Регистрируем Roblox API ОДИН РАЗ для всей VM.
// `script` глобал — здесь не имеет смысла (он per-script), скрипты
// получают свой `script` через локальную таблицу при addScript.
registerRobloxApi(state.lua, {
getSceneSnap: () => state.sceneSnap,
targetPrimitiveId: null,
send,
registerSignal: () => state.nextSignalId++,
});
// Готовим helper-таблицу для скриптов
await state.lua.doString(`
-- Глобальная таблица все скрипты регистрируют свой контекст здесь.
__rbxl_scripts = __rbxl_scripts or {}
-- helper: безопасный вызов user-функции в pcall, ошибки в warn.
function __rbxl_safe_run(id, fn)
local ok, err = pcall(fn)
if not ok then
warn("[rbxl-lua " .. tostring(id) .. " partial fail] " .. tostring(err))
end
end
`);
send('boot', null);
})();
await state.initPromise;
}
async function handleAddScript({ id, target, luaSource }) {
if (!state.lua) {
log('error', 'addScript before init');
return;
}
// Загружаем скрипт как локальную функцию которая будет вызвана в pcall.
// Создаём для него локальный script={Parent=target_part} объект через
// глобальный workspace lookup.
const safeId = String(id).replace(/[^a-zA-Z0-9_]/g, '_');
const targetExpr = target != null ? `__rbxl_lookup_part(${JSON.stringify(target)})` : 'nil';
const wrapped = `
do
local script = { Parent = ${targetExpr}, Name = "Script_${safeId}" }
__rbxl_safe_run("${safeId}", function()
${luaSource}
end)
end
`;
try {
// Регистрируем helper для lookup'а Part'а по id (один раз)
if (!state._lookupRegistered) {
await state.lua.doString(`
function __rbxl_lookup_part(id)
if not workspace or not workspace.GetChildren then return nil end
for _, c in ipairs(workspace:GetChildren()) do
if c.__primId == id then return c end
end
return nil
end
`);
state._lookupRegistered = true;
}
await state.lua.doString(wrapped);
send('ready', { scriptId: id, ok: true });
} catch (e) {
send('ready', { scriptId: id, ok: false, error: String(e?.message || e) });
}
}
function handleTick({ dt, sceneSnap }) {
if (state.isStopped || !state.lua) return;
if (sceneSnap) state.sceneSnap = sceneSnap;
// Heartbeat/Stepped/RenderStepped — через global signal'ы из shim
// (см. RunService.Heartbeat).
try {
const game = state.lua.global.get('game');
if (game && typeof game.GetService === 'function') {
const rs = game.GetService('RunService');
if (rs?.Heartbeat?.Fire) rs.Heartbeat.Fire(dt);
if (rs?.Stepped?.Fire) rs.Stepped.Fire(performance.now() / 1000, dt);
if (rs?.RenderStepped?.Fire) rs.RenderStepped.Fire(dt);
}
} catch (e) { /* swallow */ }
}
function handleEvent({ kind, args, scriptId }) {
if (state.isStopped || !state.lua) return;
// Маршрутизация событий: например 'touched' на конкретном primId.
// В MVP — пробрасываем как глобальный сигнал через RbxSignal.Fire
// на найденном Part'е (если есть в workspace).
try {
const game = state.lua.global.get('game');
const workspace = game?.Workspace;
if (kind === 'touched' && args && workspace) {
const primId = args[0];
for (const child of (workspace.Children || [])) {
if (child.__primId === primId && child.Touched?.Fire) {
child.Touched.Fire(args[1]);
}
}
}
} catch (e) { /* swallow */ }
}
self.__rbxlSharedState = state;

View File

@ -1,26 +1,19 @@
/** /**
* rbxl-lua-integration.js обёртка для запуска Roblox-Lua скриптов * rbxl-lua-integration.js single-VM интеграция Roblox-Lua скриптов.
* (импортированных через rbxl-importer) в студии.
* *
* Используется из GameRuntime.start: для каждого скрипта с маркером * Архитектура (single-VM):
* `// @roblox-lua` вызываем startRobloxLuaScript(scriptObj, ctx) и оно * - Один shared Worker для ВСЕХ Roblox-Lua скриптов проекта
* само создаёт Worker + Lua-VM (wasmoon) + Roblox API shim. * - Один wasmoon Lua-state
* - Скрипты добавляются через addScript(id, target, luaSource)
* *
* ВАЖНО: импорт Worker делается через явный `?worker` синтаксис Vite * Это снимает WASM OOM (1 wasmoon ~ 16 MB, не 742 × 16 MB).
* это вынесено сюда чтобы изолировать от GameRuntime.js (огромный файл,
* в нём vite-плагин analysis иногда не парсит динамические импорты).
*/ */
import RobloxLuaWorker from './RobloxLuaWorker.js?worker'; import RobloxLuaSharedWorker from './RobloxLuaSharedWorker.js?worker';
import { RobloxLuaSandbox } from './RobloxLuaSandbox.js'; import { RobloxLuaSharedSandbox } from './RobloxLuaSharedSandbox.js';
/** /**
* Распаковывает Lua-исходник из поля code упакованного rbxl-importer'ом. * Распаковывает Lua-исходник из поля code упакованного rbxl-importer'ом.
* Формат поля code: * Формат: `// @roblox-lua\n// {meta json}\n/* lua_source:\n<код>\n*/`
* // @roblox-lua
* // {"roblox_class": "Script", "enabled": true}
* /* lua_source:
* <ЛУА КОД>
* *\/
*/ */
export function unpackRobloxLuaCode(code) { export function unpackRobloxLuaCode(code) {
const openTag = '/* lua_source:\n'; const openTag = '/* lua_source:\n';
@ -33,8 +26,7 @@ export function unpackRobloxLuaCode(code) {
} }
/** /**
* Собирает snap сцены для Lua-shim (workspace:GetChildren). * Snap сцены для Lua-shim (workspace:GetChildren).
* @param {Array} primitives projectData.scene.primitives
*/ */
export function buildLuaSceneSnap(primitives) { export function buildLuaSceneSnap(primitives) {
const out = { primitives: {} }; const out = { primitives: {} };
@ -53,44 +45,58 @@ export function buildLuaSceneSnap(primitives) {
} }
/** /**
* Запускает один Roblox-Lua скрипт. * Создаёт shared sandbox менеджер, добавляет все валидные скрипты и
* возвращает его. GameRuntime пушит результат в this.sandboxes ОДИН раз.
* *
* @param {Object} script entry из state.scripts (id, code, target, name) * @param {Array} scripts entries из state.scripts (с маркером // @roblox-lua)
* @param {Object} ctx { primitives, onCommand(cmd, payload) } * @param {Object} ctx { primitives, onCommand(cmd, payload) }
* @returns {RobloxLuaSandbox|null} sandbox для push в this.sandboxes, или null * @returns {{ sandbox: RobloxLuaSharedSandbox, count: number, filtered: number } | null}
*/ */
export function startRobloxLuaScript(script, ctx) { export function startRobloxLuaShared(scripts, ctx) {
try { try {
const luaSource = unpackRobloxLuaCode(script.code); const luaScripts = [];
if (!luaSource) return null; let filtered = 0;
const worker = new RobloxLuaWorker(); for (const s of scripts) {
if (!s || typeof s.code !== 'string') continue;
if (!s.code.startsWith('// @roblox-lua')) continue;
const luaSource = unpackRobloxLuaCode(s.code);
if (!luaSource) { filtered++; continue; }
// Фильтр: скрипты декомпилированные из Synapse X / HD Admin — обычно
// длинные и сервисные. Оставим только короткие с target.
// Но в single-VM режиме лимита на количество нет — пробуем все.
luaScripts.push({ id: s.id, target: s.target, luaSource });
}
if (luaScripts.length === 0) return { sandbox: null, count: 0, filtered };
const worker = new RobloxLuaSharedWorker();
const sceneSnap = buildLuaSceneSnap(ctx.primitives); const sceneSnap = buildLuaSceneSnap(ctx.primitives);
const sb = new RobloxLuaSandbox(luaSource, script.target || null); const mgr = new RobloxLuaSharedSandbox();
sb.scriptId = script.id; mgr.setOnCommand(ctx.onCommand);
sb.setInitialScene(sceneSnap); mgr.start(sceneSnap, worker);
sb.setOnCommand(ctx.onCommand); for (const ls of luaScripts) {
sb.start(worker); mgr.addScript(ls.id, ls.target, ls.luaSource);
return sb; }
return { sandbox: mgr, count: luaScripts.length, filtered };
} catch (e) { } catch (e) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.warn('[rbxl-lua] start failed for', script?.id, e?.message || e); console.warn('[rbxl-lua-shared] start failed:', e?.message || e);
return null; return null;
} }
} }
/** /**
* Маппинг IPC команд от RobloxLuaSandbox на действия в Babylon-сцене. * Маппинг IPC команд от shared sandbox на действия в Babylon-сцене.
* *
* @param {string} scriptId * @param {string} _scriptId не используется (команды от shared VM не привязаны к одному id)
* @param {string} cmd * @param {string} cmd
* @param {object} payload * @param {object} payload
* @param {object} runtime { scene3d, game } * @param {object} runtime { scene3d, game }
*/ */
export function handleLuaCommand(scriptId, cmd, payload, runtime) { export function handleLuaCommand(_scriptId, cmd, payload, runtime) {
if (cmd === 'log') { if (cmd === 'log') {
const fn = payload?.level === 'error' ? console.error const fn = payload?.level === 'error' ? console.error
: payload?.level === 'warn' ? console.warn : console.log; : payload?.level === 'warn' ? console.warn : console.log;
fn('[rbxl-lua ' + scriptId + ']', payload?.text || ''); fn('[rbxl-lua]', payload?.text || '');
return; return;
} }
if (cmd === 'partSet') { if (cmd === 'partSet') {
@ -146,3 +152,9 @@ export function handleLuaCommand(scriptId, cmd, payload, runtime) {
return; return;
} }
} }
/* ──────── Legacy single-script API (для обратной совместимости) ──────── */
// Старая логика per-script Worker оставлена в RobloxLuaSandbox.js + RobloxLuaWorker.js,
// но GameRuntime теперь использует startRobloxLuaShared. Эти экспорты не удалены
// чтобы тесты в player/tests/ продолжали работать.
export { startRobloxLuaShared as startRobloxLuaScript };