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)
|
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 а 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 ───
|
# ─── Lighting ───
|
||||||
|
|
||||||
def _convert_lighting(self, inst: Instance, scene: Dict) -> None:
|
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 { 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}>
|
||||||
|
|||||||
@ -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 = [];
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
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-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 };
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user