feat(rbxl-import): Lua-runtime (wasmoon) для Roblox-скриптов
All checks were successful
All checks were successful
Часть тест-фичи импорта 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 <noreply@anthropic.com>
This commit is contained in:
parent
24b6360266
commit
f34320db91
11
.WORKTREE_NOTICE.md
Normal file
11
.WORKTREE_NOTICE.md
Normal file
@ -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
|
||||
21
package-lock.json
generated
21
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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'ом по
|
||||
|
||||
133
src/engine/RobloxLuaSandbox.js
Normal file
133
src/engine/RobloxLuaSandbox.js
Normal file
@ -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) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
169
src/engine/RobloxLuaWorker.js
Normal file
169
src/engine/RobloxLuaWorker.js
Normal file
@ -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;
|
||||
216
src/engine/roblox-physics.js
Normal file
216
src/engine/roblox-physics.js
Normal file
@ -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) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
209
src/engine/roblox-scheduler.js
Normal file
209
src/engine/roblox-scheduler.js
Normal file
@ -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) {
|
||||
// Корутина завершилась с ошибкой — просто дропаем
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
384
src/engine/roblox-services.js
Normal file
384
src/engine/roblox-services.js
Normal file
@ -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 };
|
||||
575
src/engine/roblox-shim.js
Normal file
575
src/engine/roblox-shim.js
Normal file
@ -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 '<object>';
|
||||
}
|
||||
|
||||
export { RbxVector3, RbxColor3, RbxCFrame, RbxUDim, RbxUDim2, RbxInstance, RbxPart, RbxSignal };
|
||||
204
src/engine/roblox-tween.js
Normal file
204
src/engine/roblox-tween.js
Normal file
@ -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 };
|
||||
243
tests/rbxl-lua-integration.test.js
Normal file
243
tests/rbxl-lua-integration.test.js
Normal file
@ -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);
|
||||
})();
|
||||
187
tests/rbxl-lua-mvp.test.js
Normal file
187
tests/rbxl-lua-mvp.test.js
Normal file
@ -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 };
|
||||
}
|
||||
144
tests/rbxl-lua-services.test.js
Normal file
144
tests/rbxl-lua-services.test.js
Normal file
@ -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);
|
||||
})();
|
||||
89
tests/rbxl-lua-tween.test.js
Normal file
89
tests/rbxl-lua-tween.test.js
Normal file
@ -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);
|
||||
})();
|
||||
104
tests/rbxl-lua-wait.test.js
Normal file
104
tests/rbxl-lua-wait.test.js
Normal file
@ -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);
|
||||
})();
|
||||
Loading…
x
Reference in New Issue
Block a user