feat(player): синхронизация со студией — поддержка Lua + JS-API + Roblox-импорта #22
@ -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) }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user