{/* Фаза 6.1.4: кнопка «Проверить» — включает семантический анализ TS
на 4 секунды, подсвечивает все ошибки (опечатки в game.X.Y и пр.).
@@ -394,10 +487,11 @@ function ScriptEditor({ value, onSave, onRunSolo, isSoloRunning, scriptId, targe
1000) {
+ this._touchDbgT0 = _nowDbg;
+ console.warn(`[TouchDbg] pos=(${px.toFixed(2)},${py.toFixed(2)},${pz.toFixed(2)}) scripts=${scripts.length}`);
+ }
// 1) Касания объектов с target-скриптами (ключ touchState = 's:'+scriptId)
+ let _firedThisFrame = 0;
for (const s of scripts) {
if (!s.target) continue;
- const key = 's:' + s.id;
- seen.add(key);
- const aabb = this._targetAABB(s.target);
- if (!aabb) continue;
- const overlap =
- px + phw > aabb.minX - EPS && px - phw < aabb.maxX + EPS &&
- py + phh > aabb.minY - EPS && py - phh < aabb.maxY + EPS &&
- pz + phd > aabb.minZ - EPS && pz - phd < aabb.maxZ + EPS;
- const wasTouching = this._touchState.get(key);
- if (overlap && !wasTouching) {
- this._touchState.set(key, true);
- rt.routeEvent(s.target, 'touch', {});
- rt.routeGlobalEvent('playerTouch', { target: s.target });
- } else if (!overlap && wasTouching) {
- this._touchState.set(key, false);
- rt.routeEvent(s.target, 'untouch', {});
+ try {
+ const key = 's:' + s.id;
+ seen.add(key);
+ const aabb = this._targetAABB(s.target);
+ if (!aabb) continue;
+ const overlap =
+ px + phw > aabb.minX - EPS && px - phw < aabb.maxX + EPS &&
+ py + phh > aabb.minY - EPS && py - phh < aabb.maxY + EPS &&
+ pz + phd > aabb.minZ - EPS && pz - phd < aabb.maxZ + EPS;
+ const wasTouching = this._touchState.get(key);
+ if (overlap && !wasTouching) {
+ this._touchState.set(key, true);
+ rt.routeEvent(s.target, 'touch', {});
+ rt.routeGlobalEvent('playerTouch', { target: s.target });
+ _firedThisFrame++;
+ if (_firedThisFrame === 1) {
+ console.warn(`[Touch FIRE] scriptId=${s.id} target=${s.target} pos=(${px.toFixed(2)},${py.toFixed(2)},${pz.toFixed(2)})`);
+ }
+ } else if (!overlap && wasTouching) {
+ this._touchState.set(key, false);
+ rt.routeEvent(s.target, 'untouch', {});
+ }
+ } catch (e) {
+ if (!this._touchDetectErrored) {
+ this._touchDetectErrored = true;
+ console.error('[TouchDetect] error', e, 'on script', s);
+ }
}
}
@@ -3161,6 +3180,17 @@ export class BabylonScene {
_targetAABB(target) {
if (!target) return null;
try {
+ // Импортированные Roblox-скрипты имеют target = число (primitiveId).
+ if (typeof target === 'number') {
+ const data = this.primitiveManager?.instances?.get(target);
+ if (!data || data.sx == null || data.x == null) return null;
+ const hx = data.sx / 2, hy = data.sy / 2, hz = data.sz / 2;
+ return {
+ minX: data.x - hx, maxX: data.x + hx,
+ minY: data.y - hy, maxY: data.y + hy,
+ minZ: data.z - hz, maxZ: data.z + hz,
+ };
+ }
if (target.kind === 'block') {
const r = target.ref || target;
return {
@@ -5364,6 +5394,7 @@ export class BabylonScene {
code: s.code,
name: s.name || null,
target: newTarget,
+ language: s.language || 'js',
});
}
if (srcScripts.length > 0) {
@@ -5521,7 +5552,7 @@ export class BabylonScene {
const target = kind === 'block'
? { kind: 'block', ref: { x: dstRef.x, y: dstRef.y, z: dstRef.z } }
: { kind, id: dstRef };
- this._scripts.push({ id: newId, code: s.code, name: s.name || null, target });
+ this._scripts.push({ id: newId, code: s.code, name: s.name || null, target, language: s.language || 'js' });
}
this.history?.markChange();
if (this._onSceneChange) this._onSceneChange();
@@ -6677,7 +6708,7 @@ export class BabylonScene {
}
/** Установить код одного скрипта по id. Если id нет — создать новый. */
- upsertScript(id, code, target = undefined, name = undefined) {
+ upsertScript(id, code, target = undefined, name = undefined, language = undefined) {
const i = this._scripts.findIndex(s => s.id === id);
if (i >= 0) {
this._scripts[i] = {
@@ -6685,6 +6716,7 @@ export class BabylonScene {
code,
...(target !== undefined ? { target } : {}),
...(name !== undefined ? { name } : {}),
+ ...(language !== undefined ? { language } : {}),
};
} else {
this._scripts.push({
@@ -6692,6 +6724,7 @@ export class BabylonScene {
code,
target: target !== undefined ? target : null,
name: name || null,
+ language: language || 'js',
});
}
// Скрипты — часть сцены: фиксируем в истории, иначе undo откатит
@@ -8193,6 +8226,7 @@ export class BabylonScene {
code: s.code,
target: s.target || null,
name: s.name || null,
+ language: s.language === 'lua' ? 'lua' : 'js',
}));
}
// Окружение (время суток, скайбокс, туман)
diff --git a/src/editor/engine/GameRuntime.js b/src/editor/engine/GameRuntime.js
index 16dfde9..6f45e01 100644
--- a/src/editor/engine/GameRuntime.js
+++ b/src/editor/engine/GameRuntime.js
@@ -20,6 +20,7 @@ import { STORYS_addres } from '../../api/API';
import { PhysicsWorld } from './PhysicsWorld';
import { LabelManager } from './LabelManager';
import { startRobloxLuaShared, handleLuaCommand, unpackRobloxLuaCode } from './rbxl-lua-integration.js';
+import { LuaSharedSandbox } from './lua/LuaSharedSandbox.js';
export class GameRuntime {
constructor(scene3d) {
@@ -117,11 +118,19 @@ export class GameRuntime {
// на все скрипты, одна wasmoon-VM. Снимает WASM OOM лимит.
const rbxlBatch = [];
const primitives = this.projectData?.scene?.primitives || this.scene3d?.primitiveManager?.getAllData?.() || [];
+ // НОВОЕ (Этап 2): Lua-скрипты с language='lua' идут через LuaSharedSandbox
+ // (один shared VM на всю игру). Это user-written Lua + Roblox API совместимость.
+ // Отличается от rbxl-import batch: тут код юзер написал в редакторе сам.
+ const luaUserBatch = [];
for (const s of scripts) {
if (s && typeof s.code === 'string' && s.code.startsWith('// @roblox-lua')) {
rbxlBatch.push(s);
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()) {
// eslint-disable-next-line no-console
console.warn('[GameRuntime] skipping invalid script entry', s);
@@ -167,9 +176,32 @@ export class GameRuntime {
rbxlCount = result.count;
}
}
- this._log('info', `Запущено скриптов: ${this.sandboxes.length - (this._rbxlSharedSandbox ? 1 : 0)}`);
+ // НОВОЕ (Этап 2): user-written Lua-скрипты с language='lua' через LuaSharedSandbox
+ let luaUserCount = 0;
+ if (luaUserBatch.length > 0) {
+ try {
+ const sb = new LuaSharedSandbox();
+ sb.setOnCommand(({ cmd, payload }) => {
+ this._handleCommand(null, cmd, payload);
+ });
+ for (const s of luaUserBatch) sb.addScript(s.id, s.code, s.target);
+ 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}`);
+ }
+ }
+ const jsOnly = this.sandboxes.length - (this._rbxlSharedSandbox ? 1 : 0) - (this._luaUserSandbox ? 1 : 0);
+ this._log('info', `Запущено JS-скриптов: ${jsOnly}`);
if (rbxlCount > 0) {
- this._log('info', `Запущено Roblox-Lua скриптов (single-VM): ${rbxlCount}`);
+ this._log('info', `Запущено Roblox-Lua скриптов (импортированных): ${rbxlCount}`);
+ }
+ if (luaUserCount > 0) {
+ this._log('info', `Запущено Lua-скриптов юзера: ${luaUserCount}`);
}
// Подцепляемся на изменения HP игрока, чтобы шлать событие 'hpChange'
// во все sandbox'ы. Не перезаписываем существующий обработчик —
diff --git a/src/editor/engine/RobloxLuaSharedSandbox.js b/src/editor/engine/RobloxLuaSharedSandbox.js
index 84cda46..65cd699 100644
--- a/src/editor/engine/RobloxLuaSharedSandbox.js
+++ b/src/editor/engine/RobloxLuaSharedSandbox.js
@@ -114,9 +114,20 @@ export class RobloxLuaSharedSandbox {
if (!payload || typeof payload !== 'object') return;
const type = payload.type;
// playerTouch: BabylonScene уже детектит касания → Touched на Part
- if (type === 'playerTouch' && payload.target) {
- const m = /^primitive:(\d+)$/.exec(String(payload.target));
- if (m) { this.fireEvent('touched', { primId: +m[1], isPlayer: true }); return; }
+ if (type === 'playerTouch' && payload.target != null) {
+ const t = payload.target;
+ // target может быть: число (импортированный rbxl), {id|ref}, 'primitive:'
+ let primId = null;
+ if (typeof t === 'number') primId = t;
+ else if (typeof t === 'object') primId = t.id ?? t.ref ?? null;
+ else if (typeof t === 'string') {
+ const m = /^primitive:(\d+)$/.exec(t);
+ if (m) primId = +m[1];
+ }
+ if (primId != null) {
+ this.fireEvent('touched', { primId, isPlayer: true });
+ return;
+ }
}
// GUI click — Rublox GuiOverlay шлёт guiClick с id
if (type === 'guiClick' && (payload.id || payload.localId)) {
diff --git a/src/editor/engine/RobloxLuaSharedWorker.js b/src/editor/engine/RobloxLuaSharedWorker.js
index 60c16d6..55837fb 100644
--- a/src/editor/engine/RobloxLuaSharedWorker.js
+++ b/src/editor/engine/RobloxLuaSharedWorker.js
@@ -344,11 +344,12 @@ function handleEvent(payload) {
} else if (kind === 'touched') {
const primId = payload.primId;
const part = state.api.part_by_id?.get(primId);
+ const hasFire = !!part?.Touched?.Fire;
+ const connCount = part?.Touched?.connections?.length ?? 0;
+ log('info', `[Touched] primId=${primId} hasPart=${!!part} hasFire=${hasFire} connectedHandlers=${connCount}`);
if (part?.Touched?.Fire) {
- // hit = HumanoidRootPart
part.Touched.Fire(state.api.character?.HumanoidRootPart || part);
}
- // также Humanoid.Touched на самом игроке
if (payload.isPlayer) {
state.api.humanoid?.Touched?.Fire?.(part);
}
diff --git a/src/editor/engine/lua/LuaSharedSandbox.js b/src/editor/engine/lua/LuaSharedSandbox.js
new file mode 100644
index 0000000..b12f7ca
--- /dev/null
+++ b/src/editor/engine/lua/LuaSharedSandbox.js
@@ -0,0 +1,215 @@
+/**
+ * LuaSharedSandbox — обёртка над одним wasmoon-Worker для ВСЕХ Lua-скриптов игры.
+ *
+ * Идея:
+ * - Создаётся ОДИН экземпляр на всю игру (а не на скрипт, как 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
+ *
+ * Совместимость с GameRuntime:
+ * методы sendEvent / sendGlobalEvent / sendSceneSnapshot / sendGuiSnapshot /
+ * sendDataSnapshot / sendSkinsSnapshot / sendTerrainHeightmap / stop /
+ * setOnCommand — поведение совпадает с ScriptSandbox.
+ *
+ * Отличия:
+ * - addScript(id, code, target) можно вызывать много раз ДО start() — все
+ * скрипты добавляются батчем и потом запускаются вместе.
+ * - После start() можно вызывать addScript() для live-добавления (например,
+ * Instance.new("Script", workspace) с переданным Source).
+ */
+
+import LuaSharedWorker from './LuaSharedWorker.js?worker';
+
+let _ipcId = 0;
+
+export class LuaSharedSandbox {
+ constructor() {
+ this.worker = 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;
+ }
+
+ setOnCommand(cb) { this._onCommand = cb; }
+
+ /** Добавить Lua-скрипт. Можно вызывать как ДО start() (буфер), так и ПОСЛЕ (live). */
+ addScript(id, code, target) {
+ const entry = {
+ id: String(id),
+ source: String(code || ''),
+ target: target == null ? null : target,
+ };
+ if (!this.worker) {
+ this._pendingScripts.push(entry);
+ return;
+ }
+ // 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 (_) {}
+ }
+
+ /**
+ * Запустить worker и инициализировать VM.
+ * После start() Lua-runtime готов принимать события и снапшоты.
+ */
+ start() {
+ if (this.worker) 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) => {
+ // 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 },
+ });
+ }
+
+ _handleMessage(e) {
+ if (this._isStopped) return;
+ const { cmd, payload } = e.data || {};
+ if (cmd === 'boot') return;
+ if (cmd === 'ready') {
+ this._isReady = true;
+ // Отправляем накопленные скрипты батчем
+ if (this._pendingScripts.length > 0) {
+ try {
+ this.worker.postMessage({ cmd: 'addScriptsBatch', payload: { scripts: this._pendingScripts } });
+ } catch (_) {}
+ this._pendingScripts = [];
+ }
+ // Отправляем snapshot'ы
+ if (this._pendingSceneSnapshot) {
+ try { this.worker.postMessage({ cmd: 'sceneSnapshot', payload: this._pendingSceneSnapshot }); } catch (_) {}
+ this._pendingSceneSnapshot = null;
+ }
+ if (this._pendingGuiSnapshot) {
+ try { this.worker.postMessage({ cmd: 'guiSnapshot', payload: this._pendingGuiSnapshot }); } catch (_) {}
+ this._pendingGuiSnapshot = null;
+ }
+ if (this._pendingDataSnapshot) {
+ try { this.worker.postMessage({ cmd: 'dataSnapshot', payload: this._pendingDataSnapshot }); } catch (_) {}
+ this._pendingDataSnapshot = null;
+ }
+ 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;
+ }
+ // Запустить главный loop (фаер RunService.Heartbeat/Stepped + резюм coroutines)
+ try { this.worker.postMessage({ cmd: 'kickoff' }); } catch (_) {}
+ return;
+ }
+ // Любая другая команда — прокинуть наружу как partSet/sceneCreate/log/etc
+ // _onCommand обработчик в GameRuntime разруливает их так же как от ScriptSandbox
+ this._emit(cmd, payload);
+ }
+
+ _emit(cmd, payload) {
+ if (typeof this._onCommand === 'function') {
+ try { this._onCommand({ cmd, payload }); } catch (_) {}
+ }
+ }
+
+ /** Событие target-attached скрипта (touch/untouch/click/etc). */
+ sendEvent(payload) {
+ if (!this.worker) return;
+ if (!this._isReady) return;
+ try { this.worker.postMessage({ cmd: 'event', payload }); } catch (_) {}
+ }
+
+ /** Глобальное событие игры (playerTouch/guiClick/keydown/etc). */
+ sendGlobalEvent(payload) {
+ if (!this.worker) return;
+ if (!this._isReady) return;
+ try { this.worker.postMessage({ cmd: 'globalEvent', payload }); } catch (_) {}
+ }
+
+ sendSceneSnapshot(snapshot) {
+ if (!this.worker) {
+ this._pendingSceneSnapshot = snapshot;
+ return;
+ }
+ 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;
+ }
+ try { this.worker.postMessage({ cmd: 'guiSnapshot', payload: snapshot }); } catch (_) {}
+ }
+
+ sendDataSnapshot(snapshot) {
+ if (!this.worker || !this._isReady) {
+ this._pendingDataSnapshot = snapshot;
+ return;
+ }
+ 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 (_) {}
+ }
+
+ stop() {
+ this._isStopped = true;
+ try { this.worker?.terminate(); } catch (_) {}
+ this.worker = null;
+ this._isReady = false;
+ }
+}
+
+export default LuaSharedSandbox;
diff --git a/src/editor/engine/lua/LuaSharedWorker.js b/src/editor/engine/lua/LuaSharedWorker.js
new file mode 100644
index 0000000..aff4a94
--- /dev/null
+++ b/src/editor/engine/lua/LuaSharedWorker.js
@@ -0,0 +1,242 @@
+/* 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
+ * - state.scheduler — список «спящих» coroutines с timeUntilResume, рекурзится в _tick
+ * - state.signals — RbxSignal-объекты для events; Worker слушает 'event' от main
+ * и вызывает Lua-side Fire по соответствующему signal'у
+ */
+
+let _wasmoon = null;
+
+// Главное состояние 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 });
+ // Загрузить wasmoon
+ _wasmoon = await import(/* @vite-ignore */ 'wasmoon');
+ const { LuaFactory } = _wasmoon;
+ const factory = new LuaFactory();
+ state.vm = await factory.createEngine({ openStandardLibs: true });
+
+ // Регистрируем минимальный Roblox shim
+ const { registerRobloxShim } = await import('./RobloxShim.js');
+ state.api = registerRobloxShim(state.vm, {
+ send,
+ getSceneSnapshot: () => state.scenes,
+ getGuiTree: () => state.guiTree,
+ scheduleWait: (sec) => scheduleWait(sec),
+ });
+
+ state.isReady = true;
+ send('ready', {});
+}
+
+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 = ...; 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).
+}
diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js
new file mode 100644
index 0000000..1d449fe
--- /dev/null
+++ b/src/editor/engine/lua/RobloxShim.js
@@ -0,0 +1,447 @@
+/**
+ * RobloxShim — экспорт минимально-достаточного Roblox API в wasmoon-VM.
+ *
+ * Этап 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
+ *
+ * Возвращает объект 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}.
+ */
+
+const SCHEDULER = {
+ sleeping: [], // [{coroutine, wakeAt}], wakeAt = performance.now()+ms
+ now: () => performance.now(),
+};
+
+const HEARTBEAT_SIGNAL = makeSignal();
+const STEPPED_SIGNAL = makeSignal();
+
+function makeSignal() {
+ const connections = [];
+ return {
+ __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,
+ };
+ },
+ Wait() {
+ // в реальной реализации — coroutine.yield пока не fire'нется
+ return null;
+ },
+ };
+}
+
+// --- Vector3 ---
+class RbxVector3 {
+ 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); }
+ Magnitude() { return Math.hypot(this.X, this.Y, this.Z); }
+ get magnitude() { return Math.hypot(this.X, this.Y, this.Z); }
+ Unit() {
+ const m = this.Magnitude() || 1;
+ return new RbxVector3(this.X / m, this.Y / m, this.Z / m);
+ }
+ Normalize() { return this.Unit(); }
+ Dot(b) { return this.X * b.X + this.Y * b.Y + this.Z * b.Z; }
+ Cross(b) {
+ return new RbxVector3(
+ this.Y * b.Z - this.Z * b.Y,
+ this.Z * b.X - this.X * b.Z,
+ this.X * b.Y - this.Y * b.X,
+ );
+ }
+ Lerp(b, t) {
+ return new RbxVector3(
+ this.X + (b.X - this.X) * t,
+ this.Y + (b.Y - this.Y) * t,
+ 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);
+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); }
+ static fromRGB(r, g, b) { return new RbxColor3((r || 0) / 255, (g || 0) / 255, (b || 0) / 255); }
+ 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];
+ return new RbxColor3(r, g, b);
+ }
+ Lerp(b, t) {
+ return new RbxColor3(
+ this.R + (b.R - this.R) * t,
+ this.G + (b.G - this.G) * t,
+ this.B + (b.B - this.B) * t,
+ );
+ }
+}
+
+// --- 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);
+ }
+ static new(sx, ox, sy, oy) { return new RbxUDim2(sx, ox, sy, oy); }
+ static fromScale(sx, sy) { return new RbxUDim2(sx, 0, sy, 0); }
+ static fromOffset(ox, oy) { return new RbxUDim2(0, ox, 0, oy); }
+}
+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
+ }
+ 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 Angles(_rx, _ry, _rz) { return new RbxCFrame(); }
+ static fromEulerAnglesXYZ(_rx, _ry, _rz) { return new RbxCFrame(); }
+}
+
+/**
+ * Главная регистрация. Возвращает api-объект используемый Worker'ом.
+ */
+export function registerRobloxShim(lua, opts) {
+ const { send, getSceneSnapshot, getGuiTree, scheduleWait } = opts;
+ const global = lua.global;
+
+ // ------ Vector3 ------
+ // Lua: local v = Vector3.new(1,2,3); v.X; v + v; v.Magnitude
+ const Vector3Table = {
+ new: (x, y, z) => new RbxVector3(x, y, z),
+ 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,
+ );
+ },
+ });
+
+ // ------ 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),
+ fromScale: (sx, sy) => RbxUDim2.fromScale(sx, sy),
+ fromOffset: (ox, oy) => RbxUDim2.fromOffset(ox, oy),
+ });
+ global.set('Vector2', { new: (x, y) => new RbxVector2(x, y) });
+ global.set('CFrame', {
+ new: (x, y, z) => RbxCFrame.new(x, y, z),
+ lookAt: (e, t) => RbxCFrame.lookAt(e, t),
+ Angles: RbxCFrame.Angles,
+ fromEulerAnglesXYZ: RbxCFrame.fromEulerAnglesXYZ,
+ });
+
+ // ------ Enum (минимум) ------
+ 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 }])),
+ });
+
+ // ------ 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) {
+ if (v == null) return 'nil';
+ if (typeof v === 'string') return v;
+ if (typeof v === 'number') return String(v);
+ if (typeof v === 'boolean') return v ? 'true' : 'false';
+ if (v instanceof RbxVector3) return `${v.X}, ${v.Y}, ${v.Z}`;
+ if (v instanceof RbxColor3) return `${v.R}, ${v.G}, ${v.B}`;
+ if (typeof v === 'object') {
+ if (v.Name) return String(v.Name);
+ return '[object]';
+ }
+ try { return String(v); } catch (_) { return '?'; }
+}