refactor(rbxl-import): импортированные Lua-скрипты идут через LuaSharedSandbox
Раньше было два параллельных Lua-runtime: - RobloxLuaSharedSandbox (Worker + wasmoon) для импортированных .rbxl; - LuaSharedSandbox (main thread + wasmoon) для user-Lua. Импортированные скрипты не получали фичи Этапов 4-6 (Position setters, GUI, Sound, TweenService) — их shim был в отдельном Worker'е с более старым API. Сейчас в GameRuntime.start(): 1. Скрипты с маркером '// @roblox-lua' распаковываются через unpackRobloxLuaCode() и попадают в тот же luaUserBatch что и user-Lua; 2. Собыраются _rbxlImported=true для лога; 3. Числовой script.target (примитив id) уже совместим с LuaSharedSandbox.addScript → резолвится в script.Parent. Удалены мёртвые файлы (общий размер ~2500 строк): - RobloxLuaSharedSandbox.js + RobloxLuaSharedWorker.js - RobloxLuaSandbox.js + RobloxLuaWorker.js (старая пара) - roblox-shim.js + roblox-services.js + roblox-physics.js - roblox-scheduler.js + roblox-tween.js - из rbxl-lua-integration.js убрана функция startRobloxLuaShared() Побочный эффект: импортированные Roblox-игры теперь автоматически получают: - живые part.Position/Size/Color setters; - полный GUI (Frame/TextLabel/TextButton); - TweenService:Create с реальной интерполяцией; - Sound с процедурными звуками; - Humanoid.Health/Died и прочие фичи Этапов 4-6. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
a1faf237a1
commit
2fa575ae4c
@ -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 { startRobloxLuaShared, handleLuaCommand, unpackRobloxLuaCode } from './rbxl-lua-integration.js';
|
import { handleLuaCommand, unpackRobloxLuaCode } from './rbxl-lua-integration.js';
|
||||||
import { LuaSharedSandbox } from './lua/LuaSharedSandbox.js';
|
import { LuaSharedSandbox } from './lua/LuaSharedSandbox.js';
|
||||||
|
|
||||||
export class GameRuntime {
|
export class GameRuntime {
|
||||||
@ -116,15 +116,24 @@ export class GameRuntime {
|
|||||||
try { initialScene = this._buildSceneSnapshot(); } catch (e) { initialScene = null; }
|
try { initialScene = this._buildSceneSnapshot(); } catch (e) { initialScene = null; }
|
||||||
// Roblox-Lua скрипты собираем для single-VM режима: один shared Worker
|
// Roblox-Lua скрипты собираем для single-VM режима: один shared Worker
|
||||||
// на все скрипты, одна wasmoon-VM. Снимает WASM OOM лимит.
|
// на все скрипты, одна wasmoon-VM. Снимает WASM OOM лимит.
|
||||||
const rbxlBatch = [];
|
|
||||||
const primitives = this.projectData?.scene?.primitives || this.scene3d?.primitiveManager?.getAllData?.() || [];
|
const primitives = this.projectData?.scene?.primitives || this.scene3d?.primitiveManager?.getAllData?.() || [];
|
||||||
// НОВОЕ (Этап 2): Lua-скрипты с language='lua' идут через LuaSharedSandbox
|
// Единый Lua-batch: и user-Lua (language='lua'), и импортированные .rbxl
|
||||||
// (один shared VM на всю игру). Это user-written Lua + Roblox API совместимость.
|
// скрипты (с маркером // @roblox-lua) теперь идут через ОДИН LuaSharedSandbox.
|
||||||
// Отличается от rbxl-import batch: тут код юзер написал в редакторе сам.
|
// .rbxl-скрипты распаковываем из JS-комментария-обёртки в чистый Lua.
|
||||||
const luaUserBatch = [];
|
const luaUserBatch = [];
|
||||||
for (const s of scripts) {
|
for (const s of scripts) {
|
||||||
if (s && typeof s.code === 'string' && s.code.startsWith('// @roblox-lua')) {
|
if (s && typeof s.code === 'string' && s.code.startsWith('// @roblox-lua')) {
|
||||||
rbxlBatch.push(s);
|
const luaSource = unpackRobloxLuaCode(s.code);
|
||||||
|
if (luaSource && luaSource.trim()) {
|
||||||
|
luaUserBatch.push({
|
||||||
|
id: s.id,
|
||||||
|
name: s.name,
|
||||||
|
target: s.target,
|
||||||
|
language: 'lua',
|
||||||
|
code: luaSource,
|
||||||
|
_rbxlImported: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (s && s.language === 'lua') {
|
if (s && s.language === 'lua') {
|
||||||
@ -160,23 +169,8 @@ 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);
|
||||||
}
|
}
|
||||||
// Single-VM запуск всех Roblox-Lua скриптов одним shared Worker'ом.
|
// Импортированные .rbxl-скрипты теперь идут через тот же LuaSharedSandbox
|
||||||
let rbxlCount = 0;
|
// вместе с user-Lua (см. luaUserBatch выше). Отдельный Worker больше не нужен.
|
||||||
if (rbxlBatch.length > 0) {
|
|
||||||
// GUI-дерево из projectData для pre-population
|
|
||||||
const guiElements = this.projectData?.scene?.gui || [];
|
|
||||||
const result = startRobloxLuaShared(rbxlBatch, {
|
|
||||||
primitives,
|
|
||||||
guiElements,
|
|
||||||
onCommand: (cmd, payload) => handleLuaCommand(null, cmd, payload, this),
|
|
||||||
});
|
|
||||||
if (result && result.sandbox) {
|
|
||||||
this.sandboxes.push(result.sandbox);
|
|
||||||
this._rbxlSharedSandbox = result.sandbox;
|
|
||||||
rbxlCount = result.count;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// НОВОЕ (Этап 2): user-written Lua-скрипты с language='lua' через LuaSharedSandbox
|
|
||||||
let luaUserCount = 0;
|
let luaUserCount = 0;
|
||||||
if (luaUserBatch.length > 0) {
|
if (luaUserBatch.length > 0) {
|
||||||
try {
|
try {
|
||||||
@ -211,13 +205,15 @@ export class GameRuntime {
|
|||||||
this._log('error', `Lua-runtime ошибка: ${e?.message || e}`);
|
this._log('error', `Lua-runtime ошибка: ${e?.message || e}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const jsOnly = this.sandboxes.length - (this._rbxlSharedSandbox ? 1 : 0) - (this._luaUserSandbox ? 1 : 0);
|
const rbxlImported = luaUserBatch.filter(s => s._rbxlImported).length;
|
||||||
|
const luaWritten = luaUserCount - rbxlImported;
|
||||||
|
const jsOnly = this.sandboxes.length - (this._luaUserSandbox ? 1 : 0);
|
||||||
this._log('info', `Запущено JS-скриптов: ${jsOnly}`);
|
this._log('info', `Запущено JS-скриптов: ${jsOnly}`);
|
||||||
if (rbxlCount > 0) {
|
if (rbxlImported > 0) {
|
||||||
this._log('info', `Запущено Roblox-Lua скриптов (импортированных): ${rbxlCount}`);
|
this._log('info', `Запущено Roblox-Lua скриптов (импортированных): ${rbxlImported}`);
|
||||||
}
|
}
|
||||||
if (luaUserCount > 0) {
|
if (luaWritten > 0) {
|
||||||
this._log('info', `Запущено Lua-скриптов юзера: ${luaUserCount}`);
|
this._log('info', `Запущено Lua-скриптов юзера: ${luaWritten}`);
|
||||||
}
|
}
|
||||||
// Подцепляемся на изменения HP игрока, чтобы шлать событие 'hpChange'
|
// Подцепляемся на изменения HP игрока, чтобы шлать событие 'hpChange'
|
||||||
// во все sandbox'ы. Не перезаписываем существующий обработчик —
|
// во все sandbox'ы. Не перезаписываем существующий обработчик —
|
||||||
|
|||||||
@ -1,164 +0,0 @@
|
|||||||
/**
|
|
||||||
* RobloxLuaSandbox — main-side обёртка над одним RobloxLuaWorker.
|
|
||||||
*
|
|
||||||
* Использование (по аналогии с ScriptSandbox):
|
|
||||||
* const sb = new RobloxLuaSandbox(luaSource, targetPrimitiveId);
|
|
||||||
* sb.setOnCommand((cmd, payload) => ...);
|
|
||||||
* sb.setInitialScene({primitives: {...}});
|
|
||||||
* sb.start();
|
|
||||||
* sb.tick(dt, sceneSnap);
|
|
||||||
* sb.fireEvent('touched', {primId, otherPrimId});
|
|
||||||
* sb.stop();
|
|
||||||
*
|
|
||||||
* Команды от Worker:
|
|
||||||
* { cmd: 'boot' } — Lua-VM запущена
|
|
||||||
* { cmd: 'ready' } — top-level код выполнен
|
|
||||||
* { cmd: 'log', payload: { level, text } }
|
|
||||||
* { cmd: 'partSet', payload: { primId, prop, value } }
|
|
||||||
* { cmd: 'partVel', payload: { primId, vx, vy, vz } }
|
|
||||||
* { cmd: 'playerCmd', payload: { method, args } }
|
|
||||||
* { cmd: 'tweenStart', payload: { ... } }
|
|
||||||
* { cmd: 'broadcast', payload: { msg, data } }
|
|
||||||
* { cmd: 'spawn', payload: { template, props, parentId } }
|
|
||||||
*/
|
|
||||||
|
|
||||||
let _workerUrl = null;
|
|
||||||
|
|
||||||
function getWorkerUrl() {
|
|
||||||
if (_workerUrl) return _workerUrl;
|
|
||||||
// Vite worker syntax — лучше через ?worker импорт; но мы можем
|
|
||||||
// динамически генерировать URL для ScriptSandboxWorker-style.
|
|
||||||
// Здесь упрощённо: загружаем worker как module через Vite ?worker&inline.
|
|
||||||
// Это будет настроено при интеграции в GameRuntime.
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class RobloxLuaSandbox {
|
|
||||||
constructor(luaSource, targetPrimitiveId = null) {
|
|
||||||
this.luaSource = luaSource || '';
|
|
||||||
this.targetPrimitiveId = targetPrimitiveId;
|
|
||||||
this.worker = null;
|
|
||||||
this._onCommand = null;
|
|
||||||
this._booted = false;
|
|
||||||
this._ready = false;
|
|
||||||
this._stopped = false;
|
|
||||||
this._pendingTicks = [];
|
|
||||||
this._pendingEvents = [];
|
|
||||||
this._initialScene = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
setOnCommand(cb) { this._onCommand = cb; }
|
|
||||||
setInitialScene(snap) { this._initialScene = snap; }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {Worker} worker — экземпляр Worker'а (предоставляется снаружи,
|
|
||||||
* так как Vite требует new Worker(new URL(...)) syntax который надо
|
|
||||||
* прописать в месте импорта)
|
|
||||||
*/
|
|
||||||
start(worker) {
|
|
||||||
if (this.worker) return;
|
|
||||||
if (!worker) throw new Error('RobloxLuaSandbox.start(worker): worker is required');
|
|
||||||
|
|
||||||
this.worker = worker;
|
|
||||||
this.worker.onmessage = (e) => this._handle(e);
|
|
||||||
this.worker.onerror = (err) => {
|
|
||||||
this._emit('log', { level: 'error', text: `Worker error: ${err.message || err}` });
|
|
||||||
};
|
|
||||||
this.worker.postMessage({
|
|
||||||
cmd: 'init',
|
|
||||||
payload: {
|
|
||||||
code: this.luaSource,
|
|
||||||
target: this.targetPrimitiveId,
|
|
||||||
sceneSnap: this._initialScene || { primitives: {} },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Передать кадр (snap сцены + dt). */
|
|
||||||
tick(dt, sceneSnap) {
|
|
||||||
if (!this.worker) return;
|
|
||||||
if (!this._ready) {
|
|
||||||
this._pendingTicks.push({ dt, sceneSnap });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try { this.worker.postMessage({ cmd: 'tick', payload: { dt, sceneSnap } }); } catch (e) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Передать событие. */
|
|
||||||
fireEvent(kind, args, signalId) {
|
|
||||||
if (!this.worker) return;
|
|
||||||
if (!this._ready) {
|
|
||||||
this._pendingEvents.push({ kind, args, signalId });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try { this.worker.postMessage({ cmd: 'event', payload: { kind, args, signalId } }); } catch (e) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
stop() {
|
|
||||||
this._stopped = true;
|
|
||||||
try { this.worker?.postMessage({ cmd: 'stop' }); } catch (e) {}
|
|
||||||
try { this.worker?.terminate(); } catch (e) {}
|
|
||||||
this.worker = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Совместимость с интерфейсом ScriptSandbox (GameRuntime ожидает эти методы) ──
|
|
||||||
// Все no-op либо мапятся на fireEvent — наш Worker сам держит state сцены.
|
|
||||||
sendSceneSnapshot(_snap) { /* no-op: ничего не делает, наш Worker не использует snapshot напрямую */ }
|
|
||||||
sendGuiSnapshot(_snap) { /* no-op */ }
|
|
||||||
sendSkinsSnapshot(_snap) { /* no-op */ }
|
|
||||||
sendInventorySnapshot(_snap) { /* no-op */ }
|
|
||||||
sendTerrainHeightmap(_payload) { /* no-op */ }
|
|
||||||
sendGlobalEvent(kind, payload) {
|
|
||||||
// Глобальные события (input, hpChange, broadcast) маршрутизируем в наш fireEvent.
|
|
||||||
try { this.fireEvent(kind, [payload]); } catch (e) {}
|
|
||||||
}
|
|
||||||
sendBroadcast(msg, data) {
|
|
||||||
try { this.fireEvent('broadcast', [msg, data]); } catch (e) {}
|
|
||||||
}
|
|
||||||
sendOnTouchEvent(payload) {
|
|
||||||
try { this.fireEvent('touched', [payload]); } catch (e) {}
|
|
||||||
}
|
|
||||||
sendOnTickEvent(dt) {
|
|
||||||
try { this.tick(dt, null); } catch (e) {}
|
|
||||||
}
|
|
||||||
sendTweenDone(payload) {
|
|
||||||
try { this.fireEvent('tweenDone', [payload]); } catch (e) {}
|
|
||||||
}
|
|
||||||
sendSpawnResolved(payload) {
|
|
||||||
try { this.fireEvent('spawnResolved', [payload]); } catch (e) {}
|
|
||||||
}
|
|
||||||
setInitialSelfPosition(_p) { /* no-op */ }
|
|
||||||
setModules(_modules) { /* no-op: rbxl Lua не использует наш game.require */ }
|
|
||||||
get scriptId() { return this._scriptId; }
|
|
||||||
set scriptId(v) { this._scriptId = v; }
|
|
||||||
|
|
||||||
_handle(ev) {
|
|
||||||
if (this._stopped) return;
|
|
||||||
const { cmd, payload } = ev.data || {};
|
|
||||||
if (cmd === 'boot') {
|
|
||||||
this._booted = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (cmd === 'ready') {
|
|
||||||
this._ready = true;
|
|
||||||
// флушим накопленное
|
|
||||||
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 = [];
|
|
||||||
this._emit('ready', null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this._emit(cmd, payload);
|
|
||||||
}
|
|
||||||
|
|
||||||
_emit(cmd, payload) {
|
|
||||||
if (this._onCommand) {
|
|
||||||
try { this._onCommand(cmd, payload); } catch (e) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,161 +0,0 @@
|
|||||||
/**
|
|
||||||
* RobloxLuaSharedSandbox — main-side обёртка над одним shared Lua-worker'ом.
|
|
||||||
*
|
|
||||||
* v2 (после rewrite):
|
|
||||||
* - start(sceneSnap, guiTree, worker) → init с GUI-деревом
|
|
||||||
* - addScriptsBatch(scripts) → одной пачкой все скрипты регистрируются в VM
|
|
||||||
* - kickoff() → запускает event loop, fire'ит PlayerAdded
|
|
||||||
* - tick(dt) каждый кадр
|
|
||||||
* - fireEvent(kind, payload) — маршрутизирует в Worker.handleEvent
|
|
||||||
*
|
|
||||||
* GameRuntime пушит ОДИН экземпляр в this.sandboxes.
|
|
||||||
*/
|
|
||||||
export class RobloxLuaSharedSandbox {
|
|
||||||
constructor() {
|
|
||||||
this.worker = null;
|
|
||||||
this._onCommand = null;
|
|
||||||
this._booted = false;
|
|
||||||
this._scriptsLoaded = false;
|
|
||||||
this._stopped = false;
|
|
||||||
this._pendingTicks = [];
|
|
||||||
this._pendingEvents = [];
|
|
||||||
this._pendingScripts = null;
|
|
||||||
this._pendingKickoff = false;
|
|
||||||
this.scriptId = 'rbxl-shared';
|
|
||||||
}
|
|
||||||
|
|
||||||
setOnCommand(cb) { this._onCommand = cb; }
|
|
||||||
|
|
||||||
start(sceneSnap, guiTree, 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, guiTree } });
|
|
||||||
}
|
|
||||||
|
|
||||||
addScriptsBatch(scripts) {
|
|
||||||
if (!this._booted) { this._pendingScripts = scripts; return; }
|
|
||||||
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._booted) { this._pendingTicks.push(dt); return; }
|
|
||||||
try { this.worker.postMessage({ cmd: 'tick', payload: { dt } }); } catch (e) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
fireEvent(kind, payload) {
|
|
||||||
if (!this.worker) return;
|
|
||||||
const ev = { kind, ...(payload || {}) };
|
|
||||||
if (!this._booted) { this._pendingEvents.push(ev); return; }
|
|
||||||
try { this.worker.postMessage({ cmd: 'event', payload: ev }); } 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;
|
|
||||||
// флушим pending scripts
|
|
||||||
if (this._pendingScripts) {
|
|
||||||
try { this.worker.postMessage({ cmd: 'addScripts', payload: { scripts: this._pendingScripts } }); } catch (e) {}
|
|
||||||
this._pendingScripts = null;
|
|
||||||
}
|
|
||||||
// ticks накопленные до boot
|
|
||||||
for (const dt of this._pendingTicks) {
|
|
||||||
try { this.worker.postMessage({ cmd: 'tick', payload: { dt } }); } catch (e) {}
|
|
||||||
}
|
|
||||||
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) {
|
|
||||||
try { this.worker.postMessage({ cmd: 'event', payload: e }); } catch (er) {}
|
|
||||||
}
|
|
||||||
this._pendingEvents = [];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this._emit(cmd, payload);
|
|
||||||
}
|
|
||||||
|
|
||||||
_emit(cmd, payload) {
|
|
||||||
if (this._onCommand) { try { this._onCommand(cmd, payload); } catch (e) {} }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Совместимость с ScriptSandbox (GameRuntime ожидает эти методы) ──
|
|
||||||
sendSceneSnapshot(_snap) {}
|
|
||||||
sendGuiSnapshot(_snap) {}
|
|
||||||
sendSkinsSnapshot(_snap) {}
|
|
||||||
sendInventorySnapshot(_snap) {}
|
|
||||||
sendTerrainHeightmap(_payload) {}
|
|
||||||
sendGlobalEvent(payload) {
|
|
||||||
if (!payload || typeof payload !== 'object') return;
|
|
||||||
const type = payload.type;
|
|
||||||
// playerTouch: BabylonScene уже детектит касания → Touched на Part
|
|
||||||
if (type === 'playerTouch' && payload.target != null) {
|
|
||||||
const t = payload.target;
|
|
||||||
// target может быть: число (импортированный rbxl), {id|ref}, 'primitive:<id>'
|
|
||||||
let primId = null;
|
|
||||||
if (typeof t === 'number') primId = t;
|
|
||||||
else if (typeof t === 'object') primId = t.id ?? t.ref ?? null;
|
|
||||||
else if (typeof t === 'string') {
|
|
||||||
const m = /^primitive:(\d+)$/.exec(t);
|
|
||||||
if (m) primId = +m[1];
|
|
||||||
}
|
|
||||||
if (primId != null) {
|
|
||||||
this.fireEvent('touched', { primId, isPlayer: true });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// GUI click — Rublox GuiOverlay шлёт guiClick с id
|
|
||||||
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 }); }
|
|
||||||
sendOnTouchEvent(payload) { this.fireEvent('touched', { primId: payload?.primId, isPlayer: true }); }
|
|
||||||
sendOnTickEvent(dt) { this.tick(dt); }
|
|
||||||
sendTweenDone(payload) { this.fireEvent('tweenDone', payload); }
|
|
||||||
sendSpawnResolved(payload) { this.fireEvent('spawnResolved', payload); }
|
|
||||||
setInitialSelfPosition(_p) {}
|
|
||||||
setModules(_modules) {}
|
|
||||||
}
|
|
||||||
@ -1,381 +0,0 @@
|
|||||||
/* eslint-disable no-restricted-globals */
|
|
||||||
/**
|
|
||||||
* RobloxLuaSharedWorker.js — single-VM Lua-runtime для импортированных Roblox-скриптов.
|
|
||||||
*
|
|
||||||
* Архитектура v2 (после ITERATION 5-step rewrite):
|
|
||||||
*
|
|
||||||
* ФАЗА 1 (boot): создаём wasmoon-VM, регистрируем Roblox API без скриптов.
|
|
||||||
*
|
|
||||||
* ФАЗА 2 (populate): main шлёт snapshot сцены (primitives + GUI tree).
|
|
||||||
* Создаём workspace со всеми Part'ами и PlayerGui со всем GUI-деревом.
|
|
||||||
* На каждом TextButton — MouseButton1Click сигнал, на каждом Part — Touched.
|
|
||||||
*
|
|
||||||
* ФАЗА 3 (addScripts): main шлёт ВСЕ скрипты ОДНИМ батчем. Worker загружает
|
|
||||||
* их в 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
|
|
||||||
* -> boot
|
|
||||||
* -> ready
|
|
||||||
* -> log/partSet/partVel/playerCmd/broadcast/guiUpdate
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { LuaFactory } from 'wasmoon';
|
|
||||||
import { registerRobloxApi, RbxSignal } from './roblox-shim.js';
|
|
||||||
|
|
||||||
const state = {
|
|
||||||
factory: null,
|
|
||||||
lua: null,
|
|
||||||
sceneSnap: { primitives: {} },
|
|
||||||
guiTree: [],
|
|
||||||
isStopped: false,
|
|
||||||
initPromise: null,
|
|
||||||
eventsStarted: false,
|
|
||||||
pendingEvents: [],
|
|
||||||
scriptCount: 0,
|
|
||||||
coroutines: [], // активные ждущие корутины: { co, resumeAt }
|
|
||||||
nowSec: 0,
|
|
||||||
api: null, // результат registerRobloxApi: { workspace, game, part_by_id, gui_by_id, localPlayer, humanoid }
|
|
||||||
};
|
|
||||||
|
|
||||||
function send(cmd, payload) {
|
|
||||||
self.postMessage({ cmd, payload });
|
|
||||||
}
|
|
||||||
|
|
||||||
function 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) => {
|
|
||||||
const { cmd, payload } = ev.data || {};
|
|
||||||
try {
|
|
||||||
if (cmd === 'init') await handleInit(payload);
|
|
||||||
else if (cmd === 'addScripts') await handleAddScripts(payload);
|
|
||||||
else if (cmd === 'start') handleStart();
|
|
||||||
else if (cmd === 'tick') handleTick(payload);
|
|
||||||
else if (cmd === 'event') {
|
|
||||||
if (!state.eventsStarted) state.pendingEvents.push(payload);
|
|
||||||
else handleEvent(payload);
|
|
||||||
}
|
|
||||||
else if (cmd === 'stop') {
|
|
||||||
state.isStopped = true;
|
|
||||||
try { state.lua?.global?.close?.(); } catch (e) {}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
log('error', `Worker error in ${cmd}: ${err && err.stack ? err.stack : err}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
async function handleInit({ sceneSnap, guiTree }) {
|
|
||||||
if (state.initPromise) { await state.initPromise; return; }
|
|
||||||
state.initPromise = (async () => {
|
|
||||||
state.sceneSnap = sceneSnap || { primitives: {} };
|
|
||||||
state.guiTree = guiTree || [];
|
|
||||||
state.factory = new LuaFactory();
|
|
||||||
state.lua = await state.factory.createEngine({
|
|
||||||
injectObjects: true,
|
|
||||||
enableProxy: true,
|
|
||||||
traceAllocations: false,
|
|
||||||
});
|
|
||||||
state.api = registerRobloxApi(state.lua, {
|
|
||||||
getSceneSnap: () => state.sceneSnap,
|
|
||||||
getGuiTree: () => state.guiTree,
|
|
||||||
targetPrimitiveId: null,
|
|
||||||
send,
|
|
||||||
scheduler,
|
|
||||||
});
|
|
||||||
// Передаём part_by_id в Lua как table {id → Instance}
|
|
||||||
// ВНИМАНИЕ: 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(`
|
|
||||||
__null_stub_mt = {}
|
|
||||||
function __make_null_stub()
|
|
||||||
local t = setmetatable({
|
|
||||||
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)
|
|
||||||
local ok, err = pcall(fn)
|
|
||||||
if not ok then warn("[rbxl-lua " .. tostring(id) .. " err] " .. tostring(err)) end
|
|
||||||
end
|
|
||||||
-- 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
|
|
||||||
return nil
|
|
||||||
end
|
|
||||||
`);
|
|
||||||
send('boot', null);
|
|
||||||
})();
|
|
||||||
await state.initPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleAddScripts({ scripts }) {
|
|
||||||
if (!state.lua) { log('error', 'addScripts before init'); return; }
|
|
||||||
let ok = 0, fail = 0;
|
|
||||||
for (const s of scripts) {
|
|
||||||
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;
|
|
||||||
send('ready', { ok, fail });
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleStart() {
|
|
||||||
state.eventsStarted = true;
|
|
||||||
// Эмитим Players.PlayerAdded + CharacterAdded чтобы скрипты которые
|
|
||||||
// делают game.Players.PlayerAdded:Connect(...) получили событие.
|
|
||||||
try {
|
|
||||||
const lp = state.api?.localPlayer;
|
|
||||||
const players = state.api?.services?.get('Players');
|
|
||||||
if (lp && players?.PlayerAdded?.Fire) players.PlayerAdded.Fire(lp);
|
|
||||||
if (lp?.CharacterAdded?.Fire && lp.Character) lp.CharacterAdded.Fire(lp.Character);
|
|
||||||
} catch (e) {}
|
|
||||||
// Флушим накопленные события
|
|
||||||
for (const e of state.pendingEvents) handleEvent(e);
|
|
||||||
state.pendingEvents = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleTick({ dt }) {
|
|
||||||
if (state.isStopped || !state.lua) return;
|
|
||||||
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);
|
|
||||||
const hasFire = !!part?.Touched?.Fire;
|
|
||||||
const connCount = part?.Touched?.connections?.length ?? 0;
|
|
||||||
log('info', `[Touched] primId=${primId} hasPart=${!!part} hasFire=${hasFire} connectedHandlers=${connCount}`);
|
|
||||||
if (part?.Touched?.Fire) {
|
|
||||||
part.Touched.Fire(state.api.character?.HumanoidRootPart || part);
|
|
||||||
}
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
log('warn', `event ${kind} err: ${e?.message || e}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.__rbxlSharedState = state;
|
|
||||||
@ -1,180 +0,0 @@
|
|||||||
/* eslint-disable no-restricted-globals */
|
|
||||||
/**
|
|
||||||
* RobloxLuaWorker.js — Web Worker, хостящий Lua 5.4 VM (wasmoon) для исполнения
|
|
||||||
* Roblox-Lua скриптов импортированных через rbxl-importer.
|
|
||||||
*
|
|
||||||
* Запускается из RobloxLuaSandbox.js (main thread).
|
|
||||||
*
|
|
||||||
* IPC (с main):
|
|
||||||
* <- init { code: string, target?: id, shim: 'full'|'mini', sceneSnap: object }
|
|
||||||
* <- tick { dt, sceneSnap } — каждый кадр
|
|
||||||
* <- event { kind: 'touched'|'changed'|..., args } — события сцены
|
|
||||||
* -> boot нет payload — Worker запустился, Lua-VM ready
|
|
||||||
* -> ready нет payload — top-level lua код исполнен
|
|
||||||
* -> log { level, text }
|
|
||||||
* -> partSet { primId, prop, value } — изменение свойства Part'а
|
|
||||||
* -> partVel { primId, vx, vy, vz }
|
|
||||||
* -> playerCmd { method, args } — методы game.player (teleport, damage, walkSpeed)
|
|
||||||
* -> tweenStart{ targetId, prop, from, to, durationSec, easing }
|
|
||||||
* -> broadcast { msg, data } — RemoteEvent аналог
|
|
||||||
* -> spawn { template, props, parentId } — Instance.new()
|
|
||||||
*
|
|
||||||
* Lua-runtime архитектура:
|
|
||||||
* - wasmoon = Lua 5.4 в WebAssembly, ~500KB, ~5x быстрее fengari.
|
|
||||||
* - Lua-VM глобалы: game, workspace, script, task, wait, print, warn, error.
|
|
||||||
* - Все Roblox-классы — JS-объекты-прокси (см. roblox-shim.js, регистрируемые
|
|
||||||
* через factory.setProxy).
|
|
||||||
*
|
|
||||||
* Безопасность:
|
|
||||||
* - Worker изолирован от DOM.
|
|
||||||
* - Memory limit ~50 MB на VM (через wasmoon options).
|
|
||||||
* - На каждые N=10000 инструкций Lua hook → возможность отменить (TODO).
|
|
||||||
*
|
|
||||||
* Воркер ДЕРЖИТ И ОБНОВЛЯЕТ snapshot сцены (зеркало того что в Babylon-сцене),
|
|
||||||
* чтобы Lua-код мог читать Position/Color без round-trip к main thread.
|
|
||||||
* Обновление от main: cmd='tick' с дельтой сцены.
|
|
||||||
*
|
|
||||||
* Это первый MVP-вариант. Полный shim API регистрируется в фазе 4.3-4.13.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { LuaFactory } from 'wasmoon';
|
|
||||||
import { registerRobloxApi } from './roblox-shim.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Worker-side state. Один Worker = один скрипт.
|
|
||||||
*/
|
|
||||||
const state = {
|
|
||||||
factory: null,
|
|
||||||
lua: null,
|
|
||||||
target: null, // id примитива к которому привязан script.Parent
|
|
||||||
sceneSnap: { primitives: {} },// зеркало
|
|
||||||
isStopped: false,
|
|
||||||
pendingEvents: [], // события до init
|
|
||||||
signals: new Map(), // signalId → [callbacks]
|
|
||||||
nextSignalId: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
/* ──────── IPC helpers ──────── */
|
|
||||||
|
|
||||||
function send(cmd, payload) {
|
|
||||||
self.postMessage({ cmd, payload });
|
|
||||||
}
|
|
||||||
|
|
||||||
function log(level, text) {
|
|
||||||
send('log', { level, text });
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ──────── Worker entrypoint ──────── */
|
|
||||||
|
|
||||||
self.addEventListener('message', async (ev) => {
|
|
||||||
const { cmd, payload } = ev.data || {};
|
|
||||||
try {
|
|
||||||
if (cmd === 'init') {
|
|
||||||
await handleInit(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', `Worker error in ${cmd}: ${err && err.stack ? err.stack : err}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
async function handleInit({ code, target, sceneSnap }) {
|
|
||||||
state.target = target;
|
|
||||||
state.sceneSnap = sceneSnap || { primitives: {} };
|
|
||||||
|
|
||||||
state.factory = new LuaFactory();
|
|
||||||
state.lua = await state.factory.createEngine({
|
|
||||||
injectObjects: true,
|
|
||||||
enableProxy: true,
|
|
||||||
traceAllocations: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Регистрируем Roblox API.
|
|
||||||
registerRobloxApi(state.lua, {
|
|
||||||
getSceneSnap: () => state.sceneSnap,
|
|
||||||
targetPrimitiveId: state.target,
|
|
||||||
send,
|
|
||||||
registerSignal: (callback) => {
|
|
||||||
const id = state.nextSignalId++;
|
|
||||||
const list = state.signals.get(id) || [];
|
|
||||||
list.push(callback);
|
|
||||||
state.signals.set(id, list);
|
|
||||||
return id;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
send('boot', null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Оборачиваем в pcall + ловим errors. Roblox-карты часто делают
|
|
||||||
// game.Players.LocalPlayer:WaitForChild("PlayerGui") который у нас
|
|
||||||
// даёт null — top-level код падает на первой такой строке.
|
|
||||||
// pcall ловит и даёт сигналам/Touched зарегистрироваться там где смогли.
|
|
||||||
const wrapped = `
|
|
||||||
local _ok, _err = pcall(function()
|
|
||||||
${code}
|
|
||||||
end)
|
|
||||||
if not _ok then
|
|
||||||
warn("[rbxl-lua partial fail] " .. tostring(_err))
|
|
||||||
end
|
|
||||||
`;
|
|
||||||
await state.lua.doString(wrapped);
|
|
||||||
send('ready', null);
|
|
||||||
} catch (e) {
|
|
||||||
log('error', `Lua error: ${e && e.message ? e.message : e}`);
|
|
||||||
send('ready', null);
|
|
||||||
}
|
|
||||||
|
|
||||||
// После ready доставляем events которые накопились
|
|
||||||
for (const ev of state.pendingEvents) handleEvent(ev);
|
|
||||||
state.pendingEvents = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleTick({ dt, sceneSnap }) {
|
|
||||||
if (state.isStopped || !state.lua) return;
|
|
||||||
if (sceneSnap) state.sceneSnap = sceneSnap;
|
|
||||||
// Heartbeat — для всех подписанных
|
|
||||||
fireSignalByName('Heartbeat', [dt]);
|
|
||||||
// Stepped (старая API) — тоже даём
|
|
||||||
fireSignalByName('Stepped', [dt]);
|
|
||||||
// RenderStepped — отдельно (на клиенте между physics и render)
|
|
||||||
fireSignalByName('RenderStepped', [dt]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleEvent({ kind, args, signalId }) {
|
|
||||||
if (!state.lua) {
|
|
||||||
state.pendingEvents.push({ kind, args, signalId });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (signalId != null) {
|
|
||||||
const list = state.signals.get(signalId) || [];
|
|
||||||
for (const cb of list) {
|
|
||||||
try { cb(...(args || [])); } catch (e) { log('error', `signal callback error: ${e}`); }
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
fireSignalByName(kind, args || []);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function fireSignalByName(name, args) {
|
|
||||||
// namedSignals регистрируются в roblox-shim как сильные строки
|
|
||||||
// (например 'Heartbeat'). Все callback'и под этим именем в signals.
|
|
||||||
// Без отдельной мапы — ищем линейно.
|
|
||||||
for (const [id, list] of state.signals.entries()) {
|
|
||||||
if (list.__name === name) {
|
|
||||||
for (const cb of list) {
|
|
||||||
try { cb(...args); } catch (e) { log('error', `${name} cb error: ${e}`); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ──────── Helper export для тестов ──────── */
|
|
||||||
|
|
||||||
self.__rbxlState = state;
|
|
||||||
@ -1,13 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* rbxl-lua-integration.js — single-VM интеграция (v2).
|
* rbxl-lua-integration.js — вспомогательные функции для импорта .rbxl-карт.
|
||||||
*
|
*
|
||||||
* Двухфазная инициализация:
|
* Старый отдельный Worker-based runtime удалён (2026-06-08): импортированные
|
||||||
* 1) init worker → pre-populate workspace + GUI tree (включая сигналы)
|
* Lua-скрипты теперь идут через тот же LuaSharedSandbox что и user-Lua
|
||||||
* 2) addScriptsBatch ВСЕ скрипты одним IPC сообщением
|
* (см. GameRuntime.start()). Этот файл оставлен только для:
|
||||||
* 3) ready → kickoff → emit PlayerAdded, начать tick
|
* - unpackRobloxLuaCode() — распаковка Lua из JS-комментария-обёртки;
|
||||||
|
* - handleLuaCommand() — обработка partSet/sceneCreate/sceneDelete/playerCmd
|
||||||
|
* команд от Lua-VM в BabylonScene.
|
||||||
*/
|
*/
|
||||||
import RobloxLuaSharedWorker from './RobloxLuaSharedWorker.js?worker';
|
|
||||||
import { RobloxLuaSharedSandbox } from './RobloxLuaSharedSandbox.js';
|
|
||||||
|
|
||||||
/** Распаковка lua_source из packed-кода. */
|
/** Распаковка lua_source из packed-кода. */
|
||||||
export function unpackRobloxLuaCode(code) {
|
export function unpackRobloxLuaCode(code) {
|
||||||
@ -80,37 +80,6 @@ export function buildLuaGuiTree(guiElements) {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Старт shared-sandbox: init → addScripts → kickoff.
|
|
||||||
*/
|
|
||||||
export function startRobloxLuaShared(scripts, ctx) {
|
|
||||||
try {
|
|
||||||
const luaScripts = [];
|
|
||||||
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) continue;
|
|
||||||
luaScripts.push({ id: s.id, target: s.target, luaSource });
|
|
||||||
}
|
|
||||||
if (luaScripts.length === 0) return { sandbox: null, count: 0 };
|
|
||||||
|
|
||||||
const worker = new RobloxLuaSharedWorker();
|
|
||||||
const sceneSnap = buildLuaSceneSnap(ctx.primitives);
|
|
||||||
const guiTree = buildLuaGuiTree(ctx.guiElements || []);
|
|
||||||
const mgr = new RobloxLuaSharedSandbox();
|
|
||||||
mgr.setOnCommand(ctx.onCommand);
|
|
||||||
mgr.start(sceneSnap, guiTree, worker);
|
|
||||||
mgr.addScriptsBatch(luaScripts);
|
|
||||||
mgr.kickoff();
|
|
||||||
return { sandbox: mgr, count: luaScripts.length };
|
|
||||||
} catch (e) {
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.warn('[rbxl-lua-shared v2] start failed:', e?.message || e);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Обработка IPC команд от worker'а — мапим на действия в Babylon-сцене.
|
* Обработка IPC команд от worker'а — мапим на действия в Babylon-сцене.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -1,216 +0,0 @@
|
|||||||
/**
|
|
||||||
* roblox-physics.js — эмуляция BodyMover / Constraint объектов Roblox.
|
|
||||||
*
|
|
||||||
* Roblox BodyMover'ы (старые, deprecated но массово используются):
|
|
||||||
* BodyVelocity — поддерживает заданную линейную velocity
|
|
||||||
* BodyAngularVelocity — поддерживает заданную угловую velocity
|
|
||||||
* BodyGyro — пытается удержать ориентацию (Lookat)
|
|
||||||
* BodyForce — постоянная сила
|
|
||||||
* BodyPosition — пытается удержать позицию
|
|
||||||
* BodyThrust — направленный импульс
|
|
||||||
*
|
|
||||||
* Constraint (новые):
|
|
||||||
* AlignPosition, AlignOrientation, LinearVelocity, AngularVelocity, Torque,
|
|
||||||
* VectorForce, Spring, RodConstraint, RopeConstraint, ...
|
|
||||||
*
|
|
||||||
* MVP: реализуем самые частые (BodyVelocity, BodyGyro, AlignPosition, VectorForce).
|
|
||||||
* Остальные — заглушки + warning.
|
|
||||||
*
|
|
||||||
* Архитектура:
|
|
||||||
* - Когда Lua делает `Instance.new("BodyVelocity", part)`, мы создаём RbxBodyVelocity,
|
|
||||||
* прикрепляем к Part через .Parent.
|
|
||||||
* - На каждом tick шедулера обходим активные movers и отсылаем physForce в main.
|
|
||||||
* - Main применяет к Babylon physics impostor.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { RbxInstance, RbxSignal, RbxVector3 } from './roblox-shim.js';
|
|
||||||
|
|
||||||
class RbxBodyMoverBase extends RbxInstance {
|
|
||||||
constructor(className) {
|
|
||||||
super(className, { Name: className });
|
|
||||||
this._ctx = null; // { send, registerMover }
|
|
||||||
this.__parentPart = null;
|
|
||||||
}
|
|
||||||
/** Установить родителя и зарегистрироваться в physics-manager. */
|
|
||||||
setMoverParent(part) {
|
|
||||||
this.Parent = part;
|
|
||||||
if (part && part.__primId != null) {
|
|
||||||
this.__parentPart = part;
|
|
||||||
this._ctx?.registerMover?.(this);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class RbxBodyVelocity extends RbxBodyMoverBase {
|
|
||||||
constructor() {
|
|
||||||
super('BodyVelocity');
|
|
||||||
this.Velocity = new RbxVector3(0, 0, 0);
|
|
||||||
this.MaxForce = new RbxVector3(4000, 4000, 4000);
|
|
||||||
this.P = 1250;
|
|
||||||
}
|
|
||||||
_step(_dt) {
|
|
||||||
if (!this.__parentPart || !this._ctx) return;
|
|
||||||
// posVel — желаемая velocity. Применяем как setVelocity.
|
|
||||||
this._ctx.send('partVel', {
|
|
||||||
primId: this.__parentPart.__primId,
|
|
||||||
vx: this.Velocity.X,
|
|
||||||
vy: this.Velocity.Y,
|
|
||||||
vz: this.Velocity.Z,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class RbxBodyGyro extends RbxBodyMoverBase {
|
|
||||||
constructor() {
|
|
||||||
super('BodyGyro');
|
|
||||||
this.CFrame = null; // целевое вращение
|
|
||||||
this.MaxTorque = new RbxVector3(4000, 4000, 4000);
|
|
||||||
this.D = 500;
|
|
||||||
this.P = 3000;
|
|
||||||
}
|
|
||||||
_step(_dt) {
|
|
||||||
if (!this.__parentPart || !this._ctx || !this.CFrame) return;
|
|
||||||
const [rx, ry, rz] = this.CFrame.toEulerXYZ();
|
|
||||||
this._ctx.send('partSet', {
|
|
||||||
primId: this.__parentPart.__primId,
|
|
||||||
prop: 'rotation',
|
|
||||||
value: { rx, ry, rz },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class RbxBodyPosition extends RbxBodyMoverBase {
|
|
||||||
constructor() {
|
|
||||||
super('BodyPosition');
|
|
||||||
this.Position = new RbxVector3(0, 0, 0);
|
|
||||||
this.MaxForce = new RbxVector3(4000, 4000, 4000);
|
|
||||||
this.D = 1250;
|
|
||||||
this.P = 10000;
|
|
||||||
}
|
|
||||||
_step(_dt) {
|
|
||||||
if (!this.__parentPart || !this._ctx) return;
|
|
||||||
this._ctx.send('partSet', {
|
|
||||||
primId: this.__parentPart.__primId,
|
|
||||||
prop: 'position',
|
|
||||||
value: { x: this.Position.X, y: this.Position.Y, z: this.Position.Z },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class RbxBodyForce extends RbxBodyMoverBase {
|
|
||||||
constructor() {
|
|
||||||
super('BodyForce');
|
|
||||||
this.Force = new RbxVector3(0, 0, 0);
|
|
||||||
}
|
|
||||||
_step(dt) {
|
|
||||||
if (!this.__parentPart || !this._ctx) return;
|
|
||||||
this._ctx.send('partForce', {
|
|
||||||
primId: this.__parentPart.__primId,
|
|
||||||
fx: this.Force.X * dt, fy: this.Force.Y * dt, fz: this.Force.Z * dt,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class RbxBodyAngularVelocity extends RbxBodyMoverBase {
|
|
||||||
constructor() {
|
|
||||||
super('BodyAngularVelocity');
|
|
||||||
this.AngularVelocity = new RbxVector3(0, 0, 0);
|
|
||||||
this.MaxTorque = new RbxVector3(4000, 4000, 4000);
|
|
||||||
}
|
|
||||||
_step(_dt) {
|
|
||||||
if (!this.__parentPart || !this._ctx) return;
|
|
||||||
this._ctx.send('partAngVel', {
|
|
||||||
primId: this.__parentPart.__primId,
|
|
||||||
wx: this.AngularVelocity.X, wy: this.AngularVelocity.Y, wz: this.AngularVelocity.Z,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ──────── New Constraints ──────── */
|
|
||||||
|
|
||||||
export class RbxAlignPosition extends RbxBodyMoverBase {
|
|
||||||
constructor() {
|
|
||||||
super('AlignPosition');
|
|
||||||
this.Position = new RbxVector3(0, 0, 0);
|
|
||||||
this.Attachment0 = null;
|
|
||||||
this.Attachment1 = null;
|
|
||||||
this.MaxForce = 1e6;
|
|
||||||
this.Enabled = true;
|
|
||||||
}
|
|
||||||
_step(_dt) {
|
|
||||||
if (!this.Enabled || !this.__parentPart || !this._ctx) return;
|
|
||||||
this._ctx.send('partSet', {
|
|
||||||
primId: this.__parentPart.__primId,
|
|
||||||
prop: 'position',
|
|
||||||
value: { x: this.Position.X, y: this.Position.Y, z: this.Position.Z },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class RbxLinearVelocity extends RbxBodyMoverBase {
|
|
||||||
constructor() {
|
|
||||||
super('LinearVelocity');
|
|
||||||
this.VectorVelocity = new RbxVector3(0, 0, 0);
|
|
||||||
this.MaxForce = 1e6;
|
|
||||||
this.Enabled = true;
|
|
||||||
}
|
|
||||||
_step(_dt) {
|
|
||||||
if (!this.Enabled || !this.__parentPart || !this._ctx) return;
|
|
||||||
this._ctx.send('partVel', {
|
|
||||||
primId: this.__parentPart.__primId,
|
|
||||||
vx: this.VectorVelocity.X,
|
|
||||||
vy: this.VectorVelocity.Y,
|
|
||||||
vz: this.VectorVelocity.Z,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ──────── Manager ──────── */
|
|
||||||
|
|
||||||
export class RobloxPhysicsManager {
|
|
||||||
constructor(send) {
|
|
||||||
this._send = send;
|
|
||||||
this._movers = new Set();
|
|
||||||
}
|
|
||||||
|
|
||||||
install(lua) {
|
|
||||||
const self = this;
|
|
||||||
const ctx = {
|
|
||||||
send: this._send,
|
|
||||||
registerMover: (m) => self._movers.add(m),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Подменяем Instance.new для физических классов
|
|
||||||
const origInstance = lua.global.get('Instance');
|
|
||||||
lua.global.set('Instance', {
|
|
||||||
new: (className, parent) => {
|
|
||||||
let inst = null;
|
|
||||||
switch (className) {
|
|
||||||
case 'BodyVelocity': inst = new RbxBodyVelocity(); break;
|
|
||||||
case 'BodyGyro': inst = new RbxBodyGyro(); break;
|
|
||||||
case 'BodyPosition': inst = new RbxBodyPosition(); break;
|
|
||||||
case 'BodyForce': inst = new RbxBodyForce(); break;
|
|
||||||
case 'BodyAngularVelocity':inst = new RbxBodyAngularVelocity(); break;
|
|
||||||
case 'AlignPosition': inst = new RbxAlignPosition(); break;
|
|
||||||
case 'LinearVelocity': inst = new RbxLinearVelocity(); break;
|
|
||||||
}
|
|
||||||
if (inst) {
|
|
||||||
inst._ctx = ctx;
|
|
||||||
if (parent) {
|
|
||||||
inst.setMoverParent(parent);
|
|
||||||
if (parent.Children) parent.Children.push(inst);
|
|
||||||
}
|
|
||||||
return inst;
|
|
||||||
}
|
|
||||||
return origInstance.new(className, parent);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
tick(dt) {
|
|
||||||
for (const m of [...this._movers]) {
|
|
||||||
if (m.__destroyed || !m.__parentPart) { this._movers.delete(m); continue; }
|
|
||||||
try { m._step(dt); } catch (e) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,209 +0,0 @@
|
|||||||
/**
|
|
||||||
* roblox-scheduler.js — шедулер корутин для Roblox-Lua wait/task.
|
|
||||||
*
|
|
||||||
* Архитектура:
|
|
||||||
* - Каждый верхне-уровневый Lua-код оборачивается в coroutine.
|
|
||||||
* - wait(sec) / task.wait(sec) делают coroutine.yield(sec)
|
|
||||||
* - Шедулер запоминает: { coro, resumeAt: tick + sec }
|
|
||||||
* - На каждом handleTick из main thread шедулер ресюмит готовые корутины
|
|
||||||
*
|
|
||||||
* RBXScriptSignal.Wait() = аналогично, но wait не на время, а на event'е:
|
|
||||||
* - { coro, waitingForSignal: signalName }
|
|
||||||
* - При Fire() сигнала шедулер ресюмит все ждущие
|
|
||||||
*
|
|
||||||
* Использование:
|
|
||||||
* const sched = new RobloxScheduler(luaEngine);
|
|
||||||
* sched.spawnMain(luaSource);
|
|
||||||
* // Каждый кадр:
|
|
||||||
* sched.tick(dtSec);
|
|
||||||
* // При событии:
|
|
||||||
* sched.fireSignal('Heartbeat', dt);
|
|
||||||
*/
|
|
||||||
|
|
||||||
export class RobloxScheduler {
|
|
||||||
constructor(lua) {
|
|
||||||
this.lua = lua;
|
|
||||||
this.time = 0;
|
|
||||||
this.tasks = []; // [{ coro, resumeAt, waitForSignal?, signalArgsBuf? }]
|
|
||||||
this.signalWaiters = new Map(); // name → [task]
|
|
||||||
this._coroBox = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Регистрирует глобалы wait/task.wait/task.spawn/task.delay в Lua-VM.
|
|
||||||
* Должно вызываться ПОСЛЕ registerRobloxApi (т.к. перебивает заглушки).
|
|
||||||
*/
|
|
||||||
install() {
|
|
||||||
const self = this;
|
|
||||||
// wait(sec) — yield в корутине на sec секунд
|
|
||||||
this.lua.global.set('wait', (sec) => {
|
|
||||||
// Этот wait вызовется из Lua. Если мы внутри корутины (а мы внутри
|
|
||||||
// т.к. spawnMain обернул всё) — yield. Иначе вернём дельту времени
|
|
||||||
// как обычное wait в Roblox.
|
|
||||||
const s = +sec || 0;
|
|
||||||
self._currentYield = { kind: 'sleep', sec: s };
|
|
||||||
// Возврат тут — это значение которое получит await в Lua;
|
|
||||||
// wasmoon обработает yield извне.
|
|
||||||
return s;
|
|
||||||
});
|
|
||||||
this.lua.global.set('task', {
|
|
||||||
wait: (sec) => {
|
|
||||||
self._currentYield = { kind: 'sleep', sec: +sec || 0 };
|
|
||||||
return +sec || 0;
|
|
||||||
},
|
|
||||||
spawn: (fn, ...args) => {
|
|
||||||
self.spawnCoroutine(fn, args);
|
|
||||||
},
|
|
||||||
delay: (sec, fn, ...args) => {
|
|
||||||
self.tasks.push({
|
|
||||||
resumeAt: self.time + (+sec || 0),
|
|
||||||
runFn: () => { try { fn(...args); } catch (e) {} },
|
|
||||||
});
|
|
||||||
},
|
|
||||||
defer: (fn, ...args) => {
|
|
||||||
self.tasks.push({
|
|
||||||
resumeAt: self.time,
|
|
||||||
runFn: () => { try { fn(...args); } catch (e) {} },
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
this.lua.global.set('spawn', (fn) => { self.spawnCoroutine(fn, []); });
|
|
||||||
this.lua.global.set('delay', (sec, fn) => {
|
|
||||||
self.tasks.push({
|
|
||||||
resumeAt: self.time + (+sec || 0),
|
|
||||||
runFn: () => { try { fn(); } catch (e) {} },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Запустить верхне-уровневый Lua-код как корутину.
|
|
||||||
* Возвращает Promise который резолвится когда код достиг ready (либо ушёл в первый yield).
|
|
||||||
*/
|
|
||||||
async spawnMain(luaSource) {
|
|
||||||
// Оборачиваем источник в coroutine.wrap(function() ... end)
|
|
||||||
// и сразу зовём — это даёт нам ручку на корутине через специальный
|
|
||||||
// приём: храним её в global _userCoro.
|
|
||||||
const wrapped = `
|
|
||||||
_userCoro = coroutine.create(function()
|
|
||||||
${luaSource}
|
|
||||||
end)
|
|
||||||
local ok, yieldVal = coroutine.resume(_userCoro)
|
|
||||||
if not ok then
|
|
||||||
error("user script error: " .. tostring(yieldVal))
|
|
||||||
end
|
|
||||||
return yieldVal
|
|
||||||
`;
|
|
||||||
try {
|
|
||||||
await this.lua.doString(wrapped);
|
|
||||||
const coroStatus = await this.lua.doString('return coroutine.status(_userCoro)');
|
|
||||||
if (coroStatus === 'suspended') {
|
|
||||||
// Ушла в yield — добавляем в шедулер
|
|
||||||
const yieldInfo = this._currentYield || { kind: 'sleep', sec: 0 };
|
|
||||||
this._currentYield = null;
|
|
||||||
this.tasks.push({
|
|
||||||
resumeAt: this.time + (yieldInfo.kind === 'sleep' ? yieldInfo.sec : 0),
|
|
||||||
waitForSignal: yieldInfo.kind === 'signal' ? yieldInfo.name : null,
|
|
||||||
coro: '_userCoro',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('spawnMain error:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Запустить произвольную функцию как корутину (для task.spawn).
|
|
||||||
*/
|
|
||||||
spawnCoroutine(fn, args) {
|
|
||||||
// Создаём корутину на JS-стороне: просто вызываем fn() сразу,
|
|
||||||
// а если внутри неё дёрнут wait — yield не сработает (JS не делает
|
|
||||||
// sync yield в обычной функции). Поэтому task.spawn для JS-функций
|
|
||||||
// равен прямому вызову.
|
|
||||||
// В будущем (4.7.1) можно через Lua coroutine реализовать.
|
|
||||||
try { fn(...(args || [])); } catch (e) { /* swallow */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Продвинуть время на dt и резюмить готовые корутины.
|
|
||||||
* Также автоматически fire'ит RunService.Heartbeat / Stepped / RenderStepped.
|
|
||||||
*/
|
|
||||||
async tick(dtSec) {
|
|
||||||
const dt = +dtSec || 0;
|
|
||||||
this.time += dt;
|
|
||||||
// Heartbeat / Stepped / RenderStepped для RunService
|
|
||||||
const game = this.lua.global.get('game');
|
|
||||||
if (game && typeof game.GetService === 'function') {
|
|
||||||
const rs = game.GetService('RunService');
|
|
||||||
if (rs) {
|
|
||||||
if (rs.Heartbeat && rs.Heartbeat.Fire) rs.Heartbeat.Fire(dt);
|
|
||||||
if (rs.Stepped && rs.Stepped.Fire) rs.Stepped.Fire(this.time, dt);
|
|
||||||
if (rs.RenderStepped && rs.RenderStepped.Fire) rs.RenderStepped.Fire(dt);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Резюмим всё что готово
|
|
||||||
const ready = this.tasks.filter(t => !t.waitForSignal && t.resumeAt <= this.time);
|
|
||||||
this.tasks = this.tasks.filter(t => !(ready.includes(t)));
|
|
||||||
for (const t of ready) {
|
|
||||||
await this._resumeTask(t);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fire signal — разбудить все task'и ждущие этого сигнала.
|
|
||||||
*/
|
|
||||||
async fireSignal(name, ...args) {
|
|
||||||
const waiters = this.signalWaiters.get(name) || [];
|
|
||||||
this.signalWaiters.set(name, []);
|
|
||||||
for (const t of waiters) {
|
|
||||||
// Resume корутины передавая args как возврат :Wait()
|
|
||||||
await this._resumeTask(t, args);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async _resumeTask(task, resumeArgs = []) {
|
|
||||||
if (task.runFn) {
|
|
||||||
try {
|
|
||||||
const ret = task.runFn();
|
|
||||||
if (ret && typeof ret.then === 'function') await ret;
|
|
||||||
} catch (e) {}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (task.coro) {
|
|
||||||
try {
|
|
||||||
// resumeArgs идут как аргументы в coroutine.resume
|
|
||||||
const argsCode = resumeArgs.map((a, i) => {
|
|
||||||
if (typeof a === 'number') return String(a);
|
|
||||||
if (typeof a === 'string') return JSON.stringify(a);
|
|
||||||
return 'nil';
|
|
||||||
}).join(', ');
|
|
||||||
const code = `
|
|
||||||
local ok, val = coroutine.resume(${task.coro}${argsCode ? ', ' + argsCode : ''})
|
|
||||||
if not ok then
|
|
||||||
error("coro error: " .. tostring(val))
|
|
||||||
end
|
|
||||||
return val
|
|
||||||
`;
|
|
||||||
await this.lua.doString(code);
|
|
||||||
const status = await this.lua.doString(`return coroutine.status(${task.coro})`);
|
|
||||||
if (status === 'suspended') {
|
|
||||||
// Опять ушла в yield
|
|
||||||
const yi = this._currentYield || { kind: 'sleep', sec: 0 };
|
|
||||||
this._currentYield = null;
|
|
||||||
if (yi.kind === 'sleep') {
|
|
||||||
this.tasks.push({
|
|
||||||
resumeAt: this.time + yi.sec,
|
|
||||||
coro: task.coro,
|
|
||||||
});
|
|
||||||
} else if (yi.kind === 'signal') {
|
|
||||||
const list = this.signalWaiters.get(yi.name) || [];
|
|
||||||
list.push({ coro: task.coro });
|
|
||||||
this.signalWaiters.set(yi.name, list);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Корутина завершилась с ошибкой — просто дропаем
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,384 +0,0 @@
|
|||||||
/**
|
|
||||||
* roblox-services.js — расширения Roblox-API для сервисов:
|
|
||||||
* Players / Humanoid / UserInputService / RemoteEvent / RemoteFunction
|
|
||||||
* / DataStoreService / HttpService.
|
|
||||||
*
|
|
||||||
* Регистрируется ПОСЛЕ registerRobloxApi (см. roblox-shim.js).
|
|
||||||
*
|
|
||||||
* Поведение:
|
|
||||||
* - Players.LocalPlayer.Character.Humanoid.Health, WalkSpeed, JumpPower
|
|
||||||
* мапятся на game.player.* в Rublox через `playerCmd` IPC.
|
|
||||||
* - UserInputService.InputBegan/InputEnded — пробрасываются из main
|
|
||||||
* по событию через fireEvent.
|
|
||||||
* - RemoteEvent:FireServer/FireClient → broadcast.
|
|
||||||
* - DataStoreService:GetDataStore → game.save.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { RbxInstance, RbxSignal, RbxVector3 } from './roblox-shim.js';
|
|
||||||
|
|
||||||
/* ──────── Humanoid ──────── */
|
|
||||||
|
|
||||||
class RbxHumanoid extends RbxInstance {
|
|
||||||
constructor(ctx) {
|
|
||||||
super('Humanoid', { Name: 'Humanoid' });
|
|
||||||
this._ctx = ctx; // { send, getPlayerState }
|
|
||||||
this._snap = {
|
|
||||||
Health: 100,
|
|
||||||
MaxHealth: 100,
|
|
||||||
WalkSpeed: 16,
|
|
||||||
JumpPower: 50,
|
|
||||||
JumpHeight: 7.2,
|
|
||||||
HipHeight: 0,
|
|
||||||
HumanoidStateType: 'GettingUp',
|
|
||||||
PlatformStand: false,
|
|
||||||
};
|
|
||||||
this.Died = new RbxSignal('Died');
|
|
||||||
this.HealthChanged = new RbxSignal('HealthChanged');
|
|
||||||
this.Touched = new RbxSignal('Touched');
|
|
||||||
this.Running = new RbxSignal('Running');
|
|
||||||
this.Jumping = new RbxSignal('Jumping');
|
|
||||||
this.StateChanged = new RbxSignal('StateChanged');
|
|
||||||
}
|
|
||||||
|
|
||||||
get Health() { return this._snap.Health; }
|
|
||||||
set Health(v) {
|
|
||||||
const old = this._snap.Health;
|
|
||||||
const nv = Math.max(0, +v || 0);
|
|
||||||
this._snap.Health = nv;
|
|
||||||
if (nv !== old) this.HealthChanged.Fire(nv);
|
|
||||||
if (nv <= 0 && old > 0) {
|
|
||||||
this.Died.Fire();
|
|
||||||
this._ctx.send?.('playerCmd', { method: 'die', args: [] });
|
|
||||||
} else {
|
|
||||||
this._ctx.send?.('playerCmd', { method: 'setHealth', args: [nv] });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
get MaxHealth() { return this._snap.MaxHealth; }
|
|
||||||
set MaxHealth(v) {
|
|
||||||
this._snap.MaxHealth = +v || 100;
|
|
||||||
this._ctx.send?.('playerCmd', { method: 'setMaxHealth', args: [this._snap.MaxHealth] });
|
|
||||||
}
|
|
||||||
get WalkSpeed() { return this._snap.WalkSpeed; }
|
|
||||||
set WalkSpeed(v) {
|
|
||||||
this._snap.WalkSpeed = +v || 0;
|
|
||||||
this._ctx.send?.('playerCmd', { method: 'setWalkSpeed', args: [this._snap.WalkSpeed] });
|
|
||||||
}
|
|
||||||
get JumpPower() { return this._snap.JumpPower; }
|
|
||||||
set JumpPower(v) {
|
|
||||||
this._snap.JumpPower = +v || 0;
|
|
||||||
this._ctx.send?.('playerCmd', { method: 'setJumpPower', args: [this._snap.JumpPower] });
|
|
||||||
}
|
|
||||||
get JumpHeight() { return this._snap.JumpHeight; }
|
|
||||||
set JumpHeight(v) {
|
|
||||||
this._snap.JumpHeight = +v || 0;
|
|
||||||
this._ctx.send?.('playerCmd', { method: 'setJumpHeight', args: [this._snap.JumpHeight] });
|
|
||||||
}
|
|
||||||
get PlatformStand() { return !!this._snap.PlatformStand; }
|
|
||||||
set PlatformStand(v) {
|
|
||||||
this._snap.PlatformStand = !!v;
|
|
||||||
this._ctx.send?.('playerCmd', { method: 'setPlatformStand', args: [!!v] });
|
|
||||||
}
|
|
||||||
TakeDamage(amount) {
|
|
||||||
this.Health = Math.max(0, this.Health - (+amount || 0));
|
|
||||||
}
|
|
||||||
Move(direction, relative) {
|
|
||||||
if (direction instanceof RbxVector3) {
|
|
||||||
this._ctx.send?.('playerCmd', {
|
|
||||||
method: 'move',
|
|
||||||
args: [{ x: direction.X, y: direction.Y, z: direction.Z }, !!relative],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Jump() {
|
|
||||||
this._ctx.send?.('playerCmd', { method: 'jump', args: [] });
|
|
||||||
}
|
|
||||||
LoadAnimation(animation) {
|
|
||||||
// Animation объект — content rbxassetid. Возвращаем animation-track stub.
|
|
||||||
const aid = animation?.AnimationId || '';
|
|
||||||
return {
|
|
||||||
AnimationId: aid,
|
|
||||||
Length: 0,
|
|
||||||
IsPlaying: false,
|
|
||||||
Looped: false,
|
|
||||||
Play: () => this._ctx.send?.('playerCmd', { method: 'playAnim', args: [aid] }),
|
|
||||||
Stop: () => this._ctx.send?.('playerCmd', { method: 'stopAnim', args: [aid] }),
|
|
||||||
AdjustSpeed: (s) => this._ctx.send?.('playerCmd', { method: 'animSpeed', args: [aid, s] }),
|
|
||||||
GetTimeOfKeyframe: () => 0,
|
|
||||||
KeyframeReached: new RbxSignal('KeyframeReached'),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
ChangeState(state) {
|
|
||||||
this._snap.HumanoidStateType = state;
|
|
||||||
this.StateChanged.Fire(state);
|
|
||||||
}
|
|
||||||
SetStateEnabled(_state, _enabled) { /* noop */ }
|
|
||||||
GetState() { return this._snap.HumanoidStateType; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ──────── Character / Player ──────── */
|
|
||||||
|
|
||||||
class RbxCharacter extends RbxInstance {
|
|
||||||
constructor(ctx) {
|
|
||||||
super('Model', { Name: 'Character' });
|
|
||||||
// HumanoidRootPart — это «Position персонажа»
|
|
||||||
this.HumanoidRootPart = new RbxInstance('Part', { Name: 'HumanoidRootPart', Parent: this });
|
|
||||||
// mock Position через getter — берём текущую позицию из ctx
|
|
||||||
Object.defineProperty(this.HumanoidRootPart, 'Position', {
|
|
||||||
get: () => {
|
|
||||||
const p = ctx.getPlayerState?.() || { x: 0, y: 0, z: 0 };
|
|
||||||
return new RbxVector3(p.x, p.y, p.z);
|
|
||||||
},
|
|
||||||
set: (v) => {
|
|
||||||
if (v instanceof RbxVector3) {
|
|
||||||
ctx.send?.('playerCmd', { method: 'teleport', args: [v.X, v.Y, v.Z] });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
Object.defineProperty(this.HumanoidRootPart, 'CFrame', {
|
|
||||||
get: () => {
|
|
||||||
const p = ctx.getPlayerState?.() || { x: 0, y: 0, z: 0 };
|
|
||||||
return { X: p.x, Y: p.y, Z: p.z, p: { X: p.x, Y: p.y, Z: p.z } };
|
|
||||||
},
|
|
||||||
set: (v) => {
|
|
||||||
if (v && typeof v === 'object') {
|
|
||||||
ctx.send?.('playerCmd', { method: 'teleport', args: [v.X || 0, v.Y || 0, v.Z || 0] });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
this.Children.push(this.HumanoidRootPart);
|
|
||||||
this.Humanoid = new RbxHumanoid(ctx);
|
|
||||||
this.Humanoid.Parent = this;
|
|
||||||
this.Children.push(this.Humanoid);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class RbxPlayer extends RbxInstance {
|
|
||||||
constructor(ctx) {
|
|
||||||
super('Player', { Name: 'Player' });
|
|
||||||
this.UserId = 1;
|
|
||||||
this.DisplayName = 'Player';
|
|
||||||
this.Character = new RbxCharacter(ctx);
|
|
||||||
this.CharacterAdded = new RbxSignal('CharacterAdded');
|
|
||||||
this.CharacterRemoving = new RbxSignal('CharacterRemoving');
|
|
||||||
// На MVP — характер уже создан.
|
|
||||||
setTimeout(() => this.CharacterAdded.Fire(this.Character), 0);
|
|
||||||
this.leaderstats = new RbxInstance('Folder', { Name: 'leaderstats', Parent: this });
|
|
||||||
this.Children.push(this.leaderstats);
|
|
||||||
}
|
|
||||||
GetMouse() {
|
|
||||||
return { Hit: { Position: new RbxVector3(0, 0, 0) }, Target: null,
|
|
||||||
Button1Down: new RbxSignal('Button1Down'), Move: new RbxSignal('Move') };
|
|
||||||
}
|
|
||||||
Kick(reason) {
|
|
||||||
// в нашем плеере — просто log
|
|
||||||
return reason;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ──────── UserInputService ──────── */
|
|
||||||
|
|
||||||
class RbxUserInputService extends RbxInstance {
|
|
||||||
constructor() {
|
|
||||||
super('UserInputService', { Name: 'UserInputService' });
|
|
||||||
this.InputBegan = new RbxSignal('InputBegan');
|
|
||||||
this.InputEnded = new RbxSignal('InputEnded');
|
|
||||||
this.InputChanged = new RbxSignal('InputChanged');
|
|
||||||
this.JumpRequest = new RbxSignal('JumpRequest');
|
|
||||||
this.KeyboardEnabled = true;
|
|
||||||
this.MouseEnabled = true;
|
|
||||||
this.TouchEnabled = false;
|
|
||||||
}
|
|
||||||
GetMouseLocation() { return { X: 0, Y: 0 }; }
|
|
||||||
IsKeyDown(_keyCode) { return false; } // в MVP всегда false
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ──────── RemoteEvent / RemoteFunction ──────── */
|
|
||||||
|
|
||||||
class RbxRemoteEvent extends RbxInstance {
|
|
||||||
constructor(ctx) {
|
|
||||||
super('RemoteEvent', { Name: 'RemoteEvent' });
|
|
||||||
this._ctx = ctx;
|
|
||||||
this.OnServerEvent = new RbxSignal('OnServerEvent');
|
|
||||||
this.OnClientEvent = new RbxSignal('OnClientEvent');
|
|
||||||
}
|
|
||||||
FireServer(...args) {
|
|
||||||
// singleplayer: server == client, просто отдаём в OnServerEvent
|
|
||||||
this.OnServerEvent.Fire(this._ctx.localPlayer, ...args);
|
|
||||||
this._ctx.send?.('broadcast', { msg: 'remoteEvent:' + this.Name, data: args });
|
|
||||||
}
|
|
||||||
FireClient(_player, ...args) {
|
|
||||||
this.OnClientEvent.Fire(...args);
|
|
||||||
this._ctx.send?.('broadcast', { msg: 'remoteEvent:' + this.Name, data: args });
|
|
||||||
}
|
|
||||||
FireAllClients(...args) {
|
|
||||||
this.OnClientEvent.Fire(...args);
|
|
||||||
this._ctx.send?.('broadcast', { msg: 'remoteEvent:' + this.Name, data: args });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class RbxRemoteFunction extends RbxInstance {
|
|
||||||
constructor(ctx) {
|
|
||||||
super('RemoteFunction', { Name: 'RemoteFunction' });
|
|
||||||
this._ctx = ctx;
|
|
||||||
this.OnServerInvoke = null; // function(player, ...args) → result
|
|
||||||
}
|
|
||||||
InvokeServer(...args) {
|
|
||||||
if (typeof this.OnServerInvoke === 'function') {
|
|
||||||
try { return this.OnServerInvoke(this._ctx.localPlayer, ...args); } catch (e) {}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
InvokeClient(_player, ...args) {
|
|
||||||
if (typeof this.OnClientInvoke === 'function') {
|
|
||||||
try { return this.OnClientInvoke(...args); } catch (e) {}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ──────── DataStoreService ──────── */
|
|
||||||
|
|
||||||
class RbxDataStore {
|
|
||||||
constructor(name, ctx) {
|
|
||||||
this.name = name;
|
|
||||||
this._ctx = ctx;
|
|
||||||
}
|
|
||||||
GetAsync(key) {
|
|
||||||
try {
|
|
||||||
const data = this._ctx.loadSave?.(this.name + ':' + key);
|
|
||||||
return data ?? null;
|
|
||||||
} catch (e) { return null; }
|
|
||||||
}
|
|
||||||
SetAsync(key, value) {
|
|
||||||
this._ctx.saveSave?.(this.name + ':' + key, value);
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
UpdateAsync(key, updaterFn) {
|
|
||||||
const cur = this.GetAsync(key);
|
|
||||||
const next = updaterFn(cur);
|
|
||||||
if (next !== undefined) this.SetAsync(key, next);
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
IncrementAsync(key, delta) {
|
|
||||||
const cur = +this.GetAsync(key) || 0;
|
|
||||||
const next = cur + (+delta || 1);
|
|
||||||
this.SetAsync(key, next);
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
RemoveAsync(key) {
|
|
||||||
this._ctx.removeSave?.(this.name + ':' + key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class RbxDataStoreService extends RbxInstance {
|
|
||||||
constructor(ctx) {
|
|
||||||
super('DataStoreService', { Name: 'DataStoreService' });
|
|
||||||
this._ctx = ctx;
|
|
||||||
this._stores = new Map();
|
|
||||||
}
|
|
||||||
GetDataStore(name) {
|
|
||||||
if (!this._stores.has(name)) this._stores.set(name, new RbxDataStore(name, this._ctx));
|
|
||||||
return this._stores.get(name);
|
|
||||||
}
|
|
||||||
GetGlobalDataStore() { return this.GetDataStore('__global__'); }
|
|
||||||
GetOrderedDataStore(name) { return this.GetDataStore('ordered:' + name); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ──────── HttpService ──────── */
|
|
||||||
|
|
||||||
class RbxHttpService extends RbxInstance {
|
|
||||||
constructor(ctx) {
|
|
||||||
super('HttpService', { Name: 'HttpService' });
|
|
||||||
this._ctx = ctx;
|
|
||||||
this.HttpEnabled = false; // в нашем плеере по дефолту выкл, безопаснее
|
|
||||||
}
|
|
||||||
GenerateGUID(wrap) {
|
|
||||||
const c = () => Math.random().toString(16).slice(2, 6);
|
|
||||||
const guid = `${c()}${c()}-${c()}-${c()}-${c()}-${c()}${c()}${c()}`.toUpperCase();
|
|
||||||
return wrap === false ? guid : `{${guid}}`;
|
|
||||||
}
|
|
||||||
JSONEncode(value) { try { return JSON.stringify(value); } catch (e) { return ''; } }
|
|
||||||
JSONDecode(s) { try { return JSON.parse(s); } catch (e) { return null; } }
|
|
||||||
GetAsync(url) {
|
|
||||||
// CORS / sandbox: блокируем в MVP, возвращаем заглушку
|
|
||||||
this._ctx.send?.('log', { level: 'warn', text: `HttpService:GetAsync(${url}) blocked in MVP` });
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
PostAsync(url) {
|
|
||||||
this._ctx.send?.('log', { level: 'warn', text: `HttpService:PostAsync(${url}) blocked in MVP` });
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ──────── install ──────── */
|
|
||||||
|
|
||||||
export function installRobloxServices(lua, ctx) {
|
|
||||||
// ctx: { send, getPlayerState, getSnapPlayer, loadSave, saveSave, removeSave }
|
|
||||||
const game = lua.global.get('game');
|
|
||||||
if (!game) return;
|
|
||||||
|
|
||||||
// Создаём LocalPlayer
|
|
||||||
const player = new RbxPlayer({
|
|
||||||
send: ctx.send,
|
|
||||||
getPlayerState: ctx.getPlayerState,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Players service апгрейдим
|
|
||||||
const players = game.GetService('Players');
|
|
||||||
if (players) {
|
|
||||||
players.LocalPlayer = player;
|
|
||||||
// GetPlayers / GetPlayerFromCharacter
|
|
||||||
players.GetPlayers = () => [player];
|
|
||||||
players.GetPlayerFromCharacter = (c) => (c === player.Character ? player : null);
|
|
||||||
}
|
|
||||||
|
|
||||||
// UserInputService
|
|
||||||
const uis = new RbxUserInputService();
|
|
||||||
// RemoteEvent / DataStoreService / HttpService — выдаются через GetService
|
|
||||||
const dss = new RbxDataStoreService({
|
|
||||||
loadSave: ctx.loadSave,
|
|
||||||
saveSave: ctx.saveSave,
|
|
||||||
removeSave: ctx.removeSave,
|
|
||||||
});
|
|
||||||
const httpSvc = new RbxHttpService({ send: ctx.send });
|
|
||||||
|
|
||||||
// Подмена GetService — добавляем наши новые сервисы
|
|
||||||
const origGetService = game.GetService;
|
|
||||||
game.GetService = function(svc) {
|
|
||||||
if (svc === 'UserInputService') return uis;
|
|
||||||
if (svc === 'DataStoreService') return dss;
|
|
||||||
if (svc === 'HttpService') return httpSvc;
|
|
||||||
// ContextActionService — стаб
|
|
||||||
if (svc === 'ContextActionService') {
|
|
||||||
return {
|
|
||||||
ClassName: 'ContextActionService', Name: 'ContextActionService',
|
|
||||||
BindAction: (_name, fn, _gui, ...keys) => { /* в MVP — игнор */ },
|
|
||||||
UnbindAction: () => {},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return origGetService.call(this, svc);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Instance.new('RemoteEvent') / 'RemoteFunction' — переопределяем фабрику
|
|
||||||
const origInstance = lua.global.get('Instance');
|
|
||||||
lua.global.set('Instance', {
|
|
||||||
new: (className, parent) => {
|
|
||||||
if (className === 'RemoteEvent') {
|
|
||||||
const r = new RbxRemoteEvent({ send: ctx.send, localPlayer: player });
|
|
||||||
if (parent) { r.Parent = parent; parent.Children.push(r); }
|
|
||||||
return r;
|
|
||||||
}
|
|
||||||
if (className === 'RemoteFunction') {
|
|
||||||
const r = new RbxRemoteFunction({ send: ctx.send, localPlayer: player });
|
|
||||||
if (parent) { r.Parent = parent; parent.Children.push(r); }
|
|
||||||
return r;
|
|
||||||
}
|
|
||||||
return origInstance.new(className, parent);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return { player, uis, dss, httpSvc };
|
|
||||||
}
|
|
||||||
|
|
||||||
export { RbxHumanoid, RbxCharacter, RbxPlayer, RbxUserInputService,
|
|
||||||
RbxRemoteEvent, RbxRemoteFunction, RbxDataStoreService, RbxHttpService };
|
|
||||||
@ -1,715 +0,0 @@
|
|||||||
/**
|
|
||||||
* roblox-shim.js — регистрация Roblox API внутри Lua-VM (wasmoon).
|
|
||||||
*
|
|
||||||
* Используется из RobloxLuaWorker.js. Регистрирует глобалы:
|
|
||||||
* - game, workspace, script ← Instance-прокси
|
|
||||||
* - Vector3, Color3, CFrame, UDim, UDim2 ← конструкторы математических классов
|
|
||||||
* - Instance.new(class) ← фабрика
|
|
||||||
* - wait, task, tick, os, print, warn ← стандартные глобалы
|
|
||||||
* - Enum ← enum-таблица
|
|
||||||
*
|
|
||||||
* Архитектура:
|
|
||||||
* - JS-классы (RbxVector3, RbxCFrame, ...) — обычные дата-объекты с
|
|
||||||
* перегруженными методами.
|
|
||||||
* - Instance — прокси-объект который хранит { className, properties, children, parent }.
|
|
||||||
* Геттеры/сеттеры эмулируются через __index/__newindex (mt в wasmoon).
|
|
||||||
* - RBXScriptSignal — JS-объект с Connect/Wait/Disconnect.
|
|
||||||
*
|
|
||||||
* Sandbox-side: при изменении Part.Position и т.п. отсылаем в main thread
|
|
||||||
* `partSet` → main применит к Babylon-сцене.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/* ──────── Math classes ──────── */
|
|
||||||
|
|
||||||
class RbxVector3 {
|
|
||||||
constructor(x, y, z) {
|
|
||||||
this.X = +x || 0;
|
|
||||||
this.Y = +y || 0;
|
|
||||||
this.Z = +z || 0;
|
|
||||||
}
|
|
||||||
get Magnitude() {
|
|
||||||
return Math.sqrt(this.X*this.X + this.Y*this.Y + this.Z*this.Z);
|
|
||||||
}
|
|
||||||
get Unit() {
|
|
||||||
const m = this.Magnitude || 1;
|
|
||||||
return new RbxVector3(this.X / m, this.Y / m, this.Z / m);
|
|
||||||
}
|
|
||||||
Dot(o) { return this.X*o.X + this.Y*o.Y + this.Z*o.Z; }
|
|
||||||
Cross(o) {
|
|
||||||
return new RbxVector3(
|
|
||||||
this.Y*o.Z - this.Z*o.Y,
|
|
||||||
this.Z*o.X - this.X*o.Z,
|
|
||||||
this.X*o.Y - this.Y*o.X,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Lerp(o, alpha) {
|
|
||||||
return new RbxVector3(
|
|
||||||
this.X + (o.X - this.X) * alpha,
|
|
||||||
this.Y + (o.Y - this.Y) * alpha,
|
|
||||||
this.Z + (o.Z - this.Z) * alpha,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
add(o) { return new RbxVector3(this.X + o.X, this.Y + o.Y, this.Z + o.Z); }
|
|
||||||
sub(o) { return new RbxVector3(this.X - o.X, this.Y - o.Y, this.Z - o.Z); }
|
|
||||||
mul(scalar) {
|
|
||||||
if (typeof scalar === 'number') {
|
|
||||||
return new RbxVector3(this.X * scalar, this.Y * scalar, this.Z * scalar);
|
|
||||||
}
|
|
||||||
return new RbxVector3(this.X * scalar.X, this.Y * scalar.Y, this.Z * scalar.Z);
|
|
||||||
}
|
|
||||||
toString() { return `${this.X}, ${this.Y}, ${this.Z}`; }
|
|
||||||
}
|
|
||||||
|
|
||||||
class RbxColor3 {
|
|
||||||
constructor(r, g, b) {
|
|
||||||
this.R = +r || 0;
|
|
||||||
this.G = +g || 0;
|
|
||||||
this.B = +b || 0;
|
|
||||||
}
|
|
||||||
static fromRGB(r, g, b) {
|
|
||||||
return new RbxColor3((r||0)/255, (g||0)/255, (b||0)/255);
|
|
||||||
}
|
|
||||||
static fromHex(hex) {
|
|
||||||
const h = String(hex || '#000000').replace('#','');
|
|
||||||
return new RbxColor3(
|
|
||||||
parseInt(h.slice(0,2), 16)/255,
|
|
||||||
parseInt(h.slice(2,4), 16)/255,
|
|
||||||
parseInt(h.slice(4,6), 16)/255,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Lerp(o, alpha) {
|
|
||||||
return new RbxColor3(
|
|
||||||
this.R + (o.R - this.R) * alpha,
|
|
||||||
this.G + (o.G - this.G) * alpha,
|
|
||||||
this.B + (o.B - this.B) * alpha,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
toHex() {
|
|
||||||
const h = (n) => Math.max(0, Math.min(255, Math.round(n * 255))).toString(16).padStart(2, '0');
|
|
||||||
return `#${h(this.R)}${h(this.G)}${h(this.B)}`;
|
|
||||||
}
|
|
||||||
toString() { return `${this.R}, ${this.G}, ${this.B}`; }
|
|
||||||
}
|
|
||||||
|
|
||||||
class RbxCFrame {
|
|
||||||
constructor(x, y, z, r00=1, r01=0, r02=0, r10=0, r11=1, r12=0, r20=0, r21=0, r22=1) {
|
|
||||||
this.X = +x || 0; this.Y = +y || 0; this.Z = +z || 0;
|
|
||||||
// Row-major 3x3
|
|
||||||
this.r00 = r00; this.r01 = r01; this.r02 = r02;
|
|
||||||
this.r10 = r10; this.r11 = r11; this.r12 = r12;
|
|
||||||
this.r20 = r20; this.r21 = r21; this.r22 = r22;
|
|
||||||
}
|
|
||||||
static new(x, y, z) {
|
|
||||||
if (x instanceof RbxVector3) return new RbxCFrame(x.X, x.Y, x.Z);
|
|
||||||
return new RbxCFrame(x || 0, y || 0, z || 0);
|
|
||||||
}
|
|
||||||
static Angles(rx, ry, rz) {
|
|
||||||
// Euler XYZ → 3x3 (intrinsic)
|
|
||||||
const cx = Math.cos(rx), sx = Math.sin(rx);
|
|
||||||
const cy = Math.cos(ry), sy = Math.sin(ry);
|
|
||||||
const cz = Math.cos(rz), sz = Math.sin(rz);
|
|
||||||
// R = Rx * Ry * Rz
|
|
||||||
const r00 = cy*cz, r01 = -cy*sz, r02 = sy;
|
|
||||||
const r10 = sx*sy*cz + cx*sz, r11 = -sx*sy*sz + cx*cz, r12 = -sx*cy;
|
|
||||||
const r20 = -cx*sy*cz + sx*sz, r21 = cx*sy*sz + sx*cz, r22 = cx*cy;
|
|
||||||
return new RbxCFrame(0, 0, 0, r00, r01, r02, r10, r11, r12, r20, r21, r22);
|
|
||||||
}
|
|
||||||
static fromEulerAnglesXYZ(rx, ry, rz) { return RbxCFrame.Angles(rx, ry, rz); }
|
|
||||||
get Position() { return new RbxVector3(this.X, this.Y, this.Z); }
|
|
||||||
get LookVector() { return new RbxVector3(-this.r02, -this.r12, -this.r22); }
|
|
||||||
get RightVector() { return new RbxVector3(this.r00, this.r10, this.r20); }
|
|
||||||
get UpVector() { return new RbxVector3(this.r01, this.r11, this.r21); }
|
|
||||||
Lerp(o, a) {
|
|
||||||
// Линейная интерполяция (без правильного slerp на матрицах — для MVP сойдёт)
|
|
||||||
return new RbxCFrame(
|
|
||||||
this.X + (o.X - this.X) * a,
|
|
||||||
this.Y + (o.Y - this.Y) * a,
|
|
||||||
this.Z + (o.Z - this.Z) * a,
|
|
||||||
this.r00, this.r01, this.r02,
|
|
||||||
this.r10, this.r11, this.r12,
|
|
||||||
this.r20, this.r21, this.r22,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Inverse() {
|
|
||||||
// Транспонируем 3x3 (для rotation matrix Inverse == Transpose)
|
|
||||||
return new RbxCFrame(
|
|
||||||
-this.X, -this.Y, -this.Z,
|
|
||||||
this.r00, this.r10, this.r20,
|
|
||||||
this.r01, this.r11, this.r21,
|
|
||||||
this.r02, this.r12, this.r22,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
toEulerXYZ() {
|
|
||||||
const rx = Math.atan2(this.r21, this.r22);
|
|
||||||
const ry = Math.atan2(-this.r20, Math.sqrt(this.r21*this.r21 + this.r22*this.r22));
|
|
||||||
const rz = Math.atan2(this.r10, this.r00);
|
|
||||||
return [rx, ry, rz];
|
|
||||||
}
|
|
||||||
toString() { return `${this.X}, ${this.Y}, ${this.Z}`; }
|
|
||||||
}
|
|
||||||
|
|
||||||
class RbxUDim {
|
|
||||||
constructor(scale, offset) { this.Scale = +scale || 0; this.Offset = +offset | 0; }
|
|
||||||
toString() { return `${this.Scale}, ${this.Offset}`; }
|
|
||||||
}
|
|
||||||
|
|
||||||
class RbxUDim2 {
|
|
||||||
constructor(xs, xo, ys, yo) {
|
|
||||||
this.X = new RbxUDim(xs, xo);
|
|
||||||
this.Y = new RbxUDim(ys, yo);
|
|
||||||
}
|
|
||||||
static new(xs, xo, ys, yo) { return new RbxUDim2(xs, xo, ys, yo); }
|
|
||||||
static fromScale(xs, ys) { return new RbxUDim2(xs, 0, ys, 0); }
|
|
||||||
static fromOffset(xo, yo) { return new RbxUDim2(0, xo, 0, yo); }
|
|
||||||
toString() { return `${this.X.Scale}, ${this.X.Offset}, ${this.Y.Scale}, ${this.Y.Offset}`; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ──────── RBXScriptSignal ──────── */
|
|
||||||
|
|
||||||
let _signalIdCounter = 1000;
|
|
||||||
|
|
||||||
class RbxSignal {
|
|
||||||
constructor(name) {
|
|
||||||
this.name = name;
|
|
||||||
this.id = _signalIdCounter++;
|
|
||||||
this.connections = [];
|
|
||||||
}
|
|
||||||
Connect(callback) {
|
|
||||||
const conn = { callback, connected: true };
|
|
||||||
this.connections.push(conn);
|
|
||||||
return {
|
|
||||||
Disconnect: () => { conn.connected = false; },
|
|
||||||
disconnect: () => { conn.connected = false; },
|
|
||||||
Connected: () => conn.connected,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// Legacy Roblox API — lowercase alias
|
|
||||||
connect(callback) { return this.Connect(callback); }
|
|
||||||
Wait() { return null; }
|
|
||||||
wait() { return null; }
|
|
||||||
Fire(...args) {
|
|
||||||
for (const c of this.connections) {
|
|
||||||
if (!c.connected) continue;
|
|
||||||
try { c.callback(...args); } catch (e) { /* swallow */ }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fire(...args) { return this.Fire(...args); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ──────── Instance прокси ──────── */
|
|
||||||
|
|
||||||
let _instanceCounter = 1;
|
|
||||||
|
|
||||||
// Null-stub: возвращается из FindFirstChild/WaitForChild когда объект не найден.
|
|
||||||
// Имеет все методы Instance как no-op, чтобы Lua-цепочки вроде
|
|
||||||
// game.Players.LocalPlayer:WaitForChild("PlayerGui"):WaitForChild("X").Touched:Connect(fn)
|
|
||||||
// не падали с "attempt to call js_null", когда промежуточный объект не существует.
|
|
||||||
// Все методы возвращают сам _nullStub чтобы цепочка не разорвалась.
|
|
||||||
// nullSignal: callable proxy. Lua делает x:Connect(fn) = x.Connect(x, fn),
|
|
||||||
// но также pattern signal:Connect(fn) сначала достаёт signal.Connect (это функция),
|
|
||||||
// потом вызывает её. Мы возвращаем функцию которая безусловно возвращает {Disconnect}.
|
|
||||||
const _nullConn = { Disconnect: () => {}, disconnect: () => {}, Connected: false };
|
|
||||||
const _nullSignalFn = () => _nullConn;
|
|
||||||
const _nullSignal = new Proxy(_nullSignalFn, {
|
|
||||||
get(_, k) {
|
|
||||||
if (k === 'Connect' || k === 'connect') return _nullSignalFn;
|
|
||||||
if (k === 'Wait' || k === 'wait') return () => null;
|
|
||||||
if (k === 'Fire' || k === 'fire') return () => {};
|
|
||||||
return undefined;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
// Известные имена сигналов (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 {
|
|
||||||
constructor(className, init = {}) {
|
|
||||||
this.__id = _instanceCounter++;
|
|
||||||
this.ClassName = className;
|
|
||||||
this.Name = init.Name || className;
|
|
||||||
this.Parent = init.Parent || null;
|
|
||||||
this.Children = [];
|
|
||||||
this.__props = {}; // raw properties (для Position и т.п.)
|
|
||||||
// Signals доступны как прямые свойства, плюс дублируются в __signals для serv-кода
|
|
||||||
this.Touched = new RbxSignal('Touched');
|
|
||||||
this.TouchEnded = new RbxSignal('TouchEnded');
|
|
||||||
this.Changed = new RbxSignal('Changed');
|
|
||||||
this.AncestryChanged = new RbxSignal('AncestryChanged');
|
|
||||||
this.ChildAdded = new RbxSignal('ChildAdded');
|
|
||||||
this.ChildRemoved = new RbxSignal('ChildRemoved');
|
|
||||||
this.__signals = {
|
|
||||||
Touched: this.Touched,
|
|
||||||
TouchEnded: this.TouchEnded,
|
|
||||||
Changed: this.Changed,
|
|
||||||
AncestryChanged: this.AncestryChanged,
|
|
||||||
ChildAdded: this.ChildAdded,
|
|
||||||
ChildRemoved: this.ChildRemoved,
|
|
||||||
};
|
|
||||||
this.__sceneState = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
GetChildren() { return [...this.Children]; }
|
|
||||||
GetDescendants() {
|
|
||||||
const out = [];
|
|
||||||
const walk = (n) => {
|
|
||||||
for (const c of n.Children) { out.push(c); walk(c); }
|
|
||||||
};
|
|
||||||
walk(this);
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
FindFirstChild(name, recursive) {
|
|
||||||
for (const c of this.Children) {
|
|
||||||
if (c.Name === name) return c;
|
|
||||||
if (recursive) {
|
|
||||||
const found = c.FindFirstChild(name, true);
|
|
||||||
if (found) return found;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Возвращаем undefined — wasmoon отдаст это как nil.
|
|
||||||
// Lua-side debug.setmetatable(nil) перехватит дальнейшую индексацию.
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
FindFirstChildOfClass(className) {
|
|
||||||
for (const c of this.Children) {
|
|
||||||
if (c.ClassName === className) return c;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
FindFirstAncestor(name) {
|
|
||||||
let p = this.Parent;
|
|
||||||
while (p) {
|
|
||||||
if (p.Name === name) return p;
|
|
||||||
p = p.Parent;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
WaitForChild(name, _timeout) {
|
|
||||||
// В MVP — синхронный поиск без ожидания. В 4.7 через корутины можно ждать.
|
|
||||||
return this.FindFirstChild(name);
|
|
||||||
}
|
|
||||||
IsA(className) {
|
|
||||||
if (this.ClassName === className) return true;
|
|
||||||
// Roblox class hierarchy: Part isA BasePart isA PVInstance isA Instance.
|
|
||||||
const hierarchy = {
|
|
||||||
'Part': ['BasePart', 'PVInstance', 'Instance'],
|
|
||||||
'WedgePart': ['BasePart', 'PVInstance', 'Instance'],
|
|
||||||
'CornerWedgePart': ['BasePart', 'PVInstance', 'Instance'],
|
|
||||||
'MeshPart': ['BasePart', 'PVInstance', 'Instance'],
|
|
||||||
'UnionOperation': ['PartOperation', 'BasePart', 'PVInstance', 'Instance'],
|
|
||||||
'TrussPart': ['BasePart', 'PVInstance', 'Instance'],
|
|
||||||
'SpawnLocation': ['Part', 'BasePart', 'PVInstance', 'Instance'],
|
|
||||||
'Script': ['BaseScript', 'LuaSourceContainer', 'Instance'],
|
|
||||||
'LocalScript': ['BaseScript', 'LuaSourceContainer', 'Instance'],
|
|
||||||
'ModuleScript': ['LuaSourceContainer', 'Instance'],
|
|
||||||
'Folder': ['Instance'],
|
|
||||||
'Model': ['PVInstance', 'Instance'],
|
|
||||||
'Sound': ['Instance'],
|
|
||||||
'PointLight': ['Light', 'Instance'],
|
|
||||||
'SpotLight': ['Light', 'Instance'],
|
|
||||||
'Humanoid': ['Instance'],
|
|
||||||
};
|
|
||||||
const ancestors = hierarchy[this.ClassName] || [];
|
|
||||||
return ancestors.includes(className);
|
|
||||||
}
|
|
||||||
Destroy() {
|
|
||||||
if (this.Parent && this.Parent.Children) {
|
|
||||||
const idx = this.Parent.Children.indexOf(this);
|
|
||||||
if (idx >= 0) this.Parent.Children.splice(idx, 1);
|
|
||||||
}
|
|
||||||
this.Parent = null;
|
|
||||||
this.__destroyed = true;
|
|
||||||
}
|
|
||||||
Clone() {
|
|
||||||
const cl = new RbxInstance(this.ClassName);
|
|
||||||
cl.Name = this.Name;
|
|
||||||
cl.__props = JSON.parse(JSON.stringify(this.__props));
|
|
||||||
for (const c of this.Children) {
|
|
||||||
const cc = c.Clone();
|
|
||||||
cc.Parent = cl;
|
|
||||||
cl.Children.push(cc);
|
|
||||||
}
|
|
||||||
return cl;
|
|
||||||
}
|
|
||||||
|
|
||||||
GetPropertyChangedSignal(propName) {
|
|
||||||
const sigName = `Changed:${propName}`;
|
|
||||||
if (!this.__signals[sigName]) this.__signals[sigName] = new RbxSignal(sigName);
|
|
||||||
return this.__signals[sigName];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ──────── Part — наследник Instance с реальными свойствами сцены ──────── */
|
|
||||||
|
|
||||||
class RbxPart extends RbxInstance {
|
|
||||||
constructor(primId, init = {}) {
|
|
||||||
super(init.ClassName || 'Part', init);
|
|
||||||
this.__primId = primId; // id примитива в Rublox-сцене
|
|
||||||
this.__sendFn = null; // setter из shim init
|
|
||||||
// Кешированные свойства (mirror'ятся через handleTick)
|
|
||||||
this._snap = init.snap || {};
|
|
||||||
}
|
|
||||||
|
|
||||||
get Position() {
|
|
||||||
return new RbxVector3(this._snap.x || 0, this._snap.y || 0, this._snap.z || 0);
|
|
||||||
}
|
|
||||||
set Position(v) {
|
|
||||||
if (v instanceof RbxVector3) {
|
|
||||||
this._snap.x = v.X; this._snap.y = v.Y; this._snap.z = v.Z;
|
|
||||||
this.__sendFn?.('partSet', { primId: this.__primId, prop: 'position', value: { x: v.X, y: v.Y, z: v.Z } });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
get CFrame() {
|
|
||||||
return new RbxCFrame(this._snap.x || 0, this._snap.y || 0, this._snap.z || 0);
|
|
||||||
}
|
|
||||||
set CFrame(cf) {
|
|
||||||
if (cf instanceof RbxCFrame) {
|
|
||||||
this._snap.x = cf.X; this._snap.y = cf.Y; this._snap.z = cf.Z;
|
|
||||||
const [rx, ry, rz] = cf.toEulerXYZ();
|
|
||||||
this._snap.rx = rx; this._snap.ry = ry; this._snap.rz = rz;
|
|
||||||
this.__sendFn?.('partSet', { primId: this.__primId, prop: 'cframe', value: { x: cf.X, y: cf.Y, z: cf.Z, rx, ry, rz } });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
get Size() {
|
|
||||||
return new RbxVector3(this._snap.sx || 1, this._snap.sy || 1, this._snap.sz || 1);
|
|
||||||
}
|
|
||||||
set Size(v) {
|
|
||||||
if (v instanceof RbxVector3) {
|
|
||||||
this._snap.sx = v.X; this._snap.sy = v.Y; this._snap.sz = v.Z;
|
|
||||||
this.__sendFn?.('partSet', { primId: this.__primId, prop: 'size', value: { sx: v.X, sy: v.Y, sz: v.Z } });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
get Color() { return RbxColor3.fromHex(this._snap.color || '#cccccc'); }
|
|
||||||
set Color(c) {
|
|
||||||
if (c instanceof RbxColor3) {
|
|
||||||
const hex = c.toHex();
|
|
||||||
this._snap.color = hex;
|
|
||||||
this.__sendFn?.('partSet', { primId: this.__primId, prop: 'color', value: hex });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
get BrickColor() { return { Color: this.Color, Name: 'Medium stone grey' }; }
|
|
||||||
set BrickColor(b) { if (b && b.Color) this.Color = b.Color; }
|
|
||||||
get Material() { return this._snap.material || 'glossy'; }
|
|
||||||
set Material(m) {
|
|
||||||
this._snap.material = m;
|
|
||||||
this.__sendFn?.('partSet', { primId: this.__primId, prop: 'material', value: m });
|
|
||||||
}
|
|
||||||
get Anchored() { return !!this._snap.anchored; }
|
|
||||||
set Anchored(v) {
|
|
||||||
this._snap.anchored = !!v;
|
|
||||||
this.__sendFn?.('partSet', { primId: this.__primId, prop: 'anchored', value: !!v });
|
|
||||||
}
|
|
||||||
get CanCollide() { return this._snap.canCollide !== false; }
|
|
||||||
set CanCollide(v) {
|
|
||||||
this._snap.canCollide = !!v;
|
|
||||||
this.__sendFn?.('partSet', { primId: this.__primId, prop: 'canCollide', value: !!v });
|
|
||||||
}
|
|
||||||
get Transparency() { return 1.0 - (this._snap.opacity ?? 1.0); }
|
|
||||||
set Transparency(v) {
|
|
||||||
this._snap.opacity = 1.0 - (+v || 0);
|
|
||||||
this.__sendFn?.('partSet', { primId: this.__primId, prop: 'opacity', value: this._snap.opacity });
|
|
||||||
}
|
|
||||||
get Velocity() { return new RbxVector3(0, 0, 0); }
|
|
||||||
set Velocity(v) {
|
|
||||||
if (v instanceof RbxVector3) {
|
|
||||||
this.__sendFn?.('partVel', { primId: this.__primId, vx: v.X, vy: v.Y, vz: v.Z });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ──────── Главный entry-point: регистрация в Lua-VM ──────── */
|
|
||||||
|
|
||||||
export function registerRobloxApi(lua, ctx) {
|
|
||||||
const { getSceneSnap, targetPrimitiveId, send, getGuiTree, scheduler } = ctx;
|
|
||||||
|
|
||||||
// 1. Math classes — как глобалы с .new factory
|
|
||||||
const wrap = (cls) => ({
|
|
||||||
new: (...args) => new cls(...args),
|
|
||||||
});
|
|
||||||
|
|
||||||
lua.global.set('Vector3', {
|
|
||||||
new: (x, y, z) => new RbxVector3(x, y, z),
|
|
||||||
zero: new RbxVector3(0, 0, 0),
|
|
||||||
one: new RbxVector3(1, 1, 1),
|
|
||||||
xAxis: new RbxVector3(1, 0, 0),
|
|
||||||
yAxis: new RbxVector3(0, 1, 0),
|
|
||||||
zAxis: new RbxVector3(0, 0, 1),
|
|
||||||
});
|
|
||||||
lua.global.set('Color3', {
|
|
||||||
new: (r, g, b) => new RbxColor3(r, g, b),
|
|
||||||
fromRGB: RbxColor3.fromRGB,
|
|
||||||
fromHex: RbxColor3.fromHex,
|
|
||||||
});
|
|
||||||
lua.global.set('CFrame', {
|
|
||||||
new: RbxCFrame.new,
|
|
||||||
Angles: RbxCFrame.Angles,
|
|
||||||
fromEulerAnglesXYZ: RbxCFrame.fromEulerAnglesXYZ,
|
|
||||||
});
|
|
||||||
lua.global.set('UDim', { new: (s, o) => new RbxUDim(s, o) });
|
|
||||||
lua.global.set('UDim2', {
|
|
||||||
new: RbxUDim2.new,
|
|
||||||
fromScale: RbxUDim2.fromScale,
|
|
||||||
fromOffset: RbxUDim2.fromOffset,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 2. Сцена — собираем JS-структуру из snap'а
|
|
||||||
// Workspace — корень.
|
|
||||||
const workspace = new RbxInstance('Workspace', { Name: 'Workspace' });
|
|
||||||
const part_by_id = new Map();
|
|
||||||
const snap = getSceneSnap();
|
|
||||||
if (snap && snap.primitives) {
|
|
||||||
for (const [id, p] of Object.entries(snap.primitives)) {
|
|
||||||
const part = new RbxPart(+id, {
|
|
||||||
ClassName: p.type === 'wedge' ? 'WedgePart' :
|
|
||||||
p.type === 'cornerwedge' ? 'CornerWedgePart' : 'Part',
|
|
||||||
Name: p.name || 'Part',
|
|
||||||
snap: { ...p },
|
|
||||||
});
|
|
||||||
part.__sendFn = send;
|
|
||||||
// Сигналы Part: Touched/TouchEnded существуют на каждом по умолчанию
|
|
||||||
part.Touched = new RbxSignal('Touched');
|
|
||||||
part.TouchEnded = new RbxSignal('TouchEnded');
|
|
||||||
part.Parent = workspace;
|
|
||||||
workspace.Children.push(part);
|
|
||||||
part_by_id.set(+id, part);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2b. GUI-tree: предсоздаём ScreenGui + Frame/Button/Label/etc по дереву
|
|
||||||
// конвертера. Каждый button получает MouseButton1Click/MouseButton1Down/Up
|
|
||||||
// сигналы которые 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)) {
|
|
||||||
const parentPart = part_by_id.get(targetPrimitiveId);
|
|
||||||
const scriptInst = new RbxInstance('LocalScript', { Name: 'Script' });
|
|
||||||
scriptInst.Parent = parentPart;
|
|
||||||
parentPart.Children.push(scriptInst);
|
|
||||||
lua.global.set('script', scriptInst);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. game / game:GetService
|
|
||||||
const services = new Map();
|
|
||||||
const game = new RbxInstance('DataModel', { Name: 'Game' });
|
|
||||||
game.Children.push(workspace);
|
|
||||||
workspace.Parent = game;
|
|
||||||
|
|
||||||
// Builtin services:
|
|
||||||
const lighting = new RbxInstance('Lighting', { Name: 'Lighting' });
|
|
||||||
lighting.Parent = game;
|
|
||||||
game.Children.push(lighting);
|
|
||||||
services.set('Lighting', lighting);
|
|
||||||
|
|
||||||
const replicatedStorage = new RbxInstance('ReplicatedStorage', { Name: 'ReplicatedStorage' });
|
|
||||||
replicatedStorage.Parent = game;
|
|
||||||
game.Children.push(replicatedStorage);
|
|
||||||
services.set('ReplicatedStorage', replicatedStorage);
|
|
||||||
|
|
||||||
const runService = new RbxInstance('RunService', { Name: 'RunService' });
|
|
||||||
runService.Heartbeat = new RbxSignal('Heartbeat');
|
|
||||||
runService.Stepped = new RbxSignal('Stepped');
|
|
||||||
runService.RenderStepped = new RbxSignal('RenderStepped');
|
|
||||||
services.set('RunService', runService);
|
|
||||||
|
|
||||||
const playersService = new RbxInstance('Players', { Name: 'Players' });
|
|
||||||
playersService.PlayerAdded = new RbxSignal('PlayerAdded');
|
|
||||||
playersService.PlayerRemoving = new RbxSignal('PlayerRemoving');
|
|
||||||
// LocalPlayer с PlayerGui + Character
|
|
||||||
const localPlayer = new RbxInstance('Player', { Name: 'Player1' });
|
|
||||||
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);
|
|
||||||
|
|
||||||
game.GetService = function(svc) {
|
|
||||||
if (services.has(svc)) return services.get(svc);
|
|
||||||
if (svc === 'Workspace') return workspace;
|
|
||||||
if (svc === 'Workspace') return workspace;
|
|
||||||
// Неизвестный сервис — создаём заглушку, чтобы не падало
|
|
||||||
const stub = new RbxInstance(svc, { Name: svc });
|
|
||||||
services.set(svc, stub);
|
|
||||||
return stub;
|
|
||||||
};
|
|
||||||
game.Workspace = workspace;
|
|
||||||
game.Lighting = lighting;
|
|
||||||
game.Players = playersService;
|
|
||||||
game.ReplicatedStorage = replicatedStorage;
|
|
||||||
|
|
||||||
lua.global.set('game', game);
|
|
||||||
lua.global.set('workspace', workspace);
|
|
||||||
lua.global.set('Workspace', workspace);
|
|
||||||
|
|
||||||
// 5. Instance.new
|
|
||||||
lua.global.set('Instance', {
|
|
||||||
new: (className, parent) => {
|
|
||||||
const inst = new RbxInstance(className);
|
|
||||||
if (parent && parent instanceof RbxInstance) {
|
|
||||||
inst.Parent = parent;
|
|
||||||
parent.Children.push(inst);
|
|
||||||
}
|
|
||||||
return inst;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 6. wait/task.wait через scheduler. scheduler — main-side, поддерживает
|
|
||||||
// schedule(sec, fn) что fire'ит fn после задержки в следующих tick'ах.
|
|
||||||
// spawn/delay/defer запускают функцию через scheduler.spawn (отдельная корутина).
|
|
||||||
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) => {
|
|
||||||
// В корутине: yield на (sec || 0). Scheduler сам resume'ит.
|
|
||||||
// Тут мы синхронны (вызов из Lua) — реальный yield делается в lua-wrapper
|
|
||||||
// через coroutine.yield, который мы оборачиваем в addScript.
|
|
||||||
// Здесь просто возвращаем длительность для совместимости.
|
|
||||||
return [sec || 0, 0];
|
|
||||||
});
|
|
||||||
lua.global.set('task', {
|
|
||||||
wait: (sec) => sec || 0,
|
|
||||||
spawn: (fn, ...args) => { sched.spawn(() => { try { fn(...args); } catch (e) {} }); return null; },
|
|
||||||
delay: (sec, fn, ...args) => { sched.schedule(sec || 0, () => { try { fn(...args); } catch (e) {} }); return null; },
|
|
||||||
defer: (fn, ...args) => { sched.spawn(() => { try { fn(...args); } catch (e) {} }); return null; },
|
|
||||||
});
|
|
||||||
lua.global.set('spawn', (fn) => { sched.spawn(() => { try { fn(); } catch (e) {} }); });
|
|
||||||
lua.global.set('delay', (sec, fn) => { sched.schedule(sec || 0, () => { try { fn(); } catch (e) {} }); });
|
|
||||||
// require(ModuleScript) — возвращаем nil, debug.setmetatable перехватит.
|
|
||||||
lua.global.set('require', (_arg) => undefined);
|
|
||||||
lua.global.set('tick', () => Date.now() / 1000);
|
|
||||||
lua.global.set('time', () => Date.now() / 1000);
|
|
||||||
lua.global.set('elapsedTime', () => Date.now() / 1000);
|
|
||||||
|
|
||||||
// 7. print / warn / error — пробрасываем в main как log
|
|
||||||
lua.global.set('print', (...args) => {
|
|
||||||
const text = args.map(a => luaToString(a)).join('\t');
|
|
||||||
send('log', { level: 'info', text });
|
|
||||||
});
|
|
||||||
lua.global.set('warn', (...args) => {
|
|
||||||
const text = args.map(a => luaToString(a)).join('\t');
|
|
||||||
send('log', { level: 'warn', text });
|
|
||||||
});
|
|
||||||
|
|
||||||
// 8. Enum — упрощённая заглушка для самых популярных enums
|
|
||||||
const enumTable = {
|
|
||||||
Material: { Plastic: { Value: 256, Name: 'Plastic' }, Neon: { Value: 784, Name: 'Neon' },
|
|
||||||
Metal: { Value: 512, Name: 'Metal' }, Glass: { Value: 1024, Name: 'Glass' },
|
|
||||||
Wood: { Value: 272, Name: 'Wood' }, SmoothPlastic: { Value: 496, Name: 'SmoothPlastic' } },
|
|
||||||
PartType: { Ball: { Value: 0, Name: 'Ball' }, Block: { Value: 1, Name: 'Block' },
|
|
||||||
Cylinder: { Value: 2, Name: 'Cylinder' } },
|
|
||||||
KeyCode: { Space: { Value: 32, Name: 'Space' }, W: { Value: 87, Name: 'W' },
|
|
||||||
A: { Value: 65, Name: 'A' }, S: { Value: 83, Name: 'S' }, D: { Value: 68, Name: 'D' } },
|
|
||||||
EasingStyle: { Linear: { Value: 0, Name: 'Linear' }, Quad: { Value: 1, Name: 'Quad' },
|
|
||||||
Sine: { Value: 5, Name: 'Sine' } },
|
|
||||||
EasingDirection: { In: { Value: 0, Name: 'In' }, Out: { Value: 1, Name: 'Out' },
|
|
||||||
InOut: { Value: 2, Name: 'InOut' } },
|
|
||||||
};
|
|
||||||
lua.global.set('Enum', enumTable);
|
|
||||||
|
|
||||||
return { workspace, game, part_by_id, services, gui_by_id, localPlayer, character, humanoid };
|
|
||||||
}
|
|
||||||
|
|
||||||
function luaToString(v) {
|
|
||||||
if (v == null) return 'nil';
|
|
||||||
if (typeof v === 'string') return v;
|
|
||||||
if (typeof v === 'number') return String(v);
|
|
||||||
if (typeof v === 'boolean') return String(v);
|
|
||||||
if (v.toString) return v.toString();
|
|
||||||
return '<object>';
|
|
||||||
}
|
|
||||||
|
|
||||||
export { RbxVector3, RbxColor3, RbxCFrame, RbxUDim, RbxUDim2, RbxInstance, RbxPart, RbxSignal };
|
|
||||||
@ -1,204 +0,0 @@
|
|||||||
/**
|
|
||||||
* roblox-tween.js — TweenService для Roblox Lua-shim.
|
|
||||||
*
|
|
||||||
* Использование в Lua:
|
|
||||||
* local TS = game:GetService("TweenService")
|
|
||||||
* local info = TweenInfo.new(2, Enum.EasingStyle.Quad, Enum.EasingDirection.Out)
|
|
||||||
* local tween = TS:Create(part, info, {Position = Vector3.new(0, 10, 0)})
|
|
||||||
* tween:Play()
|
|
||||||
* tween.Completed:Connect(function() print("done") end)
|
|
||||||
*
|
|
||||||
* Реализация:
|
|
||||||
* - Все активные tween'ы держатся в этом модуле.
|
|
||||||
* - На каждом tick() прогрессируется alpha = (now - startTime) / duration.
|
|
||||||
* - Применяется easing-кривая, и обновляется свойство объекта через __sendFn.
|
|
||||||
* - При alpha >= 1 — fire Completed signal и удаляем tween.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { RbxSignal, RbxVector3, RbxColor3, RbxCFrame, RbxUDim2 } from './roblox-shim.js';
|
|
||||||
|
|
||||||
/* ──────── EasingStyle / Direction ──────── */
|
|
||||||
|
|
||||||
const EASING_FNS = {
|
|
||||||
'Linear': (t) => t,
|
|
||||||
'Quad': (t) => t * t,
|
|
||||||
'Cubic': (t) => t * t * t,
|
|
||||||
'Quart': (t) => t * t * t * t,
|
|
||||||
'Quint': (t) => t * t * t * t * t,
|
|
||||||
'Sine': (t) => 1 - Math.cos((t * Math.PI) / 2),
|
|
||||||
'Bounce': (t) => {
|
|
||||||
const n1 = 7.5625, d1 = 2.75;
|
|
||||||
if (t < 1 / d1) return n1 * t * t;
|
|
||||||
if (t < 2 / d1) { t -= 1.5 / d1; return n1 * t * t + 0.75; }
|
|
||||||
if (t < 2.5 / d1) { t -= 2.25 / d1; return n1 * t * t + 0.9375; }
|
|
||||||
t -= 2.625 / d1; return n1 * t * t + 0.984375;
|
|
||||||
},
|
|
||||||
'Elastic': (t) => {
|
|
||||||
if (t === 0) return 0;
|
|
||||||
if (t === 1) return 1;
|
|
||||||
return -(2 ** (10 * (t - 1))) * Math.sin((t - 1.1) * 5 * Math.PI);
|
|
||||||
},
|
|
||||||
'Back': (t) => t * t * (2.70158 * t - 1.70158),
|
|
||||||
'Exponential': (t) => t === 0 ? 0 : 2 ** (10 * (t - 1)),
|
|
||||||
};
|
|
||||||
|
|
||||||
function applyDirection(t, direction) {
|
|
||||||
if (direction === 'In') return t;
|
|
||||||
if (direction === 'Out') return 1 - (1 - t);
|
|
||||||
if (direction === 'InOut') {
|
|
||||||
return t < 0.5 ? t * 2 : (1 - (1 - t) * 2);
|
|
||||||
}
|
|
||||||
return t;
|
|
||||||
}
|
|
||||||
|
|
||||||
function easeValue(alpha, style, direction) {
|
|
||||||
const styleFn = EASING_FNS[style] || EASING_FNS.Linear;
|
|
||||||
if (direction === 'In') return styleFn(alpha);
|
|
||||||
if (direction === 'Out') return 1 - styleFn(1 - alpha);
|
|
||||||
// InOut
|
|
||||||
if (alpha < 0.5) return styleFn(alpha * 2) / 2;
|
|
||||||
return 1 - styleFn((1 - alpha) * 2) / 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ──────── TweenInfo ──────── */
|
|
||||||
|
|
||||||
class RbxTweenInfo {
|
|
||||||
constructor(time = 1, easingStyle = 'Quad', easingDirection = 'Out',
|
|
||||||
repeatCount = 0, reverses = false, delayTime = 0) {
|
|
||||||
this.Time = +time || 0;
|
|
||||||
this.EasingStyle = typeof easingStyle === 'object' ? easingStyle.Name : easingStyle;
|
|
||||||
this.EasingDirection = typeof easingDirection === 'object' ? easingDirection.Name : easingDirection;
|
|
||||||
this.RepeatCount = repeatCount | 0;
|
|
||||||
this.Reverses = !!reverses;
|
|
||||||
this.DelayTime = +delayTime || 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ──────── Tween ──────── */
|
|
||||||
|
|
||||||
class RbxTween {
|
|
||||||
constructor(instance, info, goalProps, manager) {
|
|
||||||
this.Instance = instance;
|
|
||||||
this.TweenInfo = info;
|
|
||||||
this.GoalProps = goalProps;
|
|
||||||
this._manager = manager;
|
|
||||||
this._startTime = null;
|
|
||||||
this._fromProps = null;
|
|
||||||
this._playing = false;
|
|
||||||
this._completed = false;
|
|
||||||
this.Completed = new RbxSignal('Completed');
|
|
||||||
this.PlaybackState = 'Begin';
|
|
||||||
}
|
|
||||||
|
|
||||||
Play() {
|
|
||||||
if (this._playing) return;
|
|
||||||
// Снимок старых значений
|
|
||||||
this._fromProps = {};
|
|
||||||
for (const k of Object.keys(this.GoalProps)) {
|
|
||||||
this._fromProps[k] = this.Instance[k]; // через getter Part'а
|
|
||||||
}
|
|
||||||
this._startTime = this._manager.time;
|
|
||||||
this._playing = true;
|
|
||||||
this.PlaybackState = 'Playing';
|
|
||||||
this._manager._add(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
Pause() { this._playing = false; this.PlaybackState = 'Paused'; }
|
|
||||||
Cancel() {
|
|
||||||
this._playing = false;
|
|
||||||
this.PlaybackState = 'Cancelled';
|
|
||||||
this._manager._remove(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** internal — вызывается из manager.tick */
|
|
||||||
_step(now) {
|
|
||||||
if (!this._playing) return false;
|
|
||||||
const elapsed = now - this._startTime;
|
|
||||||
const dur = this.TweenInfo.Time || 0.001;
|
|
||||||
let alpha = Math.min(1, Math.max(0, elapsed / dur));
|
|
||||||
const ea = easeValue(alpha, this.TweenInfo.EasingStyle, this.TweenInfo.EasingDirection);
|
|
||||||
for (const k of Object.keys(this.GoalProps)) {
|
|
||||||
const from = this._fromProps[k];
|
|
||||||
const to = this.GoalProps[k];
|
|
||||||
const interp = interpolate(from, to, ea);
|
|
||||||
// Set через setter в Part — он отправит partSet в main
|
|
||||||
try { this.Instance[k] = interp; } catch (e) {}
|
|
||||||
}
|
|
||||||
if (alpha >= 1) {
|
|
||||||
this._playing = false;
|
|
||||||
this._completed = true;
|
|
||||||
this.PlaybackState = 'Completed';
|
|
||||||
this.Completed.Fire('Completed');
|
|
||||||
return true; // удалить из активных
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function interpolate(from, to, a) {
|
|
||||||
if (from instanceof RbxVector3 && to instanceof RbxVector3) {
|
|
||||||
return from.Lerp(to, a);
|
|
||||||
}
|
|
||||||
if (from instanceof RbxColor3 && to instanceof RbxColor3) {
|
|
||||||
return from.Lerp(to, a);
|
|
||||||
}
|
|
||||||
if (from instanceof RbxCFrame && to instanceof RbxCFrame) {
|
|
||||||
return from.Lerp(to, a);
|
|
||||||
}
|
|
||||||
if (typeof from === 'number' && typeof to === 'number') {
|
|
||||||
return from + (to - from) * a;
|
|
||||||
}
|
|
||||||
// Иначе ничего не интерполируем
|
|
||||||
return a >= 1 ? to : from;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ──────── Manager ──────── */
|
|
||||||
|
|
||||||
export class RobloxTweenManager {
|
|
||||||
constructor() {
|
|
||||||
this.active = new Set();
|
|
||||||
this.time = 0;
|
|
||||||
}
|
|
||||||
install(lua) {
|
|
||||||
const self = this;
|
|
||||||
// TweenInfo конструктор
|
|
||||||
lua.global.set('TweenInfo', {
|
|
||||||
new: (time, style, direction, repeat_, reverses, delay_) =>
|
|
||||||
new RbxTweenInfo(time, style, direction, repeat_, reverses, delay_),
|
|
||||||
});
|
|
||||||
// Сервис: добавляем в services через game:GetService('TweenService')
|
|
||||||
// (services map передаётся в shim — но мы не имеем к нему доступа здесь;
|
|
||||||
// делаем по-другому: регистрируем сразу глобал TweenService который
|
|
||||||
// совместим с GetService('TweenService'))
|
|
||||||
const tweenService = {
|
|
||||||
ClassName: 'TweenService',
|
|
||||||
Name: 'TweenService',
|
|
||||||
Create(instance, info, goalProps) {
|
|
||||||
return new RbxTween(instance, info, goalProps, self);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
lua.global.set('__tweenService', tweenService);
|
|
||||||
// и в game.GetService — мы делаем монки-патч если игра уже есть:
|
|
||||||
const game = lua.global.get('game');
|
|
||||||
if (game && typeof game.GetService === 'function') {
|
|
||||||
const origGetService = game.GetService;
|
|
||||||
game.GetService = function(svc) {
|
|
||||||
if (svc === 'TweenService') return tweenService;
|
|
||||||
return origGetService.call(this, svc);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_add(tween) { this.active.add(tween); }
|
|
||||||
_remove(tween) { this.active.delete(tween); }
|
|
||||||
|
|
||||||
tick(dtSec) {
|
|
||||||
this.time += +dtSec || 0;
|
|
||||||
for (const t of [...this.active]) {
|
|
||||||
const done = t._step(this.time);
|
|
||||||
if (done) this.active.delete(t);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export { RbxTweenInfo, RbxTween };
|
|
||||||
Loading…
x
Reference in New Issue
Block a user