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:
parent
b7a0b083b6
commit
2b15ec821a
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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).
|
||||
}
|
||||
@ -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(); }
|
||||
}
|
||||
static Angles(_rx, _ry, _rz) { return new RbxCFrame(); }
|
||||
static fromEulerAnglesXYZ(_rx, _ry, _rz) { return new RbxCFrame(); }
|
||||
|
||||
// ---------- Instance / Part ----------
|
||||
let _instanceMethods = null;
|
||||
function makeInstanceMethods() {
|
||||
if (_instanceMethods) return _instanceMethods;
|
||||
_instanceMethods = {
|
||||
GetChildren: function () { return [...(this.Children || [])]; },
|
||||
GetDescendants: function () {
|
||||
const out = [];
|
||||
const visit = (n) => {
|
||||
for (const c of n.Children || []) { out.push(c); visit(c); }
|
||||
};
|
||||
visit(this);
|
||||
return out;
|
||||
},
|
||||
FindFirstChild: function (name, recursive) {
|
||||
for (const c of this.Children || []) {
|
||||
if (c.Name === name) return c;
|
||||
if (recursive) {
|
||||
const f = c.FindFirstChild && c.FindFirstChild(name, true);
|
||||
if (f) return f;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
FindFirstChildOfClass: function (cls) {
|
||||
for (const c of this.Children || []) {
|
||||
if (c.ClassName === cls) return c;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
FindFirstAncestor: function (name) {
|
||||
let p = this.Parent;
|
||||
while (p) { if (p.Name === name) return p; p = p.Parent; }
|
||||
return undefined;
|
||||
},
|
||||
FindFirstAncestorOfClass: function (cls) {
|
||||
let p = this.Parent;
|
||||
while (p) { if (p.ClassName === cls) return p; p = p.Parent; }
|
||||
return undefined;
|
||||
},
|
||||
WaitForChild: function (name) { return this.FindFirstChild(name); },
|
||||
IsA: function (cls) { return this.ClassName === cls || cls === 'Instance'; },
|
||||
GetFullName: function () {
|
||||
const parts = [];
|
||||
let p = this;
|
||||
while (p && p.ClassName !== 'DataModel') {
|
||||
parts.unshift(p.Name);
|
||||
p = p.Parent;
|
||||
}
|
||||
return parts.join('.');
|
||||
},
|
||||
Destroy: function () {
|
||||
this.Destroyed = true;
|
||||
if (this.Parent && this.Parent.Children) {
|
||||
const i = this.Parent.Children.indexOf(this);
|
||||
if (i >= 0) this.Parent.Children.splice(i, 1);
|
||||
this.Parent = undefined;
|
||||
}
|
||||
},
|
||||
Clone: function () { return undefined; },
|
||||
GetAttribute: function (n) { return (this.Attributes || {})[n]; },
|
||||
SetAttribute: function (n, v) {
|
||||
if (!this.Attributes) this.Attributes = {};
|
||||
this.Attributes[n] = v;
|
||||
},
|
||||
GetPropertyChangedSignal: function () { return this.Changed; },
|
||||
};
|
||||
return _instanceMethods;
|
||||
}
|
||||
|
||||
function newInstance(className, name) {
|
||||
const m = makeInstanceMethods();
|
||||
return {
|
||||
ClassName: className || 'Instance',
|
||||
Name: name || className || 'Instance',
|
||||
Parent: undefined,
|
||||
Children: [],
|
||||
Destroyed: false,
|
||||
Attributes: {},
|
||||
ChildAdded: makeSignal(),
|
||||
ChildRemoved: makeSignal(),
|
||||
AncestryChanged: makeSignal(),
|
||||
Changed: makeSignal(),
|
||||
GetChildren: m.GetChildren,
|
||||
GetDescendants: m.GetDescendants,
|
||||
FindFirstChild: m.FindFirstChild,
|
||||
FindFirstChildOfClass: m.FindFirstChildOfClass,
|
||||
FindFirstAncestor: m.FindFirstAncestor,
|
||||
FindFirstAncestorOfClass: m.FindFirstAncestorOfClass,
|
||||
WaitForChild: m.WaitForChild,
|
||||
IsA: m.IsA,
|
||||
GetFullName: m.GetFullName,
|
||||
Destroy: m.Destroy,
|
||||
Clone: m.Clone,
|
||||
GetAttribute: m.GetAttribute,
|
||||
SetAttribute: m.SetAttribute,
|
||||
GetPropertyChangedSignal: m.GetPropertyChangedSignal,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Главная регистрация. Возвращает api-объект используемый Worker'ом.
|
||||
* Создать RbxPart-обёртку. Возвращает plain объект (без getter'ов) —
|
||||
* запись свойств идёт через метод __SetProp, которое мы экспортируем
|
||||
* глобально как `__rbxl_part_set(part, prop, value)`.
|
||||
*/
|
||||
function newPart(primData, sendFn) {
|
||||
const p = newInstance('Part', primData.name || `Part_${primData.id}`);
|
||||
p.__primId = primData.id;
|
||||
p.__sendFn = sendFn;
|
||||
p.Touched = makeSignal();
|
||||
p.TouchEnded = makeSignal();
|
||||
p.Position = new RbxVector3(primData.x || 0, primData.y || 0, primData.z || 0);
|
||||
p.Size = new RbxVector3(primData.sx || 1, primData.sy || 1, primData.sz || 1);
|
||||
p.Color = primData.color ? RbxColor3.fromHex(primData.color) : new RbxColor3(0.5, 0.5, 0.5);
|
||||
p.Anchored = !!primData.anchored;
|
||||
p.CanCollide = primData.canCollide !== false;
|
||||
p.Transparency = primData.opacity != null ? (1 - primData.opacity) : 0;
|
||||
p.Material = 'Plastic';
|
||||
p.BrickColor = { Color: p.Color, Name: 'Custom' };
|
||||
p.CFrame = new RbxCFrame(p.Position.X, p.Position.Y, p.Position.Z);
|
||||
return p;
|
||||
}
|
||||
|
||||
// ---------- Регистрация в Lua ----------
|
||||
export function registerRobloxShim(lua, opts) {
|
||||
const { send, getSceneSnapshot, getGuiTree, scheduleWait } = opts;
|
||||
const { send } = opts;
|
||||
const global = lua.global;
|
||||
|
||||
// ------ Vector3 ------
|
||||
// Lua: local v = Vector3.new(1,2,3); v.X; v + v; v.Magnitude
|
||||
const Vector3Table = {
|
||||
// === Базовые типы ===
|
||||
global.set('Vector3', {
|
||||
new: (x, y, z) => new RbxVector3(x, y, z),
|
||||
zero: RbxVector3.zero,
|
||||
one: RbxVector3.one,
|
||||
xAxis: RbxVector3.xAxis,
|
||||
yAxis: RbxVector3.yAxis,
|
||||
zAxis: RbxVector3.zAxis,
|
||||
zero: RbxVector3.zero, one: RbxVector3.one,
|
||||
xAxis: RbxVector3.xAxis, yAxis: RbxVector3.yAxis, zAxis: RbxVector3.zAxis,
|
||||
FromNormalId: () => new RbxVector3(),
|
||||
};
|
||||
global.set('Vector3', Vector3Table);
|
||||
|
||||
// ------ Color3 ------
|
||||
});
|
||||
global.set('Color3', {
|
||||
new: (r, g, b) => new RbxColor3(r, g, b),
|
||||
fromRGB: (r, g, b) => RbxColor3.fromRGB(r, g, b),
|
||||
fromHSV: (h, s, v) => RbxColor3.fromHSV(h, s, v),
|
||||
fromHex: (hex) => {
|
||||
const s = String(hex || '').replace('#', '');
|
||||
if (s.length !== 6) return new RbxColor3();
|
||||
return new RbxColor3(
|
||||
parseInt(s.slice(0, 2), 16) / 255,
|
||||
parseInt(s.slice(2, 4), 16) / 255,
|
||||
parseInt(s.slice(4, 6), 16) / 255,
|
||||
);
|
||||
},
|
||||
fromHex: (hex) => RbxColor3.fromHex(hex),
|
||||
});
|
||||
|
||||
// ------ UDim / UDim2 / Vector2 / CFrame ------
|
||||
global.set('UDim', { new: (s, o) => new RbxUDim(s, o) });
|
||||
global.set('UDim2', {
|
||||
new: (sx, ox, sy, oy) => new RbxUDim2(sx, ox, sy, oy),
|
||||
@ -205,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,
|
||||
};
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user