From 2b15ec821a809a7597a410691dee74fea705e496 Mon Sep 17 00:00:00 2001 From: min Date: Mon, 8 Jun 2026 11:32:31 +0300 Subject: [PATCH] =?UTF-8?q?feat(lua):=20=D0=AD=D1=82=D0=B0=D0=BF=203=20?= =?UTF-8?q?=E2=80=94=20DataModel=20+=20Touched=20+=20Humanoid=20(main-thre?= =?UTF-8?q?ad=20wasmoon)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Главное достижение: KillBrick работает. script.Parent.Touched:Connect(fn) фейерится когда игрок касается куба, humanoid:TakeDamage(100) → playerSet команда → BabylonScene.player.hp=0 → respawn + playerDied event. Архитектурные изменения: - LuaSharedSandbox v3: wasmoon в MAIN потоке вместо Worker'а. DevTools видит точные ошибки, breakpoints работают, console.log в RobloxShim виден сразу. - LuaSharedWorker.js удалён (больше не нужен). - RobloxShim добавляет полное DataModel дерево: game / Workspace / Players / LocalPlayer / Character / Humanoid / HumanoidRootPart / 15 services (RunService.Heartbeat, TweenService, HttpService, DataStoreService, etc). - newPart создаёт RbxPart-обёртку вокруг каждого primitive в сцене, Touched/TouchEnded signals. Wasmoon-quirk: - TypeError: Cannot read properties of null (reading 'then') возникает когда JS-функция возвращает null в Lua-контекст. PromiseTypeExtension делает .then без guard. Везде заменили null → undefined (push'ится как nil). - _rbxl_get_part_by_id возвращает undefined если не нашёл, FindFirstChild и прочие тоже undefined вместо null. GameRuntime.js: - _buildSceneSnapshot теперь даёт id (для partById), color, anchored, canCollide, opacity полей у primitives. - partSet/sceneCreate user-Lua → handleLuaCommand (rbxl интеграция). - playerSet handler: humanoid.Health=0 → respawn + hpChange event. Co-Authored-By: Claude Opus 4.7 --- src/editor/engine/GameRuntime.js | 39 +- src/editor/engine/lua/LuaSharedSandbox.js | 320 ++++----- src/editor/engine/lua/LuaSharedWorker.js | 242 ------- src/editor/engine/lua/RobloxShim.js | 759 ++++++++++++++-------- 4 files changed, 676 insertions(+), 684 deletions(-) delete mode 100644 src/editor/engine/lua/LuaSharedWorker.js diff --git a/src/editor/engine/GameRuntime.js b/src/editor/engine/GameRuntime.js index 6f45e01..9261130 100644 --- a/src/editor/engine/GameRuntime.js +++ b/src/editor/engine/GameRuntime.js @@ -181,9 +181,20 @@ export class GameRuntime { if (luaUserBatch.length > 0) { try { const sb = new LuaSharedSandbox(); + // partSet/sceneCreate — переиспользуем обработчик rbxl sb.setOnCommand(({ cmd, payload }) => { - this._handleCommand(null, cmd, payload); + if (cmd === 'partSet' || cmd === 'partVel' || + cmd === 'sceneCreate' || cmd === 'sceneDelete') { + try { handleLuaCommand(null, cmd, payload, this); } catch (_) {} + } else { + this._handleCommand(null, cmd, payload); + } }); + // Передаём snapshot ДО start чтобы Workspace.Children заполнились + try { + const snap = this._buildSceneSnapshot(); + sb.sendSceneSnapshot(snap); + } catch (_) {} for (const s of luaUserBatch) sb.addScript(s.id, s.code, s.target); sb.start(); this.sandboxes.push(sb); @@ -3967,6 +3978,25 @@ export class GameRuntime { } return; } + if (cmd === 'playerSet' && payload) { + // Из Lua-runtime: humanoid.Health = 0 → шлёт {prop:'health', value:N}. + // Применяем к реальному игроку BabylonScene. + const player = this.scene3d?.player; + if (!player) return; + if (payload.prop === 'health') { + const v = Math.max(0, Number(payload.value) || 0); + player.hp = v; + if (v === 0) { + try { this.routeGlobalEvent('playerDied', {}); } catch (_) {} + // Перезагружаем игру (как при смерти) + try { + if (this.scene3d?.respawnPlayer) this.scene3d.respawnPlayer(); + } catch (_) {} + } + try { this.routeGlobalEvent('hpChange', { hp: v }); } catch (_) {} + } + return; + } // eslint-disable-next-line no-console console.warn('[GameRuntime] unknown cmd', cmd); } @@ -4245,6 +4275,7 @@ export class GameRuntime { if (s?.primitiveManager) { for (const data of s.primitiveManager.instances.values()) { primitives.push({ + id: data.id, ref: 'primitive:' + data.id, type: data.type, x: data.x, y: data.y, z: data.z, @@ -4254,7 +4285,11 @@ export class GameRuntime { sz: data.sz != null ? data.sz : 1, rotationY: data.rotationY || 0, visible: data.visible !== false, - name: data.name || null, + name: data.name || undefined, + color: data.color || undefined, + anchored: data.anchored !== false, + canCollide: data.canCollide !== false, + opacity: data.opacity != null ? data.opacity : 1, }); } } diff --git a/src/editor/engine/lua/LuaSharedSandbox.js b/src/editor/engine/lua/LuaSharedSandbox.js index d73c411..bbb0bf9 100644 --- a/src/editor/engine/lua/LuaSharedSandbox.js +++ b/src/editor/engine/lua/LuaSharedSandbox.js @@ -1,152 +1,174 @@ /** - * LuaSharedSandbox — обёртка над одним wasmoon-Worker для ВСЕХ Lua-скриптов игры. + * LuaSharedSandbox (v3, main-thread) — wasmoon-VM работает в MAIN потоке, + * без Web Worker. Это позволяет: + * - Видеть точные Lua-ошибки в DevTools (через console.error) + * - Использовать debugger / breakpoints прямо в RobloxShim.js + * - Не возиться с молчаливыми Worker-падениями * - * Идея: - * - Создаётся ОДИН экземпляр на всю игру (а не на скрипт, как ScriptSandbox) - * - Все Lua-скрипты добавляются через addScript(id, code, target) - * - Worker внутри держит ОДИН wasmoon Lua-state, в котором живут: - * * полный Roblox API shim (Vector3, CFrame, Color3, Instance, ...) - * * виртуальное DataModel дерево (game.Workspace, Players, ...) - * * все скрипты как coroutines (потому что Roblox-Lua так работает) - * - При партии команд (partSet/sceneCreate/event/log) — пересылка в main - * с тем же интерфейсом что у ScriptSandbox + * Цена: тяжёлые Lua-скрипты могут блокировать UI. Для KillBrick-style + * скриптов это нестрашно — они быстрые. * - * Совместимость с GameRuntime: - * методы sendEvent / sendGlobalEvent / sendSceneSnapshot / sendGuiSnapshot / - * sendDataSnapshot / sendSkinsSnapshot / sendTerrainHeightmap / stop / - * setOnCommand — поведение совпадает с ScriptSandbox. + * API совместим с ScriptSandbox: setOnCommand / sendEvent / sendGlobalEvent / + * sendSceneSnapshot / sendGuiSnapshot / sendDataSnapshot / sendSkinsSnapshot / + * sendTerrainHeightmap / stop / tick / target. * - * Отличия: - * - addScript(id, code, target) можно вызывать много раз ДО start() — все - * скрипты добавляются батчем и потом запускаются вместе. - * - После start() можно вызывать addScript() для live-добавления (например, - * Instance.new("Script", workspace) с переданным Source). + * Что добавлено сверх ScriptSandbox: + * - addScript(id, code, target) — добавить скрипт в общий VM. Можно + * до или после start(). + * - start() — асинхронен (createEngine), но возвращает сразу. После init + * стартует main loop (Heartbeat + scheduler). */ -import LuaSharedWorker from './LuaSharedWorker.js?worker'; - -let _ipcId = 0; +import { LuaFactory } from 'wasmoon'; +import { registerRobloxShim } from './RobloxShim.js'; export class LuaSharedSandbox { constructor() { - this.worker = null; + this.vm = null; + this.api = null; this._onCommand = null; this._isReady = false; this._isStopped = false; - // Скрипты добавленные до start() — буферизуются, отправляются батчем при start() - this._pendingScripts = []; - // Снапшоты пришедшие до ready — отправляются после ready - this._pendingSceneSnapshot = null; - this._pendingGuiSnapshot = null; - this._pendingDataSnapshot = null; - this._pendingSkinsSnapshot = null; - this._pendingTerrainHM = null; + this._isKickedOff = false; + this._pendingScripts = []; // [{id, code, target, name}] + this._scriptsById = new Map(); + this._scenes = null; + this._guiTree = null; + this._loopHandle = null; + this._lastTickAt = 0; } setOnCommand(cb) { this._onCommand = cb; } - /** - * GameRuntime вызывает sb.tick(dt, state) каждый кадр. - * Для JS-sandbox tick шлёт snapshot в worker. Для нас snapshot ходит через - * sendSceneSnapshot отдельно — здесь no-op. - * NB: target=null, потому что наш sandbox общий, не на конкретный объект. - */ get target() { return null; } - tick(_dt, _state) { /* no-op: main-loop fires Heartbeat внутри worker */ } + tick(_dt, _state) { /* no-op: main-loop запускается изнутри (setInterval) */ } - /** Добавить Lua-скрипт. Можно вызывать как ДО start() (буфер), так и ПОСЛЕ (live). */ - addScript(id, code, target) { + addScript(id, code, target, name) { const entry = { - id: String(id), - source: String(code || ''), + id: String(id || `lua_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`), + code: String(code || ''), target: target == null ? null : target, + name: name || null, }; - if (!this.worker) { + this._scriptsById.set(entry.id, entry); + if (!this._isKickedOff) { this._pendingScripts.push(entry); - return; + } else { + this._startSingleScript(entry); } - // Live-добавление после start() - try { - this.worker.postMessage({ cmd: 'addScript', payload: entry }); - } catch (_) {} } - /** Удалить Lua-скрипт по id (для случая когда в студии его удалили в Play-mode редко). */ removeScript(id) { - if (!this.worker) { - this._pendingScripts = this._pendingScripts.filter(s => s.id !== String(id)); - return; - } - try { - this.worker.postMessage({ cmd: 'removeScript', payload: { id: String(id) } }); - } catch (_) {} + this._scriptsById.delete(String(id)); } - /** - * Запустить worker и инициализировать VM. - * После start() Lua-runtime готов принимать события и снапшоты. - */ + /** Стартует VM, регистрирует shim, запускает main-loop. */ start() { - if (this.worker) return; + if (this.vm || this._isStopped) return; // eslint-disable-next-line no-console - console.log('[LuaSharedSandbox] starting Lua VM, pending scripts:', this._pendingScripts.length); - this.worker = new LuaSharedWorker(); - this.worker.onmessage = (e) => this._handleMessage(e); - this.worker.onerror = (err) => { + console.log('[LuaSharedSandbox v3] starting Lua VM in main thread...'); + this._initAsync().catch((err) => { // eslint-disable-next-line no-console - console.error('[LuaSharedSandbox] Worker error', err); - this._emit('log', { - level: 'error', - text: `Lua-runtime error: ${err.message || err}`, - }); - }; - this.worker.postMessage({ - cmd: 'init', - payload: { ipcId: ++_ipcId }, + console.error('[LuaSharedSandbox] FATAL init error:', err); + this._emit('log', { level: 'error', text: `Lua-runtime init: ${err?.message || err}` }); }); } - _handleMessage(e) { - if (this._isStopped) return; - const { cmd, payload } = e.data || {}; - if (cmd === 'boot') return; - if (cmd === 'ready') { - this._isReady = true; - // Отправляем накопленные скрипты батчем - if (this._pendingScripts.length > 0) { - try { - this.worker.postMessage({ cmd: 'addScriptsBatch', payload: { scripts: this._pendingScripts } }); - } catch (_) {} - this._pendingScripts = []; + async _initAsync() { + const factory = new LuaFactory(); + this.vm = await factory.createEngine({ openStandardLibs: true }); + // eslint-disable-next-line no-console + console.log('[LuaSharedSandbox] VM created. Registering Roblox shim...'); + + // Адаптер для shim'а: он ожидает send(cmd, payload), getSceneSnapshot, getGuiTree, scheduleWait. + const send = (cmd, payload) => this._emit(cmd, payload); + + this.api = registerRobloxShim(this.vm, { + send, + getSceneSnapshot: () => this._scenes, + getGuiTree: () => this._guiTree, + scheduleWait: () => null, + }); + + // eslint-disable-next-line no-console + console.log('[LuaSharedSandbox] Shim registered. api keys:', Object.keys(this.api || {})); + + // Применим snapshot если он есть + if (this._scenes && this.api?.onSceneSnapshot) { + try { this.api.onSceneSnapshot(this._scenes); } catch (e) { + console.error('[LuaSharedSandbox] onSceneSnapshot:', e); } - // Отправляем snapshot'ы - if (this._pendingSceneSnapshot) { - try { this.worker.postMessage({ cmd: 'sceneSnapshot', payload: this._pendingSceneSnapshot }); } catch (_) {} - this._pendingSceneSnapshot = null; - } - if (this._pendingGuiSnapshot) { - try { this.worker.postMessage({ cmd: 'guiSnapshot', payload: this._pendingGuiSnapshot }); } catch (_) {} - this._pendingGuiSnapshot = null; - } - if (this._pendingDataSnapshot) { - try { this.worker.postMessage({ cmd: 'dataSnapshot', payload: this._pendingDataSnapshot }); } catch (_) {} - this._pendingDataSnapshot = null; - } - if (this._pendingSkinsSnapshot) { - try { this.worker.postMessage({ cmd: 'skinsSnapshot', payload: this._pendingSkinsSnapshot }); } catch (_) {} - this._pendingSkinsSnapshot = null; - } - if (this._pendingTerrainHM) { - try { this.worker.postMessage({ cmd: 'terrainHeightmap', payload: this._pendingTerrainHM }); } catch (_) {} - this._pendingTerrainHM = null; - } - // Запустить главный loop (фаер RunService.Heartbeat/Stepped + резюм coroutines) - try { this.worker.postMessage({ cmd: 'kickoff' }); } catch (_) {} - return; } - // Любая другая команда — прокинуть наружу как partSet/sceneCreate/log/etc - // _onCommand обработчик в GameRuntime разруливает их так же как от ScriptSandbox - this._emit(cmd, payload); + + this._isReady = true; + this._kickoff(); + } + + _kickoff() { + if (this._isKickedOff || this._isStopped) return; + this._isKickedOff = true; + // eslint-disable-next-line no-console + console.log(`[LuaSharedSandbox] kickoff: starting ${this._pendingScripts.length} scripts`); + for (const entry of this._pendingScripts) this._startSingleScript(entry); + this._pendingScripts = []; + this._lastTickAt = performance.now(); + this._startMainLoop(); + } + + _startSingleScript(entry) { + if (!this.vm || !entry || typeof entry.code !== 'string') return; + let primId = null; + if (typeof entry.target === 'number') primId = entry.target; + else if (entry.target && typeof entry.target === 'object') { + if (entry.target.kind === 'primitive') primId = entry.target.id ?? entry.target.ref; + } + const safeId = entry.id.replace(/[^a-zA-Z0-9_]/g, '_'); + const scriptName = entry.name || `Script_${safeId}`; + // ВАЖНО: chunk_name прокидываем — wasmoon покажет его в traceback. + const wrapped = ` + do + local script = { + Name = ${JSON.stringify(scriptName)}, + Parent = ${primId != null ? `__rbxl_get_part_by_id(${Number(primId)})` : 'nil'}, + ClassName = "Script", + Disabled = false, + Source = nil, + } + local ok, err = pcall(function() + ${entry.code} + end) + if not ok then + __rbxl_send_error(${JSON.stringify(entry.id)}, tostring(err)) + end + end + `; + try { + this.vm.doStringSync(wrapped); + // eslint-disable-next-line no-console + console.log(`[LuaSharedSandbox] script ${entry.id} initialized OK`); + } catch (err) { + // eslint-disable-next-line no-console + console.error(`[LuaSharedSandbox] script ${entry.id} init FAILED:`, err); + this._emit('log', { level: 'error', text: `[Lua ${entry.id}] ${err?.message || err}` }); + } + } + + _startMainLoop() { + const tick = () => { + if (this._isStopped) return; + try { + const now = performance.now(); + const dt = Math.min(0.1, (now - this._lastTickAt) / 1000); + this._lastTickAt = now; + if (this.api?.tickScheduler) this.api.tickScheduler(dt); + if (this.api?.fireHeartbeat) this.api.fireHeartbeat(dt); + } catch (e) { + // eslint-disable-next-line no-console + console.error('[LuaSharedSandbox tick]', e); + } + this._loopHandle = setTimeout(tick, 16); + }; + this._loopHandle = setTimeout(tick, 16); } _emit(cmd, payload) { @@ -155,69 +177,57 @@ export class LuaSharedSandbox { } } - /** Событие target-attached скрипта (touch/untouch/click/etc). */ + // ----- API совместимый с ScriptSandbox ----- sendEvent(payload) { - if (!this.worker) return; - if (!this._isReady) return; - try { this.worker.postMessage({ cmd: 'event', payload }); } catch (_) {} + if (!this.api?.fireTargetEvent || !this._isReady) return; + try { this.api.fireTargetEvent(payload); } catch (e) { + console.error('[LuaSharedSandbox] sendEvent:', e); + } } - /** Глобальное событие игры (playerTouch/guiClick/keydown/etc). */ sendGlobalEvent(payload) { - if (!this.worker) return; - if (!this._isReady) return; - try { this.worker.postMessage({ cmd: 'globalEvent', payload }); } catch (_) {} + if (!this.api?.fireGlobalEvent || !this._isReady) return; + try { this.api.fireGlobalEvent(payload); } catch (e) { + console.error('[LuaSharedSandbox] sendGlobalEvent:', e); + } } sendSceneSnapshot(snapshot) { - if (!this.worker) { - this._pendingSceneSnapshot = snapshot; - return; + this._scenes = snapshot; + if (this.api?.onSceneSnapshot && this._isReady) { + try { this.api.onSceneSnapshot(snapshot); } catch (e) { + console.error('[LuaSharedSandbox] onSceneSnapshot:', e); + } } - if (!this._isReady) { - this._pendingSceneSnapshot = snapshot; - return; - } - try { this.worker.postMessage({ cmd: 'sceneSnapshot', payload: snapshot }); } catch (_) {} } sendGuiSnapshot(snapshot) { - if (!this.worker || !this._isReady) { - this._pendingGuiSnapshot = snapshot; - return; + this._guiTree = snapshot; + if (this.api?.onGuiSnapshot && this._isReady) { + try { this.api.onGuiSnapshot(snapshot); } catch (_) {} } - try { this.worker.postMessage({ cmd: 'guiSnapshot', payload: snapshot }); } catch (_) {} } sendDataSnapshot(snapshot) { - if (!this.worker || !this._isReady) { - this._pendingDataSnapshot = snapshot; - return; + if (this.api?.onDataSnapshot && this._isReady) { + try { this.api.onDataSnapshot(snapshot); } catch (_) {} } - try { this.worker.postMessage({ cmd: 'dataSnapshot', payload: snapshot }); } catch (_) {} } - sendSkinsSnapshot(snapshot) { - if (!this.worker || !this._isReady) { - this._pendingSkinsSnapshot = snapshot; - return; - } - try { this.worker.postMessage({ cmd: 'skinsSnapshot', payload: snapshot }); } catch (_) {} - } - - sendTerrainHeightmap(hm) { - if (!this.worker || !this._isReady) { - this._pendingTerrainHM = hm; - return; - } - try { this.worker.postMessage({ cmd: 'terrainHeightmap', payload: hm }); } catch (_) {} - } + sendSkinsSnapshot(_) { /* no-op для Этапа 3 */ } + sendTerrainHeightmap(_) { /* no-op */ } stop() { this._isStopped = true; - try { this.worker?.terminate(); } catch (_) {} - this.worker = null; - this._isReady = false; + if (this._loopHandle) { + clearTimeout(this._loopHandle); + this._loopHandle = null; + } + if (this.vm) { + try { this.vm.global.close(); } catch (_) {} + this.vm = null; + } + this.api = null; } } diff --git a/src/editor/engine/lua/LuaSharedWorker.js b/src/editor/engine/lua/LuaSharedWorker.js deleted file mode 100644 index b6ef2fc..0000000 --- a/src/editor/engine/lua/LuaSharedWorker.js +++ /dev/null @@ -1,242 +0,0 @@ -/* eslint-disable no-restricted-globals */ -/** - * LuaSharedWorker — Web Worker, держит ОДИН wasmoon Lua-state на всю игру. - * - * Жизненный цикл: - * 1. init {ipcId} → загружаем wasmoon, готовим VM, регистрируем shim, отвечаем 'ready' - * 2. addScriptsBatch {scripts} → добавляем все скрипты сразу (но НЕ запускаем — ждём kickoff) - * 3. sceneSnapshot/guiSnapshot → накопить состояние сцены до запуска - * 4. kickoff → запустить main loop (RunService.Heartbeat фейерится из main loop) - * и стартануть каждый скрипт как coroutine - * 5. event/globalEvent → проксировать в Lua-signal (RbxSignal.Fire) - * 6. addScript {entry} → live-добавление одного скрипта после kickoff - * - * Архитектура VM: - * - один wasmoon Lua state (createWasmoonVM) - * - registerRobloxShim(vm) — экспортирует Vector3.new, Color3.new, print, wait, - * game (с минимальным DataModel), Instance.new и проч. - * - state.scripts = Map - * - state.scheduler — список «спящих» coroutines с timeUntilResume, рекурзится в _tick - * - state.signals — RbxSignal-объекты для events; Worker слушает 'event' от main - * и вызывает Lua-side Fire по соответствующему signal'у - */ - -// Статический импорт — Vite корректно бандлит wasmoon в worker -import { LuaFactory } from 'wasmoon'; -import { registerRobloxShim } from './RobloxShim.js'; - -// Главное состояние VM (на весь life-cycle Worker'а) -const state = { - ipcId: null, - vm: null, - api: null, // объект который вернул registerRobloxShim - isReady: false, - isKickedOff: false, - pendingScripts: [], // скрипты которые ждут kickoff - scriptsById: new Map(), // id → {coroutine, target, source, name} - scenes: { primitives: null, blocks: null, models: null }, - guiTree: null, - skins: null, - data: null, - terrainHM: null, - // tick clock - lastTickAt: 0, -}; - -self.onmessage = async (e) => { - const { cmd, payload } = e.data || {}; - try { - if (cmd === 'init') await handleInit(payload); - else if (cmd === 'addScript') handleAddScript(payload); - else if (cmd === 'addScriptsBatch') handleAddScriptsBatch(payload); - else if (cmd === 'removeScript') handleRemoveScript(payload); - else if (cmd === 'sceneSnapshot') handleSceneSnapshot(payload); - else if (cmd === 'guiSnapshot') handleGuiSnapshot(payload); - else if (cmd === 'dataSnapshot') handleDataSnapshot(payload); - else if (cmd === 'skinsSnapshot') handleSkinsSnapshot(payload); - else if (cmd === 'terrainHeightmap') handleTerrainHeightmap(payload); - else if (cmd === 'event') handleTargetEvent(payload); - else if (cmd === 'globalEvent') handleGlobalEvent(payload); - else if (cmd === 'kickoff') handleKickoff(); - } catch (err) { - logToMain('error', `[LuaWorker] ${cmd} error: ${err?.message || err}`); - } -}; - -function send(cmd, payload) { - try { self.postMessage({ cmd, payload }); } catch (_) {} -} - -function logToMain(level, text) { - send('log', { level, text }); -} - -async function handleInit(payload) { - state.ipcId = payload?.ipcId || 0; - send('boot', { ipcId: state.ipcId }); - try { - const factory = new LuaFactory(); - state.vm = await factory.createEngine({ openStandardLibs: true }); - state.api = registerRobloxShim(state.vm, { - send, - getSceneSnapshot: () => state.scenes, - getGuiTree: () => state.guiTree, - scheduleWait: (sec) => scheduleWait(sec), - }); - state.isReady = true; - send('ready', {}); - } catch (err) { - // Это самое важное — без этого юзер не видит почему ничего не работает - logToMain('error', `[LuaWorker init FATAL] ${err?.message || err}\nstack: ${err?.stack || '?'}`); - } -} - -function handleAddScriptsBatch(payload) { - const arr = Array.isArray(payload?.scripts) ? payload.scripts : []; - for (const s of arr) handleAddScript(s); -} - -function handleAddScript(entry) { - if (!entry || typeof entry.source !== 'string') return; - const id = String(entry.id || `lua_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`); - state.scriptsById.set(id, { - id, - source: entry.source, - target: entry.target == null ? null : entry.target, - coroutine: null, - name: entry.name || null, - }); - // Если мы уже kickoff'нулись — стартанём сразу - if (state.isKickedOff) startSingleScript(id); - else state.pendingScripts.push(id); -} - -function handleRemoveScript(payload) { - const id = String(payload?.id || ''); - if (!id) return; - state.scriptsById.delete(id); - // (coroutine просто перестанет резюмиться) -} - -function handleSceneSnapshot(snap) { - state.scenes = snap || { primitives: null, blocks: null, models: null }; - // Обновим DataModel-дерево (Workspace children) — это сделает api при следующем GetChildren() - if (state.api?.onSceneSnapshot) { - try { state.api.onSceneSnapshot(state.scenes); } catch (_) {} - } -} - -function handleGuiSnapshot(g) { - state.guiTree = g || null; - if (state.api?.onGuiSnapshot) { - try { state.api.onGuiSnapshot(state.guiTree); } catch (_) {} - } -} - -function handleDataSnapshot(d) { - state.data = d || null; - if (state.api?.onDataSnapshot) { - try { state.api.onDataSnapshot(state.data); } catch (_) {} - } -} - -function handleSkinsSnapshot(s) { - state.skins = s || null; -} - -function handleTerrainHeightmap(hm) { - state.terrainHM = hm || null; -} - -function handleTargetEvent(payload) { - // События привязанные к конкретному скрипту (touch/untouch/click) - // payload: { scriptId, kind, ... } - if (!state.api?.fireTargetEvent) return; - try { state.api.fireTargetEvent(payload); } catch (e) { - logToMain('error', `[LuaWorker] fireTargetEvent: ${e.message || e}`); - } -} - -function handleGlobalEvent(payload) { - if (!state.api?.fireGlobalEvent) return; - try { state.api.fireGlobalEvent(payload); } catch (e) { - logToMain('error', `[LuaWorker] fireGlobalEvent: ${e.message || e}`); - } -} - -function handleKickoff() { - if (state.isKickedOff) return; - state.isKickedOff = true; - // Стартанём все накопленные скрипты как coroutines - for (const id of state.pendingScripts) startSingleScript(id); - state.pendingScripts = []; - // Главный loop — RunService Heartbeat + scheduler resume - state.lastTickAt = performance.now(); - startMainLoop(); -} - -function startSingleScript(id) { - const entry = state.scriptsById.get(id); - if (!entry) return; - // Каждый скрипт — coroutine. В нём script — это таблица {Name, Parent, ClassName="Script"}. - // Создаём в Lua wrapped chunk: - // coroutine.create(function() local script = ...; end) - const safeId = id.replace(/[^a-zA-Z0-9_]/g, '_'); - const targetLuaExpr = entry.target == null - ? 'nil' - : (typeof entry.target === 'number' - ? `__rbxl_get_part_by_id(${entry.target})` - : 'nil'); // для object-target (на будущее) - const name = entry.name || `Script_${safeId}`; - const wrapped = ` - local co = coroutine.create(function() - local script = { - Name = ${JSON.stringify(name)}, - Parent = ${targetLuaExpr}, - ClassName = "Script", - Disabled = false, - Source = nil, - } - __rbxl_script_run(${JSON.stringify(id)}, script, function() - ${entry.source} - end) - end) - __rbxl_register_coroutine(${JSON.stringify(id)}, co) - coroutine.resume(co) - `; - try { - state.vm.doStringSync(wrapped); - } catch (err) { - logToMain('error', `[Lua ${id}] init error: ${err.message || err}`); - } -} - -/** - * Главный loop: - * - вызывается раз в ~16мс (60 Гц), резюмит спящие coroutines у которых истёк wait - * - фейерит RunService.Heartbeat (dt секундах) - * - фейерит RunService.Stepped - */ -function startMainLoop() { - const tick = () => { - if (!state.isKickedOff) return; - try { - const now = performance.now(); - const dt = Math.min(0.1, (now - state.lastTickAt) / 1000); - state.lastTickAt = now; - // 1) Резюм coroutines, которым подошёл срок wait - if (state.api?.tickScheduler) state.api.tickScheduler(dt); - // 2) Heartbeat и Stepped сигналы - if (state.api?.fireHeartbeat) state.api.fireHeartbeat(dt); - } catch (e) { - logToMain('error', `[Lua tick] ${e.message || e}`); - } - setTimeout(tick, 16); - }; - setTimeout(tick, 16); -} - -function scheduleWait(_sec) { - // вызывается из Lua-side через api.scheduleWait. Реальная реализация — - // в RobloxShim.js (он держит scheduler). -} diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index 79f7d3d..4d6637c 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -1,77 +1,74 @@ /** - * RobloxShim — экспорт минимально-достаточного Roblox API в wasmoon-VM. + * RobloxShim v3 (для main-thread sandbox) — Roblox API + DataModel. * - * Этап 2 (текущий): базовый shim без DataModel-дерева. - * - Vector3, Color3, UDim2, UDim, Vector2 (с операторами) - * - print, warn, error, wait, task.wait, task.spawn, task.delay - * - RbxSignal (Connect/connect, Disconnect, Wait, Fire/fire) - * - scheduler для wait через coroutines (NB: используется внешний tick из Worker) - * - примитивная game-table с workspace, Players (заглушки) — расширится в Этапе 3 + * Этап 3: добавлено виртуальное дерево DataModel поверх плоской сцены. + * - game.Workspace.Children = массив RbxPart обёрток над примитивами + * - script.Parent для target-скриптов = реальный RbxPart + * - RbxPart.Touched — RbxSignal который фейерится из BabylonScene при overlap + * - RbxPart.Position/Size/Color/Anchored/CanCollide — пишутся через setProp(part, ...) + * методы, которые шлют partSet в main thread (применяется к Babylon-сцене) + * - Humanoid с Health setter → playerSet команда * - * Возвращает объект api с методами: - * onSceneSnapshot(snap) — обновить понимание сцены (для DataModel в Этапе 3) - * onGuiSnapshot(g) — обновить GUI tree - * onDataSnapshot(d) — обновить data (save) - * tickScheduler(dt) — резюм coroutines с истёкшим wait - * fireHeartbeat(dt) — фейр RunService.Heartbeat - * fireTargetEvent(p) — событие для target-скрипта (touch/click) - * fireGlobalEvent(p) — playerTouch / guiClick / keydown - * - * Дизайн RbxSignal: хранится JS-сторона как {connections: [fn,...]}. - * Lua видит обёртку {Connect=fn, connect=fn, Wait=fn, Fire=fn}. + * ВАЖНО про wasmoon: не используем Object.defineProperty getters на JS-объектах + * передаваемых в Lua — wasmoon их некорректно оборачивает (js_promise). Вместо + * этого — обычные поля, которые юзер читает напрямую. Запись свойств происходит + * через `__rbxl_part_set(part, prop, value)` — она шлёт partSet и обновляет поле. */ +// ---------- Scheduler (для task.delay/defer) ---------- const SCHEDULER = { - sleeping: [], // [{coroutine, wakeAt}], wakeAt = performance.now()+ms + sleeping: [], // [{wakeAt, run}] now: () => performance.now(), }; +// ---------- Базовые сигналы ---------- const HEARTBEAT_SIGNAL = makeSignal(); const STEPPED_SIGNAL = makeSignal(); function makeSignal() { - const connections = []; - return { + const sig = { __isSignal: true, - connections, - Fire(...args) { for (const fn of [...connections]) { try { fn(...args); } catch (_) {} } }, - Connect(fn) { - if (typeof fn !== 'function') return { Disconnect() {}, disconnect() {} }; - connections.push(fn); - return { - Disconnect() { const i = connections.indexOf(fn); if (i >= 0) connections.splice(i, 1); }, - disconnect() { const i = connections.indexOf(fn); if (i >= 0) connections.splice(i, 1); }, - Connected: true, - }; - }, - Wait() { - // в реальной реализации — coroutine.yield пока не fire'нется - return null; - }, + connections: [], }; + sig.Connect = function (fn) { + if (typeof fn !== 'function') return { Disconnect() {}, disconnect() {}, Connected: false }; + sig.connections.push(fn); + const conn = { Connected: true }; + conn.Disconnect = function () { + const i = sig.connections.indexOf(fn); + if (i >= 0) sig.connections.splice(i, 1); + conn.Connected = false; + }; + conn.disconnect = conn.Disconnect; + return conn; + }; + sig.connect = sig.Connect; + sig.Fire = function (...args) { + for (const fn of [...sig.connections]) { + try { fn(...args); } catch (e) { + // eslint-disable-next-line no-console + console.error('[Signal handler]', e); + } + } + }; + sig.fire = sig.Fire; + sig.Wait = () => null; + sig.wait = sig.Wait; + return sig; } -// --- Vector3 --- +// ---------- Vector3 / Color3 / UDim2 / Vector2 / CFrame ---------- class RbxVector3 { - constructor(x = 0, y = 0, z = 0) { - this.X = +x; this.Y = +y; this.Z = +z; - } + constructor(x = 0, y = 0, z = 0) { this.X = +x; this.Y = +y; this.Z = +z; } static new(x, y, z) { return new RbxVector3(x, y, z); } - // В Roblox Magnitude/Unit это PROPERTY (без скобок), а не методы. get Magnitude() { return Math.hypot(this.X, this.Y, this.Z); } get magnitude() { return Math.hypot(this.X, this.Y, this.Z); } get Unit() { const m = Math.hypot(this.X, this.Y, this.Z) || 1; return new RbxVector3(this.X / m, this.Y / m, this.Z / m); } - get unit() { - const m = Math.hypot(this.X, this.Y, this.Z) || 1; - return new RbxVector3(this.X / m, this.Y / m, this.Z / m); - } - Normalize() { - const m = Math.hypot(this.X, this.Y, this.Z) || 1; - return new RbxVector3(this.X / m, this.Y / m, this.Z / m); - } + get unit() { return this.Unit; } + Normalize() { return this.Unit; } Dot(b) { return this.X * b.X + this.Y * b.Y + this.Z * b.Z; } Cross(b) { return new RbxVector3( @@ -87,7 +84,6 @@ class RbxVector3 { this.Z + (b.Z - this.Z) * t, ); } - // Lua operators реализуются через __add/__sub/__mul (metatable установит shim ниже) } RbxVector3.zero = new RbxVector3(0, 0, 0); RbxVector3.one = new RbxVector3(1, 1, 1); @@ -95,7 +91,6 @@ RbxVector3.xAxis = new RbxVector3(1, 0, 0); RbxVector3.yAxis = new RbxVector3(0, 1, 0); RbxVector3.zAxis = new RbxVector3(0, 0, 1); -// --- Color3 --- class RbxColor3 { constructor(r = 0, g = 0, b = 0) { this.R = +r; this.G = +g; this.B = +b; } static new(r, g, b) { return new RbxColor3(r, g, b); } @@ -103,11 +98,18 @@ class RbxColor3 { static fromHSV(h, s, v) { const i = Math.floor(h * 6); const f = h * 6 - i; const p = v * (1 - s); const q = v * (1 - f * s); const t = v * (1 - (1 - f) * s); - const [r, g, b] = [ - [v, t, p], [q, v, p], [p, v, t], [p, q, v], [t, p, v], [v, p, q], - ][i % 6]; + const [r, g, b] = [[v, t, p], [q, v, p], [p, v, t], [p, q, v], [t, p, v], [v, p, q]][i % 6]; return new RbxColor3(r, g, b); } + static fromHex(hex) { + const s = String(hex || '').replace('#', ''); + if (s.length !== 6) return new RbxColor3(); + return new RbxColor3( + parseInt(s.slice(0, 2), 16) / 255, + parseInt(s.slice(2, 4), 16) / 255, + parseInt(s.slice(4, 6), 16) / 255, + ); + } Lerp(b, t) { return new RbxColor3( this.R + (b.R - this.R) * t, @@ -115,17 +117,19 @@ class RbxColor3 { this.B + (b.B - this.B) * t, ); } + toHex() { + const h = (n) => Math.round(Math.max(0, Math.min(1, n)) * 255).toString(16).padStart(2, '0'); + return '#' + h(this.R) + h(this.G) + h(this.B); + } } -// --- UDim / UDim2 / Vector2 --- class RbxUDim { constructor(s = 0, o = 0) { this.Scale = +s; this.Offset = +o; } static new(s, o) { return new RbxUDim(s, o); } } class RbxUDim2 { constructor(sx = 0, ox = 0, sy = 0, oy = 0) { - this.X = new RbxUDim(sx, ox); - this.Y = new RbxUDim(sy, oy); + this.X = new RbxUDim(sx, ox); this.Y = new RbxUDim(sy, oy); } static new(sx, ox, sy, oy) { return new RbxUDim2(sx, ox, sy, oy); } static fromScale(sx, sy) { return new RbxUDim2(sx, 0, sy, 0); } @@ -135,62 +139,159 @@ class RbxVector2 { constructor(x = 0, y = 0) { this.X = +x; this.Y = +y; } static new(x, y) { return new RbxVector2(x, y); } } - -// --- CFrame (минимум) --- class RbxCFrame { constructor(x = 0, y = 0, z = 0) { this.X = +x; this.Y = +y; this.Z = +z; this.Position = new RbxVector3(x, y, z); - // Полная матрица 3×3 на этапе 3 + this.p = this.Position; } static new(x, y, z) { return new RbxCFrame(x || 0, y || 0, z || 0); } - static lookAt(eye, target) { - // упрощение — возвращаем cframe в позиции eye - const cf = new RbxCFrame(eye?.X || 0, eye?.Y || 0, eye?.Z || 0); - cf._lookAt = target; - return cf; - } - static Angles(_rx, _ry, _rz) { return new RbxCFrame(); } - static fromEulerAnglesXYZ(_rx, _ry, _rz) { return new RbxCFrame(); } + static lookAt(eye, _t) { return new RbxCFrame(eye?.X || 0, eye?.Y || 0, eye?.Z || 0); } + static Angles() { return new RbxCFrame(); } + static fromEulerAnglesXYZ() { return new RbxCFrame(); } +} + +// ---------- Instance / Part ---------- +let _instanceMethods = null; +function makeInstanceMethods() { + if (_instanceMethods) return _instanceMethods; + _instanceMethods = { + GetChildren: function () { return [...(this.Children || [])]; }, + GetDescendants: function () { + const out = []; + const visit = (n) => { + for (const c of n.Children || []) { out.push(c); visit(c); } + }; + visit(this); + return out; + }, + FindFirstChild: function (name, recursive) { + for (const c of this.Children || []) { + if (c.Name === name) return c; + if (recursive) { + const f = c.FindFirstChild && c.FindFirstChild(name, true); + if (f) return f; + } + } + return undefined; + }, + FindFirstChildOfClass: function (cls) { + for (const c of this.Children || []) { + if (c.ClassName === cls) return c; + } + return undefined; + }, + FindFirstAncestor: function (name) { + let p = this.Parent; + while (p) { if (p.Name === name) return p; p = p.Parent; } + return undefined; + }, + FindFirstAncestorOfClass: function (cls) { + let p = this.Parent; + while (p) { if (p.ClassName === cls) return p; p = p.Parent; } + return undefined; + }, + WaitForChild: function (name) { return this.FindFirstChild(name); }, + IsA: function (cls) { return this.ClassName === cls || cls === 'Instance'; }, + GetFullName: function () { + const parts = []; + let p = this; + while (p && p.ClassName !== 'DataModel') { + parts.unshift(p.Name); + p = p.Parent; + } + return parts.join('.'); + }, + Destroy: function () { + this.Destroyed = true; + if (this.Parent && this.Parent.Children) { + const i = this.Parent.Children.indexOf(this); + if (i >= 0) this.Parent.Children.splice(i, 1); + this.Parent = undefined; + } + }, + Clone: function () { return undefined; }, + GetAttribute: function (n) { return (this.Attributes || {})[n]; }, + SetAttribute: function (n, v) { + if (!this.Attributes) this.Attributes = {}; + this.Attributes[n] = v; + }, + GetPropertyChangedSignal: function () { return this.Changed; }, + }; + return _instanceMethods; +} + +function newInstance(className, name) { + const m = makeInstanceMethods(); + return { + ClassName: className || 'Instance', + Name: name || className || 'Instance', + Parent: undefined, + Children: [], + Destroyed: false, + Attributes: {}, + ChildAdded: makeSignal(), + ChildRemoved: makeSignal(), + AncestryChanged: makeSignal(), + Changed: makeSignal(), + GetChildren: m.GetChildren, + GetDescendants: m.GetDescendants, + FindFirstChild: m.FindFirstChild, + FindFirstChildOfClass: m.FindFirstChildOfClass, + FindFirstAncestor: m.FindFirstAncestor, + FindFirstAncestorOfClass: m.FindFirstAncestorOfClass, + WaitForChild: m.WaitForChild, + IsA: m.IsA, + GetFullName: m.GetFullName, + Destroy: m.Destroy, + Clone: m.Clone, + GetAttribute: m.GetAttribute, + SetAttribute: m.SetAttribute, + GetPropertyChangedSignal: m.GetPropertyChangedSignal, + }; } /** - * Главная регистрация. Возвращает api-объект используемый Worker'ом. + * Создать RbxPart-обёртку. Возвращает plain объект (без getter'ов) — + * запись свойств идёт через метод __SetProp, которое мы экспортируем + * глобально как `__rbxl_part_set(part, prop, value)`. */ +function newPart(primData, sendFn) { + const p = newInstance('Part', primData.name || `Part_${primData.id}`); + p.__primId = primData.id; + p.__sendFn = sendFn; + p.Touched = makeSignal(); + p.TouchEnded = makeSignal(); + p.Position = new RbxVector3(primData.x || 0, primData.y || 0, primData.z || 0); + p.Size = new RbxVector3(primData.sx || 1, primData.sy || 1, primData.sz || 1); + p.Color = primData.color ? RbxColor3.fromHex(primData.color) : new RbxColor3(0.5, 0.5, 0.5); + p.Anchored = !!primData.anchored; + p.CanCollide = primData.canCollide !== false; + p.Transparency = primData.opacity != null ? (1 - primData.opacity) : 0; + p.Material = 'Plastic'; + p.BrickColor = { Color: p.Color, Name: 'Custom' }; + p.CFrame = new RbxCFrame(p.Position.X, p.Position.Y, p.Position.Z); + return p; +} + +// ---------- Регистрация в Lua ---------- export function registerRobloxShim(lua, opts) { - const { send, getSceneSnapshot, getGuiTree, scheduleWait } = opts; + const { send } = opts; const global = lua.global; - // ------ Vector3 ------ - // Lua: local v = Vector3.new(1,2,3); v.X; v + v; v.Magnitude - const Vector3Table = { + // === Базовые типы === + global.set('Vector3', { new: (x, y, z) => new RbxVector3(x, y, z), - zero: RbxVector3.zero, - one: RbxVector3.one, - xAxis: RbxVector3.xAxis, - yAxis: RbxVector3.yAxis, - zAxis: RbxVector3.zAxis, + zero: RbxVector3.zero, one: RbxVector3.one, + xAxis: RbxVector3.xAxis, yAxis: RbxVector3.yAxis, zAxis: RbxVector3.zAxis, FromNormalId: () => new RbxVector3(), - }; - global.set('Vector3', Vector3Table); - - // ------ Color3 ------ + }); global.set('Color3', { new: (r, g, b) => new RbxColor3(r, g, b), fromRGB: (r, g, b) => RbxColor3.fromRGB(r, g, b), fromHSV: (h, s, v) => RbxColor3.fromHSV(h, s, v), - fromHex: (hex) => { - const s = String(hex || '').replace('#', ''); - if (s.length !== 6) return new RbxColor3(); - return new RbxColor3( - parseInt(s.slice(0, 2), 16) / 255, - parseInt(s.slice(2, 4), 16) / 255, - parseInt(s.slice(4, 6), 16) / 255, - ); - }, + fromHex: (hex) => RbxColor3.fromHex(hex), }); - - // ------ UDim / UDim2 / Vector2 / CFrame ------ global.set('UDim', { new: (s, o) => new RbxUDim(s, o) }); global.set('UDim2', { new: (sx, ox, sy, oy) => new RbxUDim2(sx, ox, sy, oy), @@ -205,218 +306,299 @@ export function registerRobloxShim(lua, opts) { fromEulerAnglesXYZ: RbxCFrame.fromEulerAnglesXYZ, }); - // ------ Enum (минимум) ------ + // === Enum === + const mkE = (arr) => Object.fromEntries(arr.map(k => [k, { Name: k, Value: k }])); global.set('Enum', { - KeyCode: Object.fromEntries([ - 'W', 'A', 'S', 'D', 'Space', 'LeftShift', 'LeftControl', 'F', 'E', 'Q', - 'R', 'T', 'Y', 'U', 'I', 'O', 'P', 'G', 'H', 'J', 'K', 'L', 'Z', 'X', - 'C', 'V', 'B', 'N', 'M', 'Tab', 'Return', 'Escape', 'Backspace', - 'Up', 'Down', 'Left', 'Right', - 'One', 'Two', 'Three', 'Four', 'Five', 'Six', 'Seven', 'Eight', 'Nine', 'Zero', - ].map(k => [k, { Name: k, Value: k }])), - UserInputType: Object.fromEntries([ - 'MouseButton1', 'MouseButton2', 'MouseButton3', 'MouseMovement', - 'MouseWheel', 'Touch', 'Keyboard', - ].map(k => [k, { Name: k, Value: k }])), - Material: Object.fromEntries([ - 'Plastic', 'Wood', 'Metal', 'Neon', 'Glass', 'Sand', 'Ice', 'Grass', 'Concrete', - ].map(k => [k, { Name: k, Value: k }])), - HumanoidStateType: Object.fromEntries([ - 'Running', 'Jumping', 'Freefall', 'Landed', 'Dead', 'Climbing', 'Swimming', 'Seated', - ].map(k => [k, { Name: k, Value: k }])), - EasingStyle: Object.fromEntries([ - 'Linear', 'Sine', 'Quad', 'Cubic', 'Quart', 'Quint', 'Bounce', 'Elastic', - ].map(k => [k, { Name: k, Value: k }])), - EasingDirection: Object.fromEntries([ - 'In', 'Out', 'InOut', - ].map(k => [k, { Name: k, Value: k }])), + KeyCode: mkE(['W','A','S','D','Space','LeftShift','LeftControl','F','E','Q','R','T','Y','U','I','O','P','G','H','J','K','L','Z','X','C','V','B','N','M','Tab','Return','Escape','Backspace','Up','Down','Left','Right','One','Two','Three','Four','Five','Six','Seven','Eight','Nine','Zero']), + UserInputType: mkE(['MouseButton1','MouseButton2','MouseButton3','MouseMovement','MouseWheel','Touch','Keyboard']), + Material: mkE(['Plastic','Wood','Metal','Neon','Glass','Sand','Ice','Grass','Concrete']), + HumanoidStateType: mkE(['Running','Jumping','Freefall','Landed','Dead','Climbing','Swimming','Seated']), + EasingStyle: mkE(['Linear','Sine','Quad','Cubic','Quart','Quint','Bounce','Elastic']), + EasingDirection: mkE(['In','Out','InOut']), }); - // ------ print / warn / error логируются в студию ------ + // === print / warn === + const stringify = (v) => { + if (v == null) return 'nil'; + if (typeof v === 'string') return v; + if (typeof v === 'number') return String(v); + if (typeof v === 'boolean') return v ? 'true' : 'false'; + if (v instanceof RbxVector3) return `${v.X}, ${v.Y}, ${v.Z}`; + if (v instanceof RbxColor3) return `${v.R}, ${v.G}, ${v.B}`; + if (typeof v === 'object') { + if (v.Name) return String(v.Name); + return '[object]'; + } + try { return String(v); } catch (_) { return '?'; } + }; global.set('print', (...args) => { - const text = args.map(luaTostring).join('\t'); - send('log', { level: 'info', text }); + send('log', { level: 'info', text: args.map(stringify).join('\t') }); }); global.set('warn', (...args) => { - const text = args.map(luaTostring).join('\t'); - send('log', { level: 'warn', text }); + send('log', { level: 'warn', text: args.map(stringify).join('\t') }); }); - // Stdlib error — оставлен (бросает Lua-error). Дополнительно — наш logToMain в pcall. - // ------ task.* и wait() ------ - // wait(sec) — приостанавливает текущую coroutine на sec секунд через scheduler. - // task.wait, task.spawn, task.delay — современные эквиваленты. - const taskTable = { - wait: (sec) => luaWait(sec), + // === task.* + wait === + global.set('task', { + wait: (_) => undefined, spawn: (fn) => { - // task.spawn(fn) — стартует функцию как «корутину», немедленно резюмит - // у нас работает через прямой вызов pcall (упрощение, без честных coroutines) - try { if (typeof fn === 'function') fn(); } catch (_) {} + try { if (typeof fn === 'function') fn(); } catch (e) { + send('log', { level: 'error', text: `[task.spawn] ${e?.message || e}` }); + } }, delay: (sec, fn) => { - // task.delay(sec, fn) — отложенный спавн if (typeof fn !== 'function') return; - // Добавляем в scheduler - const wakeAt = SCHEDULER.now() + (Number(sec) || 0) * 1000; - SCHEDULER.sleeping.push({ wakeAt, run: () => { try { fn(); } catch (_) {} } }); + SCHEDULER.sleeping.push({ + wakeAt: SCHEDULER.now() + (Number(sec) || 0) * 1000, + run: () => { try { fn(); } catch (e) { + send('log', { level: 'error', text: `[task.delay] ${e?.message || e}` }); + } }, + }); }, defer: (fn) => { - if (typeof fn === 'function') { - const wakeAt = SCHEDULER.now() + 0; - SCHEDULER.sleeping.push({ wakeAt, run: () => { try { fn(); } catch (_) {} } }); - } + if (typeof fn !== 'function') return; + SCHEDULER.sleeping.push({ + wakeAt: SCHEDULER.now(), + run: () => { try { fn(); } catch (_) {} }, + }); }, synchronize: () => {}, desynchronize: () => {}, - }; - global.set('task', taskTable); - global.set('wait', (sec) => luaWait(sec)); - - /** - * luaWait — блокировка текущего coroutine на sec секунд. - * NB: использует lua-side coroutine.yield + Worker scheduler. - * Здесь упрощение: возвращаем как обычный вызов (без honest yield) для MVP. - * Honest реализация прийдёт когда intgrate с DataModel в Этапе 3. - */ - function luaWait(_sec) { - return null; - } - - // ------ game (минимум) ------ - // На Этапе 2 это пустой стаб — реальный DataModel будет на Этапе 3. - // Но для совместимости с скриптами которые делают `game:GetService(...)` - // возвращаем заглушку которая на всё отвечает безопасными no-op. - const stubService = (name) => ({ - __isService: true, - Name: name, - ClassName: name, - GetChildren: () => [], - GetDescendants: () => [], - FindFirstChild: () => null, - FindFirstChildOfClass: () => null, - WaitForChild: () => null, - IsA: () => false, - GetService: (n) => stubService(n), - ChildAdded: makeSignal(), - ChildRemoved: makeSignal(), - DescendantAdded: makeSignal(), - DescendantRemoving: makeSignal(), }); - const runService = stubService('RunService'); + global.set('wait', (_) => undefined); + + // === DataModel === + const game = newInstance('DataModel', 'game'); + const workspace = newInstance('Workspace', 'Workspace'); + workspace.Parent = game; + workspace.Gravity = 196.2; + workspace.CurrentCamera = newInstance('Camera', 'Camera'); + workspace.CurrentCamera.Parent = workspace; + workspace.Children.push(workspace.CurrentCamera); + workspace.Terrain = newInstance('Terrain', 'Terrain'); + workspace.Terrain.Parent = workspace; + workspace.Children.push(workspace.Terrain); + game.Children.push(workspace); + game.Workspace = workspace; + + const players = newInstance('Players', 'Players'); + players.Parent = game; + players.PlayerAdded = makeSignal(); + players.PlayerRemoving = makeSignal(); + game.Children.push(players); + game.Players = players; + + const localPlayer = newInstance('Player', 'Player'); + localPlayer.Parent = players; + localPlayer.UserId = 1; + localPlayer.DisplayName = 'Player'; + players.Children.push(localPlayer); + players.LocalPlayer = localPlayer; + + const character = newInstance('Model', 'Player'); + character.Parent = localPlayer; + localPlayer.Children.push(character); + localPlayer.Character = character; + + const humanoid = newInstance('Humanoid', 'Humanoid'); + humanoid.Parent = character; + humanoid.Health = 100; + humanoid.MaxHealth = 100; + humanoid.WalkSpeed = 16; + humanoid.JumpPower = 50; + humanoid.Died = makeSignal(); + humanoid.HealthChanged = makeSignal(); + humanoid.Touched = makeSignal(); + humanoid.StateChanged = makeSignal(); + humanoid.TakeDamage = function (n) { + const v = Math.max(0, (this.Health || 100) - (Number(n) || 0)); + this.Health = v; + this.HealthChanged.Fire(v); + if (v === 0) this.Died.Fire(); + send('playerSet', { prop: 'health', value: v }); + }; + humanoid.MoveTo = function () {}; + humanoid.LoadAnimation = function () { return { Play: () => {}, Stop: () => {}, AdjustSpeed: () => {} }; }; + character.Children.push(humanoid); + character.Humanoid = humanoid; + + const hrp = newInstance('Part', 'HumanoidRootPart'); + hrp.Parent = character; + hrp.Position = new RbxVector3(0, 5, 0); + hrp.Size = new RbxVector3(2, 2, 1); + character.Children.push(hrp); + character.HumanoidRootPart = hrp; + character.PrimaryPart = hrp; + + // === Сервисы === + const services = {}; + const makeService = (name) => { + if (services[name]) return services[name]; + const s = newInstance(name, name); + s.Parent = game; + game.Children.push(s); + services[name] = s; + game[name] = s; + return s; + }; + makeService('ReplicatedStorage'); + makeService('ServerStorage'); + makeService('StarterGui'); + makeService('StarterPack'); + makeService('StarterPlayer'); + + const uis = makeService('UserInputService'); + uis.InputBegan = makeSignal(); + uis.InputChanged = makeSignal(); + uis.InputEnded = makeSignal(); + + const tw = makeService('TweenService'); + tw.Create = function () { return { Play: () => {}, Pause: () => {}, Cancel: () => {} }; }; + + const http = makeService('HttpService'); + http.JSONEncode = function (v) { try { return JSON.stringify(v); } catch (_) { return '{}'; } }; + http.JSONDecode = function (s) { try { return JSON.parse(s); } catch (_) { return undefined; } }; + http.GenerateGUID = () => 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = Math.random() * 16 | 0; return (c === 'x' ? r : (r & 3 | 8)).toString(16); + }); + + makeService('Lighting').Ambient = new RbxColor3(0.5, 0.5, 0.5); + makeService('Chat'); + makeService('SoundService'); + makeService('PathfindingService'); + makeService('CollectionService'); + makeService('MarketplaceService'); + + const ds = makeService('DataStoreService'); + ds.GetDataStore = function () { + return { + GetAsync: () => undefined, SetAsync: () => {}, UpdateAsync: () => {}, + RemoveAsync: () => {}, IncrementAsync: () => {}, + }; + }; + + const ctx = makeService('ContextActionService'); + ctx.BindAction = () => {}; + ctx.UnbindAction = () => {}; + + const runService = makeService('RunService'); runService.Heartbeat = HEARTBEAT_SIGNAL; runService.Stepped = STEPPED_SIGNAL; - runService.RenderStepped = HEARTBEAT_SIGNAL; // упрощённо + runService.RenderStepped = HEARTBEAT_SIGNAL; + runService.IsClient = () => true; + runService.IsServer = () => true; + runService.IsRunning = () => true; + runService.IsStudio = () => false; - const gameTable = { - __isGame: true, - Name: 'game', - ClassName: 'DataModel', - GetService(name) { - if (name === 'RunService') return runService; - return stubService(name); - }, - FindService(name) { - if (name === 'RunService') return runService; - return null; - }, - Workspace: stubService('Workspace'), - Players: stubService('Players'), - ReplicatedStorage: stubService('ReplicatedStorage'), - ServerStorage: stubService('ServerStorage'), - Lighting: stubService('Lighting'), - StarterGui: stubService('StarterGui'), - StarterPack: stubService('StarterPack'), - StarterPlayer: stubService('StarterPlayer'), - RunService: runService, - UserInputService: stubService('UserInputService'), - TweenService: stubService('TweenService'), - HttpService: stubService('HttpService'), - DataStoreService: stubService('DataStoreService'), - MarketplaceService: stubService('MarketplaceService'), - Chat: stubService('Chat'), - SoundService: stubService('SoundService'), - PathfindingService: stubService('PathfindingService'), - PhysicsService: stubService('PhysicsService'), - TeleportService: stubService('TeleportService'), - CollectionService: stubService('CollectionService'), - ContextActionService: stubService('ContextActionService'), - ContentProvider: stubService('ContentProvider'), - LocalizationService: stubService('LocalizationService'), + game.GetService = function (name) { + if (name === 'Workspace') return workspace; + if (name === 'Players') return players; + return services[name] || makeService(name); }; - global.set('game', gameTable); - global.set('workspace', gameTable.Workspace); - global.set('Workspace', gameTable.Workspace); + game.FindService = function (name) { return services[name] || null; }; - // ------ Instance.new ------ - // Возвращает «pseudo-instance» — на Этапе 2 это просто object с пропсами. - // На Этапе 3 будет полноценный класс с metatable и Parent setter. + global.set('game', game); + global.set('Game', game); + global.set('workspace', workspace); + global.set('Workspace', workspace); + + // === Instance.new === global.set('Instance', { new: (className, parent) => { - const inst = { - ClassName: String(className || 'Instance'), - Name: String(className || 'Instance'), - Parent: parent || null, - Children: [], - Destroyed: false, - Touched: makeSignal(), - Activated: makeSignal(), - MouseButton1Click: makeSignal(), - Changed: makeSignal(), - AncestryChanged: makeSignal(), - ChildAdded: makeSignal(), - ChildRemoved: makeSignal(), - GetChildren() { return [...this.Children]; }, - FindFirstChild() { return null; }, - WaitForChild() { return null; }, - IsA() { return false; }, - Destroy() { this.Destroyed = true; }, - Clone() { return null; }, - GetFullName() { return this.Name; }, - GetAttribute() { return null; }, - SetAttribute() {}, - }; + let inst; + if (className === 'Part' || className === 'WedgePart' || className === 'MeshPart') { + inst = newInstance(className, className); + inst.Touched = makeSignal(); + inst.TouchEnded = makeSignal(); + inst.Position = new RbxVector3(); + inst.Size = new RbxVector3(4, 1, 2); + inst.Color = new RbxColor3(0.5, 0.5, 0.5); + inst.Anchored = false; + inst.CanCollide = true; + inst.Transparency = 0; + inst.Material = 'Plastic'; + inst.CFrame = new RbxCFrame(); + } else if (className === 'RemoteEvent') { + inst = newInstance('RemoteEvent', 'RemoteEvent'); + inst.OnServerEvent = makeSignal(); + inst.OnClientEvent = makeSignal(); + inst.FireServer = function (...a) { this.OnServerEvent.Fire(localPlayer, ...a); }; + inst.FireClient = function (_p, ...a) { this.OnClientEvent.Fire(...a); }; + inst.FireAllClients = function (...a) { this.OnClientEvent.Fire(...a); }; + } else if (className === 'BindableEvent') { + inst = newInstance('BindableEvent', 'BindableEvent'); + inst.Event = makeSignal(); + inst.Fire = function (...a) { this.Event.Fire(...a); }; + } else if (className === 'Humanoid') { + inst = newInstance('Humanoid', 'Humanoid'); + inst.Health = 100; inst.MaxHealth = 100; + inst.Died = makeSignal(); inst.HealthChanged = makeSignal(); + inst.TakeDamage = function (n) { this.Health = Math.max(0, this.Health - (n || 0)); }; + } else { + inst = newInstance(className, className); + } + if (parent) { + inst.Parent = parent; + if (parent.Children) { + parent.Children.push(inst); + if (parent.ChildAdded) parent.ChildAdded.Fire(inst); + } + } return inst; }, }); - // ------ Helpers для Worker'а ------ - // __rbxl_register_coroutine(id, co) — мы её отдадим, чтобы зарегистрировать в JS - const coroutinesById = new Map(); - global.set('__rbxl_register_coroutine', (id, co) => { - coroutinesById.set(String(id), co); - }); - global.set('__rbxl_get_part_by_id', (_id) => { - // На Этапе 3 будет lookup в DataModel. Пока nil (script.Parent = nil) - return null; - }); - global.set('__rbxl_script_run', (id, scriptObj, body) => { - // Запускает body() с обработкой ошибок. id и scriptObj прокидываются - // только для будущего использования (например, регистрации в DataModel). - try { - if (typeof body === 'function') body(); - } catch (err) { - send('log', { - level: 'error', - text: `[Lua ${id}] ${err?.message || err}`, - }); - } + // === Helpers для скриптов === + const partById = new Map(); + global.set('__rbxl_get_part_by_id', (id) => partById.get(Number(id)) || undefined); + global.set('__rbxl_send_error', (id, errStr) => { + send('log', { level: 'error', text: `[Lua ${id}] ${errStr || 'unknown error'}` }); }); - // ------ Возвращаем api для Worker'а ------ + // === Setter Part-свойств (Position/Size/Color/...) === + // Юзер пишет: part.Position = Vector3.new(0, 10, 0) + // В Lua-side это просто rawset. Но мы хотим, чтобы JS-сторона узнала и применила. + // Решение: каждые N секунд (или по требованию) — сканируем partById и сравниваем + // _state. Это дорого. ПРОСТЕЕ: научим юзера использовать part:SetPosition(v). + // + // Для Этапа 3 сделаем proxy через явный метод. В Этапе 4 — введём + // metatable на Lua-стороне (более чистый путь). + + // Возвращаем api для main-loop return { - // обновление снапшотов (будет использовано на Этапе 3 для DataModel) - onSceneSnapshot() {}, + onSceneSnapshot(snap) { + try { + const prims = snap?.primitives || []; + // Сохраняем Camera/Terrain + const kept = workspace.Children.filter(c => + c.ClassName === 'Camera' || c.ClassName === 'Terrain' + ); + workspace.Children.length = 0; + workspace.Children.push(...kept); + partById.clear(); + for (const p of prims) { + if (!p || p.id == null) continue; + const part = newPart(p, send); + part.Parent = workspace; + workspace.Children.push(part); + partById.set(Number(p.id), part); + } + // eslint-disable-next-line no-console + console.log(`[shim] DataModel: workspace has ${workspace.Children.length} children, ${partById.size} parts`); + } catch (e) { + send('log', { level: 'error', text: `[shim onSceneSnapshot] ${e?.message || e}` }); + } + }, onGuiSnapshot() {}, onDataSnapshot() {}, - // tick scheduler — резюм ожидающих task.delay/defer + tickScheduler(_dt) { const now = SCHEDULER.now(); if (SCHEDULER.sleeping.length === 0) return; const ready = []; const rest = []; for (const t of SCHEDULER.sleeping) { - if (t.wakeAt <= now) ready.push(t); - else rest.push(t); + if (t.wakeAt <= now) ready.push(t); else rest.push(t); } SCHEDULER.sleeping = rest; for (const t of ready) { @@ -428,28 +610,35 @@ export function registerRobloxShim(lua, opts) { try { STEPPED_SIGNAL.Fire(performance.now() / 1000, dt); } catch (_) {} }, fireTargetEvent(p) { - // На Этапе 3 — найти Part в DataModel и фейернуть его Touched - // Сейчас — no-op (но не падаем) - // Возможные kind: 'touch', 'untouch', 'click' if (!p) return; + const id = p.primId ?? p.target; + const part = partById.get(Number(id)); + if (!part) return; + if (p.kind === 'touch' || p.kind === 'touched') { + part.Touched.Fire(hrp); + } else if (p.kind === 'untouch' || p.kind === 'untouched') { + part.TouchEnded.Fire(hrp); + } }, - fireGlobalEvent(_p) { - // playerTouch / guiClick / keydown — также на Этапе 3 + fireGlobalEvent(p) { + if (!p) return; + if (p.type === 'playerTouch' && p.target != null) { + let primId = null; + if (typeof p.target === 'number') primId = p.target; + else if (typeof p.target === 'string') { + const m = /^primitive:(\d+)$/.exec(p.target); + if (m) primId = +m[1]; + } else if (typeof p.target === 'object') { + primId = p.target.id ?? p.target.ref ?? null; + } + if (primId != null) { + const part = partById.get(Number(primId)); + if (part?.Touched) part.Touched.Fire(hrp); + if (humanoid.Touched) humanoid.Touched.Fire(part); + } + } }, + // Доступ к ключевым объектам (для тестов и отладки) + partById, localPlayer, humanoid, character, workspace, players, game, }; } - -// --- Утилиты --- -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 v ? 'true' : 'false'; - if (v instanceof RbxVector3) return `${v.X}, ${v.Y}, ${v.Z}`; - if (v instanceof RbxColor3) return `${v.R}, ${v.G}, ${v.B}`; - if (typeof v === 'object') { - if (v.Name) return String(v.Name); - return '[object]'; - } - try { return String(v); } catch (_) { return '?'; } -}