feat(player): GameRuntime запускает Lua через LuaSharedSandbox + cmd-handlers (Фаза 2)

This commit is contained in:
min 2026-06-10 00:02:52 +03:00
parent 3478ffafd1
commit 7389dfc660

View File

@ -17,6 +17,9 @@
import { Color3 } from '@babylonjs/core';
import { ScriptSandbox } from './ScriptSandbox';
import { STORYS_addres } from '../api/API';
import { handleLuaCommand, unpackRobloxLuaCode, parseRobloxLuaMeta } from './rbxl-lua-integration.js';
import { LuaSharedSandbox } from './lua/LuaSharedSandbox.js';
import { RbxlHudOverlay } from './RbxlHudOverlay.js';
export class GameRuntime {
constructor(scene3d) {
@ -86,12 +89,53 @@ export class GameRuntime {
// (на старте) возвращает null → подписки obj.onTouch/find не работают.
let initialScene = null;
try { initialScene = this._buildSceneSnapshot(); } catch (e) { initialScene = null; }
// Фаза 2 синхронизации со студией: и user-Lua (language='lua'), и
// импортированные .rbxl-скрипты (с маркером // @roblox-lua) теперь
// идут через ОДИН LuaSharedSandbox в main thread (wasmoon один раз).
// Снимает WASM OOM лимит и устраняет race с worker'ом.
const luaUserBatch = [];
const runImportedRbxl = !(typeof window !== 'undefined' && window.__RBXL_SKIP_IMPORTED === true);
let rbxlSkipped = 0;
for (const s of scripts) {
// Roblox-Lua скрипты импортированные через rbxl-importer:
// отдельный sandbox с wasmoon Lua-VM и Roblox-API shim.
// Запускаем по флагу kind, обходя стандартный ScriptSandbox.
if (s && s.kind === 'roblox-lua' && typeof s.lua_source === 'string' && s.lua_source.trim()) {
this._startRobloxLuaScript(s);
// Roblox-Lua скрипты импортированные через rbxl-importer.
if (s && typeof s.code === 'string' && s.code.startsWith('// @roblox-lua')) {
if (!runImportedRbxl) { rbxlSkipped++; continue; }
const meta = parseRobloxLuaMeta(s.code);
if (meta && meta.enabled === false) { rbxlSkipped++; continue; }
const sname = String(s.name || '').toLowerCase();
if (sname.startsWith('regenerate') || sname === 'regenerationscript') {
rbxlSkipped++; continue;
}
const luaSource = unpackRobloxLuaCode(s.code);
if (luaSource && (
/while\s+not\s+\w+[:.]FindFirstChild/.test(luaSource) ||
/ChildAdded:[Ww]ait\(\)/.test(luaSource) ||
/:[Gg]etChildren\(\)\s*\[\d/.test(luaSource)
)) {
rbxlSkipped++;
// eslint-disable-next-line no-console
console.warn(`[GameRuntime] skipped ${s.name}: tight-loop (WaitForChild/ChildAdded:wait)`);
continue;
}
if (luaSource && luaSource.trim()) {
let toolName = null;
if (s.target == null && /(script\.Parent|Tool)\.(Equipped|Unequipped|Activated|Deactivated)/.test(luaSource)) {
toolName = 'Tool';
}
luaUserBatch.push({
id: s.id,
name: s.name,
target: s.target,
toolName,
language: 'lua',
code: luaSource,
_rbxlImported: true,
});
}
continue;
}
if (s && s.language === 'lua') {
if (typeof s.code === 'string' && s.code.trim()) luaUserBatch.push(s);
continue;
}
if (!s || typeof s.code !== 'string' || !s.code.trim()) {
@ -123,6 +167,132 @@ export class GameRuntime {
// eslint-disable-next-line no-console
console.log('[GameRuntime] sandbox started for script id=', s.id);
}
// === Фаза 2: единый LuaSharedSandbox для user-Lua + импортированных .rbxl ===
let luaUserCount = 0;
if (luaUserBatch.length > 0) {
try {
const sb = new LuaSharedSandbox();
sb.setOnCommand(({ cmd, payload }) => {
if (cmd === 'partSet' || cmd === 'partVel' ||
cmd === 'sceneCreate' || cmd === 'sceneDelete') {
try {
handleLuaCommand(null, cmd, payload, this);
} catch (e) {
// eslint-disable-next-line no-console
console.error('[GameRuntime] handleLuaCommand failed:', cmd, payload, e);
}
} else if (cmd === 'toolRegistered') {
try { this._registerRbxlTool?.(payload); } catch (e) {
// eslint-disable-next-line no-console
console.warn('[GameRuntime] toolRegistered failed', e);
}
} else if (cmd === 'lightingTimeUpdate') {
try {
const baseHour = Number(payload?.hour);
if (baseHour >= 0 && baseHour < 24) {
if (this._lightBaseHour == null) {
this._lightBaseHour = baseHour;
this._lightStartReal = performance.now();
}
const dGame = baseHour - this._lightBaseHour;
const accel = 8;
const hour = ((this._lightBaseHour + dGame * accel) % 24 + 24) % 24;
this.scene3d?.setTimeOfDay?.(hour);
let targetPreset;
if (hour >= 6 && hour < 8) targetPreset = 'sunset';
else if (hour >= 8 && hour < 17) targetPreset = 'lowpoly-roblox';
else if (hour >= 17 && hour < 19) targetPreset = 'sunset';
else targetPreset = 'starry-night';
if (this._lightPreset !== targetPreset) {
this._lightPreset = targetPreset;
try {
const sky = this.scene3d?.skybox;
if (sky?.fadeTo) sky.fadeTo({ preset: targetPreset }, 2);
else this.scene3d?.setSkybox?.({ preset: targetPreset });
} catch (_) {}
}
}
} catch (_) {}
} else if (cmd === 'particleCreated') {
this._rbxlPendingParticles = this._rbxlPendingParticles || [];
this._rbxlPendingParticles.push(payload);
} else if (cmd === 'mouseIconChanged') {
try {
const canvas = this.scene3d?.engine?.getRenderingCanvas?.();
if (canvas) canvas.style.cursor = payload.cssCursor || 'default';
} catch (_) {}
} else if (cmd === 'hudMessage') {
try {
this._ensureRbxlHud();
if (payload.visible && payload.text) {
this._rbxlHud.showMessage(payload.text);
} else {
this._rbxlHud.hideMessage();
}
} catch (_) {}
} else if (cmd === 'killFeed') {
try {
this._ensureRbxlHud();
this._rbxlHud.addKillFeed(payload.killer, payload.victim, payload.weapon);
} catch (_) {}
} else if (cmd === 'winShow') {
try {
this._ensureRbxlHud();
this._rbxlHud.showWin(payload.text || 'WIN!');
} catch (_) {}
} else if (cmd === 'ui.showText') {
try {
this._ensureRbxlHud();
this._rbxlHud.showMessage(payload.text || '');
const dur = Number(payload.duration) || 2;
const t = payload.text || '';
setTimeout(() => {
try {
if (this._rbxlHud._lastMessage === t) {
this._rbxlHud.hideMessage();
}
} catch (_) {}
}, dur * 1000);
try { this._rbxlHud._lastMessage = t; } catch (_) {}
} catch (_) {}
} else if (cmd === 'leaderstatSet') {
try {
const lm = this.scene3d?.leaderstats;
if (lm) {
const statName = String(payload.statName || 'Stat');
if (!lm._defs.some(d => d.name === statName)) {
lm.define(statName, { initial: 0 });
}
lm.set(lm._meId || 'me', statName, Number(payload.value) || 0);
}
} catch (_) {}
} else {
this._handleCommand(null, cmd, payload);
}
});
try {
const snap = this._buildSceneSnapshot();
sb.sendSceneSnapshot(snap);
} catch (_) {}
for (const s of luaUserBatch) {
sb.addScript(s.id, s.code, s.target, s.name, { toolName: s.toolName });
}
sb.start();
this.sandboxes.push(sb);
this._luaUserSandbox = sb;
luaUserCount = luaUserBatch.length;
} catch (e) {
// eslint-disable-next-line no-console
console.error('[GameRuntime] Lua user runtime failed to init', e);
this._log('error', `Lua-runtime ошибка: ${e?.message || e}`);
}
}
if (rbxlSkipped > 0) {
this._log('info', `Импортированных .rbxl-скриптов пропущено: ${rbxlSkipped}`);
}
if (luaUserCount > 0) {
this._log('info', `Запущено Lua-скриптов (включая .rbxl): ${luaUserCount}`);
}
this._log('info', `Запущено скриптов: ${this.sandboxes.length}`);
// Подцепляемся на изменения HP игрока, чтобы шлать событие 'hpChange'
// во все sandbox'ы. Не перезаписываем существующий обработчик —
@ -181,6 +351,20 @@ export class GameRuntime {
}
}
/**
* Распаковывает Lua-исходник из поля code упакованного rbxl-importer'ом.
* Формат: см. _startRobloxLuaScript комментарий.
*/
_unpackRobloxLuaCode(code) {
// Ищем "/* lua_source:\n" и "\n*/" — выдаём что между ними.
const openIdx = code.indexOf('/* lua_source:\n');
if (openIdx < 0) return null;
const start = openIdx + '/* lua_source:\n'.length;
const closeIdx = code.lastIndexOf('\n*/');
if (closeIdx < start) return null;
return code.slice(start, closeIdx);
}
/**
* Запускает Roblox-Lua скрипт через RobloxLuaSandbox + wasmoon.
* Используется для скриптов импортированных из .rbxl файлов.
@ -604,6 +788,14 @@ export class GameRuntime {
return null;
}
/** DOM-overlay для импортированных Roblox-карт (KillFeed/Message/WinGui). */
_ensureRbxlHud() {
if (this._rbxlHud) return;
const canvas = this.scene3d?.engine?.getRenderingCanvas?.();
const parent = canvas?.parentElement || document.body;
this._rbxlHud = new RbxlHudOverlay(parent);
}
stop() {
if (this.sandboxes.length > 0) {
this._log('info', 'Остановка скриптов');
@ -611,6 +803,11 @@ export class GameRuntime {
console.log('[GameRuntime] stopping', this.sandboxes.length, 'sandboxes');
for (const sb of this.sandboxes) sb.stop();
}
// Очищаем Roblox HUD overlay (KillFeed/Message/WinGui) — Фаза 2.
try { this._rbxlHud?.dispose?.(); } catch (_) {}
this._rbxlHud = null;
this._rbxlPendingParticles = null;
this._luaUserSandbox = null;
// Удаляем все объекты, которые скрипты наспавнили через
// game.scene.spawn/clone — иначе после Stop они остаются на сцене
// и накапливаются при повторных запусках.
@ -743,7 +940,55 @@ export class GameRuntime {
tick(dt) {
if (!this._isRunning || this.sandboxes.length === 0) return;
const state = this._collectState();
// Реальная позиция игрока для Lua __rbxl_player_pos()
const playerObj = this.scene3d?.player;
let realPos = null;
if (playerObj?._pos) {
const halfH = playerObj.HALF_H ?? 0.9;
realPos = { x: playerObj._pos.x, y: playerObj._pos.y - halfH, z: playerObj._pos.z };
} else if (state?.player) {
realPos = { x: state.player.x, y: state.player.y, z: state.player.z };
}
// Позиции спавненных динамических примитивов (id >= 800000)
let spawnedPositions = null;
try {
const pm = this.scene3d?.primitiveManager;
if (pm && pm.instances) {
for (const [id, data] of pm.instances.entries()) {
if (id < 800000 || data.anchored !== false) continue;
if (!spawnedPositions) spawnedPositions = [];
spawnedPositions.push([id, data.x, data.y, data.z]);
}
}
} catch (_) {}
// Позиции NPC для Lua-shim
const npcPositions = [];
try {
const nm = this.scene3d?.npcManager;
if (nm && nm.npcs && this._localToReal) {
for (const [localRef, realRef] of this._localToReal.entries()) {
if (typeof realRef !== 'string' || !realRef.startsWith('npc:')) continue;
const npcId = Number(realRef.slice(4));
const npc = nm.npcs.get(npcId);
if (npc) npcPositions.push([localRef, npc.x, npc.y, npc.z]);
}
}
} catch (_) {}
for (const sb of this.sandboxes) {
// Синк Lua-shim позиций (LuaSharedSandbox имеет sb.api.update*)
if (realPos && sb.api?.updatePlayerPos) {
try { sb.api.updatePlayerPos(realPos.x, realPos.y, realPos.z); } catch (_) {}
}
if (spawnedPositions && sb.api?.updateSpawnedPos) {
for (const [id, x, y, z] of spawnedPositions) {
try { sb.api.updateSpawnedPos(id, x, y, z); } catch (_) {}
}
}
if (npcPositions.length > 0 && sb.api?.updateNpcPos) {
for (const [ref, x, y, z] of npcPositions) {
try { sb.api.updateNpcPos(ref, x, y, z); } catch (_) {}
}
}
// Для скриптов с target — добавляем актуальную позицию self
const stateForSb = sb.target
? { ...state, selfPosition: this._collectSelfPosition(sb.target) }