Compare commits
No commits in common. "9caea93d325bc46283d81ecfcd574fb1aedd337c" and "cc6447b851c7fa613aa8916279af9adc52f861f1" have entirely different histories.
9caea93d32
...
cc6447b851
@ -714,14 +714,9 @@ class Converter:
|
|||||||
def _convert_screen_gui(self, inst: Instance, scene: Dict) -> None:
|
def _convert_screen_gui(self, inst: Instance, scene: Dict) -> None:
|
||||||
# ScreenGui сам не рендерится — он контейнер. Дети получают parentId=None
|
# ScreenGui сам не рендерится — он контейнер. Дети получают parentId=None
|
||||||
# при конверте. Сохраняем referent чтобы _gui_parent_id() видел.
|
# при конверте. Сохраняем referent чтобы _gui_parent_id() видел.
|
||||||
# Также сохраняем Enabled-свойство: если ScreenGui.Enabled=false →
|
|
||||||
# все дети должны быть скрыты (Roblox прячет всю иерархию).
|
|
||||||
if not hasattr(self, '_screen_gui_refs'):
|
if not hasattr(self, '_screen_gui_refs'):
|
||||||
self._screen_gui_refs = set()
|
self._screen_gui_refs = set()
|
||||||
self._screen_gui_enabled = {}
|
|
||||||
self._screen_gui_refs.add(inst.referent)
|
self._screen_gui_refs.add(inst.referent)
|
||||||
enabled = inst.properties.get('Enabled', True)
|
|
||||||
self._screen_gui_enabled[inst.referent] = bool(enabled) if enabled is not None else True
|
|
||||||
|
|
||||||
def _gui_parent_id(self, parent_ref) -> Optional[str]:
|
def _gui_parent_id(self, parent_ref) -> Optional[str]:
|
||||||
if parent_ref is None:
|
if parent_ref is None:
|
||||||
@ -730,44 +725,36 @@ class Converter:
|
|||||||
return None # top-level в ScreenGui = parentId=None в Rublox
|
return None # top-level в ScreenGui = parentId=None в Rublox
|
||||||
return f'rbx_gui_{parent_ref}'
|
return f'rbx_gui_{parent_ref}'
|
||||||
|
|
||||||
def _udim_to_percent(self, udim, axis: str = 'x') -> float:
|
def _udim2_to_rublox(self, udim2, axis: str = 'x') -> int:
|
||||||
"""Roblox UDim(scale, offset) → процент (0..100) для Rublox GUI.
|
"""Roblox UDim2(scale, offset) → pixel размер для Rublox GUI.
|
||||||
Rublox использует проценты от viewport. Конвертация:
|
Reference viewport: 1280×720 (стандарт Roblox при импорте).
|
||||||
- scale (0..1) → scale * 100
|
axis='x' → используется ширина 1280, 'y' → высота 720.
|
||||||
- offset (px) → offset / viewport_size * 100 (1280×720 reference)
|
|
||||||
"""
|
"""
|
||||||
if udim is None:
|
|
||||||
return 0.0
|
|
||||||
ref = 1280.0 if axis == 'x' else 720.0
|
|
||||||
if isinstance(udim, dict):
|
|
||||||
scale = udim.get('scale', 0) or 0
|
|
||||||
offset = udim.get('offset', 0) or 0
|
|
||||||
else:
|
|
||||||
scale = getattr(udim, 'scale', 0) or 0
|
|
||||||
offset = getattr(udim, 'offset', 0) or 0
|
|
||||||
return scale * 100.0 + (offset / ref) * 100.0
|
|
||||||
|
|
||||||
def _udim2_pair(self, udim2) -> Tuple[float, float]:
|
|
||||||
"""UDim2 → (x%, y%). Поддерживает dataclass UDim2 и dict."""
|
|
||||||
if udim2 is None:
|
if udim2 is None:
|
||||||
return (0.0, 0.0)
|
return 0
|
||||||
|
ref = 1280 if axis == 'x' else 720
|
||||||
if isinstance(udim2, dict):
|
if isinstance(udim2, dict):
|
||||||
x_obj = udim2.get('x')
|
scale = udim2.get('scale', 0) or 0
|
||||||
y_obj = udim2.get('y')
|
offset = udim2.get('offset', 0) or 0
|
||||||
else:
|
return int(offset + scale * ref)
|
||||||
x_obj = getattr(udim2, 'x', None)
|
return 0
|
||||||
y_obj = getattr(udim2, 'y', None)
|
|
||||||
return (self._udim_to_percent(x_obj, 'x'), self._udim_to_percent(y_obj, 'y'))
|
def _udim2_pair(self, udim2) -> Tuple[int, int]:
|
||||||
|
"""UDim2 = {x: UDim, y: UDim} → (px, py)."""
|
||||||
|
if not isinstance(udim2, dict):
|
||||||
|
return (0, 0)
|
||||||
|
return (
|
||||||
|
self._udim2_to_rublox(udim2.get('x'), 'x'),
|
||||||
|
self._udim2_to_rublox(udim2.get('y'), 'y'),
|
||||||
|
)
|
||||||
|
|
||||||
def _color3_to_hex(self, c3) -> str:
|
def _color3_to_hex(self, c3) -> str:
|
||||||
if c3 is None:
|
if c3 is None:
|
||||||
return '#ffffff'
|
return '#ffffff'
|
||||||
try:
|
try:
|
||||||
if hasattr(c3, 'to_hex'):
|
r = int(round(c3.r * 255)) if hasattr(c3, 'r') else 255
|
||||||
return c3.to_hex()
|
g = int(round(c3.g * 255)) if hasattr(c3, 'g') else 255
|
||||||
r = int(round(getattr(c3, 'r', 1) * 255))
|
b = int(round(c3.b * 255)) if hasattr(c3, 'b') else 255
|
||||||
g = int(round(getattr(c3, 'g', 1) * 255))
|
|
||||||
b = int(round(getattr(c3, 'b', 1) * 255))
|
|
||||||
return f'#{r:02x}{g:02x}{b:02x}'
|
return f'#{r:02x}{g:02x}{b:02x}'
|
||||||
except Exception:
|
except Exception:
|
||||||
return '#ffffff'
|
return '#ffffff'
|
||||||
@ -791,14 +778,9 @@ class Converter:
|
|||||||
|
|
||||||
pos_x, pos_y = self._udim2_pair(props.get('Position'))
|
pos_x, pos_y = self._udim2_pair(props.get('Position'))
|
||||||
size_x, size_y = self._udim2_pair(props.get('Size'))
|
size_x, size_y = self._udim2_pair(props.get('Size'))
|
||||||
# В процентах. Если размер не указан — дефолт 20%×10%.
|
# Size может быть 0×0 (если только scale) — дефолтим в 100×30
|
||||||
if size_x <= 0: size_x = 20.0
|
if size_x <= 0: size_x = 100
|
||||||
if size_y <= 0: size_y = 10.0
|
if size_y <= 0: size_y = 30
|
||||||
# Округляем до 0.1% для читаемости JSON
|
|
||||||
pos_x = round(pos_x, 2)
|
|
||||||
pos_y = round(pos_y, 2)
|
|
||||||
size_x = round(size_x, 2)
|
|
||||||
size_y = round(size_y, 2)
|
|
||||||
|
|
||||||
# Фильтр Roblox CDN URL'ов: rbxasset://, rbxassetid://, rbxhttp:// —
|
# Фильтр Roblox CDN URL'ов: rbxasset://, rbxassetid://, rbxhttp:// —
|
||||||
# браузер их не поймёт, даём пустую строку. В будущем asset_downloader
|
# браузер их не поймёт, даём пустую строку. В будущем asset_downloader
|
||||||
@ -807,47 +789,6 @@ class Converter:
|
|||||||
if raw_image.startswith(('rbxasset://', 'rbxassetid://', 'rbxhttp://', 'rbxthumb://')):
|
if raw_image.startswith(('rbxasset://', 'rbxassetid://', 'rbxhttp://', 'rbxthumb://')):
|
||||||
raw_image = ''
|
raw_image = ''
|
||||||
|
|
||||||
# Видимость: если родитель — ScreenGui.Enabled=false, скрываем весь элемент.
|
|
||||||
own_visible = props.get('Visible', True)
|
|
||||||
if own_visible is None:
|
|
||||||
own_visible = True
|
|
||||||
# Поднимаемся по родителям пока не найдём ScreenGui — если он Disabled,
|
|
||||||
# элемент тоже невидим.
|
|
||||||
parent_ref = inst.parent_referent
|
|
||||||
screen_enabled = True
|
|
||||||
if hasattr(self, '_screen_gui_refs'):
|
|
||||||
cur = parent_ref
|
|
||||||
depth = 0
|
|
||||||
while cur is not None and depth < 50:
|
|
||||||
if cur in self._screen_gui_refs:
|
|
||||||
screen_enabled = self._screen_gui_enabled.get(cur, True)
|
|
||||||
break
|
|
||||||
# Поиск родителя cur в instances (если есть)
|
|
||||||
cur_inst = self.model.by_referent.get(cur) if hasattr(self, 'model') else None
|
|
||||||
cur = cur_inst.parent_referent if cur_inst else None
|
|
||||||
depth += 1
|
|
||||||
effective_visible = bool(own_visible) and screen_enabled
|
|
||||||
|
|
||||||
# Эвристика: HDAdmin/Chat/Leaderboard модалки в Roblox показываются
|
|
||||||
# отдельными Lua-скриптами по триггеру. Без работающих скриптов они
|
|
||||||
# показываются ВСЕ сразу и наслаиваются. Скрываем их по имени.
|
|
||||||
gui_name = name_or_default(props, cls)
|
|
||||||
ADMIN_HIDDEN = ('HDAdmin', 'Cmdbar', 'Console', 'TeleportTo',
|
|
||||||
'Notifications', 'Settings', 'Promo', 'PlayerList',
|
|
||||||
'BanList', 'Admin', 'CommandBar')
|
|
||||||
# Поднимаемся по родителям проверяя их имена
|
|
||||||
cur = inst.parent_referent
|
|
||||||
depth = 0
|
|
||||||
while cur is not None and depth < 10:
|
|
||||||
cur_inst = self.model.by_referent.get(cur)
|
|
||||||
if not cur_inst: break
|
|
||||||
pn = cur_inst.properties.get('Name') or cur_inst.class_name
|
|
||||||
if any(h.lower() in str(pn).lower() for h in ADMIN_HIDDEN):
|
|
||||||
effective_visible = False
|
|
||||||
break
|
|
||||||
cur = cur_inst.parent_referent
|
|
||||||
depth += 1
|
|
||||||
|
|
||||||
element = {
|
element = {
|
||||||
'id': f'rbx_gui_{inst.referent}',
|
'id': f'rbx_gui_{inst.referent}',
|
||||||
'type': r_type,
|
'type': r_type,
|
||||||
@ -858,7 +799,7 @@ class Converter:
|
|||||||
'w': size_x,
|
'w': size_x,
|
||||||
'h': size_y,
|
'h': size_y,
|
||||||
'anchor': 'top-left', # Roblox по умолчанию top-left
|
'anchor': 'top-left', # Roblox по умолчанию top-left
|
||||||
'visible': effective_visible,
|
'visible': props.get('Visible', True),
|
||||||
'bgColor': self._color3_to_hex(props.get('BackgroundColor3')),
|
'bgColor': self._color3_to_hex(props.get('BackgroundColor3')),
|
||||||
'bgOpacity': max(0.0, min(1.0, 1.0 - float(props.get('BackgroundTransparency', 0) or 0))),
|
'bgOpacity': max(0.0, min(1.0, 1.0 - float(props.get('BackgroundTransparency', 0) or 0))),
|
||||||
'borderColor': self._color3_to_hex(props.get('BorderColor3')),
|
'borderColor': self._color3_to_hex(props.get('BorderColor3')),
|
||||||
|
|||||||
@ -269,51 +269,24 @@ const HierarchyPanel = ({
|
|||||||
const [renaming, setRenaming] = useState(null);
|
const [renaming, setRenaming] = useState(null);
|
||||||
|
|
||||||
// Авто-скролл к выбранному элементу: когда юзер выделяет объект в 3D-сцене
|
// Авто-скролл к выбранному элементу: когда юзер выделяет объект в 3D-сцене
|
||||||
// (или приходит выделение извне) — раскрываем родительские папки и
|
// (или приходит выделение извне) — прокручиваем иерархию к нему.
|
||||||
// прокручиваем иерархию к нему.
|
// Работает по data-sel-id который ItemRow получает через style (см. ниже).
|
||||||
const hierarchyRootRef = useRef(null);
|
const hierarchyRootRef = useRef(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selection) return;
|
if (!selection) return;
|
||||||
|
const root = hierarchyRootRef.current;
|
||||||
|
if (!root) return;
|
||||||
let selId = null;
|
let selId = null;
|
||||||
if (selection.type === 'primitive') selId = `primitive:${selection.id}`;
|
if (selection.type === 'primitive') selId = `primitive:${selection.id}`;
|
||||||
else if (selection.type === 'model') selId = `model:${selection.instanceId}`;
|
else if (selection.type === 'model') selId = `model:${selection.instanceId}`;
|
||||||
else if (selection.type === 'block') selId = `block:${selection.gridX},${selection.gridY},${selection.gridZ}`;
|
else if (selection.type === 'block') selId = `block:${selection.gridX},${selection.gridY},${selection.gridZ}`;
|
||||||
if (!selId) return;
|
if (!selId) return;
|
||||||
|
// querySelector через CSS.escape для безопасности с двоеточием и запятыми
|
||||||
// 1) Раскрываем ВСЁ что может скрывать выбранный объект:
|
const el = root.querySelector(`[data-sel-id="${CSS.escape(selId)}"]`);
|
||||||
// - все папки (folders) - вдруг примитив сидит в Folder
|
if (el && typeof el.scrollIntoView === 'function') {
|
||||||
// - rootPrimsOpen - группа "Примитивы" в корне
|
el.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||||
// - sceneOpen — корень сцены
|
|
||||||
if (folders && folders.length > 0) {
|
|
||||||
setOpenFolders(prev => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
let changed = false;
|
|
||||||
for (const f of folders) {
|
|
||||||
if (!next.has(f.id)) { next.add(f.id); changed = true; }
|
|
||||||
}
|
|
||||||
return changed ? next : prev;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
if (!workspaceOpen) setWorkspaceOpen(true);
|
}, [selection?.type, selection?.id, selection?.instanceId, selection?.gridX, selection?.gridY, selection?.gridZ]);
|
||||||
if (selection.type === 'primitive' && !rootPrimsOpen) setRootPrimsOpen(true);
|
|
||||||
if (selection.type === 'block' && !rootBlocksOpen) setRootBlocksOpen(true);
|
|
||||||
if (selection.type === 'model' && !rootModelsOpen) setRootModelsOpen(true);
|
|
||||||
|
|
||||||
// 2) Скролл через 2 кадра (даём React перерендерить после раскрытия)
|
|
||||||
const tick = () => {
|
|
||||||
const root = hierarchyRootRef.current;
|
|
||||||
if (!root) return;
|
|
||||||
const el = root.querySelector(`[data-sel-id="${CSS.escape(selId)}"]`);
|
|
||||||
if (el && typeof el.scrollIntoView === 'function') {
|
|
||||||
el.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const raf1 = requestAnimationFrame(() => {
|
|
||||||
const raf2 = requestAnimationFrame(tick);
|
|
||||||
return () => cancelAnimationFrame(raf2);
|
|
||||||
});
|
|
||||||
return () => cancelAnimationFrame(raf1);
|
|
||||||
}, [selection?.type, selection?.id, selection?.instanceId, selection?.gridX, selection?.gridY, selection?.gridZ, folders?.length, workspaceOpen, rootPrimsOpen, rootBlocksOpen, rootModelsOpen, openFolders]);
|
|
||||||
|
|
||||||
const startRename = (kind, refKey, currentValue) => {
|
const startRename = (kind, refKey, currentValue) => {
|
||||||
setRenaming({ kind, refKey, value: currentValue || '' });
|
setRenaming({ kind, refKey, value: currentValue || '' });
|
||||||
|
|||||||
@ -138,11 +138,8 @@ export class GameRuntime {
|
|||||||
// Single-VM запуск всех Roblox-Lua скриптов одним shared Worker'ом.
|
// Single-VM запуск всех Roblox-Lua скриптов одним shared Worker'ом.
|
||||||
let rbxlCount = 0;
|
let rbxlCount = 0;
|
||||||
if (rbxlBatch.length > 0) {
|
if (rbxlBatch.length > 0) {
|
||||||
// GUI-дерево из projectData для pre-population
|
|
||||||
const guiElements = this.projectData?.scene?.gui || [];
|
|
||||||
const result = startRobloxLuaShared(rbxlBatch, {
|
const result = startRobloxLuaShared(rbxlBatch, {
|
||||||
primitives,
|
primitives,
|
||||||
guiElements,
|
|
||||||
onCommand: (cmd, payload) => handleLuaCommand(null, cmd, payload, this),
|
onCommand: (cmd, payload) => handleLuaCommand(null, cmd, payload, this),
|
||||||
});
|
});
|
||||||
if (result && result.sandbox) {
|
if (result && result.sandbox) {
|
||||||
|
|||||||
@ -1,62 +1,77 @@
|
|||||||
/**
|
/**
|
||||||
* RobloxLuaSharedSandbox — main-side обёртка над одним shared Lua-worker'ом.
|
* RobloxLuaSharedSandbox — main-side менеджер ОДНОГО shared-worker'а
|
||||||
|
* со множеством скриптов внутри.
|
||||||
*
|
*
|
||||||
* v2 (после rewrite):
|
* Использование:
|
||||||
* - start(sceneSnap, guiTree, worker) → init с GUI-деревом
|
* const mgr = new RobloxLuaSharedSandbox();
|
||||||
* - addScriptsBatch(scripts) → одной пачкой все скрипты регистрируются в VM
|
* mgr.setOnCommand((cmd, payload) => ...);
|
||||||
* - kickoff() → запускает event loop, fire'ит PlayerAdded
|
* mgr.start(initialScene, worker);
|
||||||
* - tick(dt) каждый кадр
|
* mgr.addScript(scriptId, targetPrimId, luaSource);
|
||||||
* - fireEvent(kind, payload) — маршрутизирует в Worker.handleEvent
|
* ... mgr.tick(dt, sceneSnap) ...
|
||||||
|
* mgr.fireEvent('touched', [primId, otherInfo]);
|
||||||
|
* mgr.stop();
|
||||||
*
|
*
|
||||||
* GameRuntime пушит ОДИН экземпляр в this.sandboxes.
|
* GameRuntime пушит этот менеджер ОДИН РАЗ в this.sandboxes, не за каждый
|
||||||
|
* скрипт — поэтому в this.sandboxes теперь живёт максимум 1 RobloxLuaSharedSandbox.
|
||||||
|
* Совместимость с интерфейсом ScriptSandbox: те же sendXxx no-op методы.
|
||||||
*/
|
*/
|
||||||
export class RobloxLuaSharedSandbox {
|
export class RobloxLuaSharedSandbox {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.worker = null;
|
this.worker = null;
|
||||||
this._onCommand = null;
|
this._onCommand = null;
|
||||||
this._booted = false;
|
this._booted = false;
|
||||||
this._scriptsLoaded = false;
|
|
||||||
this._stopped = false;
|
this._stopped = false;
|
||||||
|
this._scriptCount = 0;
|
||||||
this._pendingTicks = [];
|
this._pendingTicks = [];
|
||||||
this._pendingEvents = [];
|
this._pendingEvents = [];
|
||||||
this._pendingScripts = null;
|
this._pendingAdds = [];
|
||||||
this._pendingKickoff = false;
|
|
||||||
this.scriptId = 'rbxl-shared';
|
this.scriptId = 'rbxl-shared';
|
||||||
}
|
}
|
||||||
|
|
||||||
setOnCommand(cb) { this._onCommand = cb; }
|
setOnCommand(cb) { this._onCommand = cb; }
|
||||||
|
|
||||||
start(sceneSnap, guiTree, worker) {
|
/** @param {Worker} worker — экземпляр (создан через `new RobloxLuaSharedWorker()` в вызывающем коде) */
|
||||||
|
start(initialScene, worker) {
|
||||||
if (this.worker) return;
|
if (this.worker) return;
|
||||||
this.worker = worker;
|
this.worker = worker;
|
||||||
this.worker.onmessage = (e) => this._handle(e);
|
this.worker.onmessage = (e) => this._handle(e);
|
||||||
this.worker.onerror = (err) => {
|
this.worker.onerror = (err) => {
|
||||||
this._emit('log', { level: 'error', text: `SharedWorker error: ${err.message || err}` });
|
this._emit('log', { level: 'error', text: `SharedWorker error: ${err.message || err}` });
|
||||||
};
|
};
|
||||||
this.worker.postMessage({ cmd: 'init', payload: { sceneSnap, guiTree } });
|
this.worker.postMessage({
|
||||||
|
cmd: 'init',
|
||||||
|
payload: { sceneSnap: initialScene || { primitives: {} } },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
addScriptsBatch(scripts) {
|
/** Добавить скрипт в shared VM. */
|
||||||
if (!this._booted) { this._pendingScripts = scripts; return; }
|
addScript(scriptId, targetPrimId, luaSource) {
|
||||||
try { this.worker.postMessage({ cmd: 'addScripts', payload: { scripts } }); } catch (e) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
kickoff() {
|
|
||||||
if (!this._scriptsLoaded) { this._pendingKickoff = true; return; }
|
|
||||||
try { this.worker.postMessage({ cmd: 'start' }); } catch (e) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
tick(dt) {
|
|
||||||
if (!this.worker) return;
|
if (!this.worker) return;
|
||||||
if (!this._booted) { this._pendingTicks.push(dt); return; }
|
const payload = { id: scriptId, target: targetPrimId, luaSource };
|
||||||
try { this.worker.postMessage({ cmd: 'tick', payload: { dt } }); } catch (e) {}
|
if (!this._booted) {
|
||||||
|
this._pendingAdds.push(payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try { this.worker.postMessage({ cmd: 'addScript', payload }); } catch (e) {}
|
||||||
|
this._scriptCount++;
|
||||||
}
|
}
|
||||||
|
|
||||||
fireEvent(kind, payload) {
|
tick(dt, sceneSnap) {
|
||||||
if (!this.worker) return;
|
if (!this.worker) return;
|
||||||
const ev = { kind, ...(payload || {}) };
|
if (!this._booted) {
|
||||||
if (!this._booted) { this._pendingEvents.push(ev); return; }
|
this._pendingTicks.push({ dt, sceneSnap });
|
||||||
try { this.worker.postMessage({ cmd: 'event', payload: ev }); } catch (e) {}
|
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() {
|
stop() {
|
||||||
@ -71,28 +86,17 @@ export class RobloxLuaSharedSandbox {
|
|||||||
const { cmd, payload } = ev.data || {};
|
const { cmd, payload } = ev.data || {};
|
||||||
if (cmd === 'boot') {
|
if (cmd === 'boot') {
|
||||||
this._booted = true;
|
this._booted = true;
|
||||||
// флушим pending scripts
|
for (const p of this._pendingAdds) {
|
||||||
if (this._pendingScripts) {
|
try { this.worker.postMessage({ cmd: 'addScript', payload: p }); } catch (e) {}
|
||||||
try { this.worker.postMessage({ cmd: 'addScripts', payload: { scripts: this._pendingScripts } }); } catch (e) {}
|
this._scriptCount++;
|
||||||
this._pendingScripts = null;
|
|
||||||
}
|
}
|
||||||
// ticks накопленные до boot
|
this._pendingAdds = [];
|
||||||
for (const dt of this._pendingTicks) {
|
for (const t of this._pendingTicks) {
|
||||||
try { this.worker.postMessage({ cmd: 'tick', payload: { dt } }); } catch (e) {}
|
try { this.worker.postMessage({ cmd: 'tick', payload: t }); } catch (e) {}
|
||||||
}
|
}
|
||||||
this._pendingTicks = [];
|
this._pendingTicks = [];
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (cmd === 'ready') {
|
|
||||||
this._scriptsLoaded = true;
|
|
||||||
this._emit('ready', payload);
|
|
||||||
if (this._pendingKickoff) {
|
|
||||||
try { this.worker.postMessage({ cmd: 'start' }); } catch (e) {}
|
|
||||||
this._pendingKickoff = false;
|
|
||||||
}
|
|
||||||
// флушим pending events
|
|
||||||
for (const e of this._pendingEvents) {
|
for (const e of this._pendingEvents) {
|
||||||
try { this.worker.postMessage({ cmd: 'event', payload: e }); } catch (er) {}
|
try { this.worker.postMessage({ cmd: 'event', payload: e }); } catch (e) {}
|
||||||
}
|
}
|
||||||
this._pendingEvents = [];
|
this._pendingEvents = [];
|
||||||
return;
|
return;
|
||||||
@ -101,50 +105,36 @@ export class RobloxLuaSharedSandbox {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_emit(cmd, payload) {
|
_emit(cmd, payload) {
|
||||||
if (this._onCommand) { try { this._onCommand(cmd, payload); } catch (e) {} }
|
if (this._onCommand) {
|
||||||
|
try { this._onCommand(cmd, payload); } catch (e) {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Совместимость с ScriptSandbox (GameRuntime ожидает эти методы) ──
|
// ── Совместимость с ScriptSandbox (GameRuntime ожидает эти методы) ──
|
||||||
sendSceneSnapshot(_snap) {}
|
sendSceneSnapshot(_snap) { /* tick делает то же */ }
|
||||||
sendGuiSnapshot(_snap) {}
|
sendGuiSnapshot(_snap) {}
|
||||||
sendSkinsSnapshot(_snap) {}
|
sendSkinsSnapshot(_snap) {}
|
||||||
sendInventorySnapshot(_snap) {}
|
sendInventorySnapshot(_snap) {}
|
||||||
sendTerrainHeightmap(_payload) {}
|
sendTerrainHeightmap(_payload) {}
|
||||||
sendGlobalEvent(payload) {
|
sendGlobalEvent(payload) {
|
||||||
|
// GameRuntime.routeGlobalEvent шлёт {type, ...extra}.
|
||||||
if (!payload || typeof payload !== 'object') return;
|
if (!payload || typeof payload !== 'object') return;
|
||||||
const type = payload.type;
|
const type = payload.type;
|
||||||
// playerTouch: BabylonScene уже детектит касания → Touched на Part
|
|
||||||
if (type === 'playerTouch' && payload.target) {
|
if (type === 'playerTouch' && payload.target) {
|
||||||
|
// target = 'primitive:<id>' → шлём как touched на этот Part.
|
||||||
const m = /^primitive:(\d+)$/.exec(String(payload.target));
|
const m = /^primitive:(\d+)$/.exec(String(payload.target));
|
||||||
if (m) { this.fireEvent('touched', { primId: +m[1], isPlayer: true }); return; }
|
if (m) {
|
||||||
|
this.fireEvent('touched', [+m[1], { kind: 'player' }]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// GUI click — Rublox GuiOverlay шлёт guiClick с id
|
this.fireEvent(type || 'unknown', [payload]);
|
||||||
if (type === 'guiClick' && (payload.id || payload.localId)) {
|
|
||||||
this.fireEvent('guiClick', { guiId: payload.id || payload.localId });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// keyboard
|
|
||||||
if (type === 'keydown' || type === 'keyup') {
|
|
||||||
this.fireEvent(type, { key: payload.key });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// hp/death
|
|
||||||
if (type === 'hpChange' || type === 'humanoidHealth') {
|
|
||||||
this.fireEvent('humanoidHealth', { health: payload.hp || payload.health || 0 });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (type === 'died' || type === 'humanoidDied') {
|
|
||||||
this.fireEvent('humanoidDied', {});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// default: пробрасываем как kind=type
|
|
||||||
this.fireEvent(type || 'unknown', payload);
|
|
||||||
}
|
}
|
||||||
sendBroadcast(msg, data) { this.fireEvent('broadcast', { msg, data }); }
|
sendBroadcast(msg, data) { this.fireEvent('broadcast', [msg, data]); }
|
||||||
sendOnTouchEvent(payload) { this.fireEvent('touched', { primId: payload?.primId, isPlayer: true }); }
|
sendOnTouchEvent(payload) { this.fireEvent('touched', [payload?.primId, payload]); }
|
||||||
sendOnTickEvent(dt) { this.tick(dt); }
|
sendOnTickEvent(dt) { this.tick(dt, null); }
|
||||||
sendTweenDone(payload) { this.fireEvent('tweenDone', payload); }
|
sendTweenDone(payload) { this.fireEvent('tweenDone', [payload]); }
|
||||||
sendSpawnResolved(payload) { this.fireEvent('spawnResolved', payload); }
|
sendSpawnResolved(payload) { this.fireEvent('spawnResolved', [payload]); }
|
||||||
setInitialSelfPosition(_p) {}
|
setInitialSelfPosition(_p) {}
|
||||||
setModules(_modules) {}
|
setModules(_modules) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,51 +1,40 @@
|
|||||||
/* eslint-disable no-restricted-globals */
|
/* eslint-disable no-restricted-globals */
|
||||||
/**
|
/**
|
||||||
* RobloxLuaSharedWorker.js — single-VM Lua-runtime для импортированных Roblox-скриптов.
|
* RobloxLuaSharedWorker.js — ОДИН Worker, ОДНА Lua-VM, МНОЖЕСТВО скриптов.
|
||||||
*
|
*
|
||||||
* Архитектура v2 (после ITERATION 5-step rewrite):
|
* Отличие от 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)
|
||||||
*
|
*
|
||||||
* ФАЗА 1 (boot): создаём wasmoon-VM, регистрируем Roblox API без скриптов.
|
* Это снимает WASM OOM лимит: 1 wasmoon-VM ~ 16 MB, не 742 × 16.
|
||||||
*
|
*
|
||||||
* ФАЗА 2 (populate): main шлёт snapshot сцены (primitives + GUI tree).
|
* IPC (с main):
|
||||||
* Создаём workspace со всеми Part'ами и PlayerGui со всем GUI-деревом.
|
* <- init { sceneSnap }
|
||||||
* На каждом TextButton — MouseButton1Click сигнал, на каждом Part — Touched.
|
* <- addScript { id, target, luaSource }
|
||||||
*
|
* <- tick { dt, sceneSnap }
|
||||||
* ФАЗА 3 (addScripts): main шлёт ВСЕ скрипты ОДНИМ батчем. Worker загружает
|
* <- event { kind, args, scriptId?: id }
|
||||||
* их в Lua-VM как отдельные функции в pcall. Скрипты регистрируют свои
|
|
||||||
* Connect'ы (Touched, MouseButton1Click, Heartbeat, ...). top-level wait()
|
|
||||||
* yield'ится через coroutine — управление возвращается в worker.
|
|
||||||
*
|
|
||||||
* ФАЗА 4 (run): main шлёт 'startEvents'. Worker запускает scheduler-tick
|
|
||||||
* и начинает обрабатывать события (touched/guiClick/heartbeat).
|
|
||||||
*
|
|
||||||
* IPC:
|
|
||||||
* <- init { sceneSnap, guiTree }
|
|
||||||
* <- addScripts { scripts: [{id, target, luaSource}] }
|
|
||||||
* <- start
|
|
||||||
* <- tick { dt }
|
|
||||||
* <- event { kind, payload }
|
|
||||||
* <- stop
|
* <- stop
|
||||||
* -> boot
|
* -> boot
|
||||||
* -> ready
|
* -> ready { scriptId, ok, error? } — после каждого addScript
|
||||||
* -> log/partSet/partVel/playerCmd/broadcast/guiUpdate
|
* -> log, partSet, partVel, playerCmd, broadcast — общие для всех скриптов
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { LuaFactory } from 'wasmoon';
|
import { LuaFactory } from 'wasmoon';
|
||||||
import { registerRobloxApi, RbxSignal } from './roblox-shim.js';
|
import { registerRobloxApi } from './roblox-shim.js';
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
factory: null,
|
factory: null,
|
||||||
lua: null,
|
lua: null,
|
||||||
sceneSnap: { primitives: {} },
|
sceneSnap: { primitives: {} },
|
||||||
guiTree: [],
|
|
||||||
isStopped: false,
|
isStopped: false,
|
||||||
initPromise: null,
|
initPromise: null,
|
||||||
eventsStarted: false,
|
scriptIdSeq: 0,
|
||||||
pendingEvents: [],
|
nextSignalId: 1,
|
||||||
scriptCount: 0,
|
|
||||||
coroutines: [], // активные ждущие корутины: { co, resumeAt }
|
|
||||||
nowSec: 0,
|
|
||||||
api: null, // результат registerRobloxApi: { workspace, game, part_by_id, gui_by_id, localPlayer, humanoid }
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function send(cmd, payload) {
|
function send(cmd, payload) {
|
||||||
@ -56,199 +45,55 @@ function log(level, text) {
|
|||||||
send('log', { level, text });
|
send('log', { level, text });
|
||||||
}
|
}
|
||||||
|
|
||||||
const scheduler = {
|
|
||||||
now: () => state.nowSec,
|
|
||||||
schedule: (sec, fn) => {
|
|
||||||
state.coroutines.push({ resumeAt: state.nowSec + (sec || 0), fn });
|
|
||||||
},
|
|
||||||
spawn: (fn) => {
|
|
||||||
// spawn — fn запускается асинхронно (на следующем tick'е)
|
|
||||||
state.coroutines.push({ resumeAt: state.nowSec, fn });
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
self.addEventListener('message', async (ev) => {
|
self.addEventListener('message', async (ev) => {
|
||||||
const { cmd, payload } = ev.data || {};
|
const { cmd, payload } = ev.data || {};
|
||||||
try {
|
try {
|
||||||
if (cmd === 'init') await handleInit(payload);
|
if (cmd === 'init') {
|
||||||
else if (cmd === 'addScripts') await handleAddScripts(payload);
|
await handleInit(payload);
|
||||||
else if (cmd === 'start') handleStart();
|
} else if (cmd === 'addScript') {
|
||||||
else if (cmd === 'tick') handleTick(payload);
|
await handleAddScript(payload);
|
||||||
else if (cmd === 'event') {
|
} else if (cmd === 'tick') {
|
||||||
if (!state.eventsStarted) state.pendingEvents.push(payload);
|
handleTick(payload);
|
||||||
else handleEvent(payload);
|
} else if (cmd === 'event') {
|
||||||
}
|
handleEvent(payload);
|
||||||
else if (cmd === 'stop') {
|
} else if (cmd === 'stop') {
|
||||||
state.isStopped = true;
|
state.isStopped = true;
|
||||||
try { state.lua?.global?.close?.(); } catch (e) {}
|
try { state.lua?.global?.close?.(); } catch (e) {}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log('error', `Worker error in ${cmd}: ${err && err.stack ? err.stack : err}`);
|
log('error', `SharedWorker error in ${cmd}: ${err && err.stack ? err.stack : err}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function handleInit({ sceneSnap, guiTree }) {
|
async function handleInit({ sceneSnap }) {
|
||||||
if (state.initPromise) { await state.initPromise; return; }
|
if (state.initPromise) { await state.initPromise; return; }
|
||||||
state.initPromise = (async () => {
|
state.initPromise = (async () => {
|
||||||
state.sceneSnap = sceneSnap || { primitives: {} };
|
state.sceneSnap = sceneSnap || { primitives: {} };
|
||||||
state.guiTree = guiTree || [];
|
|
||||||
state.factory = new LuaFactory();
|
state.factory = new LuaFactory();
|
||||||
state.lua = await state.factory.createEngine({
|
state.lua = await state.factory.createEngine({
|
||||||
injectObjects: true,
|
injectObjects: true,
|
||||||
enableProxy: true,
|
enableProxy: true,
|
||||||
traceAllocations: false,
|
traceAllocations: false,
|
||||||
});
|
});
|
||||||
state.api = registerRobloxApi(state.lua, {
|
// Регистрируем Roblox API ОДИН РАЗ для всей VM.
|
||||||
|
// `script` глобал — здесь не имеет смысла (он per-script), скрипты
|
||||||
|
// получают свой `script` через локальную таблицу при addScript.
|
||||||
|
registerRobloxApi(state.lua, {
|
||||||
getSceneSnap: () => state.sceneSnap,
|
getSceneSnap: () => state.sceneSnap,
|
||||||
getGuiTree: () => state.guiTree,
|
|
||||||
targetPrimitiveId: null,
|
targetPrimitiveId: null,
|
||||||
send,
|
send,
|
||||||
scheduler,
|
registerSignal: () => state.nextSignalId++,
|
||||||
});
|
});
|
||||||
// Передаём part_by_id в Lua как table {id → Instance}
|
// Готовим helper-таблицу для скриптов
|
||||||
// ВНИМАНИЕ: lua_table принимает string keys, ключи кладём как строки.
|
|
||||||
try {
|
|
||||||
const m = state.api?.part_by_id;
|
|
||||||
if (m) {
|
|
||||||
const obj = {};
|
|
||||||
for (const [id, part] of m) obj[String(id)] = part;
|
|
||||||
state.lua.global.set('__rbxl_parts_by_id', obj);
|
|
||||||
}
|
|
||||||
} catch (e) {}
|
|
||||||
// null-stub builder: возвращает Instance-like объект который безопасно
|
|
||||||
// отвечает на .Parent, .Name, .WaitForChild и т.п. чтобы цепочки
|
|
||||||
// script.Parent.Parent.X не валили.
|
|
||||||
const makeNullStub = () => {
|
|
||||||
const stub = {
|
|
||||||
Name: 'NullStub',
|
|
||||||
ClassName: 'Nil',
|
|
||||||
Children: [],
|
|
||||||
__isNullStub: true,
|
|
||||||
};
|
|
||||||
// Parent — самоссылающийся nullStub
|
|
||||||
stub.Parent = stub;
|
|
||||||
stub.FindFirstChild = () => stub;
|
|
||||||
stub.FindFirstChildOfClass = () => stub;
|
|
||||||
stub.FindFirstAncestor = () => stub;
|
|
||||||
stub.FindFirstAncestorOfClass = () => stub;
|
|
||||||
stub.WaitForChild = () => stub;
|
|
||||||
stub.GetChildren = () => [];
|
|
||||||
stub.GetDescendants = () => [];
|
|
||||||
stub.IsA = () => false;
|
|
||||||
stub.Clone = () => makeNullStub();
|
|
||||||
stub.Destroy = () => {};
|
|
||||||
stub.GetService = () => stub;
|
|
||||||
// Сигналы — пустой connector
|
|
||||||
const nullSig = {
|
|
||||||
Connect: () => ({ Disconnect: () => {}, Connected: false }),
|
|
||||||
Wait: () => null,
|
|
||||||
Fire: () => {},
|
|
||||||
};
|
|
||||||
// Любой каpitalized property — сигнал-stub
|
|
||||||
return new Proxy(stub, {
|
|
||||||
get(t, k) {
|
|
||||||
if (k in t) return t[k];
|
|
||||||
if (typeof k === 'string' && /^[A-Z]/.test(k)) return nullSig;
|
|
||||||
return undefined;
|
|
||||||
},
|
|
||||||
set(t, k, v) { t[k] = v; return true; },
|
|
||||||
});
|
|
||||||
};
|
|
||||||
state.lua.global.set('__rbxl_make_null_stub', makeNullStub);
|
|
||||||
// ВАЖНО: создаём nullStub НА СТОРОНЕ LUA как настоящую table с
|
|
||||||
// metatable __index возвращающей сам stub. Это позволит цепочкам
|
|
||||||
// .Parent.X.Y:WaitForChild():Connect() корректно работать и обе
|
|
||||||
// нотации (. и :) сработают.
|
|
||||||
await state.lua.doString(`
|
await state.lua.doString(`
|
||||||
__null_stub_mt = {}
|
-- Глобальная таблица — все скрипты регистрируют свой контекст здесь.
|
||||||
function __make_null_stub()
|
__rbxl_scripts = __rbxl_scripts or {}
|
||||||
local t = setmetatable({
|
-- helper: безопасный вызов user-функции в pcall, ошибки в warn.
|
||||||
Name = "Nil",
|
|
||||||
ClassName = "Nil",
|
|
||||||
__isNullStub = true,
|
|
||||||
Visible = false,
|
|
||||||
Enabled = false,
|
|
||||||
Value = 0,
|
|
||||||
Text = "",
|
|
||||||
}, __null_stub_mt)
|
|
||||||
return t
|
|
||||||
end
|
|
||||||
__null_stub_singleton = __make_null_stub()
|
|
||||||
-- nullSignal с обоими Connect/connect:
|
|
||||||
local function null_sig_method() return { Disconnect = function() end, disconnect = function() end, Connected = false } end
|
|
||||||
__null_signal = setmetatable({
|
|
||||||
Connect = function(self, fn) return { Disconnect = function() end, disconnect = function() end, Connected = false } end,
|
|
||||||
connect = function(self, fn) return { Disconnect = function() end, disconnect = function() end, Connected = false } end,
|
|
||||||
Wait = function() return nil end,
|
|
||||||
wait = function() return nil end,
|
|
||||||
Fire = function() end,
|
|
||||||
fire = function() end,
|
|
||||||
}, { __index = function() return function() return __null_stub_singleton end end })
|
|
||||||
-- Любой index nullStub'а → возвращает либо null_signal (для уже известных
|
|
||||||
-- сигнальных имён) либо noop-функцию которая возвращает сам stub.
|
|
||||||
__null_stub_mt.__index = function(t, k)
|
|
||||||
-- известные сигнал-имена
|
|
||||||
local sig_names = {Touched=true,TouchEnded=true,Changed=true,Activated=true,
|
|
||||||
MouseButton1Click=true,MouseButton1Down=true,MouseButton1Up=true,
|
|
||||||
MouseEnter=true,MouseLeave=true,InputBegan=true,InputEnded=true,
|
|
||||||
PlayerAdded=true,PlayerRemoving=true,CharacterAdded=true,CharacterRemoving=true,
|
|
||||||
Heartbeat=true,Stepped=true,RenderStepped=true,Died=true,HealthChanged=true,
|
|
||||||
FocusLost=true,Focused=true,ChildAdded=true,ChildRemoved=true,
|
|
||||||
AncestryChanged=true,DescendantAdded=true,DescendantRemoving=true}
|
|
||||||
if sig_names[k] then return __null_signal end
|
|
||||||
-- любой метод → функция которая возвращает stub (поддерживает оба синтаксиса)
|
|
||||||
return function(...) return __null_stub_singleton end
|
|
||||||
end
|
|
||||||
__null_stub_mt.__newindex = function(t, k, v) rawset(t, k, v) end
|
|
||||||
__null_stub_mt.__call = function(t, ...) return __null_stub_singleton end
|
|
||||||
-- Сделаем __null_stub_singleton.Parent = сам себя (lazy)
|
|
||||||
rawset(__null_stub_singleton, "Parent", __null_stub_singleton)
|
|
||||||
`);
|
|
||||||
// Заменяем __rbxl_make_null_stub на Lua-side функцию
|
|
||||||
await state.lua.doString(`
|
|
||||||
function __rbxl_make_null_stub() return __null_stub_singleton end
|
|
||||||
`);
|
|
||||||
// КРИТИЧНО: расширенные metatable для nil + function + number чтобы
|
|
||||||
// любые цепочки nil.x.y:method() и func.x не валили скрипты.
|
|
||||||
await state.lua.doString(`
|
|
||||||
if debug and debug.setmetatable then
|
|
||||||
local _stub_mt = {
|
|
||||||
__index = function(t, k) return __null_stub_singleton end,
|
|
||||||
__newindex = function(t, k, v) end,
|
|
||||||
__call = function(t, ...) return __null_stub_singleton end,
|
|
||||||
__add = function(a, b) return 0 end,
|
|
||||||
__sub = function(a, b) return 0 end,
|
|
||||||
__mul = function(a, b) return 0 end,
|
|
||||||
__div = function(a, b) return 0 end,
|
|
||||||
__mod = function(a, b) return 0 end,
|
|
||||||
__pow = function(a, b) return 0 end,
|
|
||||||
__unm = function() return 0 end,
|
|
||||||
__concat = function(a, b) return "" end,
|
|
||||||
__len = function() return 0 end,
|
|
||||||
__eq = function(a, b) return false end,
|
|
||||||
__lt = function(a, b) return false end,
|
|
||||||
__le = function(a, b) return false end,
|
|
||||||
__tostring = function() return "nil" end,
|
|
||||||
}
|
|
||||||
debug.setmetatable(nil, _stub_mt)
|
|
||||||
debug.setmetatable(function() end, _stub_mt)
|
|
||||||
-- НЕ ставим на number/string/boolean — они должны работать нормально
|
|
||||||
end
|
|
||||||
`);
|
|
||||||
// helper: безопасный pcall с warn'ом при ошибке
|
|
||||||
await state.lua.doString(`
|
|
||||||
__rbxl_scripts = {}
|
|
||||||
function __rbxl_safe_run(id, fn)
|
function __rbxl_safe_run(id, fn)
|
||||||
local ok, err = pcall(fn)
|
local ok, err = pcall(fn)
|
||||||
if not ok then warn("[rbxl-lua " .. tostring(id) .. " err] " .. tostring(err)) end
|
if not ok then
|
||||||
end
|
warn("[rbxl-lua " .. tostring(id) .. " partial fail] " .. tostring(err))
|
||||||
-- Lookup Part по primitiveId. Используем __rbxl_parts_by_id из JS,
|
|
||||||
-- т.к. ipairs() на JS array не работает (0-indexed vs Lua 1-indexed).
|
|
||||||
function __rbxl_lookup_part(id)
|
|
||||||
if __rbxl_parts_by_id then
|
|
||||||
return __rbxl_parts_by_id[tostring(id)] or __rbxl_parts_by_id[id]
|
|
||||||
end
|
end
|
||||||
return nil
|
|
||||||
end
|
end
|
||||||
`);
|
`);
|
||||||
send('boot', null);
|
send('boot', null);
|
||||||
@ -256,125 +101,78 @@ async function handleInit({ sceneSnap, guiTree }) {
|
|||||||
await state.initPromise;
|
await state.initPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleAddScripts({ scripts }) {
|
async function handleAddScript({ id, target, luaSource }) {
|
||||||
if (!state.lua) { log('error', 'addScripts before init'); return; }
|
if (!state.lua) {
|
||||||
let ok = 0, fail = 0;
|
log('error', 'addScript before init');
|
||||||
for (const s of scripts) {
|
return;
|
||||||
const safeId = String(s.id).replace(/[^a-zA-Z0-9_]/g, '_');
|
|
||||||
const targetExpr = s.target != null
|
|
||||||
? `__rbxl_lookup_part(${JSON.stringify(s.target)}) or __rbxl_make_null_stub()`
|
|
||||||
: '__rbxl_make_null_stub()';
|
|
||||||
// Оборачиваем в pcall. script — локальный, не конфликтует между скриптами.
|
|
||||||
// script.Parent НИКОГДА не nil — даём nullStub чтобы цепочки
|
|
||||||
// script.Parent.Parent.X не валили.
|
|
||||||
const wrapped = `
|
|
||||||
do
|
|
||||||
local script = setmetatable({
|
|
||||||
Name = "Script_${safeId}",
|
|
||||||
Parent = ${targetExpr},
|
|
||||||
ClassName = "LocalScript",
|
|
||||||
}, { __index = function(t, k) return rawget(t, k) end })
|
|
||||||
__rbxl_safe_run("${safeId}", function()
|
|
||||||
${s.luaSource}
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
`;
|
|
||||||
try {
|
|
||||||
await state.lua.doString(wrapped);
|
|
||||||
ok++;
|
|
||||||
} catch (e) {
|
|
||||||
fail++;
|
|
||||||
// ошибки парсинга/runtime, считаем но не валим всё
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
state.scriptCount = ok;
|
// Загружаем скрипт как локальную функцию которая будет вызвана в pcall.
|
||||||
send('ready', { ok, fail });
|
// Создаём для него локальный script={Parent=target_part} объект через
|
||||||
}
|
// глобальный workspace lookup.
|
||||||
|
const safeId = String(id).replace(/[^a-zA-Z0-9_]/g, '_');
|
||||||
function handleStart() {
|
const targetExpr = target != null ? `__rbxl_lookup_part(${JSON.stringify(target)})` : 'nil';
|
||||||
state.eventsStarted = true;
|
const wrapped = `
|
||||||
// Эмитим Players.PlayerAdded + CharacterAdded чтобы скрипты которые
|
do
|
||||||
// делают game.Players.PlayerAdded:Connect(...) получили событие.
|
local script = { Parent = ${targetExpr}, Name = "Script_${safeId}" }
|
||||||
|
__rbxl_safe_run("${safeId}", function()
|
||||||
|
${luaSource}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
`;
|
||||||
try {
|
try {
|
||||||
const lp = state.api?.localPlayer;
|
// Регистрируем helper для lookup'а Part'а по id (один раз)
|
||||||
const players = state.api?.services?.get('Players');
|
if (!state._lookupRegistered) {
|
||||||
if (lp && players?.PlayerAdded?.Fire) players.PlayerAdded.Fire(lp);
|
await state.lua.doString(`
|
||||||
if (lp?.CharacterAdded?.Fire && lp.Character) lp.CharacterAdded.Fire(lp.Character);
|
function __rbxl_lookup_part(id)
|
||||||
} catch (e) {}
|
if not workspace or not workspace.GetChildren then return nil end
|
||||||
// Флушим накопленные события
|
for _, c in ipairs(workspace:GetChildren()) do
|
||||||
for (const e of state.pendingEvents) handleEvent(e);
|
if c.__primId == id then return c end
|
||||||
state.pendingEvents = [];
|
end
|
||||||
}
|
return nil
|
||||||
|
end
|
||||||
function handleTick({ dt }) {
|
`);
|
||||||
if (state.isStopped || !state.lua) return;
|
state._lookupRegistered = true;
|
||||||
state.nowSec += dt || 0;
|
|
||||||
// Резолвим планированные корутины
|
|
||||||
if (state.coroutines.length > 0) {
|
|
||||||
const due = [];
|
|
||||||
const left = [];
|
|
||||||
for (const c of state.coroutines) {
|
|
||||||
if (c.resumeAt <= state.nowSec) due.push(c); else left.push(c);
|
|
||||||
}
|
|
||||||
state.coroutines = left;
|
|
||||||
for (const c of due) {
|
|
||||||
try { c.fn(); } catch (e) { log('warn', `coroutine err: ${e?.message || e}`); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// RunService сигналы
|
|
||||||
try {
|
|
||||||
const rs = state.api?.services?.get('RunService');
|
|
||||||
if (rs?.Heartbeat?.Fire) rs.Heartbeat.Fire(dt);
|
|
||||||
if (rs?.Stepped?.Fire) rs.Stepped.Fire(state.nowSec, dt);
|
|
||||||
if (rs?.RenderStepped?.Fire) rs.RenderStepped.Fire(dt);
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleEvent(payload) {
|
|
||||||
if (state.isStopped || !state.lua || !state.api) return;
|
|
||||||
const { kind } = payload || {};
|
|
||||||
try {
|
|
||||||
if (kind === 'guiClick' || kind === 'guiActivated') {
|
|
||||||
const guiId = payload.guiId;
|
|
||||||
const inst = state.api.gui_by_id?.get(guiId);
|
|
||||||
if (inst) {
|
|
||||||
if (kind === 'guiActivated') inst.Activated?.Fire?.(1);
|
|
||||||
else inst.MouseButton1Click?.Fire?.();
|
|
||||||
}
|
|
||||||
} else if (kind === 'touched') {
|
|
||||||
const primId = payload.primId;
|
|
||||||
const part = state.api.part_by_id?.get(primId);
|
|
||||||
if (part?.Touched?.Fire) {
|
|
||||||
// hit = HumanoidRootPart
|
|
||||||
part.Touched.Fire(state.api.character?.HumanoidRootPart || part);
|
|
||||||
}
|
|
||||||
// также Humanoid.Touched на самом игроке
|
|
||||||
if (payload.isPlayer) {
|
|
||||||
state.api.humanoid?.Touched?.Fire?.(part);
|
|
||||||
}
|
|
||||||
} else if (kind === 'keydown' || kind === 'keyup') {
|
|
||||||
// UserInputService.InputBegan/Ended
|
|
||||||
const uis = state.api.services?.get('UserInputService') ||
|
|
||||||
(() => {
|
|
||||||
const s = new (state.lua.global.get('Instance')?.new ? Object : Object)();
|
|
||||||
return null;
|
|
||||||
})();
|
|
||||||
if (uis) {
|
|
||||||
if (kind === 'keydown') uis.InputBegan?.Fire?.({ KeyCode: { Name: payload.key } });
|
|
||||||
else uis.InputEnded?.Fire?.({ KeyCode: { Name: payload.key } });
|
|
||||||
}
|
|
||||||
} else if (kind === 'humanoidDied') {
|
|
||||||
state.api.humanoid?.Died?.Fire?.();
|
|
||||||
} else if (kind === 'humanoidHealth') {
|
|
||||||
const h = state.api.humanoid;
|
|
||||||
if (h) {
|
|
||||||
h.Health = payload.health;
|
|
||||||
h.HealthChanged?.Fire?.(payload.health);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
await state.lua.doString(wrapped);
|
||||||
|
send('ready', { scriptId: id, ok: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log('warn', `event ${kind} err: ${e?.message || 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;
|
self.__rbxlSharedState = state;
|
||||||
|
|||||||
@ -1,26 +1,33 @@
|
|||||||
/**
|
/**
|
||||||
* rbxl-lua-integration.js — single-VM интеграция (v2).
|
* rbxl-lua-integration.js — single-VM интеграция Roblox-Lua скриптов.
|
||||||
*
|
*
|
||||||
* Двухфазная инициализация:
|
* Архитектура (single-VM):
|
||||||
* 1) init worker → pre-populate workspace + GUI tree (включая сигналы)
|
* - Один shared Worker для ВСЕХ Roblox-Lua скриптов проекта
|
||||||
* 2) addScriptsBatch ВСЕ скрипты одним IPC сообщением
|
* - Один wasmoon Lua-state
|
||||||
* 3) ready → kickoff → emit PlayerAdded, начать tick
|
* - Скрипты добавляются через addScript(id, target, luaSource)
|
||||||
|
*
|
||||||
|
* Это снимает WASM OOM (1 wasmoon ~ 16 MB, не 742 × 16 MB).
|
||||||
*/
|
*/
|
||||||
import RobloxLuaSharedWorker from './RobloxLuaSharedWorker.js?worker';
|
import RobloxLuaSharedWorker from './RobloxLuaSharedWorker.js?worker';
|
||||||
import { RobloxLuaSharedSandbox } from './RobloxLuaSharedSandbox.js';
|
import { RobloxLuaSharedSandbox } from './RobloxLuaSharedSandbox.js';
|
||||||
|
|
||||||
/** Распаковка lua_source из packed-кода. */
|
/**
|
||||||
|
* Распаковывает Lua-исходник из поля code упакованного rbxl-importer'ом.
|
||||||
|
* Формат: "// @roblox-lua\\n// {meta json}\\n/[*] lua_source:\\n<код>\\n[*]/"
|
||||||
|
*/
|
||||||
export function unpackRobloxLuaCode(code) {
|
export function unpackRobloxLuaCode(code) {
|
||||||
const openTag = '/[*] lua_source:\n'.replace('[*]', '*');
|
const openTag = '/* lua_source:\n';
|
||||||
const i = code.indexOf(openTag);
|
const i = code.indexOf(openTag);
|
||||||
if (i < 0) return null;
|
if (i < 0) return null;
|
||||||
const start = i + openTag.length;
|
const start = i + openTag.length;
|
||||||
const closeIdx = code.lastIndexOf('\n*' + '/');
|
const closeIdx = code.lastIndexOf('\n*/');
|
||||||
if (closeIdx < start) return null;
|
if (closeIdx < start) return null;
|
||||||
return code.slice(start, closeIdx);
|
return code.slice(start, closeIdx);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Сцена → snap для shim'а (workspace:GetChildren). */
|
/**
|
||||||
|
* Snap сцены для Lua-shim (workspace:GetChildren).
|
||||||
|
*/
|
||||||
export function buildLuaSceneSnap(primitives) {
|
export function buildLuaSceneSnap(primitives) {
|
||||||
const out = { primitives: {} };
|
const out = { primitives: {} };
|
||||||
if (!Array.isArray(primitives)) return out;
|
if (!Array.isArray(primitives)) return out;
|
||||||
@ -38,81 +45,52 @@ export function buildLuaSceneSnap(primitives) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GUI-tree для shim'а. Mapping origin → __roblox_class.
|
* Создаёт shared sandbox менеджер, добавляет все валидные скрипты и
|
||||||
* scene.gui — массив элементов с {id, type, name, parentId, ...origin}.
|
* возвращает его. GameRuntime пушит результат в this.sandboxes ОДИН раз.
|
||||||
* Возвращаем массив сохраняя порядок parent → child (важно для tree-сборки).
|
*
|
||||||
*/
|
* @param {Array} scripts — entries из state.scripts (с маркером // @roblox-lua)
|
||||||
export function buildLuaGuiTree(guiElements) {
|
* @param {Object} ctx — { primitives, onCommand(cmd, payload) }
|
||||||
if (!Array.isArray(guiElements)) return [];
|
* @returns {{ sandbox: RobloxLuaSharedSandbox, count: number, filtered: number } | null}
|
||||||
const out = [];
|
|
||||||
for (const el of guiElements) {
|
|
||||||
// origin = 'roblox-textbutton' → 'TextButton'
|
|
||||||
let rblClass = 'Frame';
|
|
||||||
const origin = el.origin || '';
|
|
||||||
if (origin.startsWith('roblox-')) {
|
|
||||||
const tail = origin.slice(7);
|
|
||||||
rblClass = tail.charAt(0).toUpperCase() + tail.slice(1);
|
|
||||||
// Camel-case "textbutton" → "TextButton"
|
|
||||||
if (rblClass.toLowerCase() === 'textbutton') rblClass = 'TextButton';
|
|
||||||
else if (rblClass.toLowerCase() === 'textlabel') rblClass = 'TextLabel';
|
|
||||||
else if (rblClass.toLowerCase() === 'imagebutton') rblClass = 'ImageButton';
|
|
||||||
else if (rblClass.toLowerCase() === 'imagelabel') rblClass = 'ImageLabel';
|
|
||||||
else if (rblClass.toLowerCase() === 'textbox') rblClass = 'TextBox';
|
|
||||||
else if (rblClass.toLowerCase() === 'scrollingframe') rblClass = 'ScrollingFrame';
|
|
||||||
else if (rblClass.toLowerCase() === 'frame') rblClass = 'Frame';
|
|
||||||
} else {
|
|
||||||
// Если origin не задан — гадаем по type
|
|
||||||
const t = el.type;
|
|
||||||
if (t === 'button') rblClass = 'TextButton';
|
|
||||||
else if (t === 'text') rblClass = 'TextLabel';
|
|
||||||
else if (t === 'image') rblClass = 'ImageLabel';
|
|
||||||
else if (t === 'textbox') rblClass = 'TextBox';
|
|
||||||
}
|
|
||||||
out.push({
|
|
||||||
id: el.id,
|
|
||||||
name: el.name || rblClass,
|
|
||||||
parentId: el.parentId || null,
|
|
||||||
visible: el.visible !== false,
|
|
||||||
text: el.text || '',
|
|
||||||
__roblox_class: rblClass,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Старт shared-sandbox: init → addScripts → kickoff.
|
|
||||||
*/
|
*/
|
||||||
export function startRobloxLuaShared(scripts, ctx) {
|
export function startRobloxLuaShared(scripts, ctx) {
|
||||||
try {
|
try {
|
||||||
const luaScripts = [];
|
const luaScripts = [];
|
||||||
|
let filtered = 0;
|
||||||
for (const s of scripts) {
|
for (const s of scripts) {
|
||||||
if (!s || typeof s.code !== 'string') continue;
|
if (!s || typeof s.code !== 'string') continue;
|
||||||
if (!s.code.startsWith('// @roblox-lua')) continue;
|
if (!s.code.startsWith('// @roblox-lua')) continue;
|
||||||
const luaSource = unpackRobloxLuaCode(s.code);
|
const luaSource = unpackRobloxLuaCode(s.code);
|
||||||
if (!luaSource) continue;
|
if (!luaSource) { filtered++; continue; }
|
||||||
|
// Фильтр: скрипты декомпилированные из Synapse X / HD Admin — обычно
|
||||||
|
// длинные и сервисные. Оставим только короткие с target.
|
||||||
|
// Но в single-VM режиме лимита на количество нет — пробуем все.
|
||||||
luaScripts.push({ id: s.id, target: s.target, luaSource });
|
luaScripts.push({ id: s.id, target: s.target, luaSource });
|
||||||
}
|
}
|
||||||
if (luaScripts.length === 0) return { sandbox: null, count: 0 };
|
if (luaScripts.length === 0) return { sandbox: null, count: 0, filtered };
|
||||||
|
|
||||||
const worker = new RobloxLuaSharedWorker();
|
const worker = new RobloxLuaSharedWorker();
|
||||||
const sceneSnap = buildLuaSceneSnap(ctx.primitives);
|
const sceneSnap = buildLuaSceneSnap(ctx.primitives);
|
||||||
const guiTree = buildLuaGuiTree(ctx.guiElements || []);
|
|
||||||
const mgr = new RobloxLuaSharedSandbox();
|
const mgr = new RobloxLuaSharedSandbox();
|
||||||
mgr.setOnCommand(ctx.onCommand);
|
mgr.setOnCommand(ctx.onCommand);
|
||||||
mgr.start(sceneSnap, guiTree, worker);
|
mgr.start(sceneSnap, worker);
|
||||||
mgr.addScriptsBatch(luaScripts);
|
for (const ls of luaScripts) {
|
||||||
mgr.kickoff();
|
mgr.addScript(ls.id, ls.target, ls.luaSource);
|
||||||
return { sandbox: mgr, count: luaScripts.length };
|
}
|
||||||
|
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-shared v2] start failed:', e?.message || e);
|
console.warn('[rbxl-lua-shared] start failed:', e?.message || e);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Обработка IPC команд от worker'а — мапим на действия в Babylon-сцене.
|
* Маппинг IPC команд от shared sandbox на действия в Babylon-сцене.
|
||||||
|
*
|
||||||
|
* @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') {
|
if (cmd === 'log') {
|
||||||
@ -141,9 +119,12 @@ export function handleLuaCommand(_scriptId, cmd, payload, runtime) {
|
|||||||
else if (prop === 'anchored') patch.anchored = value;
|
else if (prop === 'anchored') patch.anchored = value;
|
||||||
else if (prop === 'canCollide') patch.canCollide = value;
|
else if (prop === 'canCollide') patch.canCollide = value;
|
||||||
else if (prop === 'opacity') patch.opacity = value;
|
else if (prop === 'opacity') patch.opacity = value;
|
||||||
|
else if (prop === 'rotation' && value) {
|
||||||
|
patch.rotationX = value.rx; patch.rotationY = value.ry; patch.rotationZ = value.rz;
|
||||||
|
}
|
||||||
if (typeof pm.applyPatch === 'function') pm.applyPatch(primId, patch);
|
if (typeof pm.applyPatch === 'function') pm.applyPatch(primId, patch);
|
||||||
else if (typeof pm.update === 'function') pm.update(primId, patch);
|
else if (typeof pm.update === 'function') pm.update(primId, patch);
|
||||||
} catch (e) {}
|
} catch (e) { /* swallow */ }
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (cmd === 'partVel') {
|
if (cmd === 'partVel') {
|
||||||
@ -170,8 +151,5 @@ export function handleLuaCommand(_scriptId, cmd, payload, runtime) {
|
|||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (cmd === 'guiUpdate') {
|
|
||||||
// TODO: scripts setting Visible/Text/Color on GUI → передать в GuiManager
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -179,21 +179,20 @@ class RbxSignal {
|
|||||||
this.connections.push(conn);
|
this.connections.push(conn);
|
||||||
return {
|
return {
|
||||||
Disconnect: () => { conn.connected = false; },
|
Disconnect: () => { conn.connected = false; },
|
||||||
disconnect: () => { conn.connected = false; },
|
|
||||||
Connected: () => conn.connected,
|
Connected: () => conn.connected,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
// Legacy Roblox API — lowercase alias
|
Wait() {
|
||||||
connect(callback) { return this.Connect(callback); }
|
// В рамках MVP — Wait не блокирует (т.к. wasmoon без корутин это сложно).
|
||||||
Wait() { return null; }
|
// Реальный Wait появится в 4.7 через task.wait.
|
||||||
wait() { return null; }
|
return null;
|
||||||
|
}
|
||||||
Fire(...args) {
|
Fire(...args) {
|
||||||
for (const c of this.connections) {
|
for (const c of this.connections) {
|
||||||
if (!c.connected) continue;
|
if (!c.connected) continue;
|
||||||
try { c.callback(...args); } catch (e) { /* swallow */ }
|
try { c.callback(...args); } catch (e) { /* swallow */ }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fire(...args) { return this.Fire(...args); }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ──────── Instance прокси ──────── */
|
/* ──────── Instance прокси ──────── */
|
||||||
@ -205,51 +204,27 @@ let _instanceCounter = 1;
|
|||||||
// game.Players.LocalPlayer:WaitForChild("PlayerGui"):WaitForChild("X").Touched:Connect(fn)
|
// game.Players.LocalPlayer:WaitForChild("PlayerGui"):WaitForChild("X").Touched:Connect(fn)
|
||||||
// не падали с "attempt to call js_null", когда промежуточный объект не существует.
|
// не падали с "attempt to call js_null", когда промежуточный объект не существует.
|
||||||
// Все методы возвращают сам _nullStub чтобы цепочка не разорвалась.
|
// Все методы возвращают сам _nullStub чтобы цепочка не разорвалась.
|
||||||
// nullSignal: callable proxy. Lua делает x:Connect(fn) = x.Connect(x, fn),
|
const _nullSignal = {
|
||||||
// но также pattern signal:Connect(fn) сначала достаёт signal.Connect (это функция),
|
Connect: () => ({ Disconnect: () => {}, Connected: () => false }),
|
||||||
// потом вызывает её. Мы возвращаем функцию которая безусловно возвращает {Disconnect}.
|
Wait: () => null,
|
||||||
const _nullConn = { Disconnect: () => {}, disconnect: () => {}, Connected: false };
|
Fire: () => {},
|
||||||
const _nullSignalFn = () => _nullConn;
|
};
|
||||||
const _nullSignal = new Proxy(_nullSignalFn, {
|
const _nullStub = new Proxy({ __isNullStub: true, ClassName: 'Nil', Name: 'Nil', Children: [], Parent: null }, {
|
||||||
get(_, k) {
|
get(target, prop) {
|
||||||
if (k === 'Connect' || k === 'connect') return _nullSignalFn;
|
if (prop in target) return target[prop];
|
||||||
if (k === 'Wait' || k === 'wait') return () => null;
|
if (prop === Symbol.toPrimitive || prop === 'toString' || prop === 'toJSON') return () => 'Nil';
|
||||||
if (k === 'Fire' || k === 'fire') return () => {};
|
if (prop === Symbol.iterator) return undefined;
|
||||||
return undefined;
|
// Любой method/прoperty access на nullStub — функция/property которая возвращает stub
|
||||||
|
return new Proxy(function () { return _nullStub; }, {
|
||||||
|
get(_, p2) {
|
||||||
|
// Сигналы (Touched, Changed, ...) — возвращаем null-сигнал
|
||||||
|
if (typeof p2 === 'string' && /^[A-Z]/.test(p2)) return _nullSignal;
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
has() { return true; },
|
||||||
});
|
});
|
||||||
// Известные имена сигналов (Touched, Changed, MouseButton1Click, ...)
|
|
||||||
const _SIGNAL_NAMES = new Set([
|
|
||||||
'Touched','TouchEnded','Changed','Activated',
|
|
||||||
'MouseButton1Click','MouseButton1Down','MouseButton1Up',
|
|
||||||
'MouseButton2Click','MouseButton2Down','MouseButton2Up',
|
|
||||||
'MouseEnter','MouseLeave','InputBegan','InputEnded','InputChanged',
|
|
||||||
'PlayerAdded','PlayerRemoving','CharacterAdded','CharacterRemoving',
|
|
||||||
'Heartbeat','Stepped','RenderStepped','Died','HealthChanged',
|
|
||||||
'FocusLost','Focused','ChildAdded','ChildRemoved',
|
|
||||||
'AncestryChanged','DescendantAdded','DescendantRemoving',
|
|
||||||
// Tool сигналы
|
|
||||||
'Equipped','Unequipped','Selected','Deselected',
|
|
||||||
// прочие популярные
|
|
||||||
'OnInvoke','OnServerInvoke','OnClientInvoke',
|
|
||||||
'OnServerEvent','OnClientEvent','Fired','Triggered',
|
|
||||||
'ChatMakeSystemMessage','ChatMade',
|
|
||||||
]);
|
|
||||||
// _makeDeepStub — рекурсивный proxy которому всё равно сколько раз его
|
|
||||||
// индексируют. На любом уровне:
|
|
||||||
// - caps-имя из _SIGNAL_NAMES → возвращает _nullSignal
|
|
||||||
// - 'Parent' → возвращает _nullStub
|
|
||||||
// - любое другое имя → callable proxy + рекурсивная глубина
|
|
||||||
// Это позволяет цепочкам типа `tool.Selected:Connect(fn)` или
|
|
||||||
// `script.Parent.Parent.Frame.Visible` молча no-op'аться.
|
|
||||||
// Вместо JS Proxy (который wasmoon оборачивает в js_promise) — используем
|
|
||||||
// специальный маркер. Реальный stub живёт на Lua-стороне.
|
|
||||||
const NULL_STUB_MARKER = { __isNullStubMarker: true };
|
|
||||||
function _makeDeepStub() { return NULL_STUB_MARKER; }
|
|
||||||
const _nullStubBase = { __isNullStub: true, ClassName: 'Nil', Name: 'Nil', Children: [], Value: 0, Text: '', Visible: false };
|
|
||||||
// _nullStub оставлен как маркер, но не используется как реальный stub —
|
|
||||||
// debug.setmetatable(nil) в Lua перехватывает всё это.
|
|
||||||
const _nullStub = _nullStubBase;
|
|
||||||
|
|
||||||
class RbxInstance {
|
class RbxInstance {
|
||||||
constructor(className, init = {}) {
|
constructor(className, init = {}) {
|
||||||
@ -291,18 +266,18 @@ class RbxInstance {
|
|||||||
if (c.Name === name) return c;
|
if (c.Name === name) return c;
|
||||||
if (recursive) {
|
if (recursive) {
|
||||||
const found = c.FindFirstChild(name, true);
|
const found = c.FindFirstChild(name, true);
|
||||||
if (found) return found;
|
if (found && found !== _nullStub) return found;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Возвращаем undefined — wasmoon отдаст это как nil.
|
// Возвращаем null-stub вместо null чтобы цепочки `:WaitForChild():Connect()`
|
||||||
// Lua-side debug.setmetatable(nil) перехватит дальнейшую индексацию.
|
// молча no-op'ались вместо падения с "attempt to call js_null".
|
||||||
return undefined;
|
return _nullStub;
|
||||||
}
|
}
|
||||||
FindFirstChildOfClass(className) {
|
FindFirstChildOfClass(className) {
|
||||||
for (const c of this.Children) {
|
for (const c of this.Children) {
|
||||||
if (c.ClassName === className) return c;
|
if (c.ClassName === className) return c;
|
||||||
}
|
}
|
||||||
return undefined;
|
return _nullStub;
|
||||||
}
|
}
|
||||||
FindFirstAncestor(name) {
|
FindFirstAncestor(name) {
|
||||||
let p = this.Parent;
|
let p = this.Parent;
|
||||||
@ -310,7 +285,7 @@ class RbxInstance {
|
|||||||
if (p.Name === name) return p;
|
if (p.Name === name) return p;
|
||||||
p = p.Parent;
|
p = p.Parent;
|
||||||
}
|
}
|
||||||
return undefined;
|
return _nullStub;
|
||||||
}
|
}
|
||||||
WaitForChild(name, _timeout) {
|
WaitForChild(name, _timeout) {
|
||||||
// В MVP — синхронный поиск без ожидания. В 4.7 через корутины можно ждать.
|
// В MVP — синхронный поиск без ожидания. В 4.7 через корутины можно ждать.
|
||||||
@ -448,7 +423,7 @@ class RbxPart extends RbxInstance {
|
|||||||
/* ──────── Главный entry-point: регистрация в Lua-VM ──────── */
|
/* ──────── Главный entry-point: регистрация в Lua-VM ──────── */
|
||||||
|
|
||||||
export function registerRobloxApi(lua, ctx) {
|
export function registerRobloxApi(lua, ctx) {
|
||||||
const { getSceneSnap, targetPrimitiveId, send, getGuiTree, scheduler } = ctx;
|
const { getSceneSnap, targetPrimitiveId, send } = ctx;
|
||||||
|
|
||||||
// 1. Math classes — как глобалы с .new factory
|
// 1. Math classes — как глобалы с .new factory
|
||||||
const wrap = (cls) => ({
|
const wrap = (cls) => ({
|
||||||
@ -494,69 +469,20 @@ export function registerRobloxApi(lua, ctx) {
|
|||||||
snap: { ...p },
|
snap: { ...p },
|
||||||
});
|
});
|
||||||
part.__sendFn = send;
|
part.__sendFn = send;
|
||||||
// Сигналы Part: Touched/TouchEnded существуют на каждом по умолчанию
|
|
||||||
part.Touched = new RbxSignal('Touched');
|
|
||||||
part.TouchEnded = new RbxSignal('TouchEnded');
|
|
||||||
part.Parent = workspace;
|
part.Parent = workspace;
|
||||||
workspace.Children.push(part);
|
workspace.Children.push(part);
|
||||||
part_by_id.set(+id, part);
|
part_by_id.set(+id, part);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2b. GUI-tree: предсоздаём ScreenGui + Frame/Button/Label/etc по дереву
|
// 3. script — обёртка над текущим скриптом.
|
||||||
// конвертера. Каждый button получает MouseButton1Click/MouseButton1Down/Up
|
const scriptInst = new RbxInstance('LocalScript', { Name: 'Script' });
|
||||||
// сигналы которые fire'аются из main через sendGlobalEvent('guiClick').
|
|
||||||
const gui_by_id = new Map();
|
|
||||||
// PlayerGui контейнер внутри Players.LocalPlayer
|
|
||||||
const playerGui = new RbxInstance('PlayerGui', { Name: 'PlayerGui' });
|
|
||||||
if (getGuiTree) {
|
|
||||||
const tree = getGuiTree() || [];
|
|
||||||
// первый проход — создаём instances
|
|
||||||
for (const el of tree) {
|
|
||||||
const cls = el.__roblox_class || 'Frame';
|
|
||||||
const inst = new RbxInstance(cls, { Name: el.name || cls });
|
|
||||||
inst.__guiId = el.id;
|
|
||||||
inst.Visible = el.visible !== false;
|
|
||||||
inst.Text = el.text || '';
|
|
||||||
// Стандартные сигналы кнопок
|
|
||||||
if (cls === 'TextButton' || cls === 'ImageButton') {
|
|
||||||
inst.MouseButton1Click = new RbxSignal('MouseButton1Click');
|
|
||||||
inst.MouseButton1Down = new RbxSignal('MouseButton1Down');
|
|
||||||
inst.MouseButton1Up = new RbxSignal('MouseButton1Up');
|
|
||||||
inst.Activated = new RbxSignal('Activated');
|
|
||||||
inst.MouseEnter = new RbxSignal('MouseEnter');
|
|
||||||
inst.MouseLeave = new RbxSignal('MouseLeave');
|
|
||||||
}
|
|
||||||
// FocusLost для textboxes
|
|
||||||
if (cls === 'TextBox') {
|
|
||||||
inst.FocusLost = new RbxSignal('FocusLost');
|
|
||||||
inst.Focused = new RbxSignal('Focused');
|
|
||||||
}
|
|
||||||
// Changed-сигнал у каждого
|
|
||||||
inst.Changed = new RbxSignal('Changed');
|
|
||||||
gui_by_id.set(el.id, inst);
|
|
||||||
}
|
|
||||||
// второй проход — parent-связи (parentId → Instance)
|
|
||||||
for (const el of tree) {
|
|
||||||
const inst = gui_by_id.get(el.id);
|
|
||||||
if (!inst) continue;
|
|
||||||
const parentInst = el.parentId ? gui_by_id.get(el.parentId) : playerGui;
|
|
||||||
if (parentInst) {
|
|
||||||
inst.Parent = parentInst;
|
|
||||||
parentInst.Children.push(inst);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. script — в shared-режиме не глобал, а локально создаётся при addScript.
|
|
||||||
// Здесь только заглушка чтобы простые non-shared скрипты не падали.
|
|
||||||
if (targetPrimitiveId != null && part_by_id.has(targetPrimitiveId)) {
|
if (targetPrimitiveId != null && part_by_id.has(targetPrimitiveId)) {
|
||||||
const parentPart = part_by_id.get(targetPrimitiveId);
|
const parentPart = part_by_id.get(targetPrimitiveId);
|
||||||
const scriptInst = new RbxInstance('LocalScript', { Name: 'Script' });
|
|
||||||
scriptInst.Parent = parentPart;
|
scriptInst.Parent = parentPart;
|
||||||
parentPart.Children.push(scriptInst);
|
parentPart.Children.push(scriptInst);
|
||||||
lua.global.set('script', scriptInst);
|
|
||||||
}
|
}
|
||||||
|
lua.global.set('script', scriptInst);
|
||||||
|
|
||||||
// 4. game / game:GetService
|
// 4. game / game:GetService
|
||||||
const services = new Map();
|
const services = new Map();
|
||||||
@ -584,35 +510,8 @@ export function registerRobloxApi(lua, ctx) {
|
|||||||
const playersService = new RbxInstance('Players', { Name: 'Players' });
|
const playersService = new RbxInstance('Players', { Name: 'Players' });
|
||||||
playersService.PlayerAdded = new RbxSignal('PlayerAdded');
|
playersService.PlayerAdded = new RbxSignal('PlayerAdded');
|
||||||
playersService.PlayerRemoving = new RbxSignal('PlayerRemoving');
|
playersService.PlayerRemoving = new RbxSignal('PlayerRemoving');
|
||||||
// LocalPlayer с PlayerGui + Character
|
// LocalPlayer заполнит фаза 4.9
|
||||||
const localPlayer = new RbxInstance('Player', { Name: 'Player1' });
|
playersService.LocalPlayer = null;
|
||||||
localPlayer.UserId = 1;
|
|
||||||
localPlayer.PlayerGui = playerGui;
|
|
||||||
playerGui.Parent = localPlayer;
|
|
||||||
localPlayer.Children.push(playerGui);
|
|
||||||
// Character заглушка с Humanoid и HumanoidRootPart
|
|
||||||
const character = new RbxInstance('Model', { Name: 'Character' });
|
|
||||||
const humanoid = new RbxInstance('Humanoid', { Name: 'Humanoid' });
|
|
||||||
humanoid.WalkSpeed = 16;
|
|
||||||
humanoid.JumpPower = 50;
|
|
||||||
humanoid.Health = 100;
|
|
||||||
humanoid.MaxHealth = 100;
|
|
||||||
humanoid.Died = new RbxSignal('Died');
|
|
||||||
humanoid.HealthChanged = new RbxSignal('HealthChanged');
|
|
||||||
humanoid.Touched = new RbxSignal('Touched');
|
|
||||||
humanoid.Parent = character;
|
|
||||||
character.Children.push(humanoid);
|
|
||||||
character.Humanoid = humanoid;
|
|
||||||
const hrp = new RbxPart(-1, { ClassName: 'Part', Name: 'HumanoidRootPart' });
|
|
||||||
hrp.Touched = new RbxSignal('Touched');
|
|
||||||
hrp.Parent = character;
|
|
||||||
character.Children.push(hrp);
|
|
||||||
character.HumanoidRootPart = hrp;
|
|
||||||
localPlayer.Character = character;
|
|
||||||
localPlayer.CharacterAdded = new RbxSignal('CharacterAdded');
|
|
||||||
localPlayer.CharacterRemoving = new RbxSignal('CharacterRemoving');
|
|
||||||
playersService.LocalPlayer = localPlayer;
|
|
||||||
playersService.Children.push(localPlayer);
|
|
||||||
services.set('Players', playersService);
|
services.set('Players', playersService);
|
||||||
|
|
||||||
game.GetService = function(svc) {
|
game.GetService = function(svc) {
|
||||||
@ -645,31 +544,25 @@ export function registerRobloxApi(lua, ctx) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// 6. wait/task.wait через scheduler. scheduler — main-side, поддерживает
|
// 6. wait / task / spawn — в фазе 4.7 заменим на корутинные.
|
||||||
// schedule(sec, fn) что fire'ит fn после задержки в следующих tick'ах.
|
// Сейчас — простой busy-wait через setTimeout не работает в Worker (sync).
|
||||||
// spawn/delay/defer запускают функцию через scheduler.spawn (отдельная корутина).
|
// Поэтому MVP: wait это no-op, log warning.
|
||||||
const sched = scheduler || {
|
|
||||||
schedule: (sec, fn) => { try { fn(); } catch (e) {} },
|
|
||||||
spawn: (fn) => { try { fn(); } catch (e) {} },
|
|
||||||
now: () => Date.now() / 1000,
|
|
||||||
};
|
|
||||||
lua.global.set('wait', (sec) => {
|
lua.global.set('wait', (sec) => {
|
||||||
// В корутине: yield на (sec || 0). Scheduler сам resume'ит.
|
// TODO 4.7: реализовать через корутины
|
||||||
// Тут мы синхронны (вызов из Lua) — реальный yield делается в lua-wrapper
|
|
||||||
// через coroutine.yield, который мы оборачиваем в addScript.
|
|
||||||
// Здесь просто возвращаем длительность для совместимости.
|
|
||||||
return [sec || 0, 0];
|
return [sec || 0, 0];
|
||||||
});
|
});
|
||||||
lua.global.set('task', {
|
lua.global.set('task', {
|
||||||
wait: (sec) => sec || 0,
|
wait: (sec) => sec || 0,
|
||||||
spawn: (fn, ...args) => { sched.spawn(() => { try { fn(...args); } catch (e) {} }); return null; },
|
spawn: (fn, ...args) => { try { fn(...args); } catch (e) {} return null; },
|
||||||
delay: (sec, fn, ...args) => { sched.schedule(sec || 0, () => { try { fn(...args); } catch (e) {} }); return null; },
|
delay: (sec, fn, ...args) => { try { fn(...args); } catch (e) {} return null; },
|
||||||
defer: (fn, ...args) => { sched.spawn(() => { try { fn(...args); } catch (e) {} }); return null; },
|
defer: (fn, ...args) => { try { fn(...args); } catch (e) {} return null; },
|
||||||
});
|
});
|
||||||
lua.global.set('spawn', (fn) => { sched.spawn(() => { try { fn(); } catch (e) {} }); });
|
lua.global.set('spawn', (fn) => { try { fn(); } catch (e) {} });
|
||||||
lua.global.set('delay', (sec, fn) => { sched.schedule(sec || 0, () => { try { fn(); } catch (e) {} }); });
|
lua.global.set('delay', (sec, fn) => { try { fn(); } catch (e) {} });
|
||||||
// require(ModuleScript) — возвращаем nil, debug.setmetatable перехватит.
|
// require(ModuleScript) в Roblox-картах — у нас нет реальных модулей,
|
||||||
lua.global.set('require', (_arg) => undefined);
|
// возвращаем nullStub, чтобы доступ к полям не падал. Сами модули
|
||||||
|
// импортируются как отдельные скрипты на верхнем уровне.
|
||||||
|
lua.global.set('require', (_arg) => _nullStub);
|
||||||
lua.global.set('tick', () => Date.now() / 1000);
|
lua.global.set('tick', () => Date.now() / 1000);
|
||||||
lua.global.set('time', () => Date.now() / 1000);
|
lua.global.set('time', () => Date.now() / 1000);
|
||||||
lua.global.set('elapsedTime', () => Date.now() / 1000);
|
lua.global.set('elapsedTime', () => Date.now() / 1000);
|
||||||
@ -700,7 +593,7 @@ export function registerRobloxApi(lua, ctx) {
|
|||||||
};
|
};
|
||||||
lua.global.set('Enum', enumTable);
|
lua.global.set('Enum', enumTable);
|
||||||
|
|
||||||
return { workspace, game, part_by_id, services, gui_by_id, localPlayer, character, humanoid };
|
return { workspace, game, part_by_id, services };
|
||||||
}
|
}
|
||||||
|
|
||||||
function luaToString(v) {
|
function luaToString(v) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user