feat(rbxl-import): single-VM, Touched, scroll-to-selected, GUI
Some checks failed
Some checks failed
Все 5 задач итерации:
1. Single-VM mode (RobloxLuaSharedWorker/Sandbox):
- один Worker, одна wasmoon-VM на ВСЕ скрипты проекта
- addScript() для каждого, общий tick/event broadcast
- снимает WASM OOM (1 VM 16MB вместо 742 × 16MB)
- убран per-script лимит 50, теперь все 742 загружаются
2. Touched events:
- sendGlobalEvent в shared sandbox распознаёт playerTouch
и пересылает в Worker как 'touched' с primId
- Worker находит Part по __primId в workspace и Fire'ит
его Touched сигнал — Lua-обработчики работают
3. Click в иерархии → scroll-to-selected:
- useEffect в HierarchyPanel ловит изменение selection
и scrollIntoView для нужного ItemRow
- data-sel-id атрибут на primitive/model/block строках
4. GUI Roblox в конвертере:
- ScreenGui/Frame/TextLabel/TextButton/ImageLabel/TextBox →
scene.gui c полным набором свойств (UDim2→pixel, Color3→hex,
BackgroundTransparency→bgOpacity, parentId)
5. Чистка:
- удалены debug-console.warn из PlayerController._loadPlayerModel
(убирает spam '[PlayerController.devlog]' в consoles)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
412bb2fad9
commit
624bbc636b
@ -293,6 +293,13 @@ class Converter:
|
||||
self._convert_light(inst, scene)
|
||||
elif cls == 'Folder' or cls == 'Model':
|
||||
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'):
|
||||
# Прикрепляются к Part'у — обрабатываются при конверте родителя
|
||||
pass
|
||||
@ -702,6 +709,101 @@ class Converter:
|
||||
'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 а scale≠0 — 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 ───
|
||||
|
||||
def _convert_lighting(self, inst: Instance, scene: Dict) -> None:
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import React, { useState, useMemo, useEffect, useRef } from 'react';
|
||||
import { getBlockType } from './engine/BlockTypes';
|
||||
import { getModelType } from './engine/ModelTypes';
|
||||
import { getPrimitiveType } from './engine/PrimitiveTypes';
|
||||
@ -37,7 +37,7 @@ const renderRowIcon = (val) => {
|
||||
const ItemRow = ({
|
||||
icon, label, title, depth = 0, selected, plusItems,
|
||||
onClick, onDoubleClick, onContextMenu, onDragStart, draggable,
|
||||
extraStyle,
|
||||
extraStyle, selId,
|
||||
}) => {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
return (
|
||||
@ -71,6 +71,7 @@ const ItemRow = ({
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
title={title || label}
|
||||
data-sel-id={selId}
|
||||
>
|
||||
<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>
|
||||
@ -267,6 +268,26 @@ const HierarchyPanel = ({
|
||||
// { kind: 'model'|'primitive'|'script', refKey: string, value: string } | 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) => {
|
||||
setRenaming({ kind, refKey, value: currentValue || '' });
|
||||
};
|
||||
@ -509,6 +530,7 @@ const HierarchyPanel = ({
|
||||
title={`${type?.name} (${b.gridX}, ${b.gridY}, ${b.gridZ})`}
|
||||
depth={depth}
|
||||
selected={isBlockSelected(b)}
|
||||
selId={`block:${b.gridX},${b.gridY},${b.gridZ}`}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, { kind: 'block', x: b.gridX, y: b.gridY, z: 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)})`}
|
||||
depth={depth}
|
||||
selected={isModelSelected(m)}
|
||||
selId={`model:${m.instanceId}`}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, { kind: 'model', id: 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)})`}
|
||||
depth={depth}
|
||||
selected={isPrimitiveSelected(p)}
|
||||
selId={`primitive:${p.id}`}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, { kind: 'primitive', id: p.id })}
|
||||
onClick={() => onSelectPrimitive?.(p.id)}
|
||||
@ -636,7 +660,7 @@ const HierarchyPanel = ({
|
||||
const rootPrims = primitivesByFolder.get(null) || [];
|
||||
|
||||
return (
|
||||
<div className={cl.hierarchy} onClick={closeContext}>
|
||||
<div className={cl.hierarchy} onClick={closeContext} ref={hierarchyRootRef}>
|
||||
<div className={cl.root}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDropOnRoot}>
|
||||
|
||||
@ -19,7 +19,7 @@ import { ScriptSandbox } from './ScriptSandbox';
|
||||
import { STORYS_addres } from '../../api/API';
|
||||
import { PhysicsWorld } from './PhysicsWorld';
|
||||
import { LabelManager } from './LabelManager';
|
||||
import { startRobloxLuaScript, handleLuaCommand } from './rbxl-lua-integration.js';
|
||||
import { startRobloxLuaShared, handleLuaCommand, unpackRobloxLuaCode } from './rbxl-lua-integration.js';
|
||||
|
||||
export class GameRuntime {
|
||||
constructor(scene3d) {
|
||||
@ -97,34 +97,13 @@ export class GameRuntime {
|
||||
// (баг «стрелка-указатель не переключается на след. цель»).
|
||||
let initialScene = null;
|
||||
try { initialScene = this._buildSceneSnapshot(); } catch (e) { initialScene = null; }
|
||||
let rbxlStarted = 0;
|
||||
let rbxlLimited = 0;
|
||||
let rbxlFiltered = 0;
|
||||
// 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
|
||||
// Roblox-Lua скрипты собираем для single-VM режима: один shared Worker
|
||||
// на все скрипты, одна wasmoon-VM. Снимает WASM OOM лимит.
|
||||
const rbxlBatch = [];
|
||||
const primitives = this.projectData?.scene?.primitives || this.scene3d?.primitiveManager?.getAllData?.() || [];
|
||||
for (const s of scripts) {
|
||||
// Roblox-Lua скрипты (маркер // @roblox-lua) — wasmoon Worker.
|
||||
if (s && typeof s.code === 'string' && s.code.startsWith('// @roblox-lua')) {
|
||||
// Фильтр 1: только короткие скрипты (длинные = HD Admin, chat, и т.п.).
|
||||
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++;
|
||||
}
|
||||
rbxlBatch.push(s);
|
||||
continue;
|
||||
}
|
||||
if (!s || typeof s.code !== 'string' || !s.code.trim()) {
|
||||
@ -156,15 +135,22 @@ export class GameRuntime {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[GameRuntime] sandbox started for script id=', s.id);
|
||||
}
|
||||
this._log('info', `Запущено скриптов: ${this.sandboxes.length}`);
|
||||
if (rbxlStarted > 0) {
|
||||
this._log('info', `Запущено Roblox-Lua скриптов (wasmoon): ${rbxlStarted}`);
|
||||
// Single-VM запуск всех Roblox-Lua скриптов одним shared Worker'ом.
|
||||
let rbxlCount = 0;
|
||||
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', `Отфильтровано Roblox-Lua скриптов (admin/chat/services): ${rbxlFiltered}`);
|
||||
}
|
||||
if (rbxlLimited > 0) {
|
||||
this._log('warn', `Пропущено ${rbxlLimited} Roblox-Lua скриптов (WASM memory limit)`);
|
||||
this._log('info', `Запущено скриптов: ${this.sandboxes.length - (this._rbxlSharedSandbox ? 1 : 0)}`);
|
||||
if (rbxlCount > 0) {
|
||||
this._log('info', `Запущено Roblox-Lua скриптов (single-VM): ${rbxlCount}`);
|
||||
}
|
||||
// Подцепляемся на изменения HP игрока, чтобы шлать событие 'hpChange'
|
||||
// во все sandbox'ы. Не перезаписываем существующий обработчик —
|
||||
@ -487,6 +473,7 @@ export class GameRuntime {
|
||||
this._physicsWorld = null;
|
||||
}
|
||||
this.sandboxes = [];
|
||||
this._rbxlSharedSandbox = null;
|
||||
this._isRunning = false;
|
||||
this._soloScriptId = null;
|
||||
this._tweens = [];
|
||||
|
||||
@ -702,20 +702,7 @@ export class PlayerController {
|
||||
/** Загрузить GLB-модель персонажа и его анимации. */
|
||||
async _loadPlayerModel() {
|
||||
const source = await this._resolveModelSource();
|
||||
// DEVLOG: явно логируем какой скин и путь
|
||||
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 (!source) return;
|
||||
if (!this._active) return;
|
||||
|
||||
// ВАЖНО: грузим через SceneLoader напрямую, НЕ через shared-кэш
|
||||
@ -734,25 +721,15 @@ export class PlayerController {
|
||||
rootUrl = source.file.substring(0, lastSlash + 1);
|
||||
filename = source.file.substring(lastSlash + 1);
|
||||
}
|
||||
// DEVLOG
|
||||
console.warn('[PlayerController.devlog] SceneLoader.LoadAssetContainerAsync',
|
||||
JSON.stringify({ rootUrl, filename, isDataUrl: !!source.isDataUrl }));
|
||||
let container;
|
||||
try {
|
||||
container = await SceneLoader.LoadAssetContainerAsync(
|
||||
rootUrl, filename, this.scene,
|
||||
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) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[PlayerController.devlog] LoadAssetContainerAsync FAILED:',
|
||||
e?.message || String(e), 'url=', rootUrl + filename);
|
||||
console.error('[PlayerController] failed to load model:', e);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
|
||||
140
src/editor/engine/RobloxLuaSharedSandbox.js
Normal file
140
src/editor/engine/RobloxLuaSharedSandbox.js
Normal 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) {}
|
||||
}
|
||||
178
src/editor/engine/RobloxLuaSharedWorker.js
Normal file
178
src/editor/engine/RobloxLuaSharedWorker.js
Normal 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;
|
||||
@ -1,26 +1,19 @@
|
||||
/**
|
||||
* rbxl-lua-integration.js — обёртка для запуска Roblox-Lua скриптов
|
||||
* (импортированных через rbxl-importer) в студии.
|
||||
* rbxl-lua-integration.js — single-VM интеграция Roblox-Lua скриптов.
|
||||
*
|
||||
* Используется из GameRuntime.start: для каждого скрипта с маркером
|
||||
* `// @roblox-lua` вызываем startRobloxLuaScript(scriptObj, ctx) и оно
|
||||
* само создаёт Worker + Lua-VM (wasmoon) + Roblox API shim.
|
||||
* Архитектура (single-VM):
|
||||
* - Один shared Worker для ВСЕХ Roblox-Lua скриптов проекта
|
||||
* - Один wasmoon Lua-state
|
||||
* - Скрипты добавляются через addScript(id, target, luaSource)
|
||||
*
|
||||
* ВАЖНО: импорт Worker делается через явный `?worker` синтаксис Vite —
|
||||
* это вынесено сюда чтобы изолировать от GameRuntime.js (огромный файл,
|
||||
* в нём vite-плагин analysis иногда не парсит динамические импорты).
|
||||
* Это снимает WASM OOM (1 wasmoon ~ 16 MB, не 742 × 16 MB).
|
||||
*/
|
||||
import RobloxLuaWorker from './RobloxLuaWorker.js?worker';
|
||||
import { RobloxLuaSandbox } from './RobloxLuaSandbox.js';
|
||||
import RobloxLuaSharedWorker from './RobloxLuaSharedWorker.js?worker';
|
||||
import { RobloxLuaSharedSandbox } from './RobloxLuaSharedSandbox.js';
|
||||
|
||||
/**
|
||||
* Распаковывает Lua-исходник из поля code упакованного rbxl-importer'ом.
|
||||
* Формат поля code:
|
||||
* // @roblox-lua
|
||||
* // {"roblox_class": "Script", "enabled": true}
|
||||
* /* lua_source:
|
||||
* <ЛУА КОД>
|
||||
* *\/
|
||||
* Формат: `// @roblox-lua\n// {meta json}\n/* lua_source:\n<код>\n*/`
|
||||
*/
|
||||
export function unpackRobloxLuaCode(code) {
|
||||
const openTag = '/* lua_source:\n';
|
||||
@ -33,8 +26,7 @@ export function unpackRobloxLuaCode(code) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Собирает snap сцены для Lua-shim (workspace:GetChildren).
|
||||
* @param {Array} primitives — projectData.scene.primitives
|
||||
* Snap сцены для Lua-shim (workspace:GetChildren).
|
||||
*/
|
||||
export function buildLuaSceneSnap(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) }
|
||||
* @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 {
|
||||
const luaSource = unpackRobloxLuaCode(script.code);
|
||||
if (!luaSource) return null;
|
||||
const worker = new RobloxLuaWorker();
|
||||
const luaScripts = [];
|
||||
let filtered = 0;
|
||||
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 sb = new RobloxLuaSandbox(luaSource, script.target || null);
|
||||
sb.scriptId = script.id;
|
||||
sb.setInitialScene(sceneSnap);
|
||||
sb.setOnCommand(ctx.onCommand);
|
||||
sb.start(worker);
|
||||
return sb;
|
||||
const mgr = new RobloxLuaSharedSandbox();
|
||||
mgr.setOnCommand(ctx.onCommand);
|
||||
mgr.start(sceneSnap, worker);
|
||||
for (const ls of luaScripts) {
|
||||
mgr.addScript(ls.id, ls.target, ls.luaSource);
|
||||
}
|
||||
return { sandbox: mgr, count: luaScripts.length, filtered };
|
||||
} catch (e) {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Маппинг IPC команд от RobloxLuaSandbox на действия в Babylon-сцене.
|
||||
* Маппинг IPC команд от shared sandbox на действия в Babylon-сцене.
|
||||
*
|
||||
* @param {string} scriptId
|
||||
* @param {string} _scriptId — не используется (команды от shared VM не привязаны к одному id)
|
||||
* @param {string} cmd
|
||||
* @param {object} payload
|
||||
* @param {object} runtime — { scene3d, game }
|
||||
*/
|
||||
export function handleLuaCommand(scriptId, cmd, payload, runtime) {
|
||||
export function handleLuaCommand(_scriptId, cmd, payload, runtime) {
|
||||
if (cmd === 'log') {
|
||||
const fn = payload?.level === 'error' ? console.error
|
||||
: payload?.level === 'warn' ? console.warn : console.log;
|
||||
fn('[rbxl-lua ' + scriptId + ']', payload?.text || '');
|
||||
fn('[rbxl-lua]', payload?.text || '');
|
||||
return;
|
||||
}
|
||||
if (cmd === 'partSet') {
|
||||
@ -146,3 +152,9 @@ export function handleLuaCommand(scriptId, cmd, payload, runtime) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/* ──────── Legacy single-script API (для обратной совместимости) ──────── */
|
||||
// Старая логика per-script Worker оставлена в RobloxLuaSandbox.js + RobloxLuaWorker.js,
|
||||
// но GameRuntime теперь использует startRobloxLuaShared. Эти экспорты не удалены
|
||||
// чтобы тесты в player/tests/ продолжали работать.
|
||||
export { startRobloxLuaShared as startRobloxLuaScript };
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user