From 2fa575ae4c61f621c47afa317c8d591b54bd0bdd Mon Sep 17 00:00:00 2001 From: min Date: Mon, 8 Jun 2026 12:56:05 +0300 Subject: [PATCH] =?UTF-8?q?refactor(rbxl-import):=20=D0=B8=D0=BC=D0=BF?= =?UTF-8?q?=D0=BE=D1=80=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=BD?= =?UTF-8?q?=D1=8B=D0=B5=20Lua-=D1=81=D0=BA=D1=80=D0=B8=D0=BF=D1=82=D1=8B?= =?UTF-8?q?=20=D0=B8=D0=B4=D1=83=D1=82=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20?= =?UTF-8?q?LuaSharedSandbox?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Раньше было два параллельных 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 --- src/editor/engine/GameRuntime.js | 52 +- src/editor/engine/RobloxLuaSandbox.js | 164 ----- src/editor/engine/RobloxLuaSharedSandbox.js | 161 ----- src/editor/engine/RobloxLuaSharedWorker.js | 381 ----------- src/editor/engine/RobloxLuaWorker.js | 180 ----- src/editor/engine/rbxl-lua-integration.js | 45 +- src/editor/engine/roblox-physics.js | 216 ------ src/editor/engine/roblox-scheduler.js | 209 ------ src/editor/engine/roblox-services.js | 384 ----------- src/editor/engine/roblox-shim.js | 715 -------------------- src/editor/engine/roblox-tween.js | 204 ------ 11 files changed, 31 insertions(+), 2680 deletions(-) delete mode 100644 src/editor/engine/RobloxLuaSandbox.js delete mode 100644 src/editor/engine/RobloxLuaSharedSandbox.js delete mode 100644 src/editor/engine/RobloxLuaSharedWorker.js delete mode 100644 src/editor/engine/RobloxLuaWorker.js delete mode 100644 src/editor/engine/roblox-physics.js delete mode 100644 src/editor/engine/roblox-scheduler.js delete mode 100644 src/editor/engine/roblox-services.js delete mode 100644 src/editor/engine/roblox-shim.js delete mode 100644 src/editor/engine/roblox-tween.js diff --git a/src/editor/engine/GameRuntime.js b/src/editor/engine/GameRuntime.js index 7a53415..0bf3b6e 100644 --- a/src/editor/engine/GameRuntime.js +++ b/src/editor/engine/GameRuntime.js @@ -19,7 +19,7 @@ import { ScriptSandbox } from './ScriptSandbox'; import { STORYS_addres } from '../../api/API'; import { PhysicsWorld } from './PhysicsWorld'; 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'; export class GameRuntime { @@ -116,15 +116,24 @@ export class GameRuntime { try { initialScene = this._buildSceneSnapshot(); } catch (e) { initialScene = null; } // Roblox-Lua скрипты собираем для single-VM режима: один shared Worker // на все скрипты, одна wasmoon-VM. Снимает WASM OOM лимит. - const rbxlBatch = []; const primitives = this.projectData?.scene?.primitives || this.scene3d?.primitiveManager?.getAllData?.() || []; - // НОВОЕ (Этап 2): Lua-скрипты с language='lua' идут через LuaSharedSandbox - // (один shared VM на всю игру). Это user-written Lua + Roblox API совместимость. - // Отличается от rbxl-import batch: тут код юзер написал в редакторе сам. + // Единый Lua-batch: и user-Lua (language='lua'), и импортированные .rbxl + // скрипты (с маркером // @roblox-lua) теперь идут через ОДИН LuaSharedSandbox. + // .rbxl-скрипты распаковываем из JS-комментария-обёртки в чистый Lua. const luaUserBatch = []; for (const s of scripts) { 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; } if (s && s.language === 'lua') { @@ -160,23 +169,8 @@ export class GameRuntime { // eslint-disable-next-line no-console console.log('[GameRuntime] sandbox started for script id=', s.id); } - // Single-VM запуск всех Roblox-Lua скриптов одним shared Worker'ом. - let rbxlCount = 0; - 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 + // Импортированные .rbxl-скрипты теперь идут через тот же LuaSharedSandbox + // вместе с user-Lua (см. luaUserBatch выше). Отдельный Worker больше не нужен. let luaUserCount = 0; if (luaUserBatch.length > 0) { try { @@ -211,13 +205,15 @@ export class GameRuntime { 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}`); - if (rbxlCount > 0) { - this._log('info', `Запущено Roblox-Lua скриптов (импортированных): ${rbxlCount}`); + if (rbxlImported > 0) { + this._log('info', `Запущено Roblox-Lua скриптов (импортированных): ${rbxlImported}`); } - if (luaUserCount > 0) { - this._log('info', `Запущено Lua-скриптов юзера: ${luaUserCount}`); + if (luaWritten > 0) { + this._log('info', `Запущено Lua-скриптов юзера: ${luaWritten}`); } // Подцепляемся на изменения HP игрока, чтобы шлать событие 'hpChange' // во все sandbox'ы. Не перезаписываем существующий обработчик — diff --git a/src/editor/engine/RobloxLuaSandbox.js b/src/editor/engine/RobloxLuaSandbox.js deleted file mode 100644 index 6a87e77..0000000 --- a/src/editor/engine/RobloxLuaSandbox.js +++ /dev/null @@ -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) {} - } - } -} diff --git a/src/editor/engine/RobloxLuaSharedSandbox.js b/src/editor/engine/RobloxLuaSharedSandbox.js deleted file mode 100644 index 65cd699..0000000 --- a/src/editor/engine/RobloxLuaSharedSandbox.js +++ /dev/null @@ -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:' - 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) {} -} diff --git a/src/editor/engine/RobloxLuaSharedWorker.js b/src/editor/engine/RobloxLuaSharedWorker.js deleted file mode 100644 index 55837fb..0000000 --- a/src/editor/engine/RobloxLuaSharedWorker.js +++ /dev/null @@ -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; diff --git a/src/editor/engine/RobloxLuaWorker.js b/src/editor/engine/RobloxLuaWorker.js deleted file mode 100644 index c58b6f7..0000000 --- a/src/editor/engine/RobloxLuaWorker.js +++ /dev/null @@ -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; diff --git a/src/editor/engine/rbxl-lua-integration.js b/src/editor/engine/rbxl-lua-integration.js index 057941d..ae1ce2e 100644 --- a/src/editor/engine/rbxl-lua-integration.js +++ b/src/editor/engine/rbxl-lua-integration.js @@ -1,13 +1,13 @@ /** - * rbxl-lua-integration.js — single-VM интеграция (v2). + * rbxl-lua-integration.js — вспомогательные функции для импорта .rbxl-карт. * - * Двухфазная инициализация: - * 1) init worker → pre-populate workspace + GUI tree (включая сигналы) - * 2) addScriptsBatch ВСЕ скрипты одним IPC сообщением - * 3) ready → kickoff → emit PlayerAdded, начать tick + * Старый отдельный Worker-based runtime удалён (2026-06-08): импортированные + * Lua-скрипты теперь идут через тот же LuaSharedSandbox что и user-Lua + * (см. GameRuntime.start()). Этот файл оставлен только для: + * - 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-кода. */ export function unpackRobloxLuaCode(code) { @@ -80,37 +80,6 @@ export function buildLuaGuiTree(guiElements) { 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-сцене. */ diff --git a/src/editor/engine/roblox-physics.js b/src/editor/engine/roblox-physics.js deleted file mode 100644 index 0962898..0000000 --- a/src/editor/engine/roblox-physics.js +++ /dev/null @@ -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) {} - } - } -} diff --git a/src/editor/engine/roblox-scheduler.js b/src/editor/engine/roblox-scheduler.js deleted file mode 100644 index 936c181..0000000 --- a/src/editor/engine/roblox-scheduler.js +++ /dev/null @@ -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) { - // Корутина завершилась с ошибкой — просто дропаем - } - } - } -} diff --git a/src/editor/engine/roblox-services.js b/src/editor/engine/roblox-services.js deleted file mode 100644 index 8ffbfba..0000000 --- a/src/editor/engine/roblox-services.js +++ /dev/null @@ -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 }; diff --git a/src/editor/engine/roblox-shim.js b/src/editor/engine/roblox-shim.js deleted file mode 100644 index 362b0bb..0000000 --- a/src/editor/engine/roblox-shim.js +++ /dev/null @@ -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 ''; -} - -export { RbxVector3, RbxColor3, RbxCFrame, RbxUDim, RbxUDim2, RbxInstance, RbxPart, RbxSignal }; diff --git a/src/editor/engine/roblox-tween.js b/src/editor/engine/roblox-tween.js deleted file mode 100644 index 4c55fd6..0000000 --- a/src/editor/engine/roblox-tween.js +++ /dev/null @@ -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 };