diff --git a/src/engine/GameRuntime.js b/src/engine/GameRuntime.js index 2ace2d7..847ed3e 100644 --- a/src/engine/GameRuntime.js +++ b/src/engine/GameRuntime.js @@ -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) }