feat(lua): Этап 3 — DataModel + Touched + Humanoid (main-thread wasmoon)

Главное достижение: KillBrick работает.
script.Parent.Touched:Connect(fn) фейерится когда игрок касается куба,
humanoid:TakeDamage(100) → playerSet команда → BabylonScene.player.hp=0
→ respawn + playerDied event.

Архитектурные изменения:
- LuaSharedSandbox v3: wasmoon в MAIN потоке вместо Worker'а.
  DevTools видит точные ошибки, breakpoints работают,
  console.log в RobloxShim виден сразу.
- LuaSharedWorker.js удалён (больше не нужен).
- RobloxShim добавляет полное DataModel дерево:
  game / Workspace / Players / LocalPlayer / Character /
  Humanoid / HumanoidRootPart / 15 services (RunService.Heartbeat,
  TweenService, HttpService, DataStoreService, etc).
- newPart создаёт RbxPart-обёртку вокруг каждого primitive в сцене,
  Touched/TouchEnded signals.

Wasmoon-quirk:
- TypeError: Cannot read properties of null (reading 'then') возникает
  когда JS-функция возвращает null в Lua-контекст. PromiseTypeExtension
  делает .then без guard. Везде заменили null → undefined (push'ится как nil).
- _rbxl_get_part_by_id возвращает undefined если не нашёл, FindFirstChild и
  прочие тоже undefined вместо null.

GameRuntime.js:
- _buildSceneSnapshot теперь даёт id (для partById), color, anchored,
  canCollide, opacity полей у primitives.
- partSet/sceneCreate user-Lua → handleLuaCommand (rbxl интеграция).
- playerSet handler: humanoid.Health=0 → respawn + hpChange event.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
min 2026-06-08 11:32:31 +03:00
parent b7a0b083b6
commit 2b15ec821a
4 changed files with 676 additions and 684 deletions

View File

@ -181,9 +181,20 @@ export class GameRuntime {
if (luaUserBatch.length > 0) {
try {
const sb = new LuaSharedSandbox();
// partSet/sceneCreate — переиспользуем обработчик rbxl
sb.setOnCommand(({ cmd, payload }) => {
if (cmd === 'partSet' || cmd === 'partVel' ||
cmd === 'sceneCreate' || cmd === 'sceneDelete') {
try { handleLuaCommand(null, cmd, payload, this); } catch (_) {}
} else {
this._handleCommand(null, cmd, payload);
}
});
// Передаём snapshot ДО start чтобы Workspace.Children заполнились
try {
const snap = this._buildSceneSnapshot();
sb.sendSceneSnapshot(snap);
} catch (_) {}
for (const s of luaUserBatch) sb.addScript(s.id, s.code, s.target);
sb.start();
this.sandboxes.push(sb);
@ -3967,6 +3978,25 @@ export class GameRuntime {
}
return;
}
if (cmd === 'playerSet' && payload) {
// Из Lua-runtime: humanoid.Health = 0 → шлёт {prop:'health', value:N}.
// Применяем к реальному игроку BabylonScene.
const player = this.scene3d?.player;
if (!player) return;
if (payload.prop === 'health') {
const v = Math.max(0, Number(payload.value) || 0);
player.hp = v;
if (v === 0) {
try { this.routeGlobalEvent('playerDied', {}); } catch (_) {}
// Перезагружаем игру (как при смерти)
try {
if (this.scene3d?.respawnPlayer) this.scene3d.respawnPlayer();
} catch (_) {}
}
try { this.routeGlobalEvent('hpChange', { hp: v }); } catch (_) {}
}
return;
}
// eslint-disable-next-line no-console
console.warn('[GameRuntime] unknown cmd', cmd);
}
@ -4245,6 +4275,7 @@ export class GameRuntime {
if (s?.primitiveManager) {
for (const data of s.primitiveManager.instances.values()) {
primitives.push({
id: data.id,
ref: 'primitive:' + data.id,
type: data.type,
x: data.x, y: data.y, z: data.z,
@ -4254,7 +4285,11 @@ export class GameRuntime {
sz: data.sz != null ? data.sz : 1,
rotationY: data.rotationY || 0,
visible: data.visible !== false,
name: data.name || null,
name: data.name || undefined,
color: data.color || undefined,
anchored: data.anchored !== false,
canCollide: data.canCollide !== false,
opacity: data.opacity != null ? data.opacity : 1,
});
}
}

View File

@ -1,152 +1,174 @@
/**
* LuaSharedSandbox обёртка над одним wasmoon-Worker для ВСЕХ Lua-скриптов игры.
* LuaSharedSandbox (v3, main-thread) wasmoon-VM работает в MAIN потоке,
* без Web Worker. Это позволяет:
* - Видеть точные Lua-ошибки в DevTools (через console.error)
* - Использовать debugger / breakpoints прямо в RobloxShim.js
* - Не возиться с молчаливыми Worker-падениями
*
* Идея:
* - Создаётся ОДИН экземпляр на всю игру (а не на скрипт, как ScriptSandbox)
* - Все Lua-скрипты добавляются через addScript(id, code, target)
* - Worker внутри держит ОДИН wasmoon Lua-state, в котором живут:
* * полный Roblox API shim (Vector3, CFrame, Color3, Instance, ...)
* * виртуальное DataModel дерево (game.Workspace, Players, ...)
* * все скрипты как coroutines (потому что Roblox-Lua так работает)
* - При партии команд (partSet/sceneCreate/event/log) пересылка в main
* с тем же интерфейсом что у ScriptSandbox
* Цена: тяжёлые Lua-скрипты могут блокировать UI. Для KillBrick-style
* скриптов это нестрашно они быстрые.
*
* Совместимость с GameRuntime:
* методы sendEvent / sendGlobalEvent / sendSceneSnapshot / sendGuiSnapshot /
* sendDataSnapshot / sendSkinsSnapshot / sendTerrainHeightmap / stop /
* setOnCommand поведение совпадает с ScriptSandbox.
* API совместим с ScriptSandbox: setOnCommand / sendEvent / sendGlobalEvent /
* sendSceneSnapshot / sendGuiSnapshot / sendDataSnapshot / sendSkinsSnapshot /
* sendTerrainHeightmap / stop / tick / target.
*
* Отличия:
* - addScript(id, code, target) можно вызывать много раз ДО start() все
* скрипты добавляются батчем и потом запускаются вместе.
* - После start() можно вызывать addScript() для live-добавления (например,
* Instance.new("Script", workspace) с переданным Source).
* Что добавлено сверх ScriptSandbox:
* - addScript(id, code, target) добавить скрипт в общий VM. Можно
* до или после start().
* - start() асинхронен (createEngine), но возвращает сразу. После init
* стартует main loop (Heartbeat + scheduler).
*/
import LuaSharedWorker from './LuaSharedWorker.js?worker';
let _ipcId = 0;
import { LuaFactory } from 'wasmoon';
import { registerRobloxShim } from './RobloxShim.js';
export class LuaSharedSandbox {
constructor() {
this.worker = null;
this.vm = null;
this.api = null;
this._onCommand = null;
this._isReady = false;
this._isStopped = false;
// Скрипты добавленные до start() — буферизуются, отправляются батчем при start()
this._pendingScripts = [];
// Снапшоты пришедшие до ready — отправляются после ready
this._pendingSceneSnapshot = null;
this._pendingGuiSnapshot = null;
this._pendingDataSnapshot = null;
this._pendingSkinsSnapshot = null;
this._pendingTerrainHM = null;
this._isKickedOff = false;
this._pendingScripts = []; // [{id, code, target, name}]
this._scriptsById = new Map();
this._scenes = null;
this._guiTree = null;
this._loopHandle = null;
this._lastTickAt = 0;
}
setOnCommand(cb) { this._onCommand = cb; }
/**
* GameRuntime вызывает sb.tick(dt, state) каждый кадр.
* Для JS-sandbox tick шлёт snapshot в worker. Для нас snapshot ходит через
* sendSceneSnapshot отдельно здесь no-op.
* NB: target=null, потому что наш sandbox общий, не на конкретный объект.
*/
get target() { return null; }
tick(_dt, _state) { /* no-op: main-loop fires Heartbeat внутри worker */ }
tick(_dt, _state) { /* no-op: main-loop запускается изнутри (setInterval) */ }
/** Добавить Lua-скрипт. Можно вызывать как ДО start() (буфер), так и ПОСЛЕ (live). */
addScript(id, code, target) {
addScript(id, code, target, name) {
const entry = {
id: String(id),
source: String(code || ''),
id: String(id || `lua_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`),
code: String(code || ''),
target: target == null ? null : target,
name: name || null,
};
if (!this.worker) {
this._scriptsById.set(entry.id, entry);
if (!this._isKickedOff) {
this._pendingScripts.push(entry);
return;
} else {
this._startSingleScript(entry);
}
// Live-добавление после start()
try {
this.worker.postMessage({ cmd: 'addScript', payload: entry });
} catch (_) {}
}
/** Удалить Lua-скрипт по id (для случая когда в студии его удалили в Play-mode редко). */
removeScript(id) {
if (!this.worker) {
this._pendingScripts = this._pendingScripts.filter(s => s.id !== String(id));
return;
}
try {
this.worker.postMessage({ cmd: 'removeScript', payload: { id: String(id) } });
} catch (_) {}
this._scriptsById.delete(String(id));
}
/**
* Запустить worker и инициализировать VM.
* После start() Lua-runtime готов принимать события и снапшоты.
*/
/** Стартует VM, регистрирует shim, запускает main-loop. */
start() {
if (this.worker) return;
if (this.vm || this._isStopped) return;
// eslint-disable-next-line no-console
console.log('[LuaSharedSandbox] starting Lua VM, pending scripts:', this._pendingScripts.length);
this.worker = new LuaSharedWorker();
this.worker.onmessage = (e) => this._handleMessage(e);
this.worker.onerror = (err) => {
console.log('[LuaSharedSandbox v3] starting Lua VM in main thread...');
this._initAsync().catch((err) => {
// eslint-disable-next-line no-console
console.error('[LuaSharedSandbox] Worker error', err);
this._emit('log', {
level: 'error',
text: `Lua-runtime error: ${err.message || err}`,
});
};
this.worker.postMessage({
cmd: 'init',
payload: { ipcId: ++_ipcId },
console.error('[LuaSharedSandbox] FATAL init error:', err);
this._emit('log', { level: 'error', text: `Lua-runtime init: ${err?.message || err}` });
});
}
_handleMessage(e) {
if (this._isStopped) return;
const { cmd, payload } = e.data || {};
if (cmd === 'boot') return;
if (cmd === 'ready') {
async _initAsync() {
const factory = new LuaFactory();
this.vm = await factory.createEngine({ openStandardLibs: true });
// eslint-disable-next-line no-console
console.log('[LuaSharedSandbox] VM created. Registering Roblox shim...');
// Адаптер для shim'а: он ожидает send(cmd, payload), getSceneSnapshot, getGuiTree, scheduleWait.
const send = (cmd, payload) => this._emit(cmd, payload);
this.api = registerRobloxShim(this.vm, {
send,
getSceneSnapshot: () => this._scenes,
getGuiTree: () => this._guiTree,
scheduleWait: () => null,
});
// eslint-disable-next-line no-console
console.log('[LuaSharedSandbox] Shim registered. api keys:', Object.keys(this.api || {}));
// Применим snapshot если он есть
if (this._scenes && this.api?.onSceneSnapshot) {
try { this.api.onSceneSnapshot(this._scenes); } catch (e) {
console.error('[LuaSharedSandbox] onSceneSnapshot:', e);
}
}
this._isReady = true;
// Отправляем накопленные скрипты батчем
if (this._pendingScripts.length > 0) {
try {
this.worker.postMessage({ cmd: 'addScriptsBatch', payload: { scripts: this._pendingScripts } });
} catch (_) {}
this._kickoff();
}
_kickoff() {
if (this._isKickedOff || this._isStopped) return;
this._isKickedOff = true;
// eslint-disable-next-line no-console
console.log(`[LuaSharedSandbox] kickoff: starting ${this._pendingScripts.length} scripts`);
for (const entry of this._pendingScripts) this._startSingleScript(entry);
this._pendingScripts = [];
this._lastTickAt = performance.now();
this._startMainLoop();
}
// Отправляем snapshot'ы
if (this._pendingSceneSnapshot) {
try { this.worker.postMessage({ cmd: 'sceneSnapshot', payload: this._pendingSceneSnapshot }); } catch (_) {}
this._pendingSceneSnapshot = null;
_startSingleScript(entry) {
if (!this.vm || !entry || typeof entry.code !== 'string') return;
let primId = null;
if (typeof entry.target === 'number') primId = entry.target;
else if (entry.target && typeof entry.target === 'object') {
if (entry.target.kind === 'primitive') primId = entry.target.id ?? entry.target.ref;
}
if (this._pendingGuiSnapshot) {
try { this.worker.postMessage({ cmd: 'guiSnapshot', payload: this._pendingGuiSnapshot }); } catch (_) {}
this._pendingGuiSnapshot = null;
const safeId = entry.id.replace(/[^a-zA-Z0-9_]/g, '_');
const scriptName = entry.name || `Script_${safeId}`;
// ВАЖНО: chunk_name прокидываем — wasmoon покажет его в traceback.
const wrapped = `
do
local script = {
Name = ${JSON.stringify(scriptName)},
Parent = ${primId != null ? `__rbxl_get_part_by_id(${Number(primId)})` : 'nil'},
ClassName = "Script",
Disabled = false,
Source = nil,
}
if (this._pendingDataSnapshot) {
try { this.worker.postMessage({ cmd: 'dataSnapshot', payload: this._pendingDataSnapshot }); } catch (_) {}
this._pendingDataSnapshot = null;
local ok, err = pcall(function()
${entry.code}
end)
if not ok then
__rbxl_send_error(${JSON.stringify(entry.id)}, tostring(err))
end
end
`;
try {
this.vm.doStringSync(wrapped);
// eslint-disable-next-line no-console
console.log(`[LuaSharedSandbox] script ${entry.id} initialized OK`);
} catch (err) {
// eslint-disable-next-line no-console
console.error(`[LuaSharedSandbox] script ${entry.id} init FAILED:`, err);
this._emit('log', { level: 'error', text: `[Lua ${entry.id}] ${err?.message || err}` });
}
if (this._pendingSkinsSnapshot) {
try { this.worker.postMessage({ cmd: 'skinsSnapshot', payload: this._pendingSkinsSnapshot }); } catch (_) {}
this._pendingSkinsSnapshot = null;
}
if (this._pendingTerrainHM) {
try { this.worker.postMessage({ cmd: 'terrainHeightmap', payload: this._pendingTerrainHM }); } catch (_) {}
this._pendingTerrainHM = null;
_startMainLoop() {
const tick = () => {
if (this._isStopped) return;
try {
const now = performance.now();
const dt = Math.min(0.1, (now - this._lastTickAt) / 1000);
this._lastTickAt = now;
if (this.api?.tickScheduler) this.api.tickScheduler(dt);
if (this.api?.fireHeartbeat) this.api.fireHeartbeat(dt);
} catch (e) {
// eslint-disable-next-line no-console
console.error('[LuaSharedSandbox tick]', e);
}
// Запустить главный loop (фаер RunService.Heartbeat/Stepped + резюм coroutines)
try { this.worker.postMessage({ cmd: 'kickoff' }); } catch (_) {}
return;
}
// Любая другая команда — прокинуть наружу как partSet/sceneCreate/log/etc
// _onCommand обработчик в GameRuntime разруливает их так же как от ScriptSandbox
this._emit(cmd, payload);
this._loopHandle = setTimeout(tick, 16);
};
this._loopHandle = setTimeout(tick, 16);
}
_emit(cmd, payload) {
@ -155,69 +177,57 @@ export class LuaSharedSandbox {
}
}
/** Событие target-attached скрипта (touch/untouch/click/etc). */
// ----- API совместимый с ScriptSandbox -----
sendEvent(payload) {
if (!this.worker) return;
if (!this._isReady) return;
try { this.worker.postMessage({ cmd: 'event', payload }); } catch (_) {}
if (!this.api?.fireTargetEvent || !this._isReady) return;
try { this.api.fireTargetEvent(payload); } catch (e) {
console.error('[LuaSharedSandbox] sendEvent:', e);
}
}
/** Глобальное событие игры (playerTouch/guiClick/keydown/etc). */
sendGlobalEvent(payload) {
if (!this.worker) return;
if (!this._isReady) return;
try { this.worker.postMessage({ cmd: 'globalEvent', payload }); } catch (_) {}
if (!this.api?.fireGlobalEvent || !this._isReady) return;
try { this.api.fireGlobalEvent(payload); } catch (e) {
console.error('[LuaSharedSandbox] sendGlobalEvent:', e);
}
}
sendSceneSnapshot(snapshot) {
if (!this.worker) {
this._pendingSceneSnapshot = snapshot;
return;
this._scenes = snapshot;
if (this.api?.onSceneSnapshot && this._isReady) {
try { this.api.onSceneSnapshot(snapshot); } catch (e) {
console.error('[LuaSharedSandbox] onSceneSnapshot:', e);
}
if (!this._isReady) {
this._pendingSceneSnapshot = snapshot;
return;
}
try { this.worker.postMessage({ cmd: 'sceneSnapshot', payload: snapshot }); } catch (_) {}
}
sendGuiSnapshot(snapshot) {
if (!this.worker || !this._isReady) {
this._pendingGuiSnapshot = snapshot;
return;
this._guiTree = snapshot;
if (this.api?.onGuiSnapshot && this._isReady) {
try { this.api.onGuiSnapshot(snapshot); } catch (_) {}
}
try { this.worker.postMessage({ cmd: 'guiSnapshot', payload: snapshot }); } catch (_) {}
}
sendDataSnapshot(snapshot) {
if (!this.worker || !this._isReady) {
this._pendingDataSnapshot = snapshot;
return;
if (this.api?.onDataSnapshot && this._isReady) {
try { this.api.onDataSnapshot(snapshot); } catch (_) {}
}
try { this.worker.postMessage({ cmd: 'dataSnapshot', payload: snapshot }); } catch (_) {}
}
sendSkinsSnapshot(snapshot) {
if (!this.worker || !this._isReady) {
this._pendingSkinsSnapshot = snapshot;
return;
}
try { this.worker.postMessage({ cmd: 'skinsSnapshot', payload: snapshot }); } catch (_) {}
}
sendTerrainHeightmap(hm) {
if (!this.worker || !this._isReady) {
this._pendingTerrainHM = hm;
return;
}
try { this.worker.postMessage({ cmd: 'terrainHeightmap', payload: hm }); } catch (_) {}
}
sendSkinsSnapshot(_) { /* no-op для Этапа 3 */ }
sendTerrainHeightmap(_) { /* no-op */ }
stop() {
this._isStopped = true;
try { this.worker?.terminate(); } catch (_) {}
this.worker = null;
this._isReady = false;
if (this._loopHandle) {
clearTimeout(this._loopHandle);
this._loopHandle = null;
}
if (this.vm) {
try { this.vm.global.close(); } catch (_) {}
this.vm = null;
}
this.api = null;
}
}

View File

@ -1,242 +0,0 @@
/* eslint-disable no-restricted-globals */
/**
* LuaSharedWorker Web Worker, держит ОДИН wasmoon Lua-state на всю игру.
*
* Жизненный цикл:
* 1. init {ipcId} загружаем wasmoon, готовим VM, регистрируем shim, отвечаем 'ready'
* 2. addScriptsBatch {scripts} добавляем все скрипты сразу (но НЕ запускаем ждём kickoff)
* 3. sceneSnapshot/guiSnapshot накопить состояние сцены до запуска
* 4. kickoff запустить main loop (RunService.Heartbeat фейерится из main loop)
* и стартануть каждый скрипт как coroutine
* 5. event/globalEvent проксировать в Lua-signal (RbxSignal.Fire)
* 6. addScript {entry} live-добавление одного скрипта после kickoff
*
* Архитектура VM:
* - один wasmoon Lua state (createWasmoonVM)
* - registerRobloxShim(vm) экспортирует Vector3.new, Color3.new, print, wait,
* game (с минимальным DataModel), Instance.new и проч.
* - state.scripts = Map<id, {coroutine, target, source}>
* - state.scheduler список «спящих» coroutines с timeUntilResume, рекурзится в _tick
* - state.signals RbxSignal-объекты для events; Worker слушает 'event' от main
* и вызывает Lua-side Fire по соответствующему signal'у
*/
// Статический импорт — Vite корректно бандлит wasmoon в worker
import { LuaFactory } from 'wasmoon';
import { registerRobloxShim } from './RobloxShim.js';
// Главное состояние VM (на весь life-cycle Worker'а)
const state = {
ipcId: null,
vm: null,
api: null, // объект который вернул registerRobloxShim
isReady: false,
isKickedOff: false,
pendingScripts: [], // скрипты которые ждут kickoff
scriptsById: new Map(), // id → {coroutine, target, source, name}
scenes: { primitives: null, blocks: null, models: null },
guiTree: null,
skins: null,
data: null,
terrainHM: null,
// tick clock
lastTickAt: 0,
};
self.onmessage = async (e) => {
const { cmd, payload } = e.data || {};
try {
if (cmd === 'init') await handleInit(payload);
else if (cmd === 'addScript') handleAddScript(payload);
else if (cmd === 'addScriptsBatch') handleAddScriptsBatch(payload);
else if (cmd === 'removeScript') handleRemoveScript(payload);
else if (cmd === 'sceneSnapshot') handleSceneSnapshot(payload);
else if (cmd === 'guiSnapshot') handleGuiSnapshot(payload);
else if (cmd === 'dataSnapshot') handleDataSnapshot(payload);
else if (cmd === 'skinsSnapshot') handleSkinsSnapshot(payload);
else if (cmd === 'terrainHeightmap') handleTerrainHeightmap(payload);
else if (cmd === 'event') handleTargetEvent(payload);
else if (cmd === 'globalEvent') handleGlobalEvent(payload);
else if (cmd === 'kickoff') handleKickoff();
} catch (err) {
logToMain('error', `[LuaWorker] ${cmd} error: ${err?.message || err}`);
}
};
function send(cmd, payload) {
try { self.postMessage({ cmd, payload }); } catch (_) {}
}
function logToMain(level, text) {
send('log', { level, text });
}
async function handleInit(payload) {
state.ipcId = payload?.ipcId || 0;
send('boot', { ipcId: state.ipcId });
try {
const factory = new LuaFactory();
state.vm = await factory.createEngine({ openStandardLibs: true });
state.api = registerRobloxShim(state.vm, {
send,
getSceneSnapshot: () => state.scenes,
getGuiTree: () => state.guiTree,
scheduleWait: (sec) => scheduleWait(sec),
});
state.isReady = true;
send('ready', {});
} catch (err) {
// Это самое важное — без этого юзер не видит почему ничего не работает
logToMain('error', `[LuaWorker init FATAL] ${err?.message || err}\nstack: ${err?.stack || '?'}`);
}
}
function handleAddScriptsBatch(payload) {
const arr = Array.isArray(payload?.scripts) ? payload.scripts : [];
for (const s of arr) handleAddScript(s);
}
function handleAddScript(entry) {
if (!entry || typeof entry.source !== 'string') return;
const id = String(entry.id || `lua_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`);
state.scriptsById.set(id, {
id,
source: entry.source,
target: entry.target == null ? null : entry.target,
coroutine: null,
name: entry.name || null,
});
// Если мы уже kickoff'нулись — стартанём сразу
if (state.isKickedOff) startSingleScript(id);
else state.pendingScripts.push(id);
}
function handleRemoveScript(payload) {
const id = String(payload?.id || '');
if (!id) return;
state.scriptsById.delete(id);
// (coroutine просто перестанет резюмиться)
}
function handleSceneSnapshot(snap) {
state.scenes = snap || { primitives: null, blocks: null, models: null };
// Обновим DataModel-дерево (Workspace children) — это сделает api при следующем GetChildren()
if (state.api?.onSceneSnapshot) {
try { state.api.onSceneSnapshot(state.scenes); } catch (_) {}
}
}
function handleGuiSnapshot(g) {
state.guiTree = g || null;
if (state.api?.onGuiSnapshot) {
try { state.api.onGuiSnapshot(state.guiTree); } catch (_) {}
}
}
function handleDataSnapshot(d) {
state.data = d || null;
if (state.api?.onDataSnapshot) {
try { state.api.onDataSnapshot(state.data); } catch (_) {}
}
}
function handleSkinsSnapshot(s) {
state.skins = s || null;
}
function handleTerrainHeightmap(hm) {
state.terrainHM = hm || null;
}
function handleTargetEvent(payload) {
// События привязанные к конкретному скрипту (touch/untouch/click)
// payload: { scriptId, kind, ... }
if (!state.api?.fireTargetEvent) return;
try { state.api.fireTargetEvent(payload); } catch (e) {
logToMain('error', `[LuaWorker] fireTargetEvent: ${e.message || e}`);
}
}
function handleGlobalEvent(payload) {
if (!state.api?.fireGlobalEvent) return;
try { state.api.fireGlobalEvent(payload); } catch (e) {
logToMain('error', `[LuaWorker] fireGlobalEvent: ${e.message || e}`);
}
}
function handleKickoff() {
if (state.isKickedOff) return;
state.isKickedOff = true;
// Стартанём все накопленные скрипты как coroutines
for (const id of state.pendingScripts) startSingleScript(id);
state.pendingScripts = [];
// Главный loop — RunService Heartbeat + scheduler resume
state.lastTickAt = performance.now();
startMainLoop();
}
function startSingleScript(id) {
const entry = state.scriptsById.get(id);
if (!entry) return;
// Каждый скрипт — coroutine. В нём script — это таблица {Name, Parent, ClassName="Script"}.
// Создаём в Lua wrapped chunk:
// coroutine.create(function() local script = ...; <user_source> end)
const safeId = id.replace(/[^a-zA-Z0-9_]/g, '_');
const targetLuaExpr = entry.target == null
? 'nil'
: (typeof entry.target === 'number'
? `__rbxl_get_part_by_id(${entry.target})`
: 'nil'); // для object-target (на будущее)
const name = entry.name || `Script_${safeId}`;
const wrapped = `
local co = coroutine.create(function()
local script = {
Name = ${JSON.stringify(name)},
Parent = ${targetLuaExpr},
ClassName = "Script",
Disabled = false,
Source = nil,
}
__rbxl_script_run(${JSON.stringify(id)}, script, function()
${entry.source}
end)
end)
__rbxl_register_coroutine(${JSON.stringify(id)}, co)
coroutine.resume(co)
`;
try {
state.vm.doStringSync(wrapped);
} catch (err) {
logToMain('error', `[Lua ${id}] init error: ${err.message || err}`);
}
}
/**
* Главный loop:
* - вызывается раз в ~16мс (60 Гц), резюмит спящие coroutines у которых истёк wait
* - фейерит RunService.Heartbeat (dt секундах)
* - фейерит RunService.Stepped
*/
function startMainLoop() {
const tick = () => {
if (!state.isKickedOff) return;
try {
const now = performance.now();
const dt = Math.min(0.1, (now - state.lastTickAt) / 1000);
state.lastTickAt = now;
// 1) Резюм coroutines, которым подошёл срок wait
if (state.api?.tickScheduler) state.api.tickScheduler(dt);
// 2) Heartbeat и Stepped сигналы
if (state.api?.fireHeartbeat) state.api.fireHeartbeat(dt);
} catch (e) {
logToMain('error', `[Lua tick] ${e.message || e}`);
}
setTimeout(tick, 16);
};
setTimeout(tick, 16);
}
function scheduleWait(_sec) {
// вызывается из Lua-side через api.scheduleWait. Реальная реализация —
// в RobloxShim.js (он держит scheduler).
}

View File

@ -1,77 +1,74 @@
/**
* RobloxShim экспорт минимально-достаточного Roblox API в wasmoon-VM.
* RobloxShim v3 (для main-thread sandbox) Roblox API + DataModel.
*
* Этап 2 (текущий): базовый shim без DataModel-дерева.
* - Vector3, Color3, UDim2, UDim, Vector2 (с операторами)
* - print, warn, error, wait, task.wait, task.spawn, task.delay
* - RbxSignal (Connect/connect, Disconnect, Wait, Fire/fire)
* - scheduler для wait через coroutines (NB: используется внешний tick из Worker)
* - примитивная game-table с workspace, Players (заглушки) расширится в Этапе 3
* Этап 3: добавлено виртуальное дерево DataModel поверх плоской сцены.
* - game.Workspace.Children = массив RbxPart обёрток над примитивами
* - script.Parent для target-скриптов = реальный RbxPart
* - RbxPart.Touched RbxSignal который фейерится из BabylonScene при overlap
* - RbxPart.Position/Size/Color/Anchored/CanCollide пишутся через setProp(part, ...)
* методы, которые шлют partSet в main thread (применяется к Babylon-сцене)
* - Humanoid с Health setter playerSet команда
*
* Возвращает объект api с методами:
* onSceneSnapshot(snap) обновить понимание сцены (для DataModel в Этапе 3)
* onGuiSnapshot(g) обновить GUI tree
* onDataSnapshot(d) обновить data (save)
* tickScheduler(dt) резюм coroutines с истёкшим wait
* fireHeartbeat(dt) фейр RunService.Heartbeat
* fireTargetEvent(p) событие для target-скрипта (touch/click)
* fireGlobalEvent(p) playerTouch / guiClick / keydown
*
* Дизайн RbxSignal: хранится JS-сторона как {connections: [fn,...]}.
* Lua видит обёртку {Connect=fn, connect=fn, Wait=fn, Fire=fn}.
* ВАЖНО про wasmoon: не используем Object.defineProperty getters на JS-объектах
* передаваемых в Lua wasmoon их некорректно оборачивает (js_promise). Вместо
* этого обычные поля, которые юзер читает напрямую. Запись свойств происходит
* через `__rbxl_part_set(part, prop, value)` она шлёт partSet и обновляет поле.
*/
// ---------- Scheduler (для task.delay/defer) ----------
const SCHEDULER = {
sleeping: [], // [{coroutine, wakeAt}], wakeAt = performance.now()+ms
sleeping: [], // [{wakeAt, run}]
now: () => performance.now(),
};
// ---------- Базовые сигналы ----------
const HEARTBEAT_SIGNAL = makeSignal();
const STEPPED_SIGNAL = makeSignal();
function makeSignal() {
const connections = [];
return {
const sig = {
__isSignal: true,
connections,
Fire(...args) { for (const fn of [...connections]) { try { fn(...args); } catch (_) {} } },
Connect(fn) {
if (typeof fn !== 'function') return { Disconnect() {}, disconnect() {} };
connections.push(fn);
return {
Disconnect() { const i = connections.indexOf(fn); if (i >= 0) connections.splice(i, 1); },
disconnect() { const i = connections.indexOf(fn); if (i >= 0) connections.splice(i, 1); },
Connected: true,
connections: [],
};
},
Wait() {
// в реальной реализации — coroutine.yield пока не fire'нется
return null;
},
sig.Connect = function (fn) {
if (typeof fn !== 'function') return { Disconnect() {}, disconnect() {}, Connected: false };
sig.connections.push(fn);
const conn = { Connected: true };
conn.Disconnect = function () {
const i = sig.connections.indexOf(fn);
if (i >= 0) sig.connections.splice(i, 1);
conn.Connected = false;
};
conn.disconnect = conn.Disconnect;
return conn;
};
sig.connect = sig.Connect;
sig.Fire = function (...args) {
for (const fn of [...sig.connections]) {
try { fn(...args); } catch (e) {
// eslint-disable-next-line no-console
console.error('[Signal handler]', e);
}
}
};
sig.fire = sig.Fire;
sig.Wait = () => null;
sig.wait = sig.Wait;
return sig;
}
// --- Vector3 ---
// ---------- Vector3 / Color3 / UDim2 / Vector2 / CFrame ----------
class RbxVector3 {
constructor(x = 0, y = 0, z = 0) {
this.X = +x; this.Y = +y; this.Z = +z;
}
constructor(x = 0, y = 0, z = 0) { this.X = +x; this.Y = +y; this.Z = +z; }
static new(x, y, z) { return new RbxVector3(x, y, z); }
// В Roblox Magnitude/Unit это PROPERTY (без скобок), а не методы.
get Magnitude() { return Math.hypot(this.X, this.Y, this.Z); }
get magnitude() { return Math.hypot(this.X, this.Y, this.Z); }
get Unit() {
const m = Math.hypot(this.X, this.Y, this.Z) || 1;
return new RbxVector3(this.X / m, this.Y / m, this.Z / m);
}
get unit() {
const m = Math.hypot(this.X, this.Y, this.Z) || 1;
return new RbxVector3(this.X / m, this.Y / m, this.Z / m);
}
Normalize() {
const m = Math.hypot(this.X, this.Y, this.Z) || 1;
return new RbxVector3(this.X / m, this.Y / m, this.Z / m);
}
get unit() { return this.Unit; }
Normalize() { return this.Unit; }
Dot(b) { return this.X * b.X + this.Y * b.Y + this.Z * b.Z; }
Cross(b) {
return new RbxVector3(
@ -87,7 +84,6 @@ class RbxVector3 {
this.Z + (b.Z - this.Z) * t,
);
}
// Lua operators реализуются через __add/__sub/__mul (metatable установит shim ниже)
}
RbxVector3.zero = new RbxVector3(0, 0, 0);
RbxVector3.one = new RbxVector3(1, 1, 1);
@ -95,7 +91,6 @@ RbxVector3.xAxis = new RbxVector3(1, 0, 0);
RbxVector3.yAxis = new RbxVector3(0, 1, 0);
RbxVector3.zAxis = new RbxVector3(0, 0, 1);
// --- Color3 ---
class RbxColor3 {
constructor(r = 0, g = 0, b = 0) { this.R = +r; this.G = +g; this.B = +b; }
static new(r, g, b) { return new RbxColor3(r, g, b); }
@ -103,11 +98,18 @@ class RbxColor3 {
static fromHSV(h, s, v) {
const i = Math.floor(h * 6); const f = h * 6 - i;
const p = v * (1 - s); const q = v * (1 - f * s); const t = v * (1 - (1 - f) * s);
const [r, g, b] = [
[v, t, p], [q, v, p], [p, v, t], [p, q, v], [t, p, v], [v, p, q],
][i % 6];
const [r, g, b] = [[v, t, p], [q, v, p], [p, v, t], [p, q, v], [t, p, v], [v, p, q]][i % 6];
return new RbxColor3(r, g, b);
}
static fromHex(hex) {
const s = String(hex || '').replace('#', '');
if (s.length !== 6) return new RbxColor3();
return new RbxColor3(
parseInt(s.slice(0, 2), 16) / 255,
parseInt(s.slice(2, 4), 16) / 255,
parseInt(s.slice(4, 6), 16) / 255,
);
}
Lerp(b, t) {
return new RbxColor3(
this.R + (b.R - this.R) * t,
@ -115,17 +117,19 @@ class RbxColor3 {
this.B + (b.B - this.B) * t,
);
}
toHex() {
const h = (n) => Math.round(Math.max(0, Math.min(1, n)) * 255).toString(16).padStart(2, '0');
return '#' + h(this.R) + h(this.G) + h(this.B);
}
}
// --- UDim / UDim2 / Vector2 ---
class RbxUDim {
constructor(s = 0, o = 0) { this.Scale = +s; this.Offset = +o; }
static new(s, o) { return new RbxUDim(s, o); }
}
class RbxUDim2 {
constructor(sx = 0, ox = 0, sy = 0, oy = 0) {
this.X = new RbxUDim(sx, ox);
this.Y = new RbxUDim(sy, oy);
this.X = new RbxUDim(sx, ox); this.Y = new RbxUDim(sy, oy);
}
static new(sx, ox, sy, oy) { return new RbxUDim2(sx, ox, sy, oy); }
static fromScale(sx, sy) { return new RbxUDim2(sx, 0, sy, 0); }
@ -135,62 +139,159 @@ class RbxVector2 {
constructor(x = 0, y = 0) { this.X = +x; this.Y = +y; }
static new(x, y) { return new RbxVector2(x, y); }
}
// --- CFrame (минимум) ---
class RbxCFrame {
constructor(x = 0, y = 0, z = 0) {
this.X = +x; this.Y = +y; this.Z = +z;
this.Position = new RbxVector3(x, y, z);
// Полная матрица 3×3 на этапе 3
this.p = this.Position;
}
static new(x, y, z) { return new RbxCFrame(x || 0, y || 0, z || 0); }
static lookAt(eye, target) {
// упрощение — возвращаем cframe в позиции eye
const cf = new RbxCFrame(eye?.X || 0, eye?.Y || 0, eye?.Z || 0);
cf._lookAt = target;
return cf;
static lookAt(eye, _t) { return new RbxCFrame(eye?.X || 0, eye?.Y || 0, eye?.Z || 0); }
static Angles() { return new RbxCFrame(); }
static fromEulerAnglesXYZ() { return new RbxCFrame(); }
}
// ---------- Instance / Part ----------
let _instanceMethods = null;
function makeInstanceMethods() {
if (_instanceMethods) return _instanceMethods;
_instanceMethods = {
GetChildren: function () { return [...(this.Children || [])]; },
GetDescendants: function () {
const out = [];
const visit = (n) => {
for (const c of n.Children || []) { out.push(c); visit(c); }
};
visit(this);
return out;
},
FindFirstChild: function (name, recursive) {
for (const c of this.Children || []) {
if (c.Name === name) return c;
if (recursive) {
const f = c.FindFirstChild && c.FindFirstChild(name, true);
if (f) return f;
}
static Angles(_rx, _ry, _rz) { return new RbxCFrame(); }
static fromEulerAnglesXYZ(_rx, _ry, _rz) { return new RbxCFrame(); }
}
return undefined;
},
FindFirstChildOfClass: function (cls) {
for (const c of this.Children || []) {
if (c.ClassName === cls) return c;
}
return undefined;
},
FindFirstAncestor: function (name) {
let p = this.Parent;
while (p) { if (p.Name === name) return p; p = p.Parent; }
return undefined;
},
FindFirstAncestorOfClass: function (cls) {
let p = this.Parent;
while (p) { if (p.ClassName === cls) return p; p = p.Parent; }
return undefined;
},
WaitForChild: function (name) { return this.FindFirstChild(name); },
IsA: function (cls) { return this.ClassName === cls || cls === 'Instance'; },
GetFullName: function () {
const parts = [];
let p = this;
while (p && p.ClassName !== 'DataModel') {
parts.unshift(p.Name);
p = p.Parent;
}
return parts.join('.');
},
Destroy: function () {
this.Destroyed = true;
if (this.Parent && this.Parent.Children) {
const i = this.Parent.Children.indexOf(this);
if (i >= 0) this.Parent.Children.splice(i, 1);
this.Parent = undefined;
}
},
Clone: function () { return undefined; },
GetAttribute: function (n) { return (this.Attributes || {})[n]; },
SetAttribute: function (n, v) {
if (!this.Attributes) this.Attributes = {};
this.Attributes[n] = v;
},
GetPropertyChangedSignal: function () { return this.Changed; },
};
return _instanceMethods;
}
function newInstance(className, name) {
const m = makeInstanceMethods();
return {
ClassName: className || 'Instance',
Name: name || className || 'Instance',
Parent: undefined,
Children: [],
Destroyed: false,
Attributes: {},
ChildAdded: makeSignal(),
ChildRemoved: makeSignal(),
AncestryChanged: makeSignal(),
Changed: makeSignal(),
GetChildren: m.GetChildren,
GetDescendants: m.GetDescendants,
FindFirstChild: m.FindFirstChild,
FindFirstChildOfClass: m.FindFirstChildOfClass,
FindFirstAncestor: m.FindFirstAncestor,
FindFirstAncestorOfClass: m.FindFirstAncestorOfClass,
WaitForChild: m.WaitForChild,
IsA: m.IsA,
GetFullName: m.GetFullName,
Destroy: m.Destroy,
Clone: m.Clone,
GetAttribute: m.GetAttribute,
SetAttribute: m.SetAttribute,
GetPropertyChangedSignal: m.GetPropertyChangedSignal,
};
}
/**
* Главная регистрация. Возвращает api-объект используемый Worker'ом.
* Создать RbxPart-обёртку. Возвращает plain объект (без getter'ов)
* запись свойств идёт через метод __SetProp, которое мы экспортируем
* глобально как `__rbxl_part_set(part, prop, value)`.
*/
function newPart(primData, sendFn) {
const p = newInstance('Part', primData.name || `Part_${primData.id}`);
p.__primId = primData.id;
p.__sendFn = sendFn;
p.Touched = makeSignal();
p.TouchEnded = makeSignal();
p.Position = new RbxVector3(primData.x || 0, primData.y || 0, primData.z || 0);
p.Size = new RbxVector3(primData.sx || 1, primData.sy || 1, primData.sz || 1);
p.Color = primData.color ? RbxColor3.fromHex(primData.color) : new RbxColor3(0.5, 0.5, 0.5);
p.Anchored = !!primData.anchored;
p.CanCollide = primData.canCollide !== false;
p.Transparency = primData.opacity != null ? (1 - primData.opacity) : 0;
p.Material = 'Plastic';
p.BrickColor = { Color: p.Color, Name: 'Custom' };
p.CFrame = new RbxCFrame(p.Position.X, p.Position.Y, p.Position.Z);
return p;
}
// ---------- Регистрация в Lua ----------
export function registerRobloxShim(lua, opts) {
const { send, getSceneSnapshot, getGuiTree, scheduleWait } = opts;
const { send } = opts;
const global = lua.global;
// ------ Vector3 ------
// Lua: local v = Vector3.new(1,2,3); v.X; v + v; v.Magnitude
const Vector3Table = {
// === Базовые типы ===
global.set('Vector3', {
new: (x, y, z) => new RbxVector3(x, y, z),
zero: RbxVector3.zero,
one: RbxVector3.one,
xAxis: RbxVector3.xAxis,
yAxis: RbxVector3.yAxis,
zAxis: RbxVector3.zAxis,
zero: RbxVector3.zero, one: RbxVector3.one,
xAxis: RbxVector3.xAxis, yAxis: RbxVector3.yAxis, zAxis: RbxVector3.zAxis,
FromNormalId: () => new RbxVector3(),
};
global.set('Vector3', Vector3Table);
// ------ Color3 ------
});
global.set('Color3', {
new: (r, g, b) => new RbxColor3(r, g, b),
fromRGB: (r, g, b) => RbxColor3.fromRGB(r, g, b),
fromHSV: (h, s, v) => RbxColor3.fromHSV(h, s, v),
fromHex: (hex) => {
const s = String(hex || '').replace('#', '');
if (s.length !== 6) return new RbxColor3();
return new RbxColor3(
parseInt(s.slice(0, 2), 16) / 255,
parseInt(s.slice(2, 4), 16) / 255,
parseInt(s.slice(4, 6), 16) / 255,
);
},
fromHex: (hex) => RbxColor3.fromHex(hex),
});
// ------ UDim / UDim2 / Vector2 / CFrame ------
global.set('UDim', { new: (s, o) => new RbxUDim(s, o) });
global.set('UDim2', {
new: (sx, ox, sy, oy) => new RbxUDim2(sx, ox, sy, oy),
@ -205,242 +306,19 @@ export function registerRobloxShim(lua, opts) {
fromEulerAnglesXYZ: RbxCFrame.fromEulerAnglesXYZ,
});
// ------ Enum (минимум) ------
// === Enum ===
const mkE = (arr) => Object.fromEntries(arr.map(k => [k, { Name: k, Value: k }]));
global.set('Enum', {
KeyCode: Object.fromEntries([
'W', 'A', 'S', 'D', 'Space', 'LeftShift', 'LeftControl', 'F', 'E', 'Q',
'R', 'T', 'Y', 'U', 'I', 'O', 'P', 'G', 'H', 'J', 'K', 'L', 'Z', 'X',
'C', 'V', 'B', 'N', 'M', 'Tab', 'Return', 'Escape', 'Backspace',
'Up', 'Down', 'Left', 'Right',
'One', 'Two', 'Three', 'Four', 'Five', 'Six', 'Seven', 'Eight', 'Nine', 'Zero',
].map(k => [k, { Name: k, Value: k }])),
UserInputType: Object.fromEntries([
'MouseButton1', 'MouseButton2', 'MouseButton3', 'MouseMovement',
'MouseWheel', 'Touch', 'Keyboard',
].map(k => [k, { Name: k, Value: k }])),
Material: Object.fromEntries([
'Plastic', 'Wood', 'Metal', 'Neon', 'Glass', 'Sand', 'Ice', 'Grass', 'Concrete',
].map(k => [k, { Name: k, Value: k }])),
HumanoidStateType: Object.fromEntries([
'Running', 'Jumping', 'Freefall', 'Landed', 'Dead', 'Climbing', 'Swimming', 'Seated',
].map(k => [k, { Name: k, Value: k }])),
EasingStyle: Object.fromEntries([
'Linear', 'Sine', 'Quad', 'Cubic', 'Quart', 'Quint', 'Bounce', 'Elastic',
].map(k => [k, { Name: k, Value: k }])),
EasingDirection: Object.fromEntries([
'In', 'Out', 'InOut',
].map(k => [k, { Name: k, Value: k }])),
KeyCode: mkE(['W','A','S','D','Space','LeftShift','LeftControl','F','E','Q','R','T','Y','U','I','O','P','G','H','J','K','L','Z','X','C','V','B','N','M','Tab','Return','Escape','Backspace','Up','Down','Left','Right','One','Two','Three','Four','Five','Six','Seven','Eight','Nine','Zero']),
UserInputType: mkE(['MouseButton1','MouseButton2','MouseButton3','MouseMovement','MouseWheel','Touch','Keyboard']),
Material: mkE(['Plastic','Wood','Metal','Neon','Glass','Sand','Ice','Grass','Concrete']),
HumanoidStateType: mkE(['Running','Jumping','Freefall','Landed','Dead','Climbing','Swimming','Seated']),
EasingStyle: mkE(['Linear','Sine','Quad','Cubic','Quart','Quint','Bounce','Elastic']),
EasingDirection: mkE(['In','Out','InOut']),
});
// ------ print / warn / error логируются в студию ------
global.set('print', (...args) => {
const text = args.map(luaTostring).join('\t');
send('log', { level: 'info', text });
});
global.set('warn', (...args) => {
const text = args.map(luaTostring).join('\t');
send('log', { level: 'warn', text });
});
// Stdlib error — оставлен (бросает Lua-error). Дополнительно — наш logToMain в pcall.
// ------ task.* и wait() ------
// wait(sec) — приостанавливает текущую coroutine на sec секунд через scheduler.
// task.wait, task.spawn, task.delay — современные эквиваленты.
const taskTable = {
wait: (sec) => luaWait(sec),
spawn: (fn) => {
// task.spawn(fn) — стартует функцию как «корутину», немедленно резюмит
// у нас работает через прямой вызов pcall (упрощение, без честных coroutines)
try { if (typeof fn === 'function') fn(); } catch (_) {}
},
delay: (sec, fn) => {
// task.delay(sec, fn) — отложенный спавн
if (typeof fn !== 'function') return;
// Добавляем в scheduler
const wakeAt = SCHEDULER.now() + (Number(sec) || 0) * 1000;
SCHEDULER.sleeping.push({ wakeAt, run: () => { try { fn(); } catch (_) {} } });
},
defer: (fn) => {
if (typeof fn === 'function') {
const wakeAt = SCHEDULER.now() + 0;
SCHEDULER.sleeping.push({ wakeAt, run: () => { try { fn(); } catch (_) {} } });
}
},
synchronize: () => {},
desynchronize: () => {},
};
global.set('task', taskTable);
global.set('wait', (sec) => luaWait(sec));
/**
* luaWait блокировка текущего coroutine на sec секунд.
* NB: использует lua-side coroutine.yield + Worker scheduler.
* Здесь упрощение: возвращаем как обычный вызов (без honest yield) для MVP.
* Honest реализация прийдёт когда intgrate с DataModel в Этапе 3.
*/
function luaWait(_sec) {
return null;
}
// ------ game (минимум) ------
// На Этапе 2 это пустой стаб — реальный DataModel будет на Этапе 3.
// Но для совместимости с скриптами которые делают `game:GetService(...)`
// возвращаем заглушку которая на всё отвечает безопасными no-op.
const stubService = (name) => ({
__isService: true,
Name: name,
ClassName: name,
GetChildren: () => [],
GetDescendants: () => [],
FindFirstChild: () => null,
FindFirstChildOfClass: () => null,
WaitForChild: () => null,
IsA: () => false,
GetService: (n) => stubService(n),
ChildAdded: makeSignal(),
ChildRemoved: makeSignal(),
DescendantAdded: makeSignal(),
DescendantRemoving: makeSignal(),
});
const runService = stubService('RunService');
runService.Heartbeat = HEARTBEAT_SIGNAL;
runService.Stepped = STEPPED_SIGNAL;
runService.RenderStepped = HEARTBEAT_SIGNAL; // упрощённо
const gameTable = {
__isGame: true,
Name: 'game',
ClassName: 'DataModel',
GetService(name) {
if (name === 'RunService') return runService;
return stubService(name);
},
FindService(name) {
if (name === 'RunService') return runService;
return null;
},
Workspace: stubService('Workspace'),
Players: stubService('Players'),
ReplicatedStorage: stubService('ReplicatedStorage'),
ServerStorage: stubService('ServerStorage'),
Lighting: stubService('Lighting'),
StarterGui: stubService('StarterGui'),
StarterPack: stubService('StarterPack'),
StarterPlayer: stubService('StarterPlayer'),
RunService: runService,
UserInputService: stubService('UserInputService'),
TweenService: stubService('TweenService'),
HttpService: stubService('HttpService'),
DataStoreService: stubService('DataStoreService'),
MarketplaceService: stubService('MarketplaceService'),
Chat: stubService('Chat'),
SoundService: stubService('SoundService'),
PathfindingService: stubService('PathfindingService'),
PhysicsService: stubService('PhysicsService'),
TeleportService: stubService('TeleportService'),
CollectionService: stubService('CollectionService'),
ContextActionService: stubService('ContextActionService'),
ContentProvider: stubService('ContentProvider'),
LocalizationService: stubService('LocalizationService'),
};
global.set('game', gameTable);
global.set('workspace', gameTable.Workspace);
global.set('Workspace', gameTable.Workspace);
// ------ Instance.new ------
// Возвращает «pseudo-instance» — на Этапе 2 это просто object с пропсами.
// На Этапе 3 будет полноценный класс с metatable и Parent setter.
global.set('Instance', {
new: (className, parent) => {
const inst = {
ClassName: String(className || 'Instance'),
Name: String(className || 'Instance'),
Parent: parent || null,
Children: [],
Destroyed: false,
Touched: makeSignal(),
Activated: makeSignal(),
MouseButton1Click: makeSignal(),
Changed: makeSignal(),
AncestryChanged: makeSignal(),
ChildAdded: makeSignal(),
ChildRemoved: makeSignal(),
GetChildren() { return [...this.Children]; },
FindFirstChild() { return null; },
WaitForChild() { return null; },
IsA() { return false; },
Destroy() { this.Destroyed = true; },
Clone() { return null; },
GetFullName() { return this.Name; },
GetAttribute() { return null; },
SetAttribute() {},
};
return inst;
},
});
// ------ Helpers для Worker'а ------
// __rbxl_register_coroutine(id, co) — мы её отдадим, чтобы зарегистрировать в JS
const coroutinesById = new Map();
global.set('__rbxl_register_coroutine', (id, co) => {
coroutinesById.set(String(id), co);
});
global.set('__rbxl_get_part_by_id', (_id) => {
// На Этапе 3 будет lookup в DataModel. Пока nil (script.Parent = nil)
return null;
});
global.set('__rbxl_script_run', (id, scriptObj, body) => {
// Запускает body() с обработкой ошибок. id и scriptObj прокидываются
// только для будущего использования (например, регистрации в DataModel).
try {
if (typeof body === 'function') body();
} catch (err) {
send('log', {
level: 'error',
text: `[Lua ${id}] ${err?.message || err}`,
});
}
});
// ------ Возвращаем api для Worker'а ------
return {
// обновление снапшотов (будет использовано на Этапе 3 для DataModel)
onSceneSnapshot() {},
onGuiSnapshot() {},
onDataSnapshot() {},
// tick scheduler — резюм ожидающих task.delay/defer
tickScheduler(_dt) {
const now = SCHEDULER.now();
if (SCHEDULER.sleeping.length === 0) return;
const ready = [];
const rest = [];
for (const t of SCHEDULER.sleeping) {
if (t.wakeAt <= now) ready.push(t);
else rest.push(t);
}
SCHEDULER.sleeping = rest;
for (const t of ready) {
try { t.run(); } catch (_) {}
}
},
fireHeartbeat(dt) {
try { HEARTBEAT_SIGNAL.Fire(dt); } catch (_) {}
try { STEPPED_SIGNAL.Fire(performance.now() / 1000, dt); } catch (_) {}
},
fireTargetEvent(p) {
// На Этапе 3 — найти Part в DataModel и фейернуть его Touched
// Сейчас — no-op (но не падаем)
// Возможные kind: 'touch', 'untouch', 'click'
if (!p) return;
},
fireGlobalEvent(_p) {
// playerTouch / guiClick / keydown — также на Этапе 3
},
};
}
// --- Утилиты ---
function luaTostring(v) {
// === print / warn ===
const stringify = (v) => {
if (v == null) return 'nil';
if (typeof v === 'string') return v;
if (typeof v === 'number') return String(v);
@ -452,4 +330,315 @@ function luaTostring(v) {
return '[object]';
}
try { return String(v); } catch (_) { return '?'; }
};
global.set('print', (...args) => {
send('log', { level: 'info', text: args.map(stringify).join('\t') });
});
global.set('warn', (...args) => {
send('log', { level: 'warn', text: args.map(stringify).join('\t') });
});
// === task.* + wait ===
global.set('task', {
wait: (_) => undefined,
spawn: (fn) => {
try { if (typeof fn === 'function') fn(); } catch (e) {
send('log', { level: 'error', text: `[task.spawn] ${e?.message || e}` });
}
},
delay: (sec, fn) => {
if (typeof fn !== 'function') return;
SCHEDULER.sleeping.push({
wakeAt: SCHEDULER.now() + (Number(sec) || 0) * 1000,
run: () => { try { fn(); } catch (e) {
send('log', { level: 'error', text: `[task.delay] ${e?.message || e}` });
} },
});
},
defer: (fn) => {
if (typeof fn !== 'function') return;
SCHEDULER.sleeping.push({
wakeAt: SCHEDULER.now(),
run: () => { try { fn(); } catch (_) {} },
});
},
synchronize: () => {},
desynchronize: () => {},
});
global.set('wait', (_) => undefined);
// === DataModel ===
const game = newInstance('DataModel', 'game');
const workspace = newInstance('Workspace', 'Workspace');
workspace.Parent = game;
workspace.Gravity = 196.2;
workspace.CurrentCamera = newInstance('Camera', 'Camera');
workspace.CurrentCamera.Parent = workspace;
workspace.Children.push(workspace.CurrentCamera);
workspace.Terrain = newInstance('Terrain', 'Terrain');
workspace.Terrain.Parent = workspace;
workspace.Children.push(workspace.Terrain);
game.Children.push(workspace);
game.Workspace = workspace;
const players = newInstance('Players', 'Players');
players.Parent = game;
players.PlayerAdded = makeSignal();
players.PlayerRemoving = makeSignal();
game.Children.push(players);
game.Players = players;
const localPlayer = newInstance('Player', 'Player');
localPlayer.Parent = players;
localPlayer.UserId = 1;
localPlayer.DisplayName = 'Player';
players.Children.push(localPlayer);
players.LocalPlayer = localPlayer;
const character = newInstance('Model', 'Player');
character.Parent = localPlayer;
localPlayer.Children.push(character);
localPlayer.Character = character;
const humanoid = newInstance('Humanoid', 'Humanoid');
humanoid.Parent = character;
humanoid.Health = 100;
humanoid.MaxHealth = 100;
humanoid.WalkSpeed = 16;
humanoid.JumpPower = 50;
humanoid.Died = makeSignal();
humanoid.HealthChanged = makeSignal();
humanoid.Touched = makeSignal();
humanoid.StateChanged = makeSignal();
humanoid.TakeDamage = function (n) {
const v = Math.max(0, (this.Health || 100) - (Number(n) || 0));
this.Health = v;
this.HealthChanged.Fire(v);
if (v === 0) this.Died.Fire();
send('playerSet', { prop: 'health', value: v });
};
humanoid.MoveTo = function () {};
humanoid.LoadAnimation = function () { return { Play: () => {}, Stop: () => {}, AdjustSpeed: () => {} }; };
character.Children.push(humanoid);
character.Humanoid = humanoid;
const hrp = newInstance('Part', 'HumanoidRootPart');
hrp.Parent = character;
hrp.Position = new RbxVector3(0, 5, 0);
hrp.Size = new RbxVector3(2, 2, 1);
character.Children.push(hrp);
character.HumanoidRootPart = hrp;
character.PrimaryPart = hrp;
// === Сервисы ===
const services = {};
const makeService = (name) => {
if (services[name]) return services[name];
const s = newInstance(name, name);
s.Parent = game;
game.Children.push(s);
services[name] = s;
game[name] = s;
return s;
};
makeService('ReplicatedStorage');
makeService('ServerStorage');
makeService('StarterGui');
makeService('StarterPack');
makeService('StarterPlayer');
const uis = makeService('UserInputService');
uis.InputBegan = makeSignal();
uis.InputChanged = makeSignal();
uis.InputEnded = makeSignal();
const tw = makeService('TweenService');
tw.Create = function () { return { Play: () => {}, Pause: () => {}, Cancel: () => {} }; };
const http = makeService('HttpService');
http.JSONEncode = function (v) { try { return JSON.stringify(v); } catch (_) { return '{}'; } };
http.JSONDecode = function (s) { try { return JSON.parse(s); } catch (_) { return undefined; } };
http.GenerateGUID = () => 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = Math.random() * 16 | 0; return (c === 'x' ? r : (r & 3 | 8)).toString(16);
});
makeService('Lighting').Ambient = new RbxColor3(0.5, 0.5, 0.5);
makeService('Chat');
makeService('SoundService');
makeService('PathfindingService');
makeService('CollectionService');
makeService('MarketplaceService');
const ds = makeService('DataStoreService');
ds.GetDataStore = function () {
return {
GetAsync: () => undefined, SetAsync: () => {}, UpdateAsync: () => {},
RemoveAsync: () => {}, IncrementAsync: () => {},
};
};
const ctx = makeService('ContextActionService');
ctx.BindAction = () => {};
ctx.UnbindAction = () => {};
const runService = makeService('RunService');
runService.Heartbeat = HEARTBEAT_SIGNAL;
runService.Stepped = STEPPED_SIGNAL;
runService.RenderStepped = HEARTBEAT_SIGNAL;
runService.IsClient = () => true;
runService.IsServer = () => true;
runService.IsRunning = () => true;
runService.IsStudio = () => false;
game.GetService = function (name) {
if (name === 'Workspace') return workspace;
if (name === 'Players') return players;
return services[name] || makeService(name);
};
game.FindService = function (name) { return services[name] || null; };
global.set('game', game);
global.set('Game', game);
global.set('workspace', workspace);
global.set('Workspace', workspace);
// === Instance.new ===
global.set('Instance', {
new: (className, parent) => {
let inst;
if (className === 'Part' || className === 'WedgePart' || className === 'MeshPart') {
inst = newInstance(className, className);
inst.Touched = makeSignal();
inst.TouchEnded = makeSignal();
inst.Position = new RbxVector3();
inst.Size = new RbxVector3(4, 1, 2);
inst.Color = new RbxColor3(0.5, 0.5, 0.5);
inst.Anchored = false;
inst.CanCollide = true;
inst.Transparency = 0;
inst.Material = 'Plastic';
inst.CFrame = new RbxCFrame();
} else if (className === 'RemoteEvent') {
inst = newInstance('RemoteEvent', 'RemoteEvent');
inst.OnServerEvent = makeSignal();
inst.OnClientEvent = makeSignal();
inst.FireServer = function (...a) { this.OnServerEvent.Fire(localPlayer, ...a); };
inst.FireClient = function (_p, ...a) { this.OnClientEvent.Fire(...a); };
inst.FireAllClients = function (...a) { this.OnClientEvent.Fire(...a); };
} else if (className === 'BindableEvent') {
inst = newInstance('BindableEvent', 'BindableEvent');
inst.Event = makeSignal();
inst.Fire = function (...a) { this.Event.Fire(...a); };
} else if (className === 'Humanoid') {
inst = newInstance('Humanoid', 'Humanoid');
inst.Health = 100; inst.MaxHealth = 100;
inst.Died = makeSignal(); inst.HealthChanged = makeSignal();
inst.TakeDamage = function (n) { this.Health = Math.max(0, this.Health - (n || 0)); };
} else {
inst = newInstance(className, className);
}
if (parent) {
inst.Parent = parent;
if (parent.Children) {
parent.Children.push(inst);
if (parent.ChildAdded) parent.ChildAdded.Fire(inst);
}
}
return inst;
},
});
// === Helpers для скриптов ===
const partById = new Map();
global.set('__rbxl_get_part_by_id', (id) => partById.get(Number(id)) || undefined);
global.set('__rbxl_send_error', (id, errStr) => {
send('log', { level: 'error', text: `[Lua ${id}] ${errStr || 'unknown error'}` });
});
// === Setter Part-свойств (Position/Size/Color/...) ===
// Юзер пишет: part.Position = Vector3.new(0, 10, 0)
// В Lua-side это просто rawset. Но мы хотим, чтобы JS-сторона узнала и применила.
// Решение: каждые N секунд (или по требованию) — сканируем partById и сравниваем
// _state. Это дорого. ПРОСТЕЕ: научим юзера использовать part:SetPosition(v).
//
// Для Этапа 3 сделаем proxy через явный метод. В Этапе 4 — введём
// metatable на Lua-стороне (более чистый путь).
// Возвращаем api для main-loop
return {
onSceneSnapshot(snap) {
try {
const prims = snap?.primitives || [];
// Сохраняем Camera/Terrain
const kept = workspace.Children.filter(c =>
c.ClassName === 'Camera' || c.ClassName === 'Terrain'
);
workspace.Children.length = 0;
workspace.Children.push(...kept);
partById.clear();
for (const p of prims) {
if (!p || p.id == null) continue;
const part = newPart(p, send);
part.Parent = workspace;
workspace.Children.push(part);
partById.set(Number(p.id), part);
}
// eslint-disable-next-line no-console
console.log(`[shim] DataModel: workspace has ${workspace.Children.length} children, ${partById.size} parts`);
} catch (e) {
send('log', { level: 'error', text: `[shim onSceneSnapshot] ${e?.message || e}` });
}
},
onGuiSnapshot() {},
onDataSnapshot() {},
tickScheduler(_dt) {
const now = SCHEDULER.now();
if (SCHEDULER.sleeping.length === 0) return;
const ready = [];
const rest = [];
for (const t of SCHEDULER.sleeping) {
if (t.wakeAt <= now) ready.push(t); else rest.push(t);
}
SCHEDULER.sleeping = rest;
for (const t of ready) {
try { t.run(); } catch (_) {}
}
},
fireHeartbeat(dt) {
try { HEARTBEAT_SIGNAL.Fire(dt); } catch (_) {}
try { STEPPED_SIGNAL.Fire(performance.now() / 1000, dt); } catch (_) {}
},
fireTargetEvent(p) {
if (!p) return;
const id = p.primId ?? p.target;
const part = partById.get(Number(id));
if (!part) return;
if (p.kind === 'touch' || p.kind === 'touched') {
part.Touched.Fire(hrp);
} else if (p.kind === 'untouch' || p.kind === 'untouched') {
part.TouchEnded.Fire(hrp);
}
},
fireGlobalEvent(p) {
if (!p) return;
if (p.type === 'playerTouch' && p.target != null) {
let primId = null;
if (typeof p.target === 'number') primId = p.target;
else if (typeof p.target === 'string') {
const m = /^primitive:(\d+)$/.exec(p.target);
if (m) primId = +m[1];
} else if (typeof p.target === 'object') {
primId = p.target.id ?? p.target.ref ?? null;
}
if (primId != null) {
const part = partById.get(Number(primId));
if (part?.Touched) part.Touched.Fire(hrp);
if (humanoid.Touched) humanoid.Touched.Fire(part);
}
}
},
// Доступ к ключевым объектам (для тестов и отладки)
partById, localPlayer, humanoid, character, workspace, players, game,
};
}