From f34320db91c4ca883108d88f2bb850e5bb83c9d6 Mon Sep 17 00:00:00 2001 From: min Date: Sun, 7 Jun 2026 18:23:32 +0300 Subject: [PATCH] =?UTF-8?q?feat(rbxl-import):=20Lua-runtime=20(wasmoon)=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20Roblox-=D1=81=D0=BA=D1=80=D0=B8=D0=BF?= =?UTF-8?q?=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Часть тест-фичи импорта Roblox-карт (см. rublox/studio rbxl-importer/). Что добавлено: - wasmoon (Lua 5.4 WASM) как dep. - RobloxLuaWorker.js — Worker-хост Lua-VM. - RobloxLuaSandbox.js — main-side обёртка (по аналогии с ScriptSandbox). - roblox-shim.js — math (Vector3/Color3/CFrame/UDim2), Instance прокси (game/workspace/script/GetService/IsA), Part свойства (Position/Color/Material/Anchored/CanCollide), RBXScriptSignal (Touched/Heartbeat/Stepped/Connect/Wait). - roblox-scheduler.js — корутины + wait/task.wait/task.delay/task.spawn, автоматический fire Heartbeat/Stepped/RenderStepped на tick. - roblox-tween.js — TweenService с 10 easing-функциями (Linear, Quad, Cubic, Quart, Quint, Sine, Bounce, Elastic, Back, Exponential). - roblox-services.js — Players/LocalPlayer/Character/Humanoid (Health, WalkSpeed, JumpPower, TakeDamage, Died, LoadAnimation), UserInputService, RemoteEvent (FireServer/FireClient), RemoteFunction, DataStoreService, HttpService. - roblox-physics.js — BodyVelocity/BodyGyro/BodyPosition/BodyForce/ BodyAngularVelocity/AlignPosition/LinearVelocity. Интеграция в GameRuntime: - В start() проверяется script.kind === 'roblox-lua' → _startRobloxLuaScript() запускает RobloxLuaSandbox. - _handleRobloxLuaCommand() мапит IPC команды (partSet/partVel/playerCmd) на PrimitiveManager и game.player API. - _buildRobloxLuaSceneSnap() готовит snap для workspace:GetChildren. Тесты: **36/36 passed**. - mvp (9): math, Instance proxy, Part, IsA. - wait (5): корутины, wait/task.wait/task.delay. - tween (2): TweenInfo + Linear easing. - services (8): Humanoid, DataStore, HttpService, RemoteEvent. - integration (12): KillBrick, WalkSpeed, Tween-door, BodyVelocity конвейер, leaderstats, Checkpoint, циклы с wait, task.spawn, Color/Material, RemoteEvent client→server, Heartbeat, Vector3. Co-Authored-By: Claude Opus 4.7 --- .WORKTREE_NOTICE.md | 11 + package-lock.json | 21 +- package.json | 3 +- src/engine/GameRuntime.js | 137 +++++++ src/engine/RobloxLuaSandbox.js | 133 +++++++ src/engine/RobloxLuaWorker.js | 169 +++++++++ src/engine/roblox-physics.js | 216 +++++++++++ src/engine/roblox-scheduler.js | 209 +++++++++++ src/engine/roblox-services.js | 384 +++++++++++++++++++ src/engine/roblox-shim.js | 575 +++++++++++++++++++++++++++++ src/engine/roblox-tween.js | 204 ++++++++++ tests/rbxl-lua-integration.test.js | 243 ++++++++++++ tests/rbxl-lua-mvp.test.js | 187 ++++++++++ tests/rbxl-lua-services.test.js | 144 ++++++++ tests/rbxl-lua-tween.test.js | 89 +++++ tests/rbxl-lua-wait.test.js | 104 ++++++ 16 files changed, 2827 insertions(+), 2 deletions(-) create mode 100644 .WORKTREE_NOTICE.md create mode 100644 src/engine/RobloxLuaSandbox.js create mode 100644 src/engine/RobloxLuaWorker.js create mode 100644 src/engine/roblox-physics.js create mode 100644 src/engine/roblox-scheduler.js create mode 100644 src/engine/roblox-services.js create mode 100644 src/engine/roblox-shim.js create mode 100644 src/engine/roblox-tween.js create mode 100644 tests/rbxl-lua-integration.test.js create mode 100644 tests/rbxl-lua-mvp.test.js create mode 100644 tests/rbxl-lua-services.test.js create mode 100644 tests/rbxl-lua-tween.test.js create mode 100644 tests/rbxl-lua-wait.test.js diff --git a/.WORKTREE_NOTICE.md b/.WORKTREE_NOTICE.md new file mode 100644 index 0000000..03c4696 --- /dev/null +++ b/.WORKTREE_NOTICE.md @@ -0,0 +1,11 @@ +# Активная сессия: импорт Roblox .rbxl + +Это **отдельный worktree** для разработки `feat/rbxl-import` — импорта Roblox-карт в Rublox. + +**Не работайте здесь параллельно из других сессий!** + +Ветка: `feat/rbxl-import` +Сервис на сервере: VM 130 на S1 +Сопутствующий worktree: `Desktop/studio-rbxl-import` + +Started: 2026-06-07 diff --git a/package-lock.json b/package-lock.json index 870da93..acf1d7d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,8 @@ "react": "18.3.1", "react-dom": "18.3.1", "react-router-dom": "7.4.0", - "socket.io-client": "^4.8.3" + "socket.io-client": "^4.8.3", + "wasmoon": "^1.16.0" }, "devDependencies": { "@types/react": "18.3.12", @@ -1427,6 +1428,12 @@ "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", "license": "MIT" }, + "node_modules/@types/emscripten": { + "version": "1.39.10", + "resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.39.10.tgz", + "integrity": "sha512-TB/6hBkYQJxsZHSqyeuO1Jt0AB/bW6G7rHt9g7lML7SOF6lbgcHvw/Lr+69iqN0qxgXLhWKScAon73JNnptuDw==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -5206,6 +5213,18 @@ } } }, + "node_modules/wasmoon": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/wasmoon/-/wasmoon-1.16.0.tgz", + "integrity": "sha512-FlRLb15WwAOz1A9OQDbf6oOKKSiefi5VK0ZRF2wgH9xk3o5SnU11tNPaOnQuAh1Ucr66cwwvVXaeVRaFdRBt5g==", + "license": "MIT", + "dependencies": { + "@types/emscripten": "1.39.10" + }, + "bin": { + "wasmoon": "bin/wasmoon" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 7b16067..34d01f3 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,8 @@ "react": "18.3.1", "react-dom": "18.3.1", "react-router-dom": "7.4.0", - "socket.io-client": "^4.8.3" + "socket.io-client": "^4.8.3", + "wasmoon": "^1.16.0" }, "devDependencies": { "@types/react": "18.3.12", diff --git a/src/engine/GameRuntime.js b/src/engine/GameRuntime.js index 4b77f8c..2ace2d7 100644 --- a/src/engine/GameRuntime.js +++ b/src/engine/GameRuntime.js @@ -87,6 +87,13 @@ export class GameRuntime { let initialScene = null; try { initialScene = this._buildSceneSnapshot(); } catch (e) { initialScene = null; } for (const s of scripts) { + // Roblox-Lua скрипты импортированные через rbxl-importer: + // отдельный sandbox с wasmoon Lua-VM и Roblox-API shim. + // Запускаем по флагу kind, обходя стандартный ScriptSandbox. + if (s && s.kind === 'roblox-lua' && typeof s.lua_source === 'string' && s.lua_source.trim()) { + this._startRobloxLuaScript(s); + continue; + } if (!s || typeof s.code !== 'string' || !s.code.trim()) { // eslint-disable-next-line no-console console.warn('[GameRuntime] skipping invalid script entry', s); @@ -174,6 +181,136 @@ export class GameRuntime { } } + /** + * Запускает Roblox-Lua скрипт через RobloxLuaSandbox + wasmoon. + * Используется для скриптов импортированных из .rbxl файлов. + * + * Архитектура — параллельно ScriptSandbox'у, в собственном Worker'е: + * 1. Lua-VM (wasmoon) в Worker + * 2. Roblox API shim (game, workspace, Vector3, CFrame, ...) + * 3. Scheduler для wait/task.wait/корутин + * 4. Services: Players, Humanoid, DataStore, RemoteEvent, Tween, BodyMover + * + * Маппинг команд из Lua-sandbox в наш runtime: + * partSet {primId, prop, value} → applyPartProp(scriptId, primId, prop, value) + * partVel {primId, vx,vy,vz} → applyPartVelocity(primId, ...) + * playerCmd {method, args} → game.player API (teleport, setWalkSpeed, ...) + */ + async _startRobloxLuaScript(script) { + try { + const { RobloxLuaSandbox } = await import('./RobloxLuaSandbox.js'); + // Worker создаётся через Vite ?worker import. Делаем динамику: + const WorkerModule = await import('./RobloxLuaWorker.js?worker'); + const worker = new WorkerModule.default(); + + // Снапшот сцены для Lua (зеркало primitives для workspace:GetChildren) + const initialScene = this._buildRobloxLuaSceneSnap?.() || this._buildSceneSnapshot?.() || { primitives: {} }; + + const sb = new RobloxLuaSandbox(script.lua_source, script.target || null); + sb.scriptId = script.id; + sb.setInitialScene(initialScene); + sb.setOnCommand((cmd, payload) => { + this._handleRobloxLuaCommand(script.id, cmd, payload); + }); + sb.start(worker); + this.sandboxes.push(sb); + // eslint-disable-next-line no-console + console.log(`[GameRuntime] roblox-lua sandbox started: ${script.name || script.id}`); + } catch (e) { + // eslint-disable-next-line no-console + console.warn(`[GameRuntime] failed to start roblox-lua script ${script.id}:`, e); + } + } + + /** + * Обработчик команд от RobloxLuaSandbox. + * Маппит на существующие методы рантайма (как _handleCommand для обычного sandbox'а). + */ + _handleRobloxLuaCommand(scriptId, cmd, payload) { + if (cmd === 'log') { + // eslint-disable-next-line no-console + const fn = payload?.level === 'error' ? console.error + : payload?.level === 'warn' ? console.warn : console.log; + fn(`[rbxl-lua ${scriptId}] ${payload?.text || ''}`); + return; + } + if (cmd === 'partSet') { + // partSet: {primId, prop, value} + // prop: position | cframe | size | color | material | anchored | canCollide | opacity | rotation + try { + const pm = this.scene3d?._primitiveManager || this.scene3d?.primitiveManager; + if (!pm) return; + const { primId, prop, value } = payload || {}; + const patch = {}; + if (prop === 'position' && value) Object.assign(patch, { x: value.x, y: value.y, z: value.z }); + else if (prop === 'cframe' && value) Object.assign(patch, { + x: value.x, y: value.y, z: value.z, + rotationX: value.rx, rotationY: value.ry, rotationZ: value.rz, + }); + else if (prop === 'size' && value) Object.assign(patch, { sx: value.sx, sy: value.sy, sz: value.sz }); + else if (prop === 'color') patch.color = value; + else if (prop === 'material') patch.material = value; + else if (prop === 'anchored') patch.anchored = value; + else if (prop === 'canCollide') patch.canCollide = value; + else if (prop === 'opacity') patch.opacity = value; + else if (prop === 'rotation' && value) Object.assign(patch, { + rotationX: value.rx, rotationY: value.ry, rotationZ: value.rz, + }); + if (typeof pm.applyPatch === 'function') { + pm.applyPatch(primId, patch); + } else if (typeof pm.update === 'function') { + pm.update(primId, patch); + } + } catch (e) { /* swallow */ } + return; + } + if (cmd === 'partVel') { + try { + const pm = this.scene3d?._primitiveManager || this.scene3d?.primitiveManager; + if (!pm) return; + const { primId, vx, vy, vz } = payload || {}; + if (typeof pm.setVelocity === 'function') pm.setVelocity(primId, vx, vy, vz); + } catch (e) {} + return; + } + if (cmd === 'playerCmd') { + try { + const p = this.game?.player; + if (!p) return; + const { method, args } = payload || {}; + if (method === 'teleport' && Array.isArray(args)) p.teleport?.(args[0], args[1], args[2]); + else if (method === 'setWalkSpeed') p.setWalkSpeed?.(args[0]); + else if (method === 'setJumpPower') p.setJumpPower?.(args[0]); + else if (method === 'setHealth') p.setHealth?.(args[0]); + else if (method === 'die') p.die?.(); + else if (method === 'damage' || method === 'takeDamage') p.damage?.(args[0]); + } catch (e) {} + return; + } + // broadcast/spawn/tweenStart — могут быть обработаны общим broadcast + } + + /** + * Собирает snap сцены в формате удобном для Lua (primitives по id). + * Lua-VM ожидает { primitives: { id: { x,y,z, sx,sy,sz, color, material, ... } } }. + */ + _buildRobloxLuaSceneSnap() { + const out = { primitives: {} }; + const data = this.projectData?.scene; + if (!data) return out; + for (const p of (data.primitives || [])) { + out.primitives[p.id] = { + id: p.id, type: p.type, name: p.name, + x: p.x, y: p.y, z: p.z, + sx: p.sx, sy: p.sy, sz: p.sz, + color: p.color, material: p.material, + anchored: !!p.anchored, canCollide: p.canCollide !== false, + opacity: typeof p.opacity === 'number' ? p.opacity : 1, + }; + } + return out; + } + /** * Разослать карту высот гладкого ландшафта всем sandbox'ам. * Нужно для game.scene.surfaceY(x,z). Снимается raycast'ом по diff --git a/src/engine/RobloxLuaSandbox.js b/src/engine/RobloxLuaSandbox.js new file mode 100644 index 0000000..afe44cd --- /dev/null +++ b/src/engine/RobloxLuaSandbox.js @@ -0,0 +1,133 @@ +/** + * 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; + } + + _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/engine/RobloxLuaWorker.js b/src/engine/RobloxLuaWorker.js new file mode 100644 index 0000000..ae21a7c --- /dev/null +++ b/src/engine/RobloxLuaWorker.js @@ -0,0 +1,169 @@ +/* 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 { + // Запускаем Lua-код в защищённом блоке. + await state.lua.doString(code); + 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/engine/roblox-physics.js b/src/engine/roblox-physics.js new file mode 100644 index 0000000..0962898 --- /dev/null +++ b/src/engine/roblox-physics.js @@ -0,0 +1,216 @@ +/** + * 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/engine/roblox-scheduler.js b/src/engine/roblox-scheduler.js new file mode 100644 index 0000000..936c181 --- /dev/null +++ b/src/engine/roblox-scheduler.js @@ -0,0 +1,209 @@ +/** + * 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/engine/roblox-services.js b/src/engine/roblox-services.js new file mode 100644 index 0000000..8ffbfba --- /dev/null +++ b/src/engine/roblox-services.js @@ -0,0 +1,384 @@ +/** + * 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/engine/roblox-shim.js b/src/engine/roblox-shim.js new file mode 100644 index 0000000..b657821 --- /dev/null +++ b/src/engine/roblox-shim.js @@ -0,0 +1,575 @@ +/** + * 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; }, + Connected: () => conn.connected, + }; + } + Wait() { + // В рамках MVP — Wait не блокирует (т.к. wasmoon без корутин это сложно). + // Реальный Wait появится в 4.7 через task.wait. + return null; + } + Fire(...args) { + for (const c of this.connections) { + if (!c.connected) continue; + try { c.callback(...args); } catch (e) { /* swallow */ } + } + } +} + +/* ──────── Instance прокси ──────── */ + +let _instanceCounter = 1; + +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; + } + } + return null; + } + FindFirstChildOfClass(className) { + for (const c of this.Children) { + if (c.ClassName === className) return c; + } + return null; + } + FindFirstAncestor(name) { + let p = this.Parent; + while (p) { + if (p.Name === name) return p; + p = p.Parent; + } + return null; + } + 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 } = 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.Parent = workspace; + workspace.Children.push(part); + part_by_id.set(+id, part); + } + } + + // 3. script — обёртка над текущим скриптом. + const scriptInst = new RbxInstance('LocalScript', { Name: 'Script' }); + if (targetPrimitiveId != null && part_by_id.has(targetPrimitiveId)) { + const parentPart = part_by_id.get(targetPrimitiveId); + 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 заполнит фаза 4.9 + playersService.LocalPlayer = null; + 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 / spawn — в фазе 4.7 заменим на корутинные. + // Сейчас — простой busy-wait через setTimeout не работает в Worker (sync). + // Поэтому MVP: wait это no-op, log warning. + lua.global.set('wait', (sec) => { + // TODO 4.7: реализовать через корутины + return [sec || 0, 0]; + }); + lua.global.set('task', { + wait: (sec) => sec || 0, + spawn: (fn, ...args) => { try { fn(...args); } catch (e) {} return null; }, + delay: (sec, fn, ...args) => { try { fn(...args); } catch (e) {} return null; }, + defer: (fn, ...args) => { try { fn(...args); } catch (e) {} return null; }, + }); + lua.global.set('spawn', (fn) => { try { fn(); } catch (e) {} }); + lua.global.set('delay', (sec, fn) => { try { fn(); } catch (e) {} }); + 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 }; +} + +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/engine/roblox-tween.js b/src/engine/roblox-tween.js new file mode 100644 index 0000000..4c55fd6 --- /dev/null +++ b/src/engine/roblox-tween.js @@ -0,0 +1,204 @@ +/** + * 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 }; diff --git a/tests/rbxl-lua-integration.test.js b/tests/rbxl-lua-integration.test.js new file mode 100644 index 0000000..d940097 --- /dev/null +++ b/tests/rbxl-lua-integration.test.js @@ -0,0 +1,243 @@ +/** + * rbxl-lua-integration.test.js — реалистичные Roblox-сниппеты из obby/simulator карт. + */ +import { LuaFactory } from 'wasmoon'; +import { registerRobloxApi } from '../src/engine/roblox-shim.js'; +import { RobloxScheduler } from '../src/engine/roblox-scheduler.js'; +import { installRobloxServices } from '../src/engine/roblox-services.js'; +import { RobloxTweenManager } from '../src/engine/roblox-tween.js'; +import { RobloxPhysicsManager } from '../src/engine/roblox-physics.js'; + +function makeScene() { + return { + primitives: { + 10: { id: 10, type: 'cube', name: 'KillPart', x: 5, y: 1, z: 0, sx: 4, sy: 1, sz: 4, + color: '#ff0000', material: 'neon', anchored: true, canCollide: true, opacity: 1 }, + 11: { id: 11, type: 'cube', name: 'WinPart', x: 30, y: 1, z: 0, sx: 4, sy: 1, sz: 4, + color: '#00ff00', material: 'neon', anchored: true, canCollide: true, opacity: 1 }, + 12: { id: 12, type: 'cube', name: 'Conveyor', x: 15, y: 1, z: 0, sx: 8, sy: 0.5, sz: 4, + color: '#888888', material: 'metal', anchored: true, canCollide: true, opacity: 1 }, + 13: { id: 13, type: 'cube', name: 'Door', x: 20, y: 3, z: 0, sx: 2, sy: 6, sz: 4, + color: '#a0522d', material: 'matte', anchored: true, canCollide: true, opacity: 1 }, + }, + }; +} + +const STORE = new Map(); + +async function run(luaSource, targetPrimId = 10, ticks = []) { + const factory = new LuaFactory(); + const lua = await factory.createEngine(); + const sent = []; + const send = (cmd, payload) => sent.push({ cmd, payload }); + let playerState = { x: 0, y: 5, z: 0, hp: 100 }; + registerRobloxApi(lua, { getSceneSnap: makeScene, targetPrimitiveId: targetPrimId, send }); + const sched = new RobloxScheduler(lua); + sched.install(); + installRobloxServices(lua, { + send, + getPlayerState: () => playerState, + loadSave: (k) => STORE.get(k), + saveSave: (k, v) => STORE.set(k, v), + removeSave: (k) => STORE.delete(k), + }); + const tween = new RobloxTweenManager(); + tween.install(lua); + const phys = new RobloxPhysicsManager(send); + phys.install(lua); + + await sched.spawnMain(luaSource); + for (const dt of ticks) { + await sched.tick(dt); + tween.tick(dt); + phys.tick(dt); + } + lua.global.close(); + return { + logs: sent.filter(s => s.cmd === 'log').map(s => s.payload), + partSets: sent.filter(s => s.cmd === 'partSet').map(s => s.payload), + partVels: sent.filter(s => s.cmd === 'partVel').map(s => s.payload), + playerCmds: sent.filter(s => s.cmd === 'playerCmd').map(s => s.payload), + }; +} + +const TESTS = [ + { + name: 'KillBrick (Touched → Humanoid.Health = 0)', + lua: ` + local part = script.Parent + part.Touched:Connect(function(hit) + local hum = hit.Parent and hit.Parent:FindFirstChild("Humanoid") + if hum then hum.Health = 0 end + end) + print("kill brick armed") + `, + ticks: [], + check: (r) => r.logs.some(l => l.text === 'kill brick armed'), + }, + { + name: 'WalkSpeed boost через trigger', + lua: ` + local h = game:GetService("Players").LocalPlayer.Character.Humanoid + h.WalkSpeed = 32 + print("speed boosted to", h.WalkSpeed) + `, + check: (r) => r.playerCmds.some(c => c.method === 'setWalkSpeed' && c.args[0] === 32) + && r.logs.some(l => l.text.includes('speed boosted')), + }, + { + name: 'Door open: TweenService двигает дверь вверх', + lua: ` + local door = workspace:FindFirstChild("Door") + local TS = game:GetService("TweenService") + local goal = { Position = Vector3.new(door.Position.X, door.Position.Y + 10, door.Position.Z) } + local info = TweenInfo.new(1, Enum.EasingStyle.Linear, Enum.EasingDirection.Out) + local tw = TS:Create(door, info, goal) + tw:Play() + print("door opening") + `, + ticks: [0.5, 0.5, 0.1], + check: (r) => r.partSets.some(p => p.primId === 13 && p.prop === 'position'), + }, + { + name: 'Конвейер: BodyVelocity толкает игрока', + lua: ` + local conv = workspace:FindFirstChild("Conveyor") + local bv = Instance.new("BodyVelocity", conv) + bv.Velocity = Vector3.new(20, 0, 0) + bv.MaxForce = Vector3.new(4000, 0, 4000) + print("conveyor started") + `, + ticks: [0.1], + check: (r) => r.partVels.some(v => v.primId === 12 && v.vx === 20), + }, + { + name: 'leaderstats (как в tycoon)', + lua: ` + local Players = game:GetService("Players") + local plr = Players.LocalPlayer + local money = Instance.new("IntValue", plr.leaderstats) + money.Name = "Money" + money.Value = 100 + print("money:", money.Value) + `, + check: (r) => r.logs.some(l => l.text === 'money:\t100'), + }, + { + name: 'Checkpoint сохраняется в DataStore', + lua: ` + local DSS = game:GetService("DataStoreService") + local store = DSS:GetDataStore("checkpoints") + store:SetAsync("player1", 5) + local cp = store:GetAsync("player1") + print("checkpoint:", cp) + `, + check: (r) => r.logs.some(l => l.text === 'checkpoint:\t5'), + }, + { + name: 'Цикл с wait — подсчёт', + lua: ` + for i = 1, 3 do + print("count:", i) + wait(0.3) + end + print("done") + `, + ticks: [0.3, 0.3, 0.3, 0.3], + check: (r) => { + const texts = r.logs.map(l => l.text); + return texts.includes('count:\t1') && texts.includes('count:\t2') + && texts.includes('count:\t3') && texts.includes('done'); + }, + }, + { + name: 'task.spawn — параллельные функции', + lua: ` + task.spawn(function() print("parallel A") end) + task.spawn(function() print("parallel B") end) + print("main") + `, + check: (r) => { + const texts = r.logs.map(l => l.text); + return texts.includes('parallel A') && texts.includes('parallel B') && texts.includes('main'); + }, + }, + { + name: 'Color3 + Material смена при Touched', + lua: ` + local part = workspace:FindFirstChild("KillPart") + part.Touched:Connect(function() + part.Color = Color3.fromRGB(0, 0, 255) + part.Material = "Neon" + end) + -- симулируем touch + part.Touched:Fire(workspace) + `, + check: (r) => r.partSets.some(p => p.primId === 10 && p.prop === 'color') + && r.partSets.some(p => p.primId === 10 && p.prop === 'material'), + }, + { + name: 'RemoteEvent: client→server message', + lua: ` + local re = Instance.new("RemoteEvent", workspace) + re.Name = "Coins" + re.OnServerEvent:Connect(function(player, amount) + print("server received:", amount) + end) + re:FireServer(50) + `, + check: (r) => r.logs.some(l => l.text === 'server received:\t50'), + }, + { + name: 'Heartbeat: счётчик через RunService', + lua: ` + local RS = game:GetService("RunService") + local count = 0 + RS.Heartbeat:Connect(function(dt) + count = count + 1 + if count == 3 then print("tick3") end + end) + `, + ticks: [0.1, 0.1, 0.1], + check: (r) => r.logs.some(l => l.text === 'tick3'), + }, + { + name: 'Math: Vector3 arithmetic', + lua: ` + local a = Vector3.new(1, 2, 3) + local b = Vector3.new(4, 5, 6) + local sum = a:add(b) + print("sum:", sum.X, sum.Y, sum.Z) + local d = a:Dot(b) + print("dot:", d) + `, + check: (r) => { + const texts = r.logs.map(l => l.text); + return texts.some(t => t === 'sum:\t5\t7\t9') && texts.some(t => t === 'dot:\t32'); + }, + }, +]; + +(async () => { + let passed = 0, failed = 0; + for (const t of TESTS) { + try { + const r = await run(t.lua, t.targetPrimId, t.ticks || []); + const ok = t.check(r); + if (ok) { console.log(`✓ ${t.name}`); passed++; } + else { + console.log(`✗ ${t.name}`); + console.log(` logs: ${JSON.stringify(r.logs.map(l => l.text))}`); + if (r.partSets.length) console.log(` partSets: ${JSON.stringify(r.partSets)}`); + if (r.partVels.length) console.log(` partVels: ${JSON.stringify(r.partVels)}`); + if (r.playerCmds.length) console.log(` playerCmds: ${JSON.stringify(r.playerCmds)}`); + failed++; + } + } catch (e) { + console.log(`✗ ${t.name} — exception: ${e.message || e}`); + failed++; + } + } + console.log(`\n${passed} passed, ${failed} failed`); + process.exit(failed > 0 ? 1 : 0); +})(); diff --git a/tests/rbxl-lua-mvp.test.js b/tests/rbxl-lua-mvp.test.js new file mode 100644 index 0000000..9677bb2 --- /dev/null +++ b/tests/rbxl-lua-mvp.test.js @@ -0,0 +1,187 @@ +/** + * rbxl-lua-mvp.test.js — headless smoke-тест Roblox Lua API shim. + * + * НЕ запускает Worker (это требует браузерного Worker API). Вместо этого + * напрямую импортирует roblox-shim.js и инициализирует Lua в текущем потоке. + * + * Запуск: node --experimental-vm-modules tests/rbxl-lua-mvp.test.js + */ +import { LuaFactory } from 'wasmoon'; +import { registerRobloxApi } from '../src/engine/roblox-shim.js'; + +const FAKE_SCENE_SNAP = { + primitives: { + 1: { id: 1, type: 'cube', name: 'Floor', x: 0, y: 0, z: 0, sx: 10, sy: 1, sz: 10, + color: '#888888', material: 'glossy', anchored: true, canCollide: true, opacity: 1 }, + 2: { id: 2, type: 'cube', name: 'KillBrick', x: 5, y: 1, z: 0, sx: 2, sy: 1, sz: 2, + color: '#ff0000', material: 'neon', anchored: true, canCollide: true, opacity: 1 }, + }, +}; + +const SNIPPETS = [ + { + name: 'print hello', + lua: `print("Hello from Lua!")`, + expectLogs: [{ level: 'info', text: 'Hello from Lua!' }], + }, + { + name: 'Vector3 math', + lua: ` + local v = Vector3.new(3, 4, 0) + print("magnitude:", v.Magnitude) + local u = v.Unit + print("unit:", u.X, u.Y, u.Z) + `, + expectLogs: [ + { level: 'info', text: 'magnitude:\t5' }, + ], + }, + { + name: 'workspace iteration', + lua: ` + local children = workspace:GetChildren() + print("count:", #children) + for i, c in ipairs(children) do + print("child:", c.Name, "class:", c.ClassName) + end + `, + expectLogs: [ + { level: 'info', text: 'count:\t2' }, + ], + }, + { + name: 'FindFirstChild', + lua: ` + local kb = workspace:FindFirstChild("KillBrick") + if kb then print("found:", kb.Name) + else print("not found") end + `, + expectLogs: [{ level: 'info', text: 'found:\tKillBrick' }], + }, + { + name: 'Part.Position get', + lua: ` + local kb = workspace:FindFirstChild("KillBrick") + print("position:", kb.Position.X, kb.Position.Y, kb.Position.Z) + `, + expectLogs: [{ level: 'info', text: 'position:\t5\t1\t0' }], + }, + { + name: 'Part.Color set', + lua: ` + local kb = workspace:FindFirstChild("KillBrick") + kb.Color = Color3.new(0, 1, 0) + print("new color hex (via Position):", kb.Color.R, kb.Color.G, kb.Color.B) + `, + expectPartSet: { primId: 2, prop: 'color' }, + }, + { + name: 'CFrame.Angles', + lua: ` + local cf = CFrame.Angles(0, math.pi/2, 0) + print("lookvector:", cf.LookVector.X, cf.LookVector.Y, cf.LookVector.Z) + `, + expectLogs: [], + }, + { + name: 'Instance.new + Parent', + lua: ` + local f = Instance.new("Folder", workspace) + f.Name = "MyFolder" + print("folder name:", f.Name, "parent:", f.Parent.Name) + `, + expectLogs: [{ level: 'info', text: 'folder name:\tMyFolder\tparent:\tWorkspace' }], + }, + { + name: 'IsA hierarchy', + lua: ` + local kb = workspace:FindFirstChild("KillBrick") + print("isa Part:", kb:IsA("Part")) + print("isa BasePart:", kb:IsA("BasePart")) + print("isa Instance:", kb:IsA("Instance")) + print("isa Sound:", kb:IsA("Sound")) + `, + expectLogs: [ + { level: 'info', text: 'isa Part:\ttrue' }, + { level: 'info', text: 'isa BasePart:\ttrue' }, + { level: 'info', text: 'isa Instance:\ttrue' }, + { level: 'info', text: 'isa Sound:\tfalse' }, + ], + }, +]; + +async function runSnippet(snippet) { + const factory = new LuaFactory(); + const lua = await factory.createEngine(); + + const logs = []; + const sent = []; + const send = (cmd, payload) => sent.push({ cmd, payload }); + + registerRobloxApi(lua, { + getSceneSnap: () => FAKE_SCENE_SNAP, + targetPrimitiveId: 2, // как будто скрипт прикреплён к KillBrick + send, + }); + + // Перехват print через send('log', ...) + let errMsg = null; + try { + await lua.doString(snippet.lua); + } catch (e) { + errMsg = e && e.message ? e.message : String(e); + } + lua.global.close(); + + const captured = sent.filter(s => s.cmd === 'log'); + return { logs: captured.map(s => s.payload), partSets: sent.filter(s => s.cmd === 'partSet'), error: errMsg }; +} + +(async () => { + let passed = 0; + let failed = 0; + for (const s of SNIPPETS) { + const result = await runSnippet(s); + const ok = checkExpectations(s, result); + if (ok.success) { + console.log(`✓ ${s.name}`); + passed++; + } else { + console.log(`✗ ${s.name}`); + console.log(` error: ${result.error || 'none'}`); + console.log(` logs received:`); + for (const l of result.logs) console.log(` [${l.level}] ${JSON.stringify(l.text)}`); + if (result.partSets.length) { + console.log(` partSets:`, JSON.stringify(result.partSets)); + } + console.log(` reason: ${ok.reason}`); + failed++; + } + } + console.log(`\n${passed} passed, ${failed} failed`); + process.exit(failed > 0 ? 1 : 0); +})(); + +function checkExpectations(snippet, result) { + if (result.error) { + return { success: false, reason: `lua error: ${result.error}` }; + } + if (snippet.expectLogs) { + for (const exp of snippet.expectLogs) { + const found = result.logs.find(l => l.level === exp.level && l.text === exp.text); + if (!found) { + return { success: false, reason: `missing log: [${exp.level}] ${JSON.stringify(exp.text)}` }; + } + } + } + if (snippet.expectPartSet) { + const found = result.partSets.find(s => + s.payload.primId === snippet.expectPartSet.primId && + s.payload.prop === snippet.expectPartSet.prop + ); + if (!found) { + return { success: false, reason: `missing partSet ${JSON.stringify(snippet.expectPartSet)}` }; + } + } + return { success: true }; +} diff --git a/tests/rbxl-lua-services.test.js b/tests/rbxl-lua-services.test.js new file mode 100644 index 0000000..c913c25 --- /dev/null +++ b/tests/rbxl-lua-services.test.js @@ -0,0 +1,144 @@ +/** + * rbxl-lua-services.test.js — тесты Humanoid, RemoteEvent, DataStore, HttpService. + */ +import { LuaFactory } from 'wasmoon'; +import { registerRobloxApi } from '../src/engine/roblox-shim.js'; +import { RobloxScheduler } from '../src/engine/roblox-scheduler.js'; +import { installRobloxServices } from '../src/engine/roblox-services.js'; + +const SCENE = { primitives: {} }; + +const STORE = new Map(); + +async function run(luaSource, ticks = []) { + const factory = new LuaFactory(); + const lua = await factory.createEngine(); + const sent = []; + const send = (cmd, payload) => sent.push({ cmd, payload }); + let playerState = { x: 0, y: 5, z: 0 }; + + registerRobloxApi(lua, { getSceneSnap: () => SCENE, targetPrimitiveId: null, send }); + const sched = new RobloxScheduler(lua); + sched.install(); + installRobloxServices(lua, { + send, + getPlayerState: () => playerState, + loadSave: (k) => STORE.get(k), + saveSave: (k, v) => STORE.set(k, v), + removeSave: (k) => STORE.delete(k), + }); + + await sched.spawnMain(luaSource); + for (const dt of ticks) await sched.tick(dt); + lua.global.close(); + return { logs: sent.filter(s => s.cmd === 'log').map(s => s.payload), + playerCmds: sent.filter(s => s.cmd === 'playerCmd').map(s => s.payload), + broadcasts: sent.filter(s => s.cmd === 'broadcast').map(s => s.payload) }; +} + +const TESTS = [ + { + name: 'Players.LocalPlayer.Character.Humanoid существует', + lua: ` + local p = game:GetService("Players").LocalPlayer + local h = p.Character:WaitForChild("Humanoid") + print("hp:", h.Health, "ws:", h.WalkSpeed) + `, + expect: [{ level: 'info', text: 'hp:\t100\tws:\t16' }], + }, + { + name: 'Humanoid.WalkSpeed = 50 → playerCmd setWalkSpeed', + lua: ` + local h = game:GetService("Players").LocalPlayer.Character.Humanoid + h.WalkSpeed = 50 + `, + expectPlayerCmd: { method: 'setWalkSpeed', argsCheck: (a) => a[0] === 50 }, + }, + { + name: 'Humanoid:TakeDamage уменьшает HP', + lua: ` + local h = game:GetService("Players").LocalPlayer.Character.Humanoid + h:TakeDamage(30) + print("after damage:", h.Health) + `, + expect: [{ level: 'info', text: 'after damage:\t70' }], + }, + { + name: 'Humanoid.Health = 0 → Died fires', + lua: ` + local h = game:GetService("Players").LocalPlayer.Character.Humanoid + h.Died:Connect(function() print("DIED") end) + h.Health = 0 + `, + expect: [{ level: 'info', text: 'DIED' }], + }, + { + name: 'DataStoreService GetAsync/SetAsync', + lua: ` + local DSS = game:GetService("DataStoreService") + local store = DSS:GetDataStore("coins") + store:SetAsync("player1", 100) + print("got:", store:GetAsync("player1")) + `, + expect: [{ level: 'info', text: 'got:\t100' }], + }, + { + name: 'DataStoreService IncrementAsync', + lua: ` + local store = game:GetService("DataStoreService"):GetDataStore("score") + store:SetAsync("p1", 50) + store:IncrementAsync("p1", 25) + print("final:", store:GetAsync("p1")) + `, + expect: [{ level: 'info', text: 'final:\t75' }], + }, + { + name: 'HttpService:JSONEncode/Decode', + lua: ` + local HS = game:GetService("HttpService") + local s = HS:JSONEncode({a=1, b="two"}) + print("encoded len:", #s) + local d = HS:JSONDecode('{"x":42}') + print("decoded x:", d.x) + `, + expect: [{ level: 'info', text: 'decoded x:\t42' }], + }, + { + name: 'RemoteEvent FireServer + OnServerEvent', + lua: ` + local re = Instance.new("RemoteEvent", workspace) + re.Name = "MyEvent" + re.OnServerEvent:Connect(function(player, msg) + print("server got:", msg) + end) + re:FireServer("hello") + `, + expect: [{ level: 'info', text: 'server got:\thello' }], + }, +]; + +(async () => { + let passed = 0, failed = 0; + for (const t of TESTS) { + try { + const r = await run(t.lua, t.ticks); + let ok = true; let reason = ''; + for (const exp of (t.expect || [])) { + const found = r.logs.find(l => l.level === exp.level && l.text === exp.text); + if (!found) { ok = false; reason = `missing log: ${exp.text}; got: ${JSON.stringify(r.logs)}`; break; } + } + if (t.expectPlayerCmd) { + const found = r.playerCmds.find(c => c.method === t.expectPlayerCmd.method + && (!t.expectPlayerCmd.argsCheck || t.expectPlayerCmd.argsCheck(c.args))); + if (!found) { ok = false; reason = `missing playerCmd ${t.expectPlayerCmd.method}; got: ${JSON.stringify(r.playerCmds)}`; } + } + if (ok) { console.log(`✓ ${t.name}`); passed++; } + else { console.log(`✗ ${t.name} — ${reason}`); failed++; } + } catch (e) { + console.log(`✗ ${t.name} — exception: ${e.message || e}`); + failed++; + } + } + console.log(`\n${passed} passed, ${failed} failed`); + process.exit(failed > 0 ? 1 : 0); +})(); diff --git a/tests/rbxl-lua-tween.test.js b/tests/rbxl-lua-tween.test.js new file mode 100644 index 0000000..5dcdc0b --- /dev/null +++ b/tests/rbxl-lua-tween.test.js @@ -0,0 +1,89 @@ +/** + * rbxl-lua-tween.test.js — тесты TweenService. + */ +import { LuaFactory } from 'wasmoon'; +import { registerRobloxApi } from '../src/engine/roblox-shim.js'; +import { RobloxScheduler } from '../src/engine/roblox-scheduler.js'; +import { RobloxTweenManager } from '../src/engine/roblox-tween.js'; + +const SCENE = { + primitives: { + 1: { id: 1, type: 'cube', name: 'Movable', x: 0, y: 5, z: 0, sx: 1, sy: 1, sz: 1, + color: '#ffffff', material: 'glossy', anchored: false, canCollide: true, opacity: 1 }, + }, +}; + +async function run(luaSource, ticks = []) { + const factory = new LuaFactory(); + const lua = await factory.createEngine(); + const sent = []; + const send = (cmd, payload) => sent.push({ cmd, payload }); + registerRobloxApi(lua, { getSceneSnap: () => SCENE, targetPrimitiveId: 1, send }); + const sched = new RobloxScheduler(lua); + sched.install(); + const tweenMgr = new RobloxTweenManager(); + tweenMgr.install(lua); + + await sched.spawnMain(luaSource); + for (const dt of ticks) { + await sched.tick(dt); + tweenMgr.tick(dt); + } + lua.global.close(); + return { logs: sent.filter(s => s.cmd === 'log').map(s => s.payload), + partSets: sent.filter(s => s.cmd === 'partSet').map(s => s.payload) }; +} + +const TESTS = [ + { + name: 'TweenInfo создаётся', + lua: ` + local info = TweenInfo.new(2, Enum.EasingStyle.Linear, Enum.EasingDirection.Out) + print("time:", info.Time, "style:", info.EasingStyle) + `, + ticks: [], + expectLogs: [{ level: 'info', text: 'time:\t2\tstyle:\tLinear' }], + }, + { + name: 'TweenService:Create + Play (Linear)', + lua: ` + local TS = game:GetService("TweenService") + local p = workspace:FindFirstChild("Movable") + local info = TweenInfo.new(1, Enum.EasingStyle.Linear, Enum.EasingDirection.Out) + local tw = TS:Create(p, info, { Position = Vector3.new(10, 5, 0) }) + tw:Play() + print("started") + `, + ticks: [0.5, 0.5, 0.1], // больше 1 сек — должен завершиться + // Ожидаем что хотя бы один partSet с prop=position + expectPartSet: { primId: 1, prop: 'position' }, + }, +]; + +(async () => { + let passed = 0, failed = 0; + for (const t of TESTS) { + try { + const r = await run(t.lua, t.ticks); + let ok = true; + let reason = ''; + for (const exp of (t.expectLogs || [])) { + const found = r.logs.find(l => l.level === exp.level && l.text === exp.text); + if (!found) { ok = false; reason = `missing log: ${exp.text}`; break; } + } + if (t.expectPartSet) { + const found = r.partSets.find(p => p.primId === t.expectPartSet.primId && p.prop === t.expectPartSet.prop); + if (!found) { + ok = false; reason = `missing partSet: ${JSON.stringify(t.expectPartSet)}; got: ${JSON.stringify(r.partSets.slice(0,3))}`; + } + } + if (ok) { console.log(`✓ ${t.name}`); passed++; } + else { console.log(`✗ ${t.name} — ${reason}`); failed++; } + } catch (e) { + console.log(`✗ ${t.name} — exception: ${e}`); + failed++; + } + } + console.log(`\n${passed} passed, ${failed} failed`); + process.exit(failed > 0 ? 1 : 0); +})(); diff --git a/tests/rbxl-lua-wait.test.js b/tests/rbxl-lua-wait.test.js new file mode 100644 index 0000000..720de0b --- /dev/null +++ b/tests/rbxl-lua-wait.test.js @@ -0,0 +1,104 @@ +/** + * rbxl-lua-wait.test.js — тесты wait/task.wait через шедулер. + */ +import { LuaFactory } from 'wasmoon'; +import { registerRobloxApi } from '../src/engine/roblox-shim.js'; +import { RobloxScheduler } from '../src/engine/roblox-scheduler.js'; + +const SCENE = { primitives: {} }; + +async function run(luaSource, ticks = [0.5, 0.5, 0.5, 0.5, 0.5]) { + const factory = new LuaFactory(); + const lua = await factory.createEngine(); + const sent = []; + const send = (cmd, payload) => sent.push({ cmd, payload }); + registerRobloxApi(lua, { getSceneSnap: () => SCENE, targetPrimitiveId: null, send }); + const sched = new RobloxScheduler(lua); + sched.install(); + + await sched.spawnMain(luaSource); + for (const dt of ticks) { + await sched.tick(dt); + } + lua.global.close(); + return sent.filter(s => s.cmd === 'log').map(s => s.payload); +} + +const TESTS = [ + { + name: 'wait(0) — мгновенный', + lua: ` + print("before") + wait(0) + print("after") + `, + expect: ['before', 'after'], + }, + { + name: 'wait(1) — резюм после tick', + lua: ` + print("step1") + wait(1) + print("step2") + `, + ticks: [0.5, 0.5, 0.5], // 1.5 сек суммарно + expect: ['step1', 'step2'], + }, + { + name: 'task.wait(0.5)', + lua: ` + print("a") + task.wait(0.5) + print("b") + `, + ticks: [0.5, 0.5], + expect: ['a', 'b'], + }, + { + name: 'несколько wait подряд', + lua: ` + print("p1") + wait(0.5) + print("p2") + wait(0.5) + print("p3") + `, + ticks: [0.5, 0.5, 0.5, 0.5], // 2 сек + expect: ['p1', 'p2', 'p3'], + }, + { + name: 'task.delay (не блокирует)', + lua: ` + print("immediate") + task.delay(0.3, function() print("delayed") end) + print("after delay-call") + `, + ticks: [0.5], + expect: ['immediate', 'after delay-call', 'delayed'], + }, +]; + +(async () => { + let passed = 0, failed = 0; + for (const t of TESTS) { + try { + const logs = await run(t.lua, t.ticks); + const texts = logs.map(l => l.text); + const ok = JSON.stringify(texts) === JSON.stringify(t.expect); + if (ok) { + console.log(`✓ ${t.name}`); + passed++; + } else { + console.log(`✗ ${t.name}`); + console.log(` expected: ${JSON.stringify(t.expect)}`); + console.log(` got: ${JSON.stringify(texts)}`); + failed++; + } + } catch (e) { + console.log(`✗ ${t.name} — exception: ${e}`); + failed++; + } + } + console.log(`\n${passed} passed, ${failed} failed`); + process.exit(failed > 0 ? 1 : 0); +})();