From a5e1558c2dde8a8f50c9dd5a059a32bfd511a1dd Mon Sep 17 00:00:00 2001 From: min Date: Tue, 9 Jun 2026 22:01:51 +0000 Subject: [PATCH] =?UTF-8?q?feat(player):=20=EF=BF=BD=EF=BF=BD=EF=BF=BD?= =?UTF-8?q?=EF=BF=BD=EF=BF=BD=EF=BF=BD=EF=BF=BD=EF=BF=BD=EF=BF=BD=EF=BF=BD?= =?UTF-8?q?=EF=BF=BD=EF=BF=BD=EF=BF=BD=20=EF=BF=BD=EF=BF=BD=20=EF=BF=BD?= =?UTF-8?q?=EF=BF=BD=EF=BF=BD=EF=BF=BD=EF=BF=BD=EF=BF=BD=EF=BF=BD=20(Lua?= =?UTF-8?q?=20+=20JS-API=20+=20Roblox-=EF=BF=BD=EF=BF=BD=EF=BF=BD=EF=BF=BD?= =?UTF-8?q?=EF=BF=BD=EF=BF=BD=20+=20LoadingOverlay)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .WORKTREE_NOTICE.md | 11 + package-lock.json | 21 +- package.json | 3 +- src/engine/BabylonScene.js | 25 +- src/engine/GameRuntime.js | 238 +++ src/engine/LabelManager.js | 373 ++++- src/engine/LoadingScreenOverlay.js | 192 ++- src/engine/RbxlHudOverlay.js | 177 ++ src/engine/ScriptSandboxWorker.js | 164 +- src/engine/lua/LuaSharedSandbox.js | 337 ++++ src/engine/lua/RobloxShim.js | 2500 ++++++++++++++++++++++++++++ src/engine/rbxl-lua-integration.js | 210 +++ tests/rbxl-lua-integration.test.js | 243 +++ tests/rbxl-lua-mvp.test.js | 187 +++ tests/rbxl-lua-services.test.js | 144 ++ tests/rbxl-lua-tween.test.js | 89 + tests/rbxl-lua-wait.test.js | 104 ++ 17 files changed, 4956 insertions(+), 62 deletions(-) create mode 100644 .WORKTREE_NOTICE.md create mode 100644 src/engine/RbxlHudOverlay.js create mode 100644 src/engine/lua/LuaSharedSandbox.js create mode 100644 src/engine/lua/RobloxShim.js create mode 100644 src/engine/rbxl-lua-integration.js create mode 100644 tests/rbxl-lua-integration.test.js create mode 100644 tests/rbxl-lua-mvp.test.js create mode 100644 tests/rbxl-lua-services.test.js create mode 100644 tests/rbxl-lua-tween.test.js create mode 100644 tests/rbxl-lua-wait.test.js diff --git a/.WORKTREE_NOTICE.md b/.WORKTREE_NOTICE.md new file mode 100644 index 0000000..03c4696 --- /dev/null +++ b/.WORKTREE_NOTICE.md @@ -0,0 +1,11 @@ +# Активная сессия: импорт Roblox .rbxl + +Это **отдельный worktree** для разработки `feat/rbxl-import` — импорта Roblox-карт в Rublox. + +**Не работайте здесь параллельно из других сессий!** + +Ветка: `feat/rbxl-import` +Сервис на сервере: VM 130 на S1 +Сопутствующий worktree: `Desktop/studio-rbxl-import` + +Started: 2026-06-07 diff --git a/package-lock.json b/package-lock.json index 870da93..acf1d7d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,8 @@ "react": "18.3.1", "react-dom": "18.3.1", "react-router-dom": "7.4.0", - "socket.io-client": "^4.8.3" + "socket.io-client": "^4.8.3", + "wasmoon": "^1.16.0" }, "devDependencies": { "@types/react": "18.3.12", @@ -1427,6 +1428,12 @@ "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", "license": "MIT" }, + "node_modules/@types/emscripten": { + "version": "1.39.10", + "resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.39.10.tgz", + "integrity": "sha512-TB/6hBkYQJxsZHSqyeuO1Jt0AB/bW6G7rHt9g7lML7SOF6lbgcHvw/Lr+69iqN0qxgXLhWKScAon73JNnptuDw==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -5206,6 +5213,18 @@ } } }, + "node_modules/wasmoon": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/wasmoon/-/wasmoon-1.16.0.tgz", + "integrity": "sha512-FlRLb15WwAOz1A9OQDbf6oOKKSiefi5VK0ZRF2wgH9xk3o5SnU11tNPaOnQuAh1Ucr66cwwvVXaeVRaFdRBt5g==", + "license": "MIT", + "dependencies": { + "@types/emscripten": "1.39.10" + }, + "bin": { + "wasmoon": "bin/wasmoon" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 7b16067..34d01f3 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,8 @@ "react": "18.3.1", "react-dom": "18.3.1", "react-router-dom": "7.4.0", - "socket.io-client": "^4.8.3" + "socket.io-client": "^4.8.3", + "wasmoon": "^1.16.0" }, "devDependencies": { "@types/react": "18.3.12", diff --git a/src/engine/BabylonScene.js b/src/engine/BabylonScene.js index 8c392b5..086ad76 100644 --- a/src/engine/BabylonScene.js +++ b/src/engine/BabylonScene.js @@ -2863,6 +2863,7 @@ export class BabylonScene { if (md.isBlock) { return { kind: 'block', ref: { x: md.gridX, y: md.gridY, z: md.gridZ } }; } + if (md.npcId != null) return { kind: 'npc', id: md.npcId }; if (md.isModel) return { kind: 'model', id: md.instanceId }; if (md.isPrimitive) return { kind: 'primitive', id: md.primitiveId }; return null; @@ -3104,7 +3105,29 @@ export class BabylonScene { } } - const pick = this._pickFromCenter(); + // В pointer-lock (1-е лицо) курсор скрыт в центре — пикаем центром. + // В 3-м лице (свободный курсор) — пикаем по реальным координатам клика. + const locked = (document.pointerLockElement === this.canvas); + let pick; + if (!locked && Number.isFinite(clickX) && Number.isFinite(clickY)) { + const pi = this.scene.pick(clickX, clickY, (mesh) => { + if (!mesh.isPickable) return false; + if (mesh.name && mesh.name.startsWith('gridLine')) return false; + return true; + }); + if (pi?.hit) { + let m = pi.pickedMesh; + if (m?.metadata?._isBlockProto && this.blockManager) { + const proxy = this.blockManager.findProxyByPickInfo?.(pi); + if (proxy) m = proxy; + } + pick = { mesh: m, point: pi.pickedPoint, pickInfo: pi }; + } else { + pick = null; + } + } else { + pick = this._pickFromCenter(); + } const target = pick?.mesh ? this._meshToTarget(pick.mesh) : null; const point = pick?.point ? { x: pick.point.x, y: pick.point.y, z: pick.point.z } : null; // 1) Self-onClick — только если target есть diff --git a/src/engine/GameRuntime.js b/src/engine/GameRuntime.js index 3b966a2..781b61c 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'; import { LabelManager } from './LabelManager'; // задача: scene.setLabel (require крашит в браузере) export class GameRuntime { @@ -101,7 +104,55 @@ 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. + 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()) { // eslint-disable-next-line no-console console.warn('[GameRuntime] skipping invalid script entry', s); @@ -131,6 +182,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'ы. Не перезаписываем существующий обработчик — @@ -482,6 +659,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', 'Остановка скриптов'); @@ -489,6 +674,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 они остаются на сцене // и накапливаются при повторных запусках. @@ -621,7 +811,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) } diff --git a/src/engine/LabelManager.js b/src/engine/LabelManager.js index 39e196a..5ce9783 100644 --- a/src/engine/LabelManager.js +++ b/src/engine/LabelManager.js @@ -1,80 +1,385 @@ /** - * LabelManager — billboard-метки (текст-плашки) над 3D-объектами. + * LabelManager — billboard-плашки (текст-надписи) над 3D-объектами. * - * Используется для game.scene.setLabel(ref, text) — имена/HP над - * персонажами, врагами, предметами. Метка всегда повёрнута лицом к камере - * (billboardMode=7), всегда поверх геометрии (renderingGroupId=1). + * game.scene.setLabel(ref, text, opts) — имена/HP/таймеры/счётчики над + * персонажами, врагами, предметами. По умолчанию плашка повёрнута лицом к + * камере (billboardMode=7), всегда поверх геометрии (renderingGroupId=1). * - * Метка привязывается к мешу объекта (parent) и висит над ним. + * Задача 10 — расширенные стили: фон/обводка/скругление (пресеты gameui/ + * warning/reward/boss-hp/plain), обводка текста, richText (//), + * faceMode billboard|fixed, attachPoint, maxDistance. + * + * Плашка привязывается к мешу объекта (parent) и висит над ним. */ import { DynamicTexture } from '@babylonjs/core/Materials/Textures/dynamicTexture'; import { StandardMaterial } from '@babylonjs/core/Materials/standardMaterial'; import { MeshBuilder } from '@babylonjs/core/Meshes/meshBuilder'; import { Color3 } from '@babylonjs/core/Maths/math.color'; +import { Mesh } from '@babylonjs/core/Meshes/mesh'; + +// === Пресеты стилей плашки (фон/обводка/текст) === +// gameui — жёлтая обводка + тёмно-синий фон + белый текст (как в Roblox-UI). +export const LABEL_PRESETS = { + plain: { + background: null, borderColor: null, borderWidth: 0, cornerRadius: 0, + color: '#ffffff', textStroke: { color: '#000', width: 8 }, + }, + gameui: { + background: '#1a3a8a', borderColor: '#f5c020', borderWidth: 10, cornerRadius: 28, + color: '#ffffff', textStroke: { color: '#0a1430', width: 6 }, + }, + warning: { + background: '#b01e1e', borderColor: '#ffce3a', borderWidth: 10, cornerRadius: 28, + color: '#ffffff', textStroke: { color: '#000', width: 6 }, + }, + reward: { + background: '#caa018', borderColor: '#fff0a0', borderWidth: 10, cornerRadius: 28, + color: '#fff8e0', textStroke: { color: '#6b4e00', width: 6 }, + gradient: ['#f7d34a', '#c98a18'], // золотой градиент фона + }, + 'boss-hp': { + background: '#3a0a0a', borderColor: '#ff4040', borderWidth: 8, cornerRadius: 20, + color: '#ffd0d0', textStroke: { color: '#000', width: 6 }, + gradient: ['#8a1414', '#3a0a0a'], + }, +}; export class LabelManager { constructor(scene) { this.scene = scene; - // ref-строка объекта → { plane, tex, mat } + // ref-строка объекта → { plane, tex, mat, lastKey, opts } this.labels = new Map(); + this._playerMesh = null; // для maxDistance — задаётся из BabylonScene } + /** Дать ссылку на меш игрока (для maxDistance-скрытия). */ + setPlayerMesh(mesh) { this._playerMesh = mesh; } + /** - * Установить/обновить метку над объектом. - * ref — ref-строка объекта (от scene.spawn / scene.find). - * anchorMesh — Babylon-меш объекта (метка крепится к нему). - * text — текст метки. - * opts — { color: '#fff', height: 2.5 (м над объектом), size: 1 } + * Установить/обновить плашку над объектом. + * ref — ref-строка объекта. + * anchorMesh — Babylon-меш объекта (плашка крепится к нему). + * text — текст (может содержать richText-теги если opts.richText). + * opts — см. LABEL_PRESETS + { color, height, size, background, + * borderColor, borderWidth, cornerRadius, padding, textStroke, + * fontWeight, faceMode, rotationY, attachPoint, preset, + * richText, maxDistance } */ setLabel(ref, anchorMesh, text, opts = {}) { if (!anchorMesh) return; - const color = opts.color || '#ffffff'; + text = String(text == null ? '' : text); + + // Пресет → база, поверх — явные opts. + const preset = opts.preset && LABEL_PRESETS[opts.preset] ? LABEL_PRESETS[opts.preset] : null; + const st = { ...(preset || {}), ...opts }; + const color = st.color || '#ffffff'; const heightAbove = Number.isFinite(opts.height) ? opts.height : 2.5; const sizeMul = Number.isFinite(opts.size) ? opts.size : 1; + const richText = !!opts.richText; + + // Диф-чек: если ничего не поменялось — не пересоздаём (важно для bindLabel). + const styleKey = JSON.stringify({ text, color, preset: opts.preset, bg: st.background, + bc: st.borderColor, bw: st.borderWidth, cr: st.cornerRadius, rich: richText, + fw: st.fontWeight, h: heightAbove, sz: sizeMul, fm: st.faceMode, ry: st.rotationY, + af: st.attachFace, tl: st.tilt, ap: typeof st.attachPoint === 'string' ? st.attachPoint : null }); + const existing = this.labels.get(ref); + if (existing && existing.lastKey === styleKey && existing.plane.parent === anchorMesh) { + return; // ничего не изменилось + } + + // Меняется только текст (тот же стиль/размер) → перерисуем canvas без + // пересоздания меша (дешевле). Иначе — полное пересоздание. + const sameStruct = existing && existing.styleStruct === this._structKey(st, richText, heightAbove, sizeMul); + if (sameStruct) { + this._drawCanvas(existing.tex, text, color, st, richText); + existing.tex.update(true); + existing.lastKey = styleKey; + existing.lastText = text; + return; + } - // Если метка уже есть — пересоздаём (текст/цвет могли измениться). this.clearLabel(ref); + // Размер текстуры: чем больше текста — тем шире, чтобы не растягивать. + const fontPx = 120; const W = 1024, H = 256; - const tex = new DynamicTexture(`lblTex_${ref}_${Date.now()}`, + const tex = new DynamicTexture(`lblTex_${ref}_${this._uid()}`, { width: W, height: H }, this.scene, true); tex.updateSamplingMode?.(3); // TRILINEAR tex.anisotropicFilteringLevel = 8; - const ctx = tex.getContext(); - ctx.clearRect(0, 0, W, H); - ctx.font = 'bold 120px "Roboto Condensed", "Segoe UI", sans-serif'; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.lineWidth = 16; - ctx.lineJoin = 'round'; - ctx.strokeStyle = '#000'; - ctx.strokeText(String(text), W / 2, H / 2); - ctx.fillStyle = color; - ctx.fillText(String(text), W / 2, H / 2); - tex.update(true); tex.hasAlpha = true; + this._drawCanvas(tex, text, color, st, richText); + tex.update(true); + // Соотношение плашки — 4:1 (1024×256). Крупная и читаемая (как в Roblox). + // ОДНОСТОРОННЯЯ плоскость (FRONTSIDE): лицо = нормаль +Z. Мы всегда + // разворачиваем плашку лицом к наблюдателю снаружи грани, поэтому текст + // читается прямо. DOUBLESIDE НЕ используем — задняя грань зеркалит UV + // (баг: «Ключ от тайника» наоборот). backFaceCulling выключен только + // чтобы плашка не пропадала при взгляде сзади (без отражённого текста). const plane = MeshBuilder.CreatePlane(`lbl_${ref}`, - { width: 2.2 * sizeMul, height: 0.55 * sizeMul }, this.scene); + { width: 3.4 * sizeMul, height: 0.85 * sizeMul, + sideOrientation: Mesh.FRONTSIDE }, this.scene); const mat = new StandardMaterial(`lblMat_${ref}`, this.scene); mat.diffuseTexture = tex; mat.diffuseTexture.hasAlpha = true; mat.emissiveColor = new Color3(1, 1, 1); + mat.diffuseColor = new Color3(0, 0, 0); mat.disableLighting = true; + // Геометрия двусторонняя (DOUBLESIDE) с прямыми backUVs — culling можно + // включить, дублей нет; текст читается с обеих сторон без зеркала. mat.backFaceCulling = false; mat.disableDepthWrite = true; + mat.useAlphaFromDiffuseTexture = true; plane.material = mat; - plane.billboardMode = 7; // всегда лицом к камере - plane.renderingGroupId = 1; // поверх геометрии + plane.renderingGroupId = 1; plane.isPickable = false; - // Крепим к объекту: метка висит над ним и двигается вместе с ним. plane.parent = anchorMesh; - plane.position.set(0, heightAbove, 0); - this.labels.set(ref, { plane, tex, mat }); + // Полуразмеры объекта по каждой оси (для крепления над верхом ИЛИ на + // грань). Берём bounding box в ЛОКАЛЬНЫХ координатах меша (min/max), чтобы + // позиция плашки-ребёнка была верной при любом масштабе/вращении родителя. + let halfX = 0.5, halfY = 0.5, halfZ = 0.5; + try { + const bb = anchorMesh.getBoundingInfo?.().boundingBox; + if (bb && bb.minimum && bb.maximum) { + halfX = (bb.maximum.x - bb.minimum.x) / 2; + halfY = (bb.maximum.y - bb.minimum.y) / 2; + halfZ = (bb.maximum.z - bb.minimum.z) / 2; + } else if (anchorMesh.scaling) { + halfX = Math.abs(anchorMesh.scaling.x) / 2; + halfY = Math.abs(anchorMesh.scaling.y) / 2; + halfZ = Math.abs(anchorMesh.scaling.z) / 2; + } + } catch (e) { /* ignore */ } + const halfH = halfY; + const halfPlane = 0.45 * sizeMul; // полувысота самой плашки (3.4×0.85) + + // attachFace — ПРИКРЕПИТЬ плашку НА ГРАНЬ объекта (как табличка на + // стене/полу): плашка лежит В ПЛОСКОСТИ грани, фиксированной ориентации, + // и перемещается/вращается ВМЕСТЕ с объектом (она его ребёнок). Это + // Roblox-style «надпись = часть постройки» (в отличие от billboard над + // верхом). Значения: 'top'|'bottom'|'front'|'back'|'left'|'right' + // (или сырые '+y'/'-y'/'+z'/'-z'/'+x'/'-x'). + const FACE = { top: '+y', bottom: '-y', front: '+z', back: '-z', + right: '+x', left: '-x' }; + let face = st.attachFace; + if (face && FACE[face]) face = FACE[face]; + + if (face) { + // На грань — всегда фиксированная ориентация (не billboard), иначе + // «связки с примитивом» не будет (плашка крутилась бы к камере). + plane.billboardMode = 0; + const gap = Number.isFinite(opts.height) ? opts.height : 0.05; + // ВАЖНО: Babylon CreatePlane (FRONTSIDE) лицевой стороной (где UV/текст + // не зеркалятся) смотрит в −Z. Поэтому чтобы ЛИЦО таблички смотрело + // НАРУЖУ выбранной грани, поворачиваем плоскость так, чтобы её −Z + // совпал с внешней нормалью грани. tiltSign — знак наклона tilt с + // учётом того, что для грани +z плоскость развёрнута на π. + let tiltSign = 1; + if (face === '+z') { plane.position.set(0, 0, halfZ + gap); plane.rotation.set(0, Math.PI, 0); tiltSign = -1; } + else if (face === '-z') { plane.position.set(0, 0, -(halfZ + gap)); plane.rotation.set(0, 0, 0); } + else if (face === '+x') { plane.position.set( halfX + gap, 0, 0); plane.rotation.set(0, -Math.PI / 2, 0); } + else if (face === '-x') { plane.position.set(-(halfX + gap), 0, 0); plane.rotation.set(0, Math.PI / 2, 0); } + else if (face === '+y') { plane.position.set(0, halfY + gap, 0); plane.rotation.set( Math.PI / 2, 0, 0); } + else if (face === '-y') { plane.position.set(0, -(halfY + gap), 0); plane.rotation.set(-Math.PI / 2, 0, 0); } + if (Number.isFinite(st.rotationY)) plane.rotation.y += st.rotationY; + // tilt — наклон таблички вокруг локальной X (ценник под ~45°, как на + // витрине Roblox). Знак нормализуем (tiltSign), чтобы «верх назад» был + // одинаковым для всех граней. Отрицательный tilt = верх отклоняется + // назад (от наблюдателя), как пюпитр. + if (Number.isFinite(st.tilt)) plane.rotation.x += st.tilt * tiltSign; + } else { + // faceMode: 'fixed' — фиксированная ориентация (вращается с объектом), + // но позиционируется как обычная плашка (над верхом/центром/низом). + if (st.faceMode === 'fixed') { + plane.billboardMode = 0; + if (Number.isFinite(st.rotationY)) plane.rotation.y = st.rotationY; + } else { + plane.billboardMode = 7; // всегда лицом к камере + } + // attachPoint: 'top'(default) — над верхом + небольшой зазор (height); + // 'center' — по центру; 'bottom' — у низа; {x,y,z} — явно. + const gap = Number.isFinite(opts.height) ? opts.height : 0.6; + let py = halfH + gap + halfPlane; // верх объекта + зазор + полувысота плашки + if (st.attachPoint === 'center') py = 0; + else if (st.attachPoint === 'bottom') py = -(halfH + gap); + else if (st.attachPoint && typeof st.attachPoint === 'object') { + plane.position.set(st.attachPoint.x || 0, st.attachPoint.y || 0, st.attachPoint.z || 0); + py = null; + } + if (py !== null) plane.position.set(0, py, 0); + } + + this.labels.set(ref, { + plane, tex, mat, + lastKey: styleKey, + lastText: text, + styleStruct: this._structKey(st, richText, heightAbove, sizeMul), + maxDistance: Number.isFinite(opts.maxDistance) ? opts.maxDistance : null, + }); } - /** Убрать метку с объекта. */ + /** Ключ «структуры» (всё кроме текста) — для решения перерисовать ли canvas. */ + _structKey(st, richText, h, sz) { + return JSON.stringify({ p: st.preset, bg: st.background, bc: st.borderColor, + bw: st.borderWidth, cr: st.cornerRadius, rich: richText, fw: st.fontWeight, + grad: st.gradient, ts: st.textStroke, h, sz, fm: st.faceMode, + af: st.attachFace, tl: st.tilt, ap: typeof st.attachPoint === 'string' ? st.attachPoint : null }); + } + + _uid() { this._seq = (this._seq || 0) + 1; return this._seq; } + + /** + * Нарисовать плашку на canvas DynamicTexture. + * Фон (roundRect + gradient/fill) → обводка border → текст (с обводкой). + */ + _drawCanvas(tex, text, color, st, richText) { + const W = 1024, H = 256; + const ctx = tex.getContext(); + ctx.clearRect(0, 0, W, H); + + const hasBg = !!st.background || (Array.isArray(st.gradient) && st.gradient.length === 2); + const pad = Number.isFinite(st.padding) ? st.padding : 28; + const cr = Number.isFinite(st.cornerRadius) ? st.cornerRadius : 0; + const bw = Number.isFinite(st.borderWidth) ? st.borderWidth : 0; + + const weight = st.fontWeight || 700; + const innerPad = (hasBg ? (bw + pad) : 24); // отступ от краёв (под рамку) + const maxTextW = W - innerPad * 2; + // Auto-fit: подбираем размер шрифта, чтобы текст влез по ширине (не обрезался). + let fontPx = 120; + if (!richText) { + ctx.font = `${weight} ${fontPx}px "Roboto Condensed", "Segoe UI", sans-serif`; + const tw = ctx.measureText(text).width; + if (tw > maxTextW) fontPx = Math.max(40, Math.floor(fontPx * maxTextW / tw)); + } + ctx.font = `${weight} ${fontPx}px "Roboto Condensed", "Segoe UI", sans-serif`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + + // === Фон-плашка === + if (hasBg) { + const m = bw / 2 + 4; // отступ рамки от края текстуры + const x = m, y = m, w = W - m * 2, h = H - m * 2; + this._roundRectPath(ctx, x, y, w, h, cr); + if (Array.isArray(st.gradient) && st.gradient.length === 2) { + const g = ctx.createLinearGradient(0, y, 0, y + h); + g.addColorStop(0, st.gradient[0]); + g.addColorStop(1, st.gradient[1]); + ctx.fillStyle = g; + } else { + ctx.fillStyle = st.background; + } + ctx.globalAlpha = Number.isFinite(st.backgroundOpacity) ? st.backgroundOpacity : 0.92; + ctx.fill(); + ctx.globalAlpha = 1; + if (bw > 0 && st.borderColor) { + ctx.lineWidth = bw; + ctx.strokeStyle = st.borderColor; + ctx.stroke(); + } + } + + // === Текст === + const ts = st.textStroke || { color: '#000', width: hasBg ? 6 : 10 }; + if (richText) { + this._drawRichText(ctx, text, color, ts, W, H); + } else { + if (ts && ts.width > 0) { + ctx.lineWidth = ts.width; + ctx.lineJoin = 'round'; + ctx.strokeStyle = ts.color || '#000'; + ctx.strokeText(text, W / 2, H / 2 + 4); + } + ctx.fillStyle = color; + ctx.fillText(text, W / 2, H / 2 + 4); + } + } + + /** Путь скруглённого прямоугольника (roundRect не везде есть). */ + _roundRectPath(ctx, x, y, w, h, r) { + r = Math.min(r, w / 2, h / 2); + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.arcTo(x + w, y, x + w, y + h, r); + ctx.arcTo(x + w, y + h, x, y + h, r); + ctx.arcTo(x, y + h, x, y, r); + ctx.arcTo(x, y, x + w, y, r); + ctx.closePath(); + } + + /** + * RichText: парсим теги ..., ..., .... + * Рисуем сегментами по горизонтали, центрируя всю строку. Вложенность не + * поддерживается (на MVP) — берём последний открытый тег каждого типа. + */ + _drawRichText(ctx, text, baseColor, ts, W, H) { + const segs = this._parseRich(text, baseColor); + const fontPx = 120; + // Замер ширины каждого сегмента в его размере. + let total = 0; + for (const s of segs) { + ctx.font = `${s.bold ? 800 : 700} ${Math.round(fontPx * s.sizeMul)}px "Roboto Condensed","Segoe UI",sans-serif`; + s.w = ctx.measureText(s.text).width; + total += s.w; + } + let x = (W - total) / 2; + for (const s of segs) { + ctx.font = `${s.bold ? 800 : 700} ${Math.round(fontPx * s.sizeMul)}px "Roboto Condensed","Segoe UI",sans-serif`; + ctx.textAlign = 'left'; + if (ts && ts.width > 0) { + ctx.lineWidth = ts.width; + ctx.lineJoin = 'round'; + ctx.strokeStyle = ts.color || '#000'; + ctx.strokeText(s.text, x, H / 2 + 4); + } + ctx.fillStyle = s.color; + ctx.fillText(s.text, x, H / 2 + 4); + x += s.w; + } + ctx.textAlign = 'center'; + } + + /** Простой парсер richText → [{text, color, bold, sizeMul}]. */ + _parseRich(text, baseColor) { + const segs = []; + let color = baseColor, bold = false, sizeMul = 1; + // Разбиваем по тегам (открывающим/закрывающим). + const re = /<(\/?)(?:(color)=([#0-9a-fA-F]+)|(b)|(i)|(size)=(\d+))>|([^<]+)/g; + let m; + while ((m = re.exec(text)) !== null) { + const closing = m[1] === '/'; + if (m[8] != null) { + // текстовый кусок + if (m[8]) segs.push({ text: m[8], color, bold, sizeMul }); + } else if (m[2]) { // + color = closing ? baseColor : m[3]; + } else if (m[4]) { // + bold = !closing; + } else if (m[6]) { // + sizeMul = closing ? 1 : Math.max(0.4, Math.min(2, Number(m[7]) / 100)); + } + // игнорим визуально (italic в canvas через font-style — опускаем на MVP) + } + if (segs.length === 0) segs.push({ text: '', color: baseColor, bold: false, sizeMul: 1 }); + return segs; + } + + /** Каждый кадр: maxDistance-скрытие плашек дальше порога от игрока. */ + update() { + if (!this._playerMesh) return; + const pp = this._playerMesh.position; + for (const rec of this.labels.values()) { + if (rec.maxDistance == null) continue; + const ap = rec.plane.getAbsolutePosition(); + const dx = ap.x - pp.x, dy = ap.y - pp.y, dz = ap.z - pp.z; + const far = (dx * dx + dy * dy + dz * dz) > rec.maxDistance * rec.maxDistance; + rec.plane.setEnabled(!far); + } + } + + /** Убрать плашку с объекта. */ clearLabel(ref) { const rec = this.labels.get(ref); if (!rec) return; @@ -84,7 +389,7 @@ export class LabelManager { this.labels.delete(ref); } - /** Удалить все метки (при выходе из Play). */ + /** Удалить все плашки (при выходе из Play). */ clearAll() { for (const ref of [...this.labels.keys()]) this.clearLabel(ref); } diff --git a/src/engine/LoadingScreenOverlay.js b/src/engine/LoadingScreenOverlay.js index 47f6b56..d0666f2 100644 --- a/src/engine/LoadingScreenOverlay.js +++ b/src/engine/LoadingScreenOverlay.js @@ -35,7 +35,25 @@ function injectSpinnerCss() { style.textContent = '@keyframes kbn-ls-spin{from{transform:rotate(0)}to{transform:rotate(360deg)}}' + '.kbn-ls-spinner{animation:kbn-ls-spin 0.9s linear infinite}' + - '@media (prefers-reduced-motion:reduce){.kbn-ls-spinner{animation:none}}'; + // Ken Burns — медленный pan+zoom фона (задача 05). + '@keyframes kbn-ls-kenburns{' + + '0%{transform:scale(1.0) translate3d(0,0,0)}' + + '50%{transform:scale(1.10) translate3d(-3%,-2%,0)}' + + '100%{transform:scale(1.0) translate3d(-6%,0,0)}}' + + '.kbn-ls-kenburns{animation:kbn-ls-kenburns 24s ease-in-out infinite}' + + // particles — медленно всплывающие искры. + '@keyframes kbn-ls-rise{' + + '0%{transform:translateY(0) scale(1);opacity:0}' + + '10%{opacity:0.9}' + + '90%{opacity:0.7}' + + '100%{transform:translateY(-110vh) scale(1.4);opacity:0}}' + + '.kbn-ls-particle{animation:kbn-ls-rise linear infinite}' + + // лёгкий «дыхательный» glow карточки-превью. + '@keyframes kbn-ls-cardglow{' + + '0%,100%{box-shadow:0 18px 60px rgba(0,0,0,0.6),0 0 0 rgba(120,160,255,0)}' + + '50%{box-shadow:0 18px 60px rgba(0,0,0,0.6),0 0 40px rgba(120,160,255,0.35)}}' + + '.kbn-ls-cardglow{animation:kbn-ls-cardglow 4s ease-in-out infinite}' + + '@media (prefers-reduced-motion:reduce){.kbn-ls-spinner,.kbn-ls-kenburns,.kbn-ls-particle,.kbn-ls-cardglow{animation:none}}'; document.head.appendChild(style); } catch { /* ignore */ } } @@ -49,14 +67,17 @@ export class LoadingScreenOverlay { // Мост наружу (GameRuntime подписывает) — id-based колбэки. this._onSkipCb = null; // (id) => void this._onCompleteCb = null; // (id) => void + this._onHideCb = null; // () => void — задача 05 (game.loading.onHide) + this._parallaxHandler = null; // DOM-ссылки активного экрана: this._els = null; } /** Колбэки-мост к GameRuntime (он рассылает globalEvent в sandbox'ы). */ - setBridge(onSkip, onComplete) { + setBridge(onSkip, onComplete, onHide) { this._onSkipCb = onSkip; this._onCompleteCb = onComplete; + if (onHide) this._onHideCb = onHide; } /** Конфиг проекта (логотип/акцент по умолчанию) из BabylonScene._loadingConfig. */ @@ -104,6 +125,15 @@ export class LoadingScreenOverlay { logoCornerRadius: opts.logoCornerRadius != null ? Number(opts.logoCornerRadius) : 12, // Текст под картинкой text: opts.text != null ? String(opts.text) : '', + // --- Задача 05: Ken-Burns фон + карточка места --- + // style: 'ken-burns' | 'static' | 'parallax' | 'particles' + style: opts.style || cfg.style || 'ken-burns', + // фоновое размытое изображение (на весь экран); резолвится в _resolveCover. + background: opts.background != null ? opts.background : (cfg.background || null), + // карточка-витрина по центру (название места + автор), как в Roblox. + placeName: opts.placeName != null ? String(opts.placeName) : (cfg.placeName || ''), + studioName: opts.studioName != null ? String(opts.studioName) : (cfg.studioName || ''), + verified: opts.verified != null ? !!opts.verified : !!cfg.verified, // Поведение blockInput: opts.blockInput !== false, pauseSimulation: opts.pauseSimulation !== false, @@ -163,20 +193,107 @@ export class LoadingScreenOverlay { // (используем opacity всего root для fade, а bgOpacity — через rgba фон): root.style.background = this._bgRgba(st.bgColor, st.bgOpacity); - // --- Cover (картинка по центру) --- - const coverUrl = this._resolveCover(cover); - const coverImg = document.createElement('div'); - coverImg.style.cssText = - 'max-width:min(62vw,860px);width:62vw;aspect-ratio:16/9;border-radius:16px;' + - 'box-shadow:0 18px 60px rgba(0,0,0,0.6);background-size:cover;background-position:center;' + - 'background-color:#1a1f2b;margin-bottom:140px;'; - if (coverUrl) coverImg.style.backgroundImage = `url("${coverUrl}")`; + // --- Фоновый слой (Ken Burns / parallax / static) --- + // Размытое изображение игры на весь экран. Отдельный div под контентом, + // чтобы blur/анимация не трогали карточку и текст. + const bgUrl = this._resolveCover(st.background); + const bgLayer = document.createElement('div'); + let bgClass = ''; + if (bgUrl) { + if (st.style === 'ken-burns') bgClass = 'kbn-ls-kenburns'; + bgLayer.className = bgClass; + bgLayer.style.cssText = + 'position:absolute;inset:-8%;z-index:0;background-size:cover;background-position:center;' + + 'filter:blur(8px) brightness(0.55);will-change:transform;' + + `background-image:url("${bgUrl}");`; + // parallax — лёгкий сдвиг по мыши (2 слоя имитируем одним + transform). + if (st.style === 'parallax') { + bgLayer.style.transition = 'transform 0.25s ease-out'; + this._parallaxHandler = (e) => { + const cx = (e.clientX / window.innerWidth - 0.5) * 28; + const cy = (e.clientY / window.innerHeight - 0.5) * 18; + bgLayer.style.transform = `translate3d(${-cx}px,${-cy}px,0) scale(1.06)`; + }; + window.addEventListener('mousemove', this._parallaxHandler); + } + root.appendChild(bgLayer); + } - // --- Текст под картинкой --- + // --- particles слой (медленные искры) --- + if (st.style === 'particles') { + const pLayer = document.createElement('div'); + pLayer.style.cssText = 'position:absolute;inset:0;z-index:1;overflow:hidden;pointer-events:none;'; + for (let i = 0; i < 26; i++) { + const sp = document.createElement('span'); + sp.className = 'kbn-ls-particle'; + const size = 2 + Math.round(Math.random() * 4); + const dur = 7 + Math.random() * 10; + sp.style.cssText = + `position:absolute;bottom:-10px;left:${(Math.random() * 100).toFixed(1)}%;` + + `width:${size}px;height:${size}px;border-radius:50%;` + + `background:rgba(${180 + Math.round(Math.random() * 70)},${190 + Math.round(Math.random() * 60)},255,0.85);` + + `box-shadow:0 0 ${size * 2}px rgba(140,170,255,0.7);` + + `animation-duration:${dur.toFixed(1)}s;animation-delay:${(-Math.random() * dur).toFixed(1)}s;`; + pLayer.appendChild(sp); + } + root.appendChild(pLayer); + } + + // Обёртка контента (над фоном). + const content = document.createElement('div'); + content.style.cssText = + 'position:relative;z-index:2;display:flex;flex-direction:column;align-items:center;justify-content:center;width:100%;height:100%;'; + + // --- Cover (картинка-карточка по центру) --- + const coverUrl = this._resolveCover(cover); + // Режим карточки места (задача 05): квадрат + название + автор под ней. + const hasPlaceCard = !!(st.placeName || st.studioName); + const coverImg = document.createElement('div'); + if (hasPlaceCard) { + coverImg.className = 'kbn-ls-cardglow'; + coverImg.style.cssText = + 'width:min(42vw,400px);aspect-ratio:1/1;border-radius:18px;' + + 'background-size:cover;background-position:center;background-color:#1a1f2b;' + + 'border:2px solid rgba(255,255,255,0.12);'; + } else { + coverImg.style.cssText = + 'max-width:min(62vw,860px);width:62vw;aspect-ratio:16/9;border-radius:16px;' + + 'box-shadow:0 18px 60px rgba(0,0,0,0.6);background-size:cover;background-position:center;' + + 'background-color:#1a1f2b;margin-bottom:140px;'; + } + if (coverUrl) coverImg.style.backgroundImage = `url("${coverUrl}")`; + else if (bgUrl && hasPlaceCard) coverImg.style.backgroundImage = `url("${bgUrl}")`; + + // --- Название места (крупный белый, под карточкой) --- + const placeEl = document.createElement('div'); + placeEl.style.cssText = + 'margin-top:22px;color:#fff;font-size:38px;font-weight:800;letter-spacing:0.5px;' + + 'text-align:center;text-shadow:0 3px 14px rgba(0,0,0,0.7);' + + (st.placeName ? '' : 'display:none;'); + placeEl.textContent = st.placeName || ''; + + // --- Автор + verified-галочка --- + const studioRow = document.createElement('div'); + studioRow.style.cssText = + 'margin-top:8px;display:flex;align-items:center;gap:7px;' + + 'color:#cdd6e6;font-size:16px;font-weight:600;text-shadow:0 1px 4px rgba(0,0,0,0.6);' + + (st.studioName ? '' : 'display:none;'); + const studioTxt = document.createElement('span'); + studioTxt.textContent = st.studioName || ''; + studioRow.appendChild(studioTxt); + if (st.verified) studioRow.appendChild(this._buildVerifiedBadge()); + + // --- Текст под картинкой (для не-карточного режима / mid-game) --- const textEl = document.createElement('div'); - textEl.style.cssText = - 'position:absolute;left:50%;transform:translateX(-50%);bottom:170px;' + - 'color:#e8edf5;font-size:18px;font-weight:500;text-align:center;text-shadow:0 1px 3px rgba(0,0,0,0.6);'; + if (hasPlaceCard) { + textEl.style.cssText = + 'margin-top:14px;color:#e8edf5;font-size:16px;font-weight:500;text-align:center;' + + 'text-shadow:0 1px 3px rgba(0,0,0,0.6);' + (st.text ? '' : 'display:none;'); + } else { + textEl.style.cssText = + 'position:absolute;left:50%;transform:translateX(-50%);bottom:170px;' + + 'color:#e8edf5;font-size:18px;font-weight:500;text-align:center;text-shadow:0 1px 3px rgba(0,0,0,0.6);'; + } textEl.textContent = st.text || ''; // --- Прогресс-бар --- @@ -245,8 +362,13 @@ export class LoadingScreenOverlay { spinWrap.appendChild(spinTxt); spinWrap.appendChild(spinCircle); - root.appendChild(coverImg); - root.appendChild(textEl); + // Центральная композиция (карточка + название + автор + текст) — в content. + content.appendChild(coverImg); + content.appendChild(placeEl); + content.appendChild(studioRow); + content.appendChild(textEl); + root.appendChild(content); + // Нижние/угловые элементы — абсолютно на root (поверх фона, рядом с content). root.appendChild(barWrap); root.appendChild(percent); root.appendChild(skipBtn); @@ -255,7 +377,19 @@ export class LoadingScreenOverlay { parent.appendChild(root); this.root = root; - this._els = { root, coverImg, textEl, barWrap, bar, percent, skipBtn, logo, spinWrap }; + this._els = { root, content, coverImg, placeEl, studioRow, textEl, barWrap, bar, percent, skipBtn, logo, spinWrap }; + } + + /** Verified-галочка (синий круг + белый чек) — inline SVG, без emoji-шрифта. */ + _buildVerifiedBadge() { + const wrap = document.createElement('span'); + wrap.style.cssText = 'display:inline-flex;align-items:center;'; + wrap.innerHTML = + '' + + '' + + ''; + return wrap; } /** tick — fade-фазы, авто-duration, появление кнопки Пропустить. */ @@ -329,6 +463,23 @@ export class LoadingScreenOverlay { if (url) this._els.coverImg.style.backgroundImage = `url("${url}")`; } + /** Задача 05: сменить фоновое (Ken-Burns) изображение на лету. */ + setBackground(bg) { + if (!this._st || !this._els) return; + const url = this._resolveCover(bg); + if (!url) return; + this._st.background = bg; + // фоновый слой — первый ребёнок root с background-image; найдём его. + const layer = this._els.root.querySelector('.kbn-ls-kenburns') + || this._els.root.firstElementChild; + if (layer && layer !== this._els.content) layer.style.backgroundImage = `url("${url}")`; + } + + /** Задача 05: виден ли экран сейчас. */ + isVisible() { + return !!(this._st && this._st.phase !== 'out'); + } + /** Закрыть программно (с fadeOut). */ close() { const st = this._st; @@ -361,6 +512,13 @@ export class LoadingScreenOverlay { if (st.blockInput) { try { this.s.player?.setInputBlocked?.(false); } catch { /* ignore */ } } if (st.pauseSimulation && this.s.gameRuntime) { try { this.s.gameRuntime.paused = false; } catch { /* ignore */ } } } + // Снять parallax-listener (задача 05). + if (this._parallaxHandler) { + try { window.removeEventListener('mousemove', this._parallaxHandler); } catch { /* ignore */ } + this._parallaxHandler = null; + } + // onHide-мост (задача 05) — сообщаем скриптам что экран скрылся. + if (this._onHideCb) { try { this._onHideCb(); } catch { /* ignore */ } } if (this.root) { try { this.root.remove(); } catch { /* ignore */ } } this.root = null; this._els = null; diff --git a/src/engine/RbxlHudOverlay.js b/src/engine/RbxlHudOverlay.js new file mode 100644 index 0000000..ae17084 --- /dev/null +++ b/src/engine/RbxlHudOverlay.js @@ -0,0 +1,177 @@ +/** + * RbxlHudOverlay — DOM-оверлей с HUD-элементами для импортированных + * Roblox-карт: KillFeed (правый верх), Message/Hint (центр), WinGui. + * + * Один контейнер `.rbxl-hud-overlay` на canvas.parentElement, в нём дочерние + * блоки по типу. Стили inline, ничего не зависит от CSS приложения. + * + * API: + * const hud = new RbxlHudOverlay(canvasParent); + * hud.addKillFeed(killer, victim, weapon) + * hud.showMessage(text, opts) + * hud.hideMessage() + * hud.showWin(text) + * hud.dispose() + */ + +export class RbxlHudOverlay { + constructor(parent) { + this._parent = parent || document.body; + this._root = null; + this._killFeed = null; + this._message = null; + this._winBox = null; + this._killEntries = []; // [{el, expireAt}] + this._mount(); + } + + _mount() { + if (this._root) return; + const root = document.createElement('div'); + root.className = 'rbxl-hud-overlay'; + Object.assign(root.style, { + position: 'absolute', + inset: '0', + pointerEvents: 'none', + zIndex: '999', + fontFamily: 'system-ui, -apple-system, "Segoe UI", sans-serif', + }); + this._parent.appendChild(root); + this._root = root; + + // KillFeed — правый верхний угол + const kf = document.createElement('div'); + Object.assign(kf.style, { + position: 'absolute', + top: '60px', + right: '12px', + display: 'flex', + flexDirection: 'column', + gap: '6px', + maxWidth: '320px', + pointerEvents: 'none', + }); + root.appendChild(kf); + this._killFeed = kf; + + // Message — центр сверху (Roblox Message по центру экрана, + // но в верхней трети чтобы не мешать игре) + const msg = document.createElement('div'); + Object.assign(msg.style, { + position: 'absolute', + top: '15%', + left: '50%', + transform: 'translateX(-50%)', + padding: '10px 24px', + background: 'rgba(0,0,0,0.6)', + color: '#fff', + fontSize: '22px', + fontWeight: '600', + borderRadius: '6px', + textShadow: '0 2px 4px rgba(0,0,0,0.8)', + display: 'none', + pointerEvents: 'none', + }); + root.appendChild(msg); + this._message = msg; + + // WinGui — большая надпись по центру + const win = document.createElement('div'); + Object.assign(win.style, { + position: 'absolute', + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + padding: '24px 48px', + background: 'rgba(0,0,0,0.75)', + color: '#ffd86b', + fontSize: '48px', + fontWeight: '800', + borderRadius: '12px', + textShadow: '0 4px 8px rgba(0,0,0,0.8)', + display: 'none', + pointerEvents: 'none', + }); + root.appendChild(win); + this._winBox = win; + + // Тик для авто-исчезновения KillFeed entries (через 5с) + this._tickInterval = setInterval(() => this._cleanupKills(), 500); + } + + addKillFeed(killer, victim, weapon) { + if (!this._killFeed) return; + const entry = document.createElement('div'); + Object.assign(entry.style, { + background: 'rgba(0,0,0,0.55)', + color: '#fff', + padding: '6px 10px', + borderRadius: '4px', + fontSize: '13px', + display: 'flex', + gap: '6px', + alignItems: 'center', + animation: 'rbxlHudFadeIn 0.3s', + }); + const killerEl = document.createElement('span'); + killerEl.textContent = String(killer || '?'); + killerEl.style.color = '#5bd1e8'; + const arrow = document.createElement('span'); + arrow.textContent = weapon ? `→ [${weapon}] →` : '→'; + arrow.style.color = '#ff9a52'; + const victimEl = document.createElement('span'); + victimEl.textContent = String(victim || '?'); + victimEl.style.color = '#f87a7a'; + entry.appendChild(killerEl); + entry.appendChild(arrow); + entry.appendChild(victimEl); + this._killFeed.appendChild(entry); + this._killEntries.push({ el: entry, expireAt: performance.now() + 5000 }); + // Keep only last 8 + while (this._killEntries.length > 8) { + const old = this._killEntries.shift(); + try { old.el.remove(); } catch (_) {} + } + } + + _cleanupKills() { + const now = performance.now(); + const keep = []; + for (const e of this._killEntries) { + if (e.expireAt < now) { try { e.el.remove(); } catch (_) {} } + else keep.push(e); + } + this._killEntries = keep; + } + + showMessage(text, opts = {}) { + if (!this._message) return; + this._message.textContent = String(text || ''); + this._message.style.display = text ? 'block' : 'none'; + if (opts.duration) { + clearTimeout(this._msgTimer); + this._msgTimer = setTimeout(() => this.hideMessage(), opts.duration); + } + } + + hideMessage() { + if (this._message) this._message.style.display = 'none'; + } + + showWin(text) { + if (!this._winBox) return; + this._winBox.textContent = String(text || ''); + this._winBox.style.display = 'block'; + // Auto-hide через 6с + clearTimeout(this._winTimer); + this._winTimer = setTimeout(() => { if (this._winBox) this._winBox.style.display = 'none'; }, 6000); + } + + dispose() { + try { this._root?.remove(); } catch (_) {} + clearInterval(this._tickInterval); + clearTimeout(this._msgTimer); + clearTimeout(this._winTimer); + this._root = null; + } +} diff --git a/src/engine/ScriptSandboxWorker.js b/src/engine/ScriptSandboxWorker.js index ca6053d..38100ff 100644 --- a/src/engine/ScriptSandboxWorker.js +++ b/src/engine/ScriptSandboxWorker.js @@ -121,6 +121,13 @@ let _unlockedSkins = []; let _currentSkin = null; let _skinChangeHandlers = []; let _skinCoins = 0; // локальная валюта магазина скинов (рублики проекта) +// Phase 6.4 / задача 20: custom tools, leaderstats, achievements, remote events +let _toolSeq = 0; +let _toolCallbacks = {}; // toolId → { activated, equipped, unequipped } +let _lsMirror = {}; // playerId('@me'|sid) → { statName: value } +let _lsChangeHandlers = []; +let _achUnlocked = {}; // id → true +let _remoteHandlers = {}; // remoteName → [fn] // Подписки game.gui.onClick(id, fn) let _guiClickHandlers = {}; // Подписки game.gui.onSubmit(id, fn) — ввод в TextBox завершён (Enter) @@ -682,7 +689,9 @@ function _buildSelfApi() { _send('self.move', { target: _target, x: nx, y: ny, z: nz }); } }, - /** Повернуть объект-носитель вокруг оси Y на угол ry (радианы). */ + /** + * Повернуть объект-носитель вокруг вертикальной оси Y на угол ry (радианы). + */ rotate(ry) { const r = Number(ry); if (!Number.isFinite(r)) return; @@ -697,7 +706,7 @@ function _buildSelfApi() { const id = _target.id ?? _target.ref; _send('scene.setVisible', { kind: k, id, ref: (k && id != null) ? (k + ':' + id) : undefined, visible: !!vis }); }, - /** Включить/выключить столкновения объекта-носителя. */ + /** Включить/выключить столкновения объекта-носителя (проходимость). */ setCollide(can) { const k = _target.kind; const id = _target.id ?? _target.ref; @@ -710,13 +719,14 @@ function _buildSelfApi() { const id = _target.id ?? _target.ref; _send('scene.setColor', { kind: k, id, ref: (k && id != null) ? (k + ':' + id) : undefined, color: hex }); }, - /** Повесить текст-метку над объектом-носителем. */ + /** Повесить текст-метку над объектом-носителем (имя/HP). */ setLabel(text, opts) { const k = _target.kind; const id = _target.id ?? _target.ref; const ref = (k && id != null) ? (k + ':' + id) : undefined; _send('scene.setLabel', { ref, text: String(text == null ? '' : text), opts: opts || {} }); }, + /** Убрать метку с объекта-носителя. */ clearLabel() { const k = _target.kind; const id = _target.id ?? _target.ref; @@ -1155,6 +1165,18 @@ const game = { * game.player.giveTool('blaster-blaster-a', { equip: true }); */ giveTool(toolType, opts) { + // Phase 6.4: принимаем и Tool-объект (из game.tools.create), и строку. + if (toolType && typeof toolType === 'object' && toolType.id) { + _send('inventory.give', { + kind: toolType.kind || 'tool', + modelTypeId: toolType.modelTypeId || null, + name: toolType.name, + customToolId: toolType.id, + params: {}, + equip: opts?.equip === true, + }); + return; + } if (typeof toolType !== 'string' || !toolType) return; opts = opts || {}; const isBlaster = toolType.indexOf('blaster') === 0; @@ -1269,7 +1291,8 @@ const game = { * game.tween(coin, { sy: 1.4 }, { duration: 0.5, yoyo: true, repeat: -1 }); */ tween(ref, props, opts) { - if (typeof ref !== 'string' || !props || typeof props !== 'object') return null; + ref = _normRef(ref); + if (!ref || !props || typeof props !== 'object') return null; opts = opts || {}; const id = ++_tweenSeq; if (typeof opts.onDone === 'function') _tweenCallbacks[id] = opts.onDone; @@ -1380,6 +1403,32 @@ const game = { if (!sessionId) return; _send('mp.sendTo', { sessionId, name, data }); }, + + /** + * Phase 6.6: RemoteEvent — именованные сетевые события (как в Roblox). + * const ev = game.remote.create('PlayerShoot'); + * ev.fireAllClients({ x: 10, y: 5 }); + * ev.on(({ from, data }) => { ... }); + */ + remote: { + create(name) { + const evName = String(name || ''); + return { + get name() { return evName; }, + fireAllClients(data) { _send('mp.remoteFire', { name: evName, target: 'all', data }); }, + fireOthers(data) { _send('mp.remoteFire', { name: evName, target: 'others', data }); }, + fireClient(player, data) { + const sid = typeof player === 'string' ? player : (player && player.sessionId); + if (!sid) return; + _send('mp.remoteFire', { name: evName, target: sid, data }); + }, + on(fn) { + if (typeof fn !== 'function') return; + (_remoteHandlers[evName] = _remoteHandlers[evName] || []).push(fn); + }, + }; + }, + }, /** * Подписаться на изменение HP игрока (получение урона / лечение / смерть). * fn(event) где event = { hp, maxHp, source, damaged, delta }. @@ -2807,7 +2856,7 @@ const game = { clear() { _send('inventory.clear', {}); }, - // === Задача 44: drag-drop инвентарь === + // === Задача 44: drag-drop инвентарь (сетка 8×5 + hotbar 9 + стаки) === give(itemId, count) { _send('inv2.add', { itemId, count: Number(count) || 1 }); }, take(itemId, count) { _send('inv2.remove', { itemId, count: Number(count) || 1 }); }, open() { _send('inv2.open', {}); }, @@ -2816,12 +2865,87 @@ const game = { sort(by) { _send('inv2.sort', { by: by || 'rarity' }); }, setActiveHotbar(i) { _send('inv2.setActive', { i: Number(i) || 0 }); }, }, + + // === Phase 6.4: пользовательские tools (как Roblox Tool) === + tools: { + create(name, opts) { + opts = opts || {}; + _toolSeq++; + const toolId = 'custom:' + _toolSeq; + _toolCallbacks[toolId] = {}; + const tool = { + get id() { return toolId; }, + get name() { return String(name || ('Tool ' + _toolSeq)); }, + get modelTypeId() { return opts.model || null; }, + get kind() { return opts.kind || 'tool'; }, + onActivated(fn) { if (typeof fn === 'function') _toolCallbacks[toolId].activated = fn; }, + onEquipped(fn) { if (typeof fn === 'function') _toolCallbacks[toolId].equipped = fn; }, + onUnequipped(fn) { if (typeof fn === 'function') _toolCallbacks[toolId].unequipped = fn; }, + dropAt(pos) { + if (!pos || typeof pos !== 'object') return; + _send('tools.drop', { + toolId, name: String(name), model: opts.model || null, + params: opts.params || {}, + x: Number(pos.x) || 0, y: Number(pos.y) || 0, z: Number(pos.z) || 0, + }); + }, + }; + return tool; + }, + }, + + // === Определения предметов (задача 44) === items: { define(def) { if (Array.isArray(def)) { for (const d of def) _send('items.define', { def: d }); return; } _send('items.define', { def: def || {} }); }, }, + + // === Лидерборды (leaderstats) — задача 20 === + leaderstats: { + define(name, opts) { + if (typeof name !== 'string' || !name) return; + _send('leaderstats.define', { name, opts: opts || {} }); + }, + set(playerId, name, value) { + _send('leaderstats.set', { playerId: playerId == null ? null : String(playerId), name, value: Number(value) || 0 }); + const pid = playerId == null ? '@me' : String(playerId); + if (!_lsMirror[pid]) _lsMirror[pid] = {}; + _lsMirror[pid][name] = Number(value) || 0; + }, + add(playerId, name, delta) { + _send('leaderstats.add', { playerId: playerId == null ? null : String(playerId), name, delta: Number(delta) || 0 }); + const pid = playerId == null ? '@me' : String(playerId); + if (!_lsMirror[pid]) _lsMirror[pid] = {}; + _lsMirror[pid][name] = (_lsMirror[pid][name] || 0) + (Number(delta) || 0); + }, + get(playerId, name) { + const pid = playerId == null ? '@me' : String(playerId); + return (_lsMirror[pid] && _lsMirror[pid][name]) || 0; + }, + onChange(fn) { if (typeof fn === 'function') _lsChangeHandlers.push(fn); }, + me: { + set(name, value) { game.leaderstats.set(null, name, value); }, + add(name, delta) { game.leaderstats.add(null, name, delta); }, + get(name) { return game.leaderstats.get(null, name); }, + }, + }, + + // === Достижения — задача 20 === + achievements: { + define(list) { _send('achievements.define', { list: Array.isArray(list) ? list : [list] }); }, + unlock(id, playerId) { + if (typeof id !== 'string') return; + _achUnlocked[id] = true; + _send('achievements.unlock', { id, playerId: playerId == null ? null : String(playerId) }); + }, + has(id) { return !!_achUnlocked[id]; }, + bindToStat(id, statName, cond) { _send('achievements.bindToStat', { id, statName, cond: cond || {} }); }, + setButtonVisible(v) { _send('achievements.setButtonVisible', { visible: !!v }); }, + openPage() { _send('achievements.openPage', {}); }, + }, + /** * Игроки комнаты (Фаза 4.3 — мультиплеер). * В одиночной игре (редактор) — только локальный игрок. @@ -3935,12 +4059,15 @@ self.onmessage = (e) => { if (t === 'click') { for (const fn of _globalClickHandlers) _safeCall(fn, payload, 'onClick'); } else if (t === 'leaderstatsChange') { - // Задача 20: стат изменился на main — обновляем зеркало + onChange. + // Задача 20: стат изменился на сервере/main — обновляем зеркало + onChange. const pid = payload.playerId == null ? '@me' : String(payload.playerId); if (!_lsMirror[pid]) _lsMirror[pid] = {}; _lsMirror[pid][payload.name] = payload.newValue; if (payload.isMe) { if (!_lsMirror['@me']) _lsMirror['@me'] = {}; _lsMirror['@me'][payload.name] = payload.newValue; } - for (const fn of _lsChangeHandlers) { try { fn(payload.playerId, payload.name, payload.newValue, payload.oldValue); } catch (err) { _send('log', { level: 'error', text: 'leaderstats.onChange: ' + (err && err.message ? err.message : err) }); } } + for (const fn of _lsChangeHandlers) { + try { fn(payload.playerId, payload.name, payload.newValue, payload.oldValue); } + catch (err) { _send('log', { level: 'error', text: 'leaderstats.onChange: ' + (err && err.message ? err.message : err) }); } + } } else if (t === 'achievementUnlocked') { _achUnlocked[payload.id] = true; } else if (t === 'mouseMove') { @@ -3997,13 +4124,34 @@ self.onmessage = (e) => { for (const fn of arr) _safeCall(fn, ev, 'npc.onDeath'); } } else if (t === 'toolUse') { - // payload: { tool: {kind, modelTypeId, name}, point, target } + // payload: { tool: {kind, modelTypeId, name, customToolId?}, point, target } const ev = { tool: payload.tool || null, point: payload.point || null, target: payload.target || null, }; + // Phase 6.4: per-tool callback из game.tools.create -> onActivated. + const customId = payload.tool && payload.tool.customToolId; + if (customId && _toolCallbacks[customId] && _toolCallbacks[customId].activated) { + _safeCall(_toolCallbacks[customId].activated, ev, 'tool.onActivated:' + customId); + } for (const fn of _toolUseHandlers) _safeCall(fn, ev, 'onToolUse'); + } else if (t === 'toolEquipped') { + const customId = payload && payload.tool && payload.tool.customToolId; + if (customId && _toolCallbacks[customId] && _toolCallbacks[customId].equipped) { + _safeCall(_toolCallbacks[customId].equipped, payload, 'tool.onEquipped:' + customId); + } + } else if (t === 'toolUnequipped') { + const customId = payload && payload.tool && payload.tool.customToolId; + if (customId && _toolCallbacks[customId] && _toolCallbacks[customId].unequipped) { + _safeCall(_toolCallbacks[customId].unequipped, payload, 'tool.onUnequipped:' + customId); + } + } else if (t === 'remoteEvent') { + // Phase 6.6: RemoteEvent от сервера. payload: { from, name, data } + const arr = _remoteHandlers[payload.name] || []; + for (const fn of arr) { + _safeCall(fn, { from: payload.from, data: payload.data }, 'remote.on:' + payload.name); + } } else if (t === 'cutsceneDone') { // Катсцена камеры завершилась (Фаза 5.7). for (const fn of _cutsceneDoneHandlers) _safeCall(fn, undefined, 'onCutsceneDone'); diff --git a/src/engine/lua/LuaSharedSandbox.js b/src/engine/lua/LuaSharedSandbox.js new file mode 100644 index 0000000..466a257 --- /dev/null +++ b/src/engine/lua/LuaSharedSandbox.js @@ -0,0 +1,337 @@ +/** + * LuaSharedSandbox (v3, main-thread) — wasmoon-VM работает в MAIN потоке, + * без Web Worker. Это позволяет: + * - Видеть точные Lua-ошибки в DevTools (через console.error) + * - Использовать debugger / breakpoints прямо в RobloxShim.js + * - Не возиться с молчаливыми Worker-падениями + * + * Цена: тяжёлые Lua-скрипты могут блокировать UI. Для KillBrick-style + * скриптов это нестрашно — они быстрые. + * + * API совместим с ScriptSandbox: setOnCommand / sendEvent / sendGlobalEvent / + * sendSceneSnapshot / sendGuiSnapshot / sendDataSnapshot / sendSkinsSnapshot / + * sendTerrainHeightmap / stop / tick / target. + * + * Что добавлено сверх ScriptSandbox: + * - addScript(id, code, target) — добавить скрипт в общий VM. Можно + * до или после start(). + * - start() — асинхронен (createEngine), но возвращает сразу. После init + * стартует main loop (Heartbeat + scheduler). + */ + +import { LuaFactory } from 'wasmoon'; +import { registerRobloxShim } from './RobloxShim.js'; + +export class LuaSharedSandbox { + constructor() { + this.vm = null; + this.api = null; + this._onCommand = null; + this._isReady = false; + this._isStopped = false; + 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; + // Маркер для GameRuntime.routeEvent — этот sandbox принимает все + // события и сам маршрутизирует через shim.fireTargetEvent. + this._luaShared = true; + } + + setOnCommand(cb) { this._onCommand = cb; } + + get target() { return null; } + tick(_dt, _state) { /* no-op: main-loop запускается изнутри (setInterval) */ } + + addScript(id, code, target, name, extra) { + const entry = { + id: String(id || `lua_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`), + code: String(code || ''), + target: target == null ? null : target, + name: name || null, + toolName: extra?.toolName || null, + }; + this._scriptsById.set(entry.id, entry); + if (!this._isKickedOff) { + this._pendingScripts.push(entry); + } else { + this._startSingleScript(entry); + } + } + + removeScript(id) { + this._scriptsById.delete(String(id)); + } + + /** Стартует VM, регистрирует shim, запускает main-loop. */ + start() { + if (this.vm || this._isStopped) return; + // eslint-disable-next-line no-console + console.log('[LuaSharedSandbox v3] starting Lua VM in main thread...'); + this._initAsync().catch((err) => { + // eslint-disable-next-line no-console + console.error('[LuaSharedSandbox] FATAL init error:', err); + this._emit('log', { level: 'error', text: `Lua-runtime init: ${err?.message || err}` }); + }); + } + + 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; + 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`); + const pending = this._pendingScripts; + this._pendingScripts = []; + // Запускаем main-loop сразу — он начнёт tick'ать как только будут coroutines. + this._lastTickAt = performance.now(); + this._startMainLoop(); + // Init батчами по 5 с задержкой 20мс между ними, чтобы UI отзывался. + const BATCH_SIZE = 5; + let idx = 0; + const initBatch = () => { + if (this._isStopped) return; + const end = Math.min(idx + BATCH_SIZE, pending.length); + for (let i = idx; i < end; i++) { + try { this._startSingleScript(pending[i]); } + catch (e) { + // eslint-disable-next-line no-console + console.error('[LuaSharedSandbox] init batch err:', e); + } + } + idx = end; + if (idx < pending.length) { + setTimeout(initBatch, 20); + } else { + // eslint-disable-next-line no-console + console.log(`[LuaSharedSandbox] all ${pending.length} scripts kicked off`); + // После того как все скрипты подключили хендлеры — фейрим + // events для уже существующих сущностей. Roblox-конвенция: + // если игрок уже на сервере когда скрипт подключается, + // Players.PlayerAdded не сработает повторно. Юзеру нужно + // делать ручной обход GetPlayers() — но это редко кто помнит. + // Мы дублируем событие через короткую задержку. + setTimeout(() => { + try { + if (this.api?.fireExistingPlayers) { + this.api.fireExistingPlayers(); + } + } catch (e) { + console.warn('[LuaSharedSandbox] fireExistingPlayers failed:', e); + } + }, 100); + } + }; + setTimeout(initBatch, 0); + } + + _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; + } + const safeId = entry.id.replace(/[^a-zA-Z0-9_]/g, '_'); + const scriptName = entry.name || `Script_${safeId}`; + // Скрипт оборачиваем в coroutine — это позволяет task.wait через yield. + // Резюмим coroutine из main-loop когда наступило время. + // Регистрируем coroutine в __rbxl_coroutines с id для возобновления. + // Скрипт оборачиваем в coroutine. task.wait()→coroutine.yield(sec) возвращает + // delay из resume → планируем следующий resume через scheduleResume. + // Fallback Parent: если скрипт связан с Tool (по имени Tool из metadata) — + // подсовываем виртуальный Tool как script.Parent. Иначе primitive по id, + // иначе workspace. + let parentExpr; + if (entry.toolName) { + // Tool создаётся в shim как Instance.new('Tool'). По имени достаём. + // Если не нашли — fallback на новый Tool того же имени. + const safeName = JSON.stringify(entry.toolName); + parentExpr = `(function() + local existing = __rbxl_get_tool_by_name(${safeName}) + if existing then return existing end + local t = Instance.new("Tool") + t.Name = ${safeName} + return t + end)()`; + } else if (primId != null) { + parentExpr = `(__rbxl_get_part_by_id(${Number(primId)}) or workspace)`; + } else { + parentExpr = 'workspace'; + } + const wrapped = ` + do + -- Если parentExpr вернул primitive — у него уже есть :FindFirstChild и пр. + -- Если ничего не вернёт — workspace (всегда валидный). + -- script.Parent.Parent (Tool.Parent = StarterPack / Backpack / workspace). + local _scriptParent = ${parentExpr} + if _scriptParent == nil then _scriptParent = workspace end + if _scriptParent.Parent == nil then _scriptParent.Parent = workspace end + local script = setmetatable({ + Name = ${JSON.stringify(scriptName)}, + Parent = _scriptParent, + ClassName = "Script", + Disabled = false, + Source = nil, + }, { + -- Любой доступ к несуществующему полю → workspace + -- (на случай script.Foo:Bar() в старом коде) + __index = function(t, k) + if k == "FindFirstChild" or k == "WaitForChild" or k == "GetChildren" then + return function() return nil end + end + return workspace[k] + end, + }) + local co = coroutine.create(function() + -- WATCHDOG: каждые 100000 инструкций — yield 1 кадр. + -- НЕ оборачиваем в pcall — внутри C-call boundary yield + -- упадёт ошибкой, что прервёт скрипт. Это лучше чем виснуть. + debug.sethook(function() + coroutine.yield(0.016) + end, "", 20000) + -- pcall защищает от runtime-ошибок которые иначе крашат + -- coroutine и могут повредить WASM-стейт. Возвраты + -- handler'а намеренно поглощаются. + local ok_, err_ = pcall(function() + ${entry.code} + end) + if not ok_ then + __rbxl_send_error(${JSON.stringify(entry.id)}, tostring(err_)) + end + end) + __rbxl_register_coroutine(${JSON.stringify(entry.id)}, co) + local ok, ret = coroutine.resume(co) + if not ok then + __rbxl_send_error(${JSON.stringify(entry.id)}, tostring(ret)) + __rbxl_unregister_coroutine(${JSON.stringify(entry.id)}) + elseif type(ret) == 'number' then + -- скрипт yield'нул с delay (через task.wait) — планируем resume + __rbxl_schedule_resume(${JSON.stringify(entry.id)}, ret) + elseif coroutine.status(co) == 'dead' then + __rbxl_unregister_coroutine(${JSON.stringify(entry.id)}) + 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}` }); + } + } + + _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); + } + this._loopHandle = setTimeout(tick, 16); + }; + this._loopHandle = setTimeout(tick, 16); + } + + _emit(cmd, payload) { + if (typeof this._onCommand === 'function') { + try { this._onCommand({ cmd, payload }); } catch (_) {} + } + } + + // ----- API совместимый с ScriptSandbox ----- + sendEvent(payload) { + if (!this.api?.fireTargetEvent || !this._isReady) return; + try { this.api.fireTargetEvent(payload); } catch (e) { + console.error('[LuaSharedSandbox] sendEvent:', e); + } + } + + sendGlobalEvent(payload) { + if (!this.api?.fireGlobalEvent || !this._isReady) return; + try { this.api.fireGlobalEvent(payload); } catch (e) { + console.error('[LuaSharedSandbox] sendGlobalEvent:', e); + } + } + + sendSceneSnapshot(snapshot) { + this._scenes = snapshot; + if (this.api?.onSceneSnapshot && this._isReady) { + try { this.api.onSceneSnapshot(snapshot); } catch (e) { + console.error('[LuaSharedSandbox] onSceneSnapshot:', e); + } + } + } + + sendGuiSnapshot(snapshot) { + this._guiTree = snapshot; + if (this.api?.onGuiSnapshot && this._isReady) { + try { this.api.onGuiSnapshot(snapshot); } catch (_) {} + } + } + + sendDataSnapshot(snapshot) { + if (this.api?.onDataSnapshot && this._isReady) { + try { this.api.onDataSnapshot(snapshot); } catch (_) {} + } + } + + sendSkinsSnapshot(_) { /* no-op для Этапа 3 */ } + sendTerrainHeightmap(_) { /* no-op */ } + + stop() { + this._isStopped = true; + if (this._loopHandle) { + clearTimeout(this._loopHandle); + this._loopHandle = null; + } + if (this.vm) { + try { this.vm.global.close(); } catch (_) {} + this.vm = null; + } + this.api = null; + } +} + +export default LuaSharedSandbox; diff --git a/src/engine/lua/RobloxShim.js b/src/engine/lua/RobloxShim.js new file mode 100644 index 0000000..02d5bd4 --- /dev/null +++ b/src/engine/lua/RobloxShim.js @@ -0,0 +1,2500 @@ +/** + * RobloxShim v3 (для main-thread sandbox) — Roblox API + DataModel. + * + * Этап 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 команда + * + * ВАЖНО про wasmoon: не используем Object.defineProperty getters на JS-объектах + * передаваемых в Lua — wasmoon их некорректно оборачивает (js_promise). Вместо + * этого — обычные поля, которые юзер читает напрямую. Запись свойств происходит + * через `__rbxl_part_set(part, prop, value)` — она шлёт partSet и обновляет поле. + */ + +// ---------- Scheduler (для task.delay/defer) ---------- +const SCHEDULER = { + sleeping: [], // [{wakeAt, run}] + now: () => performance.now(), +}; + +// ---------- Базовые сигналы ---------- +const HEARTBEAT_SIGNAL = makeSignal(); +const STEPPED_SIGNAL = makeSignal(); + +// Очередь handler'ов которые надо запустить на следующем tickScheduler. +// Этим мы выходим из C-boundary — wait() внутри handler'а становится +// безопасным yield в собственной coroutine, потому что handler стартует +// уже из main loop, а не из синхронного JS-callback. +const _pendingHandlerQueue = []; + +function makeSignal() { + const sig = { + __isSignal: true, + connections: [], + }; + 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]) { + // Кладём в очередь, чтобы handler стартовал не в текущем + // JS-callback (откуда yield запрещён), а из tickScheduler + // в своей coroutine. Безопасно для wait() внутри. + _pendingHandlerQueue.push({ fn, args }); + } + }; + sig.fire = sig.Fire; + // Wait() возвращает -1 как маркер "yield 1 кадр" — наш Lua-prelude + // оборачивает все Signal:Wait через __rbxl_signal_wait который при + // получении -1 делает rbx_wait(0.05) (yield в coroutine). + sig.Wait = () => -1; + sig.wait = sig.Wait; + return sig; +} + +// ---------- Vector3 / Color3 / UDim2 / Vector2 / CFrame ---------- +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); } + 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() { 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( + 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, + ); + } +} +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); + +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); + } + 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, + this.G + (b.G - this.G) * t, + 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); + } +} + +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); } +} +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); + this.p = this.Position; + } + static new(x, y, z) { return new RbxCFrame(x || 0, y || 0, z || 0); } + 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(); } +} + +// ---------- 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) { + // В Roblox WaitForChild блокирует пока ребёнок не появится. У нас + // нет yield с произвольных JS-функций, поэтому возвращаем либо + // существующего ребёнка, либо ленивый stub-Folder чтобы избежать + // падений типа "attempt to index a nil value" в импортированных + // скриптах. Stub автоматически добавляется в Children. + const existing = this.FindFirstChild(name); + if (existing) return existing; + try { + const stub = newInstance('Folder', String(name)); + stub.Parent = this; + if (this.Children) this.Children.push(stub); + return stub; + } catch (_) { + return undefined; + } + }, + 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; + // Если это Part с примитивом — шлём sceneDelete + if (this.__primId != null && this.__sendDestroy) { + try { this.__sendDestroy(this.__primId); } catch (_) {} + } + 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 () { + // Поверхностный клон — достаточно для большинства Roblox-паттернов + // (Tool/Pellet/Bomb клонируются и parent'ятся в Workspace). + // Глубокий клон не делаем — Children копируются по ссылке (как в Roblox + // Clone() это deep copy, но у нас нет полной physical model). + try { + const copy = Object.assign({}, this); + copy.Children = (this.Children || []).slice(); + copy.Parent = undefined; + return copy; + } catch (_) { + return undefined; + } + }, + // Старый Roblox API: lowercase :clone() + clone: function () { return this.Clone && this.Clone(); }, + // model:makeJoints() — заглушка (Welds мы не делаем) + MakeJoints: function () {}, + makeJoints: function () {}, + BreakJoints: function () {}, + breakJoints: function () {}, + Remove: function () { this.Parent = undefined; }, + remove: function () { this.Parent = 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; +} + +// Создаёт stub-signal который ничего не делает — для unknown свойств Instance +// которые скрипты пытаются использовать как сигнал (script.Parent.Selected:Connect). +function makeStubSignal() { + const sig = makeSignal(); + // Помечаем чтобы знать что это stub (для возможной отладки) + sig.__stub = true; + return sig; +} + +// Callable proxy: сам вызывается как function (ничего не делает), также имеет +// поля Connect/Disconnect и Fire/fire — то есть выглядит и как метод, и как +// сигнал, и как объект. Используется для unknown method-like свойств. +function makeStubCallable() { + const fn = function () { return undefined; }; + fn.__stub = true; + fn.Connect = function () { return { Disconnect: () => {}, disconnect: () => {}, Connected: false }; }; + fn.connect = fn.Connect; + fn.Fire = function () {}; + fn.fire = fn.Fire; + fn.Wait = function () { return undefined; }; + fn.wait = fn.Wait; + return fn; +} + +// Эвристика: какие имена свойств вероятно сигналы? +// В Roblox сигналы заканчиваются на: Changed, Added, Removed, Began, Ended, +// Clicked, Activated, Touched, Selected, Deselected, Equipped, Unequipped, и т.д. +function isProbablySignalName(prop) { + if (typeof prop !== 'string') return false; + return /^(Mouse|Touch|Input|Render|Step|Heart|Render|On|Char|Player|Selected|Deselect|Equipped|Unequipped|Activated|Click|Changed|Added|Removed|Began|Ended|Died|Spawned|Reached|Loaded|Hover)/.test(prop) + || /(Changed|Added|Removed|Began|Ended|Clicked|Activated|Touched|Died|Loaded|Hover|Connect|Event|Signal|Reached)$/.test(prop); +} + +// Универсальный object-stub: ведёт себя как сигнал, как Instance, как Tool/Folder. +// НЕ function — иначе wasmoon мапит в Lua-function и Lua-индексация `.field` +// падает с "attempt to index a function value". +function makeObjectStub(name) { + const target = { + __stubName: name || 'stub', + // Signal API + Connect() { return { Disconnect() {}, disconnect() {}, Connected: false }; }, + connect() { return this.Connect(); }, + Wait() { return undefined; }, + wait() { return undefined; }, + Fire() {}, + fire() {}, + Disconnect() {}, + disconnect() {}, + // Instance read-API + FindFirstChild() { return undefined; }, + FindFirstChildOfClass() { return undefined; }, + FindFirstAncestor() { return undefined; }, + FindFirstAncestorOfClass() { return undefined; }, + GetChildren() { return []; }, + GetDescendants() { return []; }, + IsA() { return false; }, + GetFullName() { return name || 'stub'; }, + Destroy() {}, + Clone() { return makeObjectStub(name); }, + GetAttribute() { return undefined; }, + SetAttribute() {}, + GetPropertyChangedSignal() { return makeObjectStub('Changed'); }, + // Tool/Animation/Sound — частые no-op методы + Activate() {}, Deactivate() {}, Equip() {}, Unequip() {}, + Play() {}, Stop() {}, Pause() {}, Resume() {}, + AdjustSpeed() {}, LoadAnimation() { return makeObjectStub('Animation'); }, + TakeDamage() {}, MoveTo() {}, + // Базовые поля + Parent: undefined, + Name: name || 'stub', + ClassName: 'Folder', + Children: [], + }; + target.WaitForChild = function (childName) { return makeObjectStub(childName); }; + return new Proxy(target, { + get(t, prop) { + if (Object.prototype.hasOwnProperty.call(t, prop) || prop in t) { + return t[prop]; + } + if (typeof prop !== 'string') return undefined; + if (prop === 'then' || prop === 'catch' || prop === 'finally' || + prop === 'toJSON' || prop === 'constructor' || prop === 'prototype' || + prop.startsWith('__') || prop.startsWith('Symbol')) { + return undefined; + } + const child = makeObjectStub(prop); + t[prop] = child; + return child; + }, + set(t, prop, value) { t[prop] = value; return true; }, + }); +} + +function newInstance(className, name) { + const m = makeInstanceMethods(); + const target = { + 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, + }; + // Proxy: для unknown свойств возвращаем stub чтобы скрипты не падали. + let proxyRef; + proxyRef = new Proxy(target, { + get(t, prop) { + // Существующее свойство всегда возвращаем как есть (включая методы) + if (Object.prototype.hasOwnProperty.call(t, prop) || prop in t) { + return t[prop]; + } + // Не-строки и Symbol.* — undefined чтобы wasmoon не путался + if (typeof prop !== 'string') return undefined; + // wasmoon JS-internal ключи — undefined + if (prop === 'then' || prop === 'catch' || prop === 'finally' || + prop === 'toJSON' || prop === 'toString' || prop === 'valueOf' || + prop === 'constructor' || prop === 'prototype' || + prop.startsWith('__') || prop.startsWith('Symbol')) { + return undefined; + } + // Object-stub: ведёт себя как сигнал (Connect), как Instance + // (WaitForChild, GetChildren), как Tool (Activate). НЕ function — + // иначе Lua упадёт с "attempt to index a function value". + const stub = makeObjectStub(prop); + t[prop] = stub; + return stub; + }, + set(t, prop, value) { + // Авто-управление иерархией при `inst.Parent = X`: + // 1) удаляем себя из Children старого Parent + // 2) пушим в Children нового Parent + // 3) фейерим ChildAdded/ChildRemoved + if (prop === 'Parent') { + const oldP = t.Parent; + if (oldP && oldP.Children) { + const i = oldP.Children.indexOf(proxyRef); + if (i >= 0) { + oldP.Children.splice(i, 1); + try { oldP.ChildRemoved && oldP.ChildRemoved.Fire(proxyRef); } catch (_) {} + } + } + t[prop] = value; + if (value && value.Children && value.Children.indexOf(proxyRef) < 0) { + value.Children.push(proxyRef); + try { value.ChildAdded && value.ChildAdded.Fire(proxyRef); } catch (_) {} + } + // Спец-регистрация для ClickDetector — чтобы клик по Part + // мог сфейерить MouseClick через fireTargetEvent. + if (t.ClassName === 'ClickDetector' && value) { + try { value._clickDetector = proxyRef; } catch (_) {} + } + try { t.AncestryChanged && t.AncestryChanged.Fire(proxyRef, value); } catch (_) {} + return true; + } + t[prop] = value; + return true; + }, + has(t, prop) { + // Для in-проверок отвечаем true почти всегда (чтобы Lua не падал на + // условиях вроде if obj.SomeField then ...) + return true; + }, + }); + return proxyRef; +} + +/** + * Создать 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.__sendDestroy = (id) => sendFn('sceneDelete', { primId: id }); + p.Touched = makeSignal(); + p.TouchEnded = makeSignal(); + p.Material = 'Plastic'; + + // Внутренний state: реальные значения хранятся здесь, в Lua через getter/setter. + p._state = { + Position: new RbxVector3(primData.x || 0, primData.y || 0, primData.z || 0), + Size: new RbxVector3(primData.sx || 1, primData.sy || 1, primData.sz || 1), + Color: primData.color ? RbxColor3.fromHex(primData.color) : new RbxColor3(0.5, 0.5, 0.5), + Anchored: !!primData.anchored, + CanCollide: primData.canCollide !== false, + Transparency: primData.opacity != null ? (1 - primData.opacity) : 0, + }; + + // Setter'ы шлют partSet → BabylonScene.primitiveManager через handleLuaCommand. + const send = (prop, value) => { + try { sendFn('partSet', { primId: p.__primId, prop, value }); } catch (_) {} + }; + Object.defineProperty(p, 'Position', { + get() { return p._state.Position; }, + set(v) { + if (!v) return; + const nv = new RbxVector3(v.X || 0, v.Y || 0, v.Z || 0); + p._state.Position = nv; + send('position', { x: nv.X, y: nv.Y, z: nv.Z }); + }, + enumerable: true, configurable: true, + }); + Object.defineProperty(p, 'Size', { + get() { return p._state.Size; }, + set(v) { + if (!v) return; + const nv = new RbxVector3(v.X || 1, v.Y || 1, v.Z || 1); + p._state.Size = nv; + send('size', { sx: nv.X, sy: nv.Y, sz: nv.Z }); + }, + enumerable: true, configurable: true, + }); + Object.defineProperty(p, 'Color', { + get() { return p._state.Color; }, + set(v) { + if (!v) return; + const nv = v instanceof RbxColor3 ? v : new RbxColor3(v.R || 0, v.G || 0, v.B || 0); + p._state.Color = nv; + // handleLuaCommand ожидает строку для color + send('color', nv.toHex()); + }, + enumerable: true, configurable: true, + }); + Object.defineProperty(p, 'BrickColor', { + get() { return { Color: p._state.Color, Name: 'Custom' }; }, + set(v) { if (v && v.Color) p.Color = v.Color; }, + enumerable: true, configurable: true, + }); + Object.defineProperty(p, 'Anchored', { + get() { return p._state.Anchored; }, + set(v) { + p._state.Anchored = !!v; + send('anchored', !!v); + }, + enumerable: true, configurable: true, + }); + Object.defineProperty(p, 'CanCollide', { + get() { return p._state.CanCollide; }, + set(v) { + p._state.CanCollide = !!v; + send('canCollide', !!v); + }, + enumerable: true, configurable: true, + }); + Object.defineProperty(p, 'Transparency', { + get() { return p._state.Transparency; }, + set(v) { + const nv = Math.max(0, Math.min(1, Number(v) || 0)); + p._state.Transparency = nv; + // handleLuaCommand ожидает number для opacity + send('opacity', 1 - nv); + }, + enumerable: true, configurable: true, + }); + Object.defineProperty(p, 'CFrame', { + get() { + const pos = p._state.Position; + return new RbxCFrame(pos.X, pos.Y, pos.Z); + }, + set(v) { + if (v && v.Position) p.Position = v.Position; + else if (v && v.X != null) p.Position = new RbxVector3(v.X, v.Y, v.Z); + }, + enumerable: true, configurable: true, + }); + return p; +} + +// ---------- Регистрация в Lua ---------- +export function registerRobloxShim(lua, opts) { + const { send } = opts; + const global = lua.global; + + // === Базовые типы === + 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, + FromNormalId: () => new RbxVector3(), + }); + 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) => RbxColor3.fromHex(hex), + }); + // BrickColor — старая система цветов Roblox по имени + const BRICK_COLORS = { + 'White': [1, 1, 1], 'Black': [0.1, 0.1, 0.1], 'Grey': [0.6, 0.6, 0.6], + 'Bright red': [0.77, 0.2, 0.2], 'Bright blue': [0.05, 0.4, 0.7], + 'Bright green': [0.3, 0.8, 0.2], 'Bright yellow': [1, 0.85, 0.1], + 'Bright orange': [0.85, 0.5, 0.15], 'Bright violet': [0.45, 0.2, 0.65], + 'Dark blue': [0.05, 0.15, 0.4], 'Dark green': [0.15, 0.4, 0.2], + 'Dark red': [0.4, 0.1, 0.1], 'Lime green': [0.7, 0.95, 0.3], + 'Pink': [1, 0.55, 0.7], 'Brown': [0.4, 0.25, 0.15], + 'Reddish brown': [0.45, 0.2, 0.15], 'Sand red': [0.85, 0.6, 0.55], + 'Medium blue': [0.4, 0.65, 0.85], 'Cyan': [0, 0.8, 0.8], + 'Magenta': [0.85, 0, 0.85], 'Really red': [1, 0, 0], 'Really blue': [0, 0, 1], + 'Really black': [0, 0, 0], 'Really white': [1, 1, 1], + }; + function _brickToColor3(name) { + const rgb = BRICK_COLORS[name] || [0.5, 0.5, 0.5]; + return new RbxColor3(rgb[0], rgb[1], rgb[2]); + } + global.set('BrickColor', { + new(nameOrR, g, b) { + // BrickColor.new("Bright red") или BrickColor.new(r, g, b) + const name = typeof nameOrR === 'string' ? nameOrR : 'White'; + const c = typeof nameOrR === 'string' + ? _brickToColor3(nameOrR) + : new RbxColor3(nameOrR, g, b); + return { Color: c, Name: name, Number: 1, R: c.R, G: c.G, B: c.B, + r: c.R, g: c.G, b: c.B }; + }, + random() { return { Color: new RbxColor3(Math.random(), Math.random(), Math.random()), Name: 'Random' }; }, + White() { return this.new('White'); }, + Black() { return this.new('Black'); }, + Gray() { return this.new('Grey'); }, + Red() { return this.new('Bright red'); }, + Yellow() { return this.new('Bright yellow'); }, + Green() { return this.new('Bright green'); }, + Blue() { return this.new('Bright blue'); }, + DarkGray() { return this.new('Dark stone grey'); }, + palette(n) { return this.new('White'); }, + }); + // Ray — луч, используется в raycast + global.set('Ray', { + new(origin, direction) { return { Origin: origin, Direction: direction }; }, + }); + // Region3 — куб в пространстве + global.set('Region3', { + new(min, max) { return { Min: min, Max: max, CFrame: { Position: min }, Size: max }; }, + }); + 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 === + const mkE = (arr) => Object.fromEntries(arr.map(k => [k, { Name: k, Value: k }])); + global.set('Enum', { + 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']), + // Часто используемые в туториалах + InfoType: mkE(['Asset','BundleDetails','Subscription','GamePass','UserProductsInExperience']), + SortOrder: mkE(['Name','Custom','LayoutOrder']), + FillDirection: mkE(['Horizontal','Vertical']), + HorizontalAlignment: mkE(['Left','Center','Right']), + VerticalAlignment: mkE(['Top','Center','Bottom']), + Font: mkE(['Legacy','Arial','SourceSans','Code','Highway','SciFi','Cartoon','Gotham','GothamBold']), + TextXAlignment: mkE(['Left','Center','Right']), + TextYAlignment: mkE(['Top','Center','Bottom']), + ScaleType: mkE(['Stretch','Slice','Tile','Fit','Crop']), + AspectType: mkE(['FitWithinMaxSize','ScaleWithParentSize']), + DominantAxis: mkE(['Width','Height']), + BorderMode: mkE(['Outline','Middle','Inset']), + FormFactor: mkE(['Symmetric','Brick','Plate','Custom']), + PartType: mkE(['Ball','Block','Cylinder','Wedge','CornerWedge']), + SurfaceType: mkE(['Smooth','Glue','Weld','Studs','Inlet','Universal']), + ContextActionResult: mkE(['Pass','Sink']), + UserInputState: mkE(['Begin','Change','End','Cancel','None']), + }); + + // TweenInfo — конструктор объекта с параметрами анимации + // Сигнатура: TweenInfo.new(time, easingStyle, easingDirection, repeatCount, reverses, delayTime) + global.set('TweenInfo', { + new(time, easingStyle, easingDirection, repeatCount, reverses, delayTime) { + return { + Time: time || 1, + EasingStyle: easingStyle, + EasingDirection: easingDirection, + RepeatCount: repeatCount || 0, + Reverses: !!reverses, + DelayTime: delayTime || 0, + }; + }, + }); + + // NumberSequence, ColorSequence — упрощённые конструкторы для GUI-эффектов + global.set('NumberSequence', { + new(...args) { return { Keypoints: [], __ns: true }; }, + }); + global.set('ColorSequence', { + new(...args) { return { Keypoints: [], __cs: true }; }, + }); + global.set('NumberRange', { + new(min, max) { return { Min: min, Max: max == null ? min : max }; }, + }); + global.set('Rect', { + new(minX, minY, maxX, maxY) { return { Min: { X: minX, Y: minY }, Max: { X: maxX, Y: maxY } }; }, + }); + + // === print / warn === + const stringify = (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 '?'; } + }; + 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') }); + }); + + // require(ModuleScript) — в Roblox загружает модуль. У нас модулей нет — + // возвращаем undefined (Lua nil) чтобы скрипты типа local mod = require(...) + // не падали. require строкой (стандартный Lua) перехватывать не будем. + global.set('require', (mod) => { + // Если передали Instance-stub — возвращаем сам stub (чтобы хоть + // что-то можно было сделать с возвращённым значением). + if (mod && typeof mod === 'object') return mod; + return undefined; + }); + + // === task.* + wait === + // task.wait/wait — реальный yield через coroutines. Юзер пишет: + // while true do part.Position = ... ; task.wait(0.1) end + // Это работает потому что **скрипт сам запускается как coroutine** + // (см. LuaSharedSandbox._startSingleScript → мы оборачиваем код в pcall, + // НО для yield нам нужно завернуть в coroutine.create). Делаем это + // через Lua-prelude: глобальная функция `_run_in_coroutine(fn)`. + global.set('task', { + 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: () => {}, + }); + // task.wait и wait определяются через Lua coroutine.yield в prelude (см. ниже) + + // === 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; + // PlayerGui — контейнер для GUI принадлежащих игроку. В Rublox это no-op + // (overlay глобальный), но Roblox-скрипты часто делают gui.Parent = playerGui. + const playerGui = newInstance('PlayerGui', 'PlayerGui'); + playerGui.Parent = localPlayer; + localPlayer.Children.push(playerGui); + localPlayer.PlayerGui = playerGui; + localPlayer.DisplayName = 'Player'; + localPlayer.Name = 'Player'; + localPlayer.Neutral = true; // не в команде по умолчанию + localPlayer.Team = undefined; + localPlayer.TeamColor = { Name: 'White', Color: new RbxColor3(1, 1, 1) }; + localPlayer.Kick = function () {}; + localPlayer.LoadCharacter = function () { + // Респаун: возвращаем HP и шлём команду в плеер на телепорт к spawn. + // Plus сбрасываем humanoid.Health на MaxHealth. + try { + if (humanoid && humanoid.MaxHealth) { + humanoid.Health = humanoid.MaxHealth; + } + send('playerSet', { prop: 'respawn', value: true }); + } catch (_) {} + }; + localPlayer.HasAppearanceLoaded = function () { return true; }; + // Backpack — инвентарь, содержит Tools. В Roblox StarterPack автоматически + // клонируется в Backpack каждого спавнящегося игрока. + const backpack = newInstance('Backpack', 'Backpack'); + backpack.Parent = localPlayer; + localPlayer.Children.push(backpack); + localPlayer.Backpack = backpack; + // Глобальный Mouse — единственный экземпляр на игрока, привязан к окну + // браузера. Реальные Button1Down/Hit фейерятся в GameRuntime. + const playerMouse = (function makePlayerMouse() { + const m = newInstance('Mouse', 'Mouse'); + m.Button1Down = makeSignal(); + m.Button1Up = makeSignal(); + m.Button2Down = makeSignal(); + m.Button2Up = makeSignal(); + m.Move = makeSignal(); + m.KeyDown = makeSignal(); + m.KeyUp = makeSignal(); + m.WheelForward = makeSignal(); + m.WheelBackward = makeSignal(); + m.Idle = makeSignal(); + // m.Icon reactive — меняет CSS cursor на canvas + let _icon = ''; + Object.defineProperty(m, 'Icon', { + get() { return _icon; }, + set(v) { + _icon = String(v || ''); + // rbxassetid → стрелочный курсор-прицел (наш дефолт) + const cssCursor = _icon && _icon.includes('rbxasset') + ? 'crosshair' : (_icon ? 'crosshair' : 'default'); + send('mouseIconChanged', { icon: _icon, cssCursor }); + }, + }); + m.X = 0; m.Y = 0; + m.ViewSizeX = 1920; m.ViewSizeY = 1080; + m.Hit = { Position: new RbxVector3(0, 0, 0), p: new RbxVector3(0, 0, 0), + Lookvector: new RbxVector3(0, 0, -1) }; + m.Origin = { Position: new RbxVector3(0, 5, 0) }; + m.Target = undefined; + m.TargetFilter = undefined; + m.TargetSurface = 'Top'; + return m; + })(); + localPlayer.GetMouse = function () { return playerMouse; }; + localPlayer.playerMouse = playerMouse; + players.Children.push(localPlayer); + players.LocalPlayer = localPlayer; + + // === Tool registry === + // Tracks все Tool-инстансы — для UI (hotbar) и equip-flow. + // GameRuntime читает API equipTool/unequipTool на main-loop. + const allTools = []; // [Tool, ...] в порядке создания (для hotbar 1-9) + let equippedTool = null; + + const character = newInstance('Model', 'Player'); + character.Parent = localPlayer; + localPlayer.Children.push(character); + localPlayer.Character = character; + localPlayer.CharacterAdded = makeSignal(); + localPlayer.CharacterRemoving = makeSignal(); + localPlayer.CharacterAppearanceLoaded = makeSignal(); + + const humanoid = newInstance('Humanoid', 'Humanoid'); + humanoid.Parent = character; + let _hp = 100, _maxHp = 100, _ws = 16, _jp = 50; + Object.defineProperty(humanoid, 'Health', { + get() { return _hp; }, + set(v) { + _hp = Math.max(0, Math.min(_maxHp, Number(v) || 0)); + try { humanoid.HealthChanged.Fire(_hp); } catch (_) {} + send('playerSet', { prop: 'health', value: _hp }); + }, + }); + Object.defineProperty(humanoid, 'MaxHealth', { + get() { return _maxHp; }, + set(v) { + _maxHp = Math.max(1, Number(v) || 100); + if (_hp > _maxHp) humanoid.Health = _maxHp; + send('playerSet', { prop: 'maxHealth', value: _maxHp }); + }, + }); + Object.defineProperty(humanoid, 'WalkSpeed', { + get() { return _ws; }, + set(v) { _ws = Number(v) || 16; send('playerSet', { prop: 'walkSpeed', value: _ws }); }, + }); + Object.defineProperty(humanoid, 'JumpPower', { + get() { return _jp; }, + set(v) { _jp = Number(v) || 50; send('playerSet', { prop: 'jumpPower', value: _jp }); }, + }); + 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) { + // Creator-tag: ищем creator-ObjectValue в Humanoid.Children для kill feed + let killerName = null; + for (const c of (this.Children || [])) { + if (c && c.Name === 'creator' && c.Value) { + killerName = String(c.Value.Name || c.Value.DisplayName || '?'); + break; + } + } + if (killerName) { + send('killFeed', { killer: killerName, victim: localPlayer.Name || 'Player', weapon: '' }); + } + this.Died.Fire(); + // В Roblox после Died игрок респавнится — у нас через playerSet=respawn + setTimeout(() => { + this.Health = this.MaxHealth || 100; + this.HealthChanged.Fire(this.Health); + send('playerSet', { prop: 'health', value: this.Health }); + }, 2000); + } + 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); + // Реактивные Position и Velocity — Lua скрипт может задавать. + Object.defineProperty(hrp, 'Position', { + get() { return hrp._position; }, + set(v) { + if (!v) return; + hrp._position = new RbxVector3(v.X || 0, v.Y || 0, v.Z || 0); + try { send('playerSet', { prop: 'position', + value: { x: hrp._position.X, y: hrp._position.Y, z: hrp._position.Z } }); } + catch (_) {} + }, + }); + let _hrpCFrame = null; + Object.defineProperty(hrp, 'CFrame', { + get() { return _hrpCFrame || { Position: hrp._position, p: hrp._position }; }, + set(v) { + if (!v) return; + _hrpCFrame = v; + const pos = v.Position || v.p || v; + if (pos && pos.X !== undefined) { + hrp._position = new RbxVector3(pos.X, pos.Y, pos.Z); + try { send('playerSet', { prop: 'position', + value: { x: pos.X, y: pos.Y, z: pos.Z } }); } + catch (_) {} + } + }, + }); + Object.defineProperty(hrp, 'Velocity', { + get() { return hrp._velocity || new RbxVector3(0, 0, 0); }, + set(v) { + if (!v) return; + hrp._velocity = new RbxVector3(v.X || 0, v.Y || 0, v.Z || 0); + if (v.Y > 10) { + try { send('playerSet', { prop: 'jumpVelocity', value: v.Y * 0.3 }); } + catch (_) {} + } + }, + }); + 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'); + + // Teams сервис — PvP-команды (TeamBeacon Black/Blue/Red/Green в Roblox Battle) + const teams = makeService('Teams'); + teams.Children = []; + teams.GetTeams = function () { return teams.Children.slice(); }; + teams.GetChildren = function () { return teams.Children.slice(); }; + + const uis = makeService('UserInputService'); + uis.InputBegan = makeSignal(); + uis.InputChanged = makeSignal(); + uis.InputEnded = makeSignal(); + + // TweenService — реальная интерполяция через Heartbeat + const tw = makeService('TweenService'); + const activeTweens = []; // [{inst, props, duration, startAt, startVals, onDone}] + tw.Create = function (inst, info, propGoals) { + // info: TweenInfo (duration, EasingStyle, ...) — упрощённо берём только duration + const duration = (info && (info.Time || info.duration)) || 1; + const tween = { + __completed: makeSignal(), + Completed: undefined, + Play() { + if (!inst || !propGoals) return; + const startVals = {}; + for (const k of Object.keys(propGoals)) { + try { startVals[k] = inst[k]; } catch (_) {} + } + activeTweens.push({ + inst, props: propGoals, duration, + startAt: performance.now(), + startVals, + onDone: () => tween.__completed.Fire(), + }); + }, + Pause() {}, + Cancel() {}, + }; + tween.Completed = tween.__completed; + return tween; + }; + function _stepTweens(_dt) { + if (activeTweens.length === 0) return; + const now = performance.now(); + for (let i = activeTweens.length - 1; i >= 0; i--) { + const t = activeTweens[i]; + const elapsed = (now - t.startAt) / 1000; + const k = Math.min(1, elapsed / t.duration); + for (const prop of Object.keys(t.props)) { + const goal = t.props[prop]; + const start = t.startVals[prop]; + if (!start || !goal) continue; + if (start instanceof RbxVector3 && goal instanceof RbxVector3) { + try { t.inst[prop] = start.Lerp(goal, k); } catch (_) {} + } else if (start instanceof RbxColor3 && goal instanceof RbxColor3) { + try { t.inst[prop] = start.Lerp(goal, k); } catch (_) {} + } else if (typeof start === 'number' && typeof goal === 'number') { + try { t.inst[prop] = start + (goal - start) * k; } catch (_) {} + } + } + if (k >= 1) { + activeTweens.splice(i, 1); + try { t.onDone(); } catch (_) {} + } + } + } + + 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); + }); + + const lighting = makeService('Lighting'); + lighting.Ambient = new RbxColor3(0.5, 0.5, 0.5); + lighting.Brightness = 1; + lighting.ClockTime = 14; + lighting.TimeOfDay = "14:00:00"; + lighting.OutdoorAmbient = new RbxColor3(0.5, 0.5, 0.5); + lighting.FogEnd = 100000; + lighting.FogStart = 0; + lighting.FogColor = new RbxColor3(0.75, 0.75, 0.75); + lighting._minutes = 14 * 60; + lighting.GetMinutesAfterMidnight = function () { return lighting._minutes; }; + let _lastLightSent = 0; + lighting.SetMinutesAfterMidnight = function (m) { + lighting._minutes = (Number(m) || 0) % 1440; + lighting.ClockTime = lighting._minutes / 60; + // Тротлинг: не чаще раза в 250мс. Скрипты Day/Night обновляют это + // каждый кадр (100+ Hz), это убивает WASM. + const now = performance.now(); + if (now - _lastLightSent < 250) return; + _lastLightSent = now; + send('lightingTimeUpdate', { hour: lighting.ClockTime }); + }; + lighting.GetMoonDirection = function () { return new RbxVector3(0, 1, 0); }; + lighting.GetSunDirection = function () { return new RbxVector3(0, 1, 0); }; + makeService('Chat'); + const soundService = makeService('SoundService'); + soundService.PlayLocalSound = function (sound) { + if (sound && typeof sound.Play === 'function') sound.Play(); + }; + makeService('PathfindingService'); + + // CollectionService — теги на инстансах + const cs = makeService('CollectionService'); + const tagMap = new Map(); // tag → Set + const instTags = new WeakMap(); // instance → Set + const tagAddSignals = new Map(); // tag → Signal (InstanceAddedSignal) + const tagRemoveSignals = new Map(); // tag → Signal (InstanceRemovedSignal) + cs.AddTag = function (inst, tag) { + if (!inst || !tag) return; + let set = tagMap.get(tag); + if (!set) { set = new Set(); tagMap.set(tag, set); } + if (set.has(inst)) return; + set.add(inst); + let tags = instTags.get(inst); + if (!tags) { tags = new Set(); instTags.set(inst, tags); } + tags.add(tag); + const sig = tagAddSignals.get(tag); + if (sig) try { sig.Fire(inst); } catch (_) {} + }; + cs.RemoveTag = function (inst, tag) { + const set = tagMap.get(tag); + if (set) set.delete(inst); + const tags = instTags.get(inst); + if (tags) tags.delete(tag); + const sig = tagRemoveSignals.get(tag); + if (sig) try { sig.Fire(inst); } catch (_) {} + }; + cs.HasTag = function (inst, tag) { + const set = tagMap.get(tag); + return !!(set && set.has(inst)); + }; + cs.GetTagged = function (tag) { + const set = tagMap.get(tag); + return set ? [...set] : []; + }; + cs.GetTags = function (inst) { + const tags = instTags.get(inst); + return tags ? [...tags] : []; + }; + cs.GetInstanceAddedSignal = function (tag) { + let sig = tagAddSignals.get(tag); + if (!sig) { sig = makeSignal(); tagAddSignals.set(tag, sig); } + return sig; + }; + cs.GetInstanceRemovedSignal = function (tag) { + let sig = tagRemoveSignals.get(tag); + if (!sig) { sig = makeSignal(); tagRemoveSignals.set(tag, sig); } + return sig; + }; + + // Debris — удаление инстансов через N секунд + const debris = makeService('Debris'); + debris.AddItem = function (inst, lifetime) { + if (!inst || typeof inst.Destroy !== 'function') return; + const t = Math.max(0, Number(lifetime) || 0); + setTimeout(() => { + try { inst.Destroy(); } catch (_) {} + }, t * 1000); + }; + + 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); + }; + // Старый Roblox API: game:service(name) lowercase + game.service = game.GetService; + game.GetServiceFromName = game.GetService; + game.FindService = function (name) { return services[name] || null; }; + game.JobId = ''; + game.PlaceId = 0; + game.GameId = 0; + game.CreatorId = 0; + game.CreatorType = 'User'; + + // Players API extensions + players.GetPlayers = function () { return [players.LocalPlayer].filter(Boolean); }; + players.GetPlayerFromCharacter = function (character) { + if (character && players.LocalPlayer && players.LocalPlayer.Character === character) { + return players.LocalPlayer; + } + return undefined; + }; + players.playerFromCharacter = players.GetPlayerFromCharacter; + players.PlayerAdded = makeSignal(); + players.PlayerRemoving = makeSignal(); + players.ChildAdded = makeSignal(); + + global.set('game', game); + global.set('Game', game); + global.set('workspace', workspace); + global.set('Workspace', workspace); + + // === Instance.new === + // === Helper: создание GUI-элемента через game.gui.create === + // Roblox: Frame/TextLabel/TextButton/ImageLabel/TextBox/ScrollingFrame. + // Шлём gui.create команду в main thread → GuiManager создаёт элемент. + // Возвращаем Lua-объект с setter'ами для основных свойств. + let _nextGuiLocalRef = 0; + function newGuiInstance(robloxClass) { + const localRef = `_gui_lua_${_nextGuiLocalRef++}`; + const inst = newInstance(robloxClass, robloxClass); + inst.__guiLocalRef = localRef; + inst.__guiClass = robloxClass; + // Маппим Roblox-класс на тип в GuiManager + const guiType = ({ + Frame: 'frame', + TextLabel: 'text', + TextButton: 'button', + ImageLabel: 'image', + ImageButton: 'button', + TextBox: 'textbox', + ScrollingFrame: 'scroll', + })[robloxClass] || 'frame'; + // Внутренние стейты + inst._gui = { + type: guiType, + text: '', + bgColor: '#3a2820', + bgOpacity: 1, + textColor: '#f0e6d8', + textSize: 16, + x: 50, y: 50, w: 20, h: 10, + visible: true, + }; + // Шлём create при первом обращении (lazy) или сейчас — лучше сейчас, чтобы + // не было гонок при моментальной правке свойств после Instance.new. + send('gui.create', { + type: guiType, + opts: { ...inst._gui, _scriptCreated: true }, + localRef, + }); + // Сигналы (для кнопок) + if (robloxClass === 'TextButton' || robloxClass === 'ImageButton') { + inst.MouseButton1Click = makeSignal(); + inst.MouseEnter = makeSignal(); + inst.MouseLeave = makeSignal(); + inst.Activated = inst.MouseButton1Click; + } + // Setters + const updateField = (field, value) => { + inst._gui[field] = value; + send('gui.update', { id: localRef, patch: { [field]: value } }); + }; + Object.defineProperty(inst, 'Text', { + get() { return inst._gui.text; }, + set(v) { updateField('text', String(v ?? '')); }, + enumerable: true, + }); + Object.defineProperty(inst, 'Visible', { + get() { return inst._gui.visible; }, + set(v) { updateField('visible', !!v); }, + enumerable: true, + }); + Object.defineProperty(inst, 'BackgroundColor3', { + get() { return RbxColor3.fromHex(inst._gui.bgColor); }, + set(v) { + if (!v) return; + const hex = v.toHex ? v.toHex() : (v instanceof RbxColor3 ? v.toHex() : '#3a2820'); + updateField('bgColor', hex); + }, + enumerable: true, + }); + Object.defineProperty(inst, 'BackgroundTransparency', { + get() { return 1 - (inst._gui.bgOpacity ?? 1); }, + set(v) { updateField('bgOpacity', 1 - Math.max(0, Math.min(1, +v || 0))); }, + enumerable: true, + }); + Object.defineProperty(inst, 'TextColor3', { + get() { return RbxColor3.fromHex(inst._gui.textColor); }, + set(v) { + if (!v) return; + const hex = v.toHex ? v.toHex() : '#f0e6d8'; + updateField('textColor', hex); + }, + enumerable: true, + }); + Object.defineProperty(inst, 'TextSize', { + get() { return inst._gui.textSize; }, + set(v) { updateField('textSize', Math.max(8, Math.min(72, +v || 16))); }, + enumerable: true, + }); + // Position: UDim2 → x,y проценты (Roblox-style: scale=%, offset=px) + // Упрощённо берём scale*100 как x/y; offset игнорируем. + Object.defineProperty(inst, 'Position', { + get() { + return new RbxUDim2(inst._gui.x / 100, 0, inst._gui.y / 100, 0); + }, + set(v) { + if (!v) return; + const xPct = (v.X?.Scale || 0) * 100 + (v.X?.Offset || 0) / 10; + const yPct = (v.Y?.Scale || 0) * 100 + (v.Y?.Offset || 0) / 10; + inst._gui.x = xPct; + inst._gui.y = yPct; + send('gui.update', { id: localRef, patch: { x: xPct, y: yPct } }); + }, + enumerable: true, + }); + Object.defineProperty(inst, 'Size', { + get() { + return new RbxUDim2(inst._gui.w / 100, 0, inst._gui.h / 100, 0); + }, + set(v) { + if (!v) return; + const wPct = (v.X?.Scale || 0) * 100 + (v.X?.Offset || 0) / 10; + const hPct = (v.Y?.Scale || 0) * 100 + (v.Y?.Offset || 0) / 10; + inst._gui.w = wPct; + inst._gui.h = hPct; + send('gui.update', { id: localRef, patch: { w: wPct, h: hPct } }); + }, + enumerable: true, + }); + // Destroy — удаление GUI + const origDestroy = inst.Destroy; + inst.Destroy = function () { + try { send('gui.remove', { id: localRef }); } catch (_) {} + origDestroy.call(inst); + }; + return inst; + } + + // Регистрация в guiByLocalRef для дальнейшей маршрутизации событий клика + const guiByLocalRef = new Map(); + + // Счётчик для новых Part'ов, создаваемых через Instance.new("Part"): + // primitiveManager.addInstance даст уникальный id, мы используем временный + // отрицательный id пока не пришёл sceneCreate ACK. Но проще: даём свой + // негативный id, primitiveManager заменит на свой. Для простоты — даём + // высокий positive id (10000+ random) и primitiveManager его использует + // если не занят. + let _nextNewPartId = 100000 + Math.floor(Math.random() * 10000); + + global.set('Instance', { + new: (className, parent) => { + let inst; + if (className === 'Part' || className === 'WedgePart' || className === 'MeshPart') { + // Реальный примитив на сцене: шлём sceneCreate, регистрируем в partById + const newId = _nextNewPartId++; + const fakePrim = { + id: newId, + name: `Part_${newId}`, + x: 0, y: 0, z: 0, + sx: 4, sy: 1, sz: 2, + color: '#A0A0A0', + anchored: true, + canCollide: true, + }; + send('sceneCreate', { + primId: newId, + type: className === 'WedgePart' ? 'wedge' : 'cube', + x: 0, y: 0, z: 0, + sx: 4, sy: 1, sz: 2, + color: '#A0A0A0', + anchored: true, + canCollide: true, + }); + inst = newPart(fakePrim, send); + partById.set(newId, inst); + } 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 === 'SpecialMesh' || className === 'BlockMesh' + || className === 'CylinderMesh' || className === 'FileMesh') { + inst = newInstance(className, className); + inst.MeshType = { Name: 'Brick', Value: 0 }; + inst.MeshId = ''; + inst.TextureId = ''; + inst.Scale = new RbxVector3(1, 1, 1); + inst.Offset = new RbxVector3(0, 0, 0); + inst.VertexColor = new RbxVector3(1, 1, 1); + } else if (className === 'ClickDetector') { + // ClickDetector — клик по 3D-объекту (нужен Тиру и т.п.). + // Регистрация в part._clickDetector происходит автоматически + // через Proxy.set когда юзер делает clickDet.Parent = part. + inst = newInstance('ClickDetector', 'ClickDetector'); + inst.MouseClick = makeSignal(); + inst.MouseHoverEnter = makeSignal(); + inst.MouseHoverLeave = makeSignal(); + inst.MaxActivationDistance = 32; + } else if (className === 'BindableEvent') { + inst = newInstance('BindableEvent', 'BindableEvent'); + inst.Event = makeSignal(); + inst.Fire = function (...a) { this.Event.Fire(...a); }; + } else if (className === 'BindableFunction') { + // BindableFunction — синхронный RPC внутри клиента. + // OnInvoke = single-callback; Invoke вызывает его и возвращает значение. + inst = newInstance('BindableFunction', 'BindableFunction'); + inst.OnInvoke = undefined; // юзер ставит function + inst.Invoke = function (...args) { + if (typeof this.OnInvoke === 'function') { + try { return this.OnInvoke(...args); } catch (_) { return undefined; } + } + return undefined; + }; + } else if (className === 'RemoteFunction') { + inst = newInstance('RemoteFunction', 'RemoteFunction'); + inst.OnServerInvoke = undefined; + inst.OnClientInvoke = undefined; + inst.InvokeServer = function (...args) { + if (typeof this.OnServerInvoke === 'function') { + try { return this.OnServerInvoke(localPlayer, ...args); } catch (_) {} + } + return undefined; + }; + inst.InvokeClient = function (_p, ...args) { + if (typeof this.OnClientInvoke === 'function') { + try { return this.OnClientInvoke(...args); } catch (_) {} + } + return undefined; + }; + } else if (className === 'Message' || className === 'Hint') { + // Roblox Message — текстовая надпись по центру экрана, + // когда .Parent = workspace или nil. Hint — то же но мельче. + inst = newInstance(className, className); + let _txt = ''; + Object.defineProperty(inst, 'Text', { + get() { return _txt; }, + set(v) { _txt = String(v || ''); send('hudMessage', { kind: className, text: _txt, visible: !!inst.Parent }); }, + }); + // При смене Parent: nil → скрываем, workspace → показываем + const _origParent = Object.getOwnPropertyDescriptor(inst, 'Parent'); + Object.defineProperty(inst, 'Parent', { + get() { return this._parent; }, + set(v) { + this._parent = v; + send('hudMessage', { kind: className, text: _txt, visible: !!v }); + }, + }); + } 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 if (className === 'Sound') { + // Sound — процедурные звуки через _playSound. + // SoundId → имя процедурного звука (rbxassetid игнорится). + inst = newInstance('Sound', 'Sound'); + inst.SoundId = ''; + inst.Volume = 1; + inst.PlaybackSpeed = 1; + inst.Pitch = 1; + inst.Looped = false; + inst.IsPlaying = false; + inst.Played = makeSignal(); + inst.Ended = makeSignal(); + // Map SoundId/имя на встроенный звук (jump/pickup/win/lose/click/hit/coin). + const _mapSoundName = (idOrName) => { + if (!idOrName) return 'click'; + const s = String(idOrName).toLowerCase(); + // Прямые ключи имеют приоритет + if (['jump','pickup','win','lose','click','hit','coin'].indexOf(s) >= 0) return s; + // Эвристика по части строки (для Roblox AssetID) + if (s.includes('jump')) return 'jump'; + if (s.includes('pickup') || s.includes('collect')) return 'pickup'; + if (s.includes('win') || s.includes('victory')) return 'win'; + if (s.includes('lose') || s.includes('death')) return 'lose'; + if (s.includes('hit') || s.includes('damage')) return 'hit'; + if (s.includes('coin') || s.includes('gem')) return 'coin'; + return 'click'; + }; + inst.Play = function () { + const name = _mapSoundName(this.SoundId || this.Name); + const pitch = +this.PlaybackSpeed || +this.Pitch || 1; + const volume = +this.Volume || 1; + send('sound.play', { name, volume, pitch }); + this.IsPlaying = true; + this.Played.Fire(); + // Простая модель: считаем что звук длится 0.5с + SCHEDULER.sleeping.push({ + wakeAt: SCHEDULER.now() + 500, + run: () => { + this.IsPlaying = false; + this.Ended.Fire(); + if (this.Looped) this.Play(); + }, + }); + }; + inst.Stop = function () { this.IsPlaying = false; }; + inst.Pause = function () { this.IsPlaying = false; }; + inst.Resume = function () { if (!this.IsPlaying) this.Play(); }; + } else if (className === 'ScreenGui') { + // ScreenGui — логический корень GUI. В Rublox overlay глобальный, + // поэтому ScreenGui это просто контейнер-no-op (без gui.create). + inst = newInstance('ScreenGui', 'ScreenGui'); + inst.__isScreenGui = true; + inst.Enabled = true; + } else if (className === 'Frame' || className === 'TextLabel' + || className === 'TextButton' || className === 'ImageLabel' + || className === 'ImageButton' || className === 'TextBox' + || className === 'ScrollingFrame') { + inst = newGuiInstance(className); + guiByLocalRef.set(inst.__guiLocalRef, inst); + } else if (className === 'Team') { + inst = newInstance('Team', 'Team'); + inst.TeamColor = { Name: 'Bright red', Color: new RbxColor3(0.77, 0.2, 0.2) }; + inst.Score = 0; + inst.AutoAssignable = true; + inst.PlayerAdded = makeSignal(); + inst.PlayerRemoved = makeSignal(); + inst.GetPlayers = function () { + return (players?.Children || []).filter(p => p.Team === this); + }; + // Регистрация в teams сервисе при Parent = teams + Object.defineProperty(inst, 'Parent', { + get() { return this._parent; }, + set(v) { + this._parent = v; + if (v === teams && !teams.Children.includes(this)) { + teams.Children.push(this); + } + }, + }); + } else if (className === 'Tool' || className === 'HopperBin') { + inst = newInstance(className, 'Tool'); + inst.Equipped = makeSignal(); + inst.Unequipped = makeSignal(); + inst.Activated = makeSignal(); + inst.Deactivated = makeSignal(); + inst.GripForward = new RbxVector3(0, -1, 0); + inst.GripRight = new RbxVector3(1, 0, 0); + inst.GripUp = new RbxVector3(0, 1, 0); + inst.GripPos = new RbxVector3(0, 0, 0); + inst.CanBeDropped = true; + inst.Enabled = true; + inst.RequiresHandle = true; + inst.TextureId = ''; + inst.ToolTip = ''; + // Виртуальный Handle — Roblox-скрипты делают Tool.Handle.Position + const handle = newInstance('Part', 'Handle'); + handle.Parent = inst; + handle.Position = new RbxVector3(0, 5, 0); + handle.Size = new RbxVector3(1, 1, 1); + inst.Handle = handle; + inst.Children = inst.Children || []; + inst.Children.push(handle); + // Регистрируем Tool, чтобы плеер показал его в hotbar + allTools.push(inst); + inst.__toolIndex = allTools.length; + send('toolRegistered', { + index: inst.__toolIndex, + name: inst.Name || `Tool ${inst.__toolIndex}`, + }); + } else if (className === 'IntValue' || className === 'NumberValue' + || className === 'BoolValue' || className === 'StringValue' + || className === 'ObjectValue' || className === 'CFrameValue' + || className === 'Vector3Value' || className === 'Color3Value' + || className === 'BrickColorValue' || className === 'RayValue') { + inst = newInstance(className, className); + let _val = className === 'BoolValue' ? false + : className === 'StringValue' ? '' + : (className === 'IntValue' || className === 'NumberValue') ? 0 + : undefined; + inst.Changed = makeSignal(); + // Реактивное поле Value — фейерим Changed + обновляем leaderstats + // если этот *Value лежит внутри leaderstats-родителя (Roblox-pattern). + Object.defineProperty(inst, 'Value', { + get() { return _val; }, + set(v) { + _val = v; + try { inst.Changed.Fire(v); } catch (_) {} + // Если этот IntValue — leaderstat (родитель Name=leaderstats): + if (inst.Parent && inst.Parent.Name === 'leaderstats') { + send('leaderstatSet', { + playerName: inst.Parent.Parent?.Name || 'Player', + statName: inst.Name || 'Stat', + value: Number(v) || 0, + }); + } + }, + }); + } else if (className === 'BodyForce' || className === 'BodyVelocity' + || className === 'BodyPosition' || className === 'BodyGyro' + || className === 'BodyAngularVelocity' || className === 'BodyThrust') { + inst = newInstance(className, className); + let _vel = new RbxVector3(0, 0, 0); + Object.defineProperty(inst, 'velocity', { + get() { return _vel; }, + set(v) { + _vel = v; + // Эвристика батута: BodyVelocity с +Y и Parent=Torso/HRP + // = "толкаем игрока вверх". Если это игрок — шлём jumpVelocity. + if (className === 'BodyVelocity' && v && v.Y > 10) { + const p = inst.Parent; + if (p && (p.Name === 'Torso' || p.Name === 'HumanoidRootPart' || + p.Name === 'UpperTorso')) { + send('playerSet', { prop: 'jumpVelocity', value: v.Y * 0.3 }); + } + } + }, + }); + Object.defineProperty(inst, 'Velocity', { + get() { return _vel; }, + set(v) { inst.velocity = v; }, + }); + inst.force = new RbxVector3(0, 0, 0); + inst.Force = inst.force; + inst.MaxForce = new RbxVector3(0, 0, 0); + inst.P = 1000; inst.D = 100; + } else if (className === 'Weld' || className === 'WeldConstraint' + || className === 'Motor6D' || className === 'Snap' + || className === 'HingeConstraint' || className === 'BallSocketConstraint' + || className === 'RopeConstraint' || className === 'SpringConstraint') { + inst = newInstance(className, className); + inst.Part0 = undefined; inst.Part1 = undefined; + inst.C0 = { Position: new RbxVector3(0, 0, 0) }; + inst.C1 = { Position: new RbxVector3(0, 0, 0) }; + inst.Enabled = true; + } else if (className === 'Sparkles' || className === 'ParticleEmitter' + || className === 'Smoke' || className === 'Fire' || className === 'Trail' + || className === 'Beam' || className === 'PointLight' + || className === 'SurfaceLight' || className === 'SpotLight') { + inst = newInstance(className, className); + inst.Enabled = true; + inst.Color = new RbxColor3(1, 1, 1); + inst.Rate = 20; + inst.Lifetime = { Min: 1, Max: 1 }; + inst.Brightness = 1; + inst.Range = 8; + inst.__particleKind = className.toLowerCase(); + // Шлём событие "создан particle-effect" — GameRuntime может его + // привязать к мешу на сцене (например, рукам игрока). + send('particleCreated', { + kind: inst.__particleKind, + color: [inst.Color.R, inst.Color.G, inst.Color.B], + }); + } else if (className === 'Mouse') { + inst = newInstance('Mouse', 'Mouse'); + inst.Button1Down = makeSignal(); + inst.Button1Up = makeSignal(); + inst.Button2Down = makeSignal(); + inst.Button2Up = makeSignal(); + inst.Move = makeSignal(); + inst.KeyDown = makeSignal(); + inst.KeyUp = makeSignal(); + inst.WheelForward = makeSignal(); + inst.WheelBackward = makeSignal(); + inst.Icon = ''; + inst.Hit = { Position: new RbxVector3(0, 0, 0), p: new RbxVector3(0, 0, 0) }; + inst.Target = undefined; + inst.TargetSurface = 'Top'; + inst.X = 0; inst.Y = 0; + inst.ViewSizeX = 1920; inst.ViewSizeY = 1080; + } 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; + }, + }); + + // === Leaderboard scan === + // Roblox-скрипт делает: Instance.new('IntValue').Name='leaderstats', + // stats.Parent = newPlayer, потом IntValue Reputation/Level внутри. + // Поскольку наш Lua не вызывает Children.push при Parent= (Lua делает rawset), + // мы периодически сканируем localPlayer на наличие leaderstats и шлём в плеер. + // === Helpers для скриптов === + const partById = new Map(); + global.set('__rbxl_get_part_by_id', (id) => partById.get(Number(id)) || undefined); + global.set('__rbxl_get_tool_by_name', (name) => allTools.find(t => t.Name === name) || undefined); + global.set('__rbxl_send_error', (id, errStr) => { + send('log', { level: 'error', text: `[Lua ${id}] ${errStr || 'unknown error'}` }); + }); + + // === Coroutines registry + task.wait через yield === + // Каждый скрипт стартует как coroutine. Когда юзер пишет task.wait(sec), + // мы делаем coroutine.yield(sec). Main-loop резюмирует когда время вышло. + const coroutines = new Map(); // id → coroutine + const waitingCoros = []; // [{coId, wakeAt}] + global.set('__rbxl_register_coroutine', (id, co) => { + coroutines.set(String(id), co); + }); + global.set('__rbxl_unregister_coroutine', (id) => { + coroutines.delete(String(id)); + }); + global.set('__rbxl_get_co', (id) => coroutines.get(String(id)) || undefined); + global.set('__rbxl_schedule_resume', (coId, delaySec) => { + waitingCoros.push({ + coId: String(coId), + wakeAt: SCHEDULER.now() + (Number(delaySec) || 0) * 1000, + }); + }); + + // Lua-prelude: task.wait через coroutine.yield + готовая resume-функция. + // Главное: __rbxl_resume_co определена в Lua и вызывается из JS через + // lua.global.get('__rbxl_resume_co') — это безопаснее чем doStringSync + // потому что не парсит код заново и не создаёт re-entrant проблем. + // Lua-side helper для логов (используется в task.wait/resume для отладки) + global.set('__log', (level, text) => { + send('log', { level: String(level || 'info'), text: String(text || '') }); + }); + + lua.doStringSync(` + local function rbx_wait(sec) + sec = sec or 0 + -- Минимум 1 кадр (≈0.0166с). wait() и wait(0) в Roblox ждут до + -- следующего Heartbeat — без этого while true do wait() end + -- стал бы tight loop без yield и упёрся в WASM stack overflow. + if sec < 0.016 then sec = 0.016 end + local ret = coroutine.yield(sec) + return ret or sec + end + + -- Глобальный безопасный yield для любых stub-сигналов / любых + -- "ждунов". Используется в Lua-обёртках вокруг WaitForChild и т.п. + function __rbxl_yield_frame() + coroutine.yield(0.05) + end + -- task — JS-object из shim ('userdata'/'table'). Сохраняем + -- существующие методы (delay/spawn/defer) и добавляем wait. + if type(task) == 'table' or type(task) == 'userdata' then + local existing = task + local jsDelay = existing.delay + local jsSpawn = existing.spawn + local jsDefer = existing.defer + task = { + wait = rbx_wait, + delay = jsDelay or function(_, fn) if fn then fn() end end, + spawn = jsSpawn or function(fn) if fn then fn() end end, + defer = jsDefer or function(fn) if fn then fn() end end, + synchronize = function() end, + desynchronize = function() end, + } + else + task = { wait = rbx_wait } + end + wait = rbx_wait + + -- Roblox legacy globals + tick = function() return os.time() end -- секунды с epoch + time = function() return os.clock() * 1000 end -- ms аптайм + delay = function(sec, fn) -- delay(sec, fn) — задержка + вызов + if type(fn) ~= 'function' then return end + local co = coroutine.create(function() + rbx_wait(sec or 0) + pcall(fn) + end) + coroutine.resume(co) + end + spawn = function(fn) -- spawn(fn) — запуск в отдельной coroutine + if type(fn) ~= 'function' then return end + local co = coroutine.create(function() pcall(fn) end) + coroutine.resume(co) + end + -- LoadLibrary("RbxStamper"/"RbxUtility") — старый Roblox 2009. + -- Возвращаем пустую таблицу-стаб чтобы скрипт не упал. + LoadLibrary = function(name) + return setmetatable({}, { __index = function() return function() end end }) + end + require = require or function(_) return {} end + + function __rbxl_resume_co(co) + if not co or coroutine.status(co) ~= 'suspended' then return nil end + local ok, ret = coroutine.resume(co) + if not ok then return false, tostring(ret) end + if coroutine.status(co) == 'dead' then return nil end + if type(ret) == 'number' then return ret end + return 0 + end + + -- Запуск Lua-handler'а из очереди в собственной coroutine. + -- Вызывается из JS tickScheduler — мы УЖЕ вышли из C-callback, + -- так что wait() внутри handler'а — yield в свою coroutine. + __rbxl_next_handler_id = 0 + function __rbxl_drain_handler(fn, a1, a2, a3, a4) + __rbxl_next_handler_id = __rbxl_next_handler_id + 1 + local handlerId = "handler_" .. __rbxl_next_handler_id + local co = coroutine.create(function() + debug.sethook(function() + coroutine.yield(0.016) + end, "", 20000) + __log("warn", "[lua-handler] " .. handlerId .. " starting") + local ok, err = pcall(fn, a1, a2, a3, a4) + if ok then + __log("warn", "[lua-handler] " .. handlerId .. " finished OK") + else + __log("error", "[lua-handler] " .. handlerId .. " ERROR: " .. tostring(err)) + end + return 1 + end) + __rbxl_register_coroutine(handlerId, co) + pcall(coroutine.resume, co) + if coroutine.status(co) == 'dead' then + __rbxl_unregister_coroutine(handlerId) + end + return 1 + end + `); + // Кешируем ссылку на Lua-функцию запуска handler'а + const luaDrainHandler = lua.global.get('__rbxl_drain_handler'); + // Добавим Lua-side helper для лога + global.set('__log', (level, text) => { + send('log', { level: String(level || 'info'), text: String(text || '') }); + }); + // === Хелперы паритета с JS game.ui / game.scene === + // Красивый центрированный текст без рамки (как game.ui.showText). + global.set('__rbxl_show_text', (text, duration, color) => { + send('ui.showText', { + text: String(text || ''), + duration: Number(duration) || 2, + color: color || '#ffffff', + }); + }); + // Установка/удаление HUD-плашки в фиксированной позиции — паритет с + // JS game.ui.set / game.ui.showInteractHint и аналогами. + // opts = {x, y, color, size} (x,y в процентах 0-100; color — hex) + global.set('__rbxl_hud_set', (id, text, x, y, color, size) => { + const payload = { id: String(id || ''), text: text || null }; + if (text != null) { + payload.opts = { + x: Number(x) || 50, + y: Number(y) || 75, + color: color || '#ffe44a', + size: Number(size) || 20, + }; + } + send('ui.set', payload); + }); + // Спавн NPC — паритет с JS game.scene.spawnNpc(modelType, opts). + // Возвращает локальный ref (строку 'npc_lua_N'), который можно передавать + // в __rbxl_npc_say(ref, text, duration). + let _nextNpcRef = 0; + const _localToRealNpc = new Map(); + global.set('__rbxl_spawn_npc', (modelType, x, y, z, name, hp, speed) => { + const ref = 'npc_lua_' + (_nextNpcRef++); + send('npc.spawn', { + modelType: String(modelType || 'character-a'), + ref, + x: +x || 0, y: +y || 0, z: +z || 0, + name: name ? String(name) : undefined, + hp: hp != null ? +hp : undefined, + speed: speed != null ? +speed : undefined, + }); + return ref; + }); + global.set('__rbxl_npc_say', (ref, text, duration) => { + send('npc.say', { + ref: String(ref || ''), + text: String(text || ''), + duration: +duration || 3, + }); + }); + global.set('__rbxl_npc_follow', (ref, targetRef) => { + send('npc.follow', { + ref: String(ref || ''), + target: String(targetRef || 'player'), + }); + }); + global.set('__rbxl_npc_stop', (ref) => { + send('npc.stop', { ref: String(ref || '') }); + }); + global.set('__rbxl_npc_moveto', (ref, x, z) => { + send('npc.moveTo', { + ref: String(ref || ''), + x: +x || 0, z: +z || 0, + }); + }); + global.set('__rbxl_npc_remove', (ref) => { + send('npc.remove', { ref: String(ref || '') }); + }); + // Позиция NPC — резолвится через GameRuntime по локальному ref. + // GameRuntime в tick шлёт api.updateNpcPos(localRef, x, y, z). + const _npcPositions = new Map(); // localRef → {x,y,z} + global.set('__rbxl_npc_pos', (ref) => { + const p = _npcPositions.get(String(ref || '')); + if (!p) return { x: 0, y: 0, z: 0, ok: false }; + return { x: p.x, y: p.y, z: p.z, ok: true }; + }); + // Отдельные x/y/z — обходим wasmoon userdata-proxy. + global.set('__rbxl_npc_x', (ref) => (_npcPositions.get(String(ref || ''))?.x ?? 0)); + global.set('__rbxl_npc_y', (ref) => (_npcPositions.get(String(ref || ''))?.y ?? 0)); + global.set('__rbxl_npc_z', (ref) => (_npcPositions.get(String(ref || ''))?.z ?? 0)); + global.set('__rbxl_npc_damage', (ref, amount) => { + send('npc.damage', { + ref: String(ref || ''), + amount: +amount || 0, + }); + }); + // Метка с именем/HP над NPC или примитивом — паритет с JS scene.setLabel. + global.set('__rbxl_set_label', (ref, text, color, height) => { + send('scene.setLabel', { + ref: String(ref || ''), + text: String(text || ''), + opts: { + color: color || '#ff5555', + height: Number(height) || 3, + }, + }); + }); + global.set('__rbxl_clear_label', (ref) => { + send('scene.clearLabel', { ref: String(ref || '') }); + }); + // Регистрация коллбэка onDeath для NPC. GameRuntime шлёт globalEvent + // 'npcDeath' с {ref} при смерти. Shim фильтрует по ref и зовёт. + const _npcDeathCbs = new Map(); // ref → fn + global.set('__rbxl_npc_on_death', (ref, fn) => { + if (typeof fn === 'function') _npcDeathCbs.set(String(ref || ''), fn); + }); + const _npcClickCbs = new Map(); // localRef → fn + global.set('__rbxl_npc_on_click', (ref, fn) => { + if (typeof fn === 'function') _npcClickCbs.set(String(ref || ''), fn); + }); + // Инвентарь invUI — паритет с JS game.inventory.add(itemId, count). + // Сначала определяем итем (один раз), потом добавляем. + const _localInventory = new Map(); + const _definedItems = new Set(); + global.set('__rbxl_inventory_define', (itemId, name, color) => { + const id = String(itemId || ''); + if (!id || _definedItems.has(id)) return; + _definedItems.add(id); + send('items.define', { + def: { + id, + name: name ? String(name) : id, + color: color || '#ffd700', + stack: 99, + }, + }); + }); + global.set('__rbxl_inventory_add', (itemId, count) => { + const id = String(itemId || ''); + if (!id) return; + const c = Number(count) || 1; + _localInventory.set(id, (_localInventory.get(id) || 0) + c); + send('inv2.add', { itemId: id, count: c }); + }); + global.set('__rbxl_inventory_has', (itemId) => { + return (_localInventory.get(String(itemId || '')) || 0) > 0; + }); + global.set('__rbxl_inventory_remove', (itemId, count) => { + const id = String(itemId || ''); + const c = Number(count) || 1; + const cur = _localInventory.get(id) || 0; + const newCount = Math.max(0, cur - c); + if (newCount === 0) _localInventory.delete(id); + else _localInventory.set(id, newCount); + send('inv2.remove', { itemId: id, count: c }); + }); + // Урон игроку — паритет с JS game.player.damage(amount). + // У игрока есть i-frames (~0.5с), так что урон не каждый кадр. + global.set('__rbxl_damage_player', (amount) => { + send('player.damage', { amount: Number(amount) || 0 }); + }); + // Лечение игрока — паритет с JS game.player.heal(amount). + global.set('__rbxl_heal_player', (amount) => { + send('player.heal', { amount: Number(amount) || 0 }); + }); + // Счёт в углу — паритет с JS game.ui.score = N. null → скрыть. + global.set('__rbxl_score_set', (value) => { + const text = value == null ? null : ('Очки: ' + value); + send('ui.set', { id: '__score', text }); + }); + // Таймер — паритет с JS game.ui.timer = seconds. Формат mm:ss. + global.set('__rbxl_timer_set', (seconds) => { + if (seconds == null) { + send('ui.set', { id: '__timer', text: null }); + return; + } + const n = Number(seconds); + if (!Number.isFinite(n)) return; + const mm = Math.floor(Math.max(0, n) / 60); + const ss = Math.floor(Math.max(0, n) % 60); + const txt = (mm < 10 ? '0' : '') + mm + ':' + (ss < 10 ? '0' : '') + ss; + send('ui.set', { id: '__timer', text: txt }); + }); + // Двойной прыжок — паритет с JS game.player.setDoubleJump(bool). + global.set('__rbxl_set_double_jump', (enabled) => { + send('player.setDoubleJump', { enabled: !!enabled }); + }); + // Точка возрождения — паритет с JS game.player.setSpawn({x,y,z}). + global.set('__rbxl_set_spawn', (x, y, z) => { + send('player.setSpawn', { x: +x || 0, y: +y || 1, z: +z || 0 }); + }); + // Множитель скорости — паритет с JS game.player.setSpeed(mul). 1=обычная. + global.set('__rbxl_set_speed', (mul) => { + send('player.setSpeed', { mul: +mul || 1 }); + }); + // Камера-облёт — паритет с JS game.camera.cutscene(points, opts). + // pointsFlat/lookAtFlat: x1,y1,z1,x2,y2,z2,... — потому что массив + // объектов в wasmoon через C-boundary неудобен. + global.set('__rbxl_camera_cutscene', (pointsFlat, segDuration, lookAtFlat) => { + const parse = (s) => { + const out = []; + const arr = String(s || '').split(',').map((v) => Number(v) || 0); + for (let i = 0; i + 2 < arr.length; i += 3) { + out.push({ x: arr[i], y: arr[i + 1], z: arr[i + 2] }); + } + return out; + }; + send('camera.cutscene', { + points: parse(pointsFlat), + lookAt: lookAtFlat ? parse(lookAtFlat) : [], + segDuration: Number(segDuration) || 1.5, + }); + }); + const _cutsceneDoneCbs = []; + global.set('__rbxl_on_cutscene_done', (fn) => { + if (typeof fn === 'function') _cutsceneDoneCbs.push(fn); + }); + // Подброс игрока — паритет с JS game.player.boostJump(strength). + // 1.0 = обычный прыжок, 3.0 = втрое выше, и т.д. + global.set('__rbxl_boost_jump', (strength) => { + send('player.boostJump', { strength: Number(strength) || 1 }); + }); + // Эффекты частиц (confetti, sparks и т.п.) — как game.scene.spawnParticles. + // BabylonScene._spawnParticleEffect ждёт payload.type и payload.position. + global.set('__rbxl_spawn_particles', (kind, x, y, z, duration, count) => { + send('scene.particles', { + type: String(kind || 'confetti'), + position: { x: +x, y: +y, z: +z }, + duration: Number(duration) || 2, + count: Number(count) || 1, + }); + }); + // Спавн примитива (паритет с JS game.scene.spawn) — кладёт в сцену + // примитив с указанным состоянием (включая anchored/canCollide). Возвращает + // id примитива (число) для дальнейших операций. + let _nextSpawnedId = 800000 + Math.floor(Math.random() * 10000); + global.set('__rbxl_spawn_part', (opts) => { + try { + const id = _nextSpawnedId++; + const o = opts || {}; + send('sceneCreate', { + primId: id, + type: String(o.type || 'cube'), + x: +o.x || 0, y: +o.y || 0, z: +o.z || 0, + sx: +o.sx || 1, sy: +o.sy || 1, sz: +o.sz || 1, + color: o.color || '#A0A0A0', + anchored: o.anchored !== false, + canCollide: o.canCollide !== false, + }); + // Создаём Lua-side представление для скриптов + const fakePrim = { + id, name: o.name || `Spawned_${id}`, + x: +o.x || 0, y: +o.y || 0, z: +o.z || 0, + sx: +o.sx || 1, sy: +o.sy || 1, sz: +o.sz || 1, + color: o.color || '#A0A0A0', + anchored: o.anchored !== false, + canCollide: o.canCollide !== false, + }; + const part = newPart(fakePrim, send); + partById.set(id, part); + return part; + } catch (e) { + // eslint-disable-next-line no-console + console.warn('[__rbxl_spawn_part]', e?.message || e); + return null; + } + }); + // Позиция игрока для удобства — отдельные функции для x/y/z, чтобы + // wasmoon не оборачивал результат в userdata-proxy. + global.set('__rbxl_player_x', () => { + const p = api._realPlayerPos || hrp._position || { X: 0 }; + return Number(p.x ?? p.X) || 0; + }); + global.set('__rbxl_player_y', () => { + const p = api._realPlayerPos || hrp._position || { Y: 0 }; + return Number(p.y ?? p.Y) || 0; + }); + global.set('__rbxl_player_z', () => { + const p = api._realPlayerPos || hrp._position || { Z: 0 }; + return Number(p.z ?? p.Z) || 0; + }); + // Совместимость: __rbxl_player_pos() возвращает 3 числа (x, y, z). + global.set('__rbxl_player_pos', () => { + const p = api._realPlayerPos || hrp._position || { X: 0, Y: 0, Z: 0 }; + return { + x: Number(p.x ?? p.X) || 0, + y: Number(p.y ?? p.Y) || 0, + z: Number(p.z ?? p.Z) || 0, + }; + }); + // Достаём ссылку на Lua-функцию один раз; вызовы безопасны (не doStringSync) + const luaResumeCo = lua.global.get('__rbxl_resume_co'); + + // === 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. api объявляется заранее, чтобы closures + // вроде __rbxl_player_pos и updatePlayerPos могли его видеть. + const api = { + _realPlayerPos: null, + // GameRuntime зовёт после npc.spawn-резолва: маппинг локального + // ref ('npc_lua_N') на реальный ('npc:'). Нужно для npcDeath. + setNpcLocalRef(localRef, realRef) { + _localToRealNpc.set(String(localRef), String(realRef)); + }, + // GameRuntime каждый кадр обновляет позиции NPC для Lua-скриптов. + updateNpcPos(localRef, x, y, z) { + _npcPositions.set(String(localRef), { x: +x, y: +y, z: +z }); + }, + 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); // setters внутри шлют через send + part.Parent = workspace; + workspace.Children.push(part); + partById.set(Number(p.id), part); + } + // Teams из импорта .rbxl — создаём Team-инстансы в teams сервисе + const teamsList = snap?.teams || []; + if (teamsList.length > 0 && teams.Children.length === 0) { + for (const t of teamsList) { + const team = newInstance('Team', String(t.name || 'Team')); + team.TeamColor = { + Name: String(t.name || 'White'), + Color: RbxColor3.fromHex(t.color_hex || '#ffffff'), + }; + team.Score = 0; + team.AutoAssignable = !!t.auto_assignable; + team.PlayerAdded = makeSignal(); + team.PlayerRemoved = makeSignal(); + team._parent = teams; + teams.Children.push(team); + } + // Авто-назначение игрока в первую auto_assignable команду + const first = teams.Children.find(t => t.AutoAssignable); + if (first) { + localPlayer.Team = first; + localPlayer.TeamColor = first.TeamColor; + localPlayer.Neutral = false; + } + } + // eslint-disable-next-line no-console + console.log(`[shim] DataModel: workspace has ${workspace.Children.length} children, ${partById.size} parts, ${teams.Children.length} teams`); + } catch (e) { + send('log', { level: 'error', text: `[shim onSceneSnapshot] ${e?.message || e}` }); + } + }, + onGuiSnapshot() {}, + onDataSnapshot() {}, + + /** Фейр PlayerAdded для уже существующих игроков после того как + * скрипты успели подключить хендлеры. Roblox-конвенция: + * Players.PlayerAdded не срабатывает для игроков уже на сервере. + * Мы дублируем чтобы простые скрипты вроде + * Players.PlayerAdded:Connect(...) работали из коробки. */ + fireExistingPlayers() { + try { + if (players?.PlayerAdded?.Fire) { + players.PlayerAdded.Fire(localPlayer); + } + // CharacterAdded — то же самое + if (localPlayer?.CharacterAdded?.Fire && character) { + localPlayer.CharacterAdded.Fire(character); + } + } catch (_) {} + }, + + tickScheduler(_dt) { + // 0a. Lua-handlers из очереди (signal.Fire отложил их сюда). + // Запускаем каждый в своей coroutine — wait() внутри безопасен. + if (_pendingHandlerQueue.length > 0) { + const queue = _pendingHandlerQueue.splice(0, _pendingHandlerQueue.length); + for (const h of queue) { + try { + // ПРЯМОЙ вызов JS-обёртки Lua-функции (без передачи fn + // обратно в Lua через luaDrainHandler — это создаёт + // wasmoon Promise-detection crash на null.then). + // wasmoon вернёт Promise — ловим через .catch. + const result = h.fn(...(h.args || [])); + if (result && typeof result.then === 'function') { + result.catch((err) => { + // eslint-disable-next-line no-console + console.warn('[handler-async-err]', err?.message || err); + }); + } + } catch (e) { + // eslint-disable-next-line no-console + console.warn('[handler-sync-err]', e?.message || e); + } + } + } + // 0b. Tweens + _stepTweens(_dt); + const now = SCHEDULER.now(); + // 1. task.delay / task.defer + if (SCHEDULER.sleeping.length > 0) { + 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 (_) {} + } + } + // 2. Резюм coroutine'ов которые task.wait() + const dueCoros = []; + for (let i = waitingCoros.length - 1; i >= 0; i--) { + if (waitingCoros[i].wakeAt <= now) { + dueCoros.push(waitingCoros[i]); + waitingCoros.splice(i, 1); + } + } + for (const entry of dueCoros) { + const co = coroutines.get(entry.coId); + if (!co) continue; + try { + const result = luaResumeCo(co); + if (result === null || result === undefined) { + coroutines.delete(entry.coId); + } else if (typeof result === 'number') { + waitingCoros.push({ + coId: entry.coId, + wakeAt: SCHEDULER.now() + result * 1000, + }); + } else if (result === false) { + coroutines.delete(entry.coId); + } + } catch (e) { + send('log', { level: 'error', text: `[coroutine ${entry.coId}] ${e?.message || e}` }); + coroutines.delete(entry.coId); + } + } + }, + fireHeartbeat(dt) { + try { HEARTBEAT_SIGNAL.Fire(dt); } catch (_) {} + try { STEPPED_SIGNAL.Fire(performance.now() / 1000, dt); } catch (_) {} + // Авто-детект Touched на спавненных частях (id >= 800000): + // Спавненные через __rbxl_spawn_part примитивы (падающие кубы, + // снаряды) Babylon не знает (target=null), поэтому делаем + // proximity-check игрок↔part прямо в shim каждый кадр. + // + // Используем РАСШИРЕННЫЙ радиус (не строгий AABB), потому что + // физтело куба отталкивается от игрока при контакте — куб может + // успеть отскочить ДО следующего кадра. Расширяем зону на 1.2 + // единицы, чтобы поймать "почти-контакт". + try { + const pp = api._realPlayerPos; + if (!pp) return; + const phw = 0.4, phh = 0.9, phd = 0.4; + const SLACK = 1.2; // расширение зоны касания + for (const [id, part] of partById.entries()) { + if (id < 800000) continue; + if (!part || part.Destroyed) continue; + if (!part.Touched || part.Touched.connections.length === 0) continue; + const pos = part._state?.Position; + const size = part._state?.Size; + if (!pos || !size) continue; + const hw = size.X / 2 + SLACK; + const hh = size.Y / 2 + SLACK; + const hd = size.Z / 2 + SLACK; + const overlap = + pp.x + phw > pos.X - hw && pp.x - phw < pos.X + hw && + pp.y + phh > pos.Y - hh && pp.y - phh < pos.Y + hh && + pp.z + phd > pos.Z - hd && pp.z - phd < pos.Z + hd; + if (overlap && !part.__lastTouching) { + part.__lastTouching = true; + try { part.Touched.Fire(hrp); } catch (_) {} + } else if (!overlap && part.__lastTouching) { + part.__lastTouching = false; + try { part.TouchEnded.Fire(hrp); } catch (_) {} + } + } + } 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); + } else if (p.kind === 'click') { + // ClickDetector — стрельба по 3D-объектам. + // Фейерим без аргумента (передача объектов в Lua через wasmoon + // может крашить с null.then). + try { + const cd = part._clickDetector; + if (cd && cd.MouseClick) cd.MouseClick.Fire(); + } catch (_) {} + try { + if (part.Clicked) part.Clicked.Fire(); + } catch (_) {} + } + }, + 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)); + // НЕ фейерим part.Touched — это делает fireTargetEvent + // в routeEvent('touch'). Иначе двойной счёт. + if (part && humanoid.Touched) humanoid.Touched.Fire(part); + } + } + // Cutscene камеры закончилась — фейерим зарегистрированные cb. + if (p.type === 'cutsceneDone') { + for (const fn of _cutsceneDoneCbs) { + _pendingHandlerQueue.push({ fn, args: [] }); + } + } + // NPC погиб — фейерим registered cb для конкретного локального ref. + if (p.type === 'npcDeath' && p.npcId != null) { + const realRef = 'npc:' + p.npcId; + // Ищем локальный ref по реальному + let localRef = null; + for (const [k, v] of _localToRealNpc.entries()) { + if (v === realRef) { localRef = k; break; } + } + // Вызываем все cb с подходящим ref + if (_npcDeathCbs.size > 0) { + for (const [ref, fn] of _npcDeathCbs.entries()) { + if (ref === realRef || ref === localRef) { + _pendingHandlerQueue.push({ fn, args: [] }); + } + } + } + } + // GUI-клик: GameRuntime шлёт {type:'guiClick', localId, id} + if (p.type === 'guiClick') { + const ref = p.localId || p.id; + const guiEl = guiByLocalRef.get(ref); + if (guiEl?.MouseButton1Click) { + guiEl.MouseButton1Click.Fire(); + } + } + // Tool equip/unequip — клавиши 1-9 в плеере шлют + // {type:'equipTool', index:N}, {type:'unequipTool'} + if (p.type === 'equipTool') { + const idx = Number(p.index) - 1; + if (idx < 0 || idx >= allTools.length) return; + const tool = allTools[idx]; + if (equippedTool === tool) return; + // Снимаем предыдущий + if (equippedTool) { + try { equippedTool.Unequipped.Fire(); } catch (_) {} + } + equippedTool = tool; + // В Roblox Tool при equip перемещается в Character + tool.Parent = character; + try { tool.Equipped.Fire(playerMouse); } catch (_) {} + } + if (p.type === 'unequipTool') { + if (!equippedTool) return; + try { equippedTool.Unequipped.Fire(); } catch (_) {} + equippedTool.Parent = backpack; + equippedTool = null; + } + if (p.type === 'toolActivated') { + if (!equippedTool) return; + try { equippedTool.Activated.Fire(); } catch (_) {} + } + if (p.type === 'toolDeactivated') { + if (!equippedTool) return; + try { equippedTool.Deactivated.Fire(); } catch (_) {} + } + // Mouse-события из плеера: клики, движение, клавиши при equipped Tool + // BabylonScene шлёт глобальный 'click' при ЛКМ. Если в payload + // target — это попадание по 3D-объекту. Для NPC фейерим cb. + if (p.type === 'click' && p.target && p.target.kind === 'npc' && p.target.id != null) { + const realRef = 'npc:' + p.target.id; + let localRef = null; + for (const [k, v] of _localToRealNpc.entries()) { + if (v === realRef) { localRef = k; break; } + } + for (const [ref, fn] of _npcClickCbs.entries()) { + if (ref === realRef || ref === localRef) { + _pendingHandlerQueue.push({ fn, args: [] }); + } + } + } + // BabylonScene шлёт глобальный 'click' при ЛКМ — это эквивалент + // mouseButton1Down. Мапим в наши handler-ы. + if (p.type === 'click' || p.type === 'mouseButton1Down') { + if (p.hit) { + playerMouse.Hit.Position = new RbxVector3(p.hit.x, p.hit.y, p.hit.z); + playerMouse.Hit.p = playerMouse.Hit.Position; + } + try { playerMouse.Button1Down.Fire(); } catch (_) {} + // Также фейерим UserInputService.InputBegan с UserInputType.MouseButton1 + try { + const uitEnum = global.get('Enum')?.UserInputType || {}; + const inputObj = { + UserInputType: uitEnum.MouseButton1 + || { Name: 'MouseButton1', Value: 'MouseButton1' }, + KeyCode: { Name: 'Unknown', Value: 'Unknown' }, + }; + uis.InputBegan.Fire(inputObj, false); + } catch (_) {} + } + if (p.type === 'mouseButton1Up') { + try { playerMouse.Button1Up.Fire(); } catch (_) {} + try { + const uitEnum = global.get('Enum')?.UserInputType || {}; + const inputObj = { + UserInputType: uitEnum.MouseButton1 + || { Name: 'MouseButton1', Value: 'MouseButton1' }, + KeyCode: { Name: 'Unknown', Value: 'Unknown' }, + }; + uis.InputEnded.Fire(inputObj, false); + } catch (_) {} + } + if (p.type === 'keyDown' || p.type === 'keydown') { + const k = String(p.key || '').toLowerCase(); + try { playerMouse.KeyDown.Fire(k); } catch (_) {} + // Также фейерим UserInputService.InputBegan с InputObject. + // KeyCode должна быть та же ссылка что и Enum.KeyCode.E, + // чтобы скрипт мог сравнивать input.KeyCode == Enum.KeyCode.E. + try { + const keyEnum = global.get('Enum')?.KeyCode || {}; + const kc = keyEnum[k.toUpperCase()] + || { Name: k.toUpperCase(), Value: k.toUpperCase() }; + const inputObj = { UserInputType: 'Keyboard', KeyCode: kc }; + uis.InputBegan.Fire(inputObj, false); + } catch (_) {} + } + if (p.type === 'keyUp' || p.type === 'keyup') { + const k = String(p.key || '').toLowerCase(); + try { playerMouse.KeyUp.Fire(k); } catch (_) {} + try { + const keyEnum = global.get('Enum')?.KeyCode || {}; + const kc = keyEnum[k.toUpperCase()] + || { Name: k.toUpperCase(), Value: k.toUpperCase() }; + const inputObj = { UserInputType: 'Keyboard', KeyCode: kc }; + uis.InputEnded.Fire(inputObj, false); + } catch (_) {} + } + }, + // Tool registry (для GameRuntime: какой Tool сделать script.Parent) + getToolByName(name) { + return allTools.find(t => t.Name === name); + }, + getAllTools() { return allTools.slice(); }, + // GameRuntime каждый кадр шлёт реальную позицию игрока сюда. + // __rbxl_player_pos() её возвращает Lua-скриптам. + updatePlayerPos(x, y, z) { + api._realPlayerPos = { x: +x, y: +y, z: +z }; + }, + // Синхронизация позиций спавненных физических частей (падающие кубы). + // GameRuntime каждый кадр зовёт это с актуальными координатами от + // pm.instances — иначе наш AABB-touched-check считает позиции + // устаревшими (на момент создания) и не ловит касание. + updateSpawnedPos(id, x, y, z) { + const part = partById.get(Number(id)); + if (part && part._state && part._state.Position) { + part._state.Position = new RbxVector3(x, y, z); + } + }, + // Доступ к ключевым объектам (для тестов и отладки) + partById, localPlayer, humanoid, character, workspace, players, game, + }; + return api; +} diff --git a/src/engine/rbxl-lua-integration.js b/src/engine/rbxl-lua-integration.js new file mode 100644 index 0000000..093b50a --- /dev/null +++ b/src/engine/rbxl-lua-integration.js @@ -0,0 +1,210 @@ +/** + * rbxl-lua-integration.js — вспомогательные функции для импорта .rbxl-карт. + * + * Старый отдельный Worker-based runtime удалён (2026-06-08): импортированные + * Lua-скрипты теперь идут через тот же LuaSharedSandbox что и user-Lua + * (см. GameRuntime.start()). Этот файл оставлен только для: + * - unpackRobloxLuaCode() — распаковка Lua из JS-комментария-обёртки; + * - handleLuaCommand() — обработка partSet/sceneCreate/sceneDelete/playerCmd + * команд от Lua-VM в BabylonScene. + */ + +/** Распаковка lua_source из packed-кода. */ +export function unpackRobloxLuaCode(code) { + const openTag = '/[*] lua_source:\n'.replace('[*]', '*'); + const i = code.indexOf(openTag); + if (i < 0) return null; + const start = i + openTag.length; + const closeIdx = code.lastIndexOf('\n*' + '/'); + if (closeIdx < start) return null; + return code.slice(start, closeIdx); +} + +/** Парсит JSON-метадату из 2-й строки packed-кода (`// {"roblox_class":..., "enabled": true}`). */ +export function parseRobloxLuaMeta(code) { + if (typeof code !== 'string') return null; + const lines = code.split('\n'); + if (lines.length < 2) return null; + const metaLine = lines[1]; + if (!metaLine.startsWith('// ')) return null; + try { + return JSON.parse(metaLine.slice(3)); + } catch (_) { + return null; + } +} + +/** Сцена → snap для shim'а (workspace:GetChildren). */ +export function buildLuaSceneSnap(primitives) { + const out = { primitives: {} }; + if (!Array.isArray(primitives)) return out; + for (const p of primitives) { + out.primitives[p.id] = { + id: p.id, type: p.type, name: p.name, + x: p.x, y: p.y, z: p.z, + sx: p.sx, sy: p.sy, sz: p.sz, + color: p.color, material: p.material, + anchored: !!p.anchored, canCollide: p.canCollide !== false, + opacity: typeof p.opacity === 'number' ? p.opacity : 1, + }; + } + return out; +} + +/** + * GUI-tree для shim'а. Mapping origin → __roblox_class. + * scene.gui — массив элементов с {id, type, name, parentId, ...origin}. + * Возвращаем массив сохраняя порядок parent → child (важно для tree-сборки). + */ +export function buildLuaGuiTree(guiElements) { + if (!Array.isArray(guiElements)) return []; + const out = []; + for (const el of guiElements) { + // origin = 'roblox-textbutton' → 'TextButton' + let rblClass = 'Frame'; + const origin = el.origin || ''; + if (origin.startsWith('roblox-')) { + const tail = origin.slice(7); + rblClass = tail.charAt(0).toUpperCase() + tail.slice(1); + // Camel-case "textbutton" → "TextButton" + if (rblClass.toLowerCase() === 'textbutton') rblClass = 'TextButton'; + else if (rblClass.toLowerCase() === 'textlabel') rblClass = 'TextLabel'; + else if (rblClass.toLowerCase() === 'imagebutton') rblClass = 'ImageButton'; + else if (rblClass.toLowerCase() === 'imagelabel') rblClass = 'ImageLabel'; + else if (rblClass.toLowerCase() === 'textbox') rblClass = 'TextBox'; + else if (rblClass.toLowerCase() === 'scrollingframe') rblClass = 'ScrollingFrame'; + else if (rblClass.toLowerCase() === 'frame') rblClass = 'Frame'; + } else { + // Если origin не задан — гадаем по type + const t = el.type; + if (t === 'button') rblClass = 'TextButton'; + else if (t === 'text') rblClass = 'TextLabel'; + else if (t === 'image') rblClass = 'ImageLabel'; + else if (t === 'textbox') rblClass = 'TextBox'; + } + out.push({ + id: el.id, + name: el.name || rblClass, + parentId: el.parentId || null, + visible: el.visible !== false, + text: el.text || '', + __roblox_class: rblClass, + }); + } + return out; +} + +/** + * Обработка IPC команд от worker'а — мапим на действия в Babylon-сцене. + */ +export function handleLuaCommand(_scriptId, cmd, payload, runtime) { + if (cmd === 'log') { + const fn = payload?.level === 'error' ? console.error + : payload?.level === 'warn' ? console.warn : console.log; + fn('[rbxl-lua]', payload?.text || ''); + return; + } + if (cmd === 'partSet') { + const pm = runtime.scene3d?.primitiveManager; + if (!pm) { + console.warn('[partSet] no primitiveManager. scene3d=', !!runtime.scene3d); + return; + } + const primId = payload?.primId; + const prop = payload?.prop; + const value = payload?.value; + const patch = {}; + if (prop === 'position' && value) { + patch.x = value.x; patch.y = value.y; patch.z = value.z; + } else if (prop === 'cframe' && value) { + patch.x = value.x; patch.y = value.y; patch.z = value.z; + patch.rotationX = value.rx; patch.rotationY = value.ry; patch.rotationZ = value.rz; + } else if (prop === 'size' && value) { + patch.sx = value.sx; patch.sy = value.sy; patch.sz = value.sz; + } else if (prop === 'color') patch.color = value; + else if (prop === 'material') patch.material = value; + else if (prop === 'anchored') patch.anchored = value; + else if (prop === 'canCollide') patch.canCollide = value; + else if (prop === 'opacity') patch.opacity = value; + try { + if (typeof pm.updateInstance === 'function') pm.updateInstance(primId, patch); + else if (typeof pm.applyPatch === 'function') pm.applyPatch(primId, patch); + else if (typeof pm.update === 'function') pm.update(primId, patch); + } catch (e) { + console.error('[partSet] updateInstance failed:', e); + } + return; + } + if (cmd === 'sceneCreate') { + // Lua: Instance.new("Part") + part.Parent = workspace → создание примитива. + // payload: { primId (предложенный id), type, x, y, z, sx, sy, sz, color, anchored } + try { + const pm = runtime.scene3d?.primitiveManager; + if (!pm || typeof pm.addInstance !== 'function') return; + const opts = { + id: payload?.primId, + x: payload?.x || 0, y: payload?.y || 0, z: payload?.z || 0, + sx: payload?.sx || 1, sy: payload?.sy || 1, sz: payload?.sz || 1, + color: payload?.color, + anchored: payload?.anchored !== false, + canCollide: payload?.canCollide !== false, + }; + pm.addInstance(payload?.type || 'cube', opts); + // Если unanchored — регистрируем в физике на лету, иначе он не падает. + if (opts.anchored === false) { + try { + const dm = runtime.scene3d?.dynamics; + const data = pm.instances?.get?.(opts.id); + if (dm && data && typeof dm.registerPrimitive === 'function') { + dm.registerPrimitive(data); + } + } catch (e) { + console.warn('[sceneCreate] registerPrimitive failed', e); + } + } + } catch (e) { + console.error('[sceneCreate]', e); + } + return; + } + if (cmd === 'sceneDelete') { + // Lua: part:Destroy() → удаление примитива. + try { + const pm = runtime.scene3d?.primitiveManager; + if (!pm || typeof pm.removeInstance !== 'function') return; + const id = payload?.primId; + if (id != null) pm.removeInstance(Number(id)); + } catch (e) { + console.error('[sceneDelete]', e); + } + return; + } + if (cmd === 'partVel') { + try { + const pm = runtime.scene3d?.primitiveManager; + if (pm && typeof pm.setVelocity === 'function') { + pm.setVelocity(payload.primId, payload.vx, payload.vy, payload.vz); + } + } catch (e) {} + return; + } + if (cmd === 'playerCmd') { + try { + const p = runtime.game?.player; + if (!p) return; + const method = payload?.method; + const args = payload?.args || []; + if (method === 'teleport') p.teleport && p.teleport(args[0], args[1], args[2]); + else if (method === 'setWalkSpeed') p.setWalkSpeed && p.setWalkSpeed(args[0]); + else if (method === 'setJumpPower') p.setJumpPower && p.setJumpPower(args[0]); + else if (method === 'setHealth') p.setHealth && p.setHealth(args[0]); + else if (method === 'die') p.die && p.die(); + else if (method === 'damage' || method === 'takeDamage') p.damage && p.damage(args[0]); + } catch (e) {} + return; + } + if (cmd === 'guiUpdate') { + // TODO: scripts setting Visible/Text/Color on GUI → передать в GuiManager + return; + } +} diff --git a/tests/rbxl-lua-integration.test.js b/tests/rbxl-lua-integration.test.js new file mode 100644 index 0000000..d940097 --- /dev/null +++ b/tests/rbxl-lua-integration.test.js @@ -0,0 +1,243 @@ +/** + * rbxl-lua-integration.test.js — реалистичные Roblox-сниппеты из obby/simulator карт. + */ +import { LuaFactory } from 'wasmoon'; +import { registerRobloxApi } from '../src/engine/roblox-shim.js'; +import { RobloxScheduler } from '../src/engine/roblox-scheduler.js'; +import { installRobloxServices } from '../src/engine/roblox-services.js'; +import { RobloxTweenManager } from '../src/engine/roblox-tween.js'; +import { RobloxPhysicsManager } from '../src/engine/roblox-physics.js'; + +function makeScene() { + return { + primitives: { + 10: { id: 10, type: 'cube', name: 'KillPart', x: 5, y: 1, z: 0, sx: 4, sy: 1, sz: 4, + color: '#ff0000', material: 'neon', anchored: true, canCollide: true, opacity: 1 }, + 11: { id: 11, type: 'cube', name: 'WinPart', x: 30, y: 1, z: 0, sx: 4, sy: 1, sz: 4, + color: '#00ff00', material: 'neon', anchored: true, canCollide: true, opacity: 1 }, + 12: { id: 12, type: 'cube', name: 'Conveyor', x: 15, y: 1, z: 0, sx: 8, sy: 0.5, sz: 4, + color: '#888888', material: 'metal', anchored: true, canCollide: true, opacity: 1 }, + 13: { id: 13, type: 'cube', name: 'Door', x: 20, y: 3, z: 0, sx: 2, sy: 6, sz: 4, + color: '#a0522d', material: 'matte', anchored: true, canCollide: true, opacity: 1 }, + }, + }; +} + +const STORE = new Map(); + +async function run(luaSource, targetPrimId = 10, ticks = []) { + const factory = new LuaFactory(); + const lua = await factory.createEngine(); + const sent = []; + const send = (cmd, payload) => sent.push({ cmd, payload }); + let playerState = { x: 0, y: 5, z: 0, hp: 100 }; + registerRobloxApi(lua, { getSceneSnap: makeScene, targetPrimitiveId: targetPrimId, send }); + const sched = new RobloxScheduler(lua); + sched.install(); + installRobloxServices(lua, { + send, + getPlayerState: () => playerState, + loadSave: (k) => STORE.get(k), + saveSave: (k, v) => STORE.set(k, v), + removeSave: (k) => STORE.delete(k), + }); + const tween = new RobloxTweenManager(); + tween.install(lua); + const phys = new RobloxPhysicsManager(send); + phys.install(lua); + + await sched.spawnMain(luaSource); + for (const dt of ticks) { + await sched.tick(dt); + tween.tick(dt); + phys.tick(dt); + } + lua.global.close(); + return { + logs: sent.filter(s => s.cmd === 'log').map(s => s.payload), + partSets: sent.filter(s => s.cmd === 'partSet').map(s => s.payload), + partVels: sent.filter(s => s.cmd === 'partVel').map(s => s.payload), + playerCmds: sent.filter(s => s.cmd === 'playerCmd').map(s => s.payload), + }; +} + +const TESTS = [ + { + name: 'KillBrick (Touched → Humanoid.Health = 0)', + lua: ` + local part = script.Parent + part.Touched:Connect(function(hit) + local hum = hit.Parent and hit.Parent:FindFirstChild("Humanoid") + if hum then hum.Health = 0 end + end) + print("kill brick armed") + `, + ticks: [], + check: (r) => r.logs.some(l => l.text === 'kill brick armed'), + }, + { + name: 'WalkSpeed boost через trigger', + lua: ` + local h = game:GetService("Players").LocalPlayer.Character.Humanoid + h.WalkSpeed = 32 + print("speed boosted to", h.WalkSpeed) + `, + check: (r) => r.playerCmds.some(c => c.method === 'setWalkSpeed' && c.args[0] === 32) + && r.logs.some(l => l.text.includes('speed boosted')), + }, + { + name: 'Door open: TweenService двигает дверь вверх', + lua: ` + local door = workspace:FindFirstChild("Door") + local TS = game:GetService("TweenService") + local goal = { Position = Vector3.new(door.Position.X, door.Position.Y + 10, door.Position.Z) } + local info = TweenInfo.new(1, Enum.EasingStyle.Linear, Enum.EasingDirection.Out) + local tw = TS:Create(door, info, goal) + tw:Play() + print("door opening") + `, + ticks: [0.5, 0.5, 0.1], + check: (r) => r.partSets.some(p => p.primId === 13 && p.prop === 'position'), + }, + { + name: 'Конвейер: BodyVelocity толкает игрока', + lua: ` + local conv = workspace:FindFirstChild("Conveyor") + local bv = Instance.new("BodyVelocity", conv) + bv.Velocity = Vector3.new(20, 0, 0) + bv.MaxForce = Vector3.new(4000, 0, 4000) + print("conveyor started") + `, + ticks: [0.1], + check: (r) => r.partVels.some(v => v.primId === 12 && v.vx === 20), + }, + { + name: 'leaderstats (как в tycoon)', + lua: ` + local Players = game:GetService("Players") + local plr = Players.LocalPlayer + local money = Instance.new("IntValue", plr.leaderstats) + money.Name = "Money" + money.Value = 100 + print("money:", money.Value) + `, + check: (r) => r.logs.some(l => l.text === 'money:\t100'), + }, + { + name: 'Checkpoint сохраняется в DataStore', + lua: ` + local DSS = game:GetService("DataStoreService") + local store = DSS:GetDataStore("checkpoints") + store:SetAsync("player1", 5) + local cp = store:GetAsync("player1") + print("checkpoint:", cp) + `, + check: (r) => r.logs.some(l => l.text === 'checkpoint:\t5'), + }, + { + name: 'Цикл с wait — подсчёт', + lua: ` + for i = 1, 3 do + print("count:", i) + wait(0.3) + end + print("done") + `, + ticks: [0.3, 0.3, 0.3, 0.3], + check: (r) => { + const texts = r.logs.map(l => l.text); + return texts.includes('count:\t1') && texts.includes('count:\t2') + && texts.includes('count:\t3') && texts.includes('done'); + }, + }, + { + name: 'task.spawn — параллельные функции', + lua: ` + task.spawn(function() print("parallel A") end) + task.spawn(function() print("parallel B") end) + print("main") + `, + check: (r) => { + const texts = r.logs.map(l => l.text); + return texts.includes('parallel A') && texts.includes('parallel B') && texts.includes('main'); + }, + }, + { + name: 'Color3 + Material смена при Touched', + lua: ` + local part = workspace:FindFirstChild("KillPart") + part.Touched:Connect(function() + part.Color = Color3.fromRGB(0, 0, 255) + part.Material = "Neon" + end) + -- симулируем touch + part.Touched:Fire(workspace) + `, + check: (r) => r.partSets.some(p => p.primId === 10 && p.prop === 'color') + && r.partSets.some(p => p.primId === 10 && p.prop === 'material'), + }, + { + name: 'RemoteEvent: client→server message', + lua: ` + local re = Instance.new("RemoteEvent", workspace) + re.Name = "Coins" + re.OnServerEvent:Connect(function(player, amount) + print("server received:", amount) + end) + re:FireServer(50) + `, + check: (r) => r.logs.some(l => l.text === 'server received:\t50'), + }, + { + name: 'Heartbeat: счётчик через RunService', + lua: ` + local RS = game:GetService("RunService") + local count = 0 + RS.Heartbeat:Connect(function(dt) + count = count + 1 + if count == 3 then print("tick3") end + end) + `, + ticks: [0.1, 0.1, 0.1], + check: (r) => r.logs.some(l => l.text === 'tick3'), + }, + { + name: 'Math: Vector3 arithmetic', + lua: ` + local a = Vector3.new(1, 2, 3) + local b = Vector3.new(4, 5, 6) + local sum = a:add(b) + print("sum:", sum.X, sum.Y, sum.Z) + local d = a:Dot(b) + print("dot:", d) + `, + check: (r) => { + const texts = r.logs.map(l => l.text); + return texts.some(t => t === 'sum:\t5\t7\t9') && texts.some(t => t === 'dot:\t32'); + }, + }, +]; + +(async () => { + let passed = 0, failed = 0; + for (const t of TESTS) { + try { + const r = await run(t.lua, t.targetPrimId, t.ticks || []); + const ok = t.check(r); + if (ok) { console.log(`✓ ${t.name}`); passed++; } + else { + console.log(`✗ ${t.name}`); + console.log(` logs: ${JSON.stringify(r.logs.map(l => l.text))}`); + if (r.partSets.length) console.log(` partSets: ${JSON.stringify(r.partSets)}`); + if (r.partVels.length) console.log(` partVels: ${JSON.stringify(r.partVels)}`); + if (r.playerCmds.length) console.log(` playerCmds: ${JSON.stringify(r.playerCmds)}`); + failed++; + } + } catch (e) { + console.log(`✗ ${t.name} — exception: ${e.message || e}`); + failed++; + } + } + console.log(`\n${passed} passed, ${failed} failed`); + process.exit(failed > 0 ? 1 : 0); +})(); diff --git a/tests/rbxl-lua-mvp.test.js b/tests/rbxl-lua-mvp.test.js new file mode 100644 index 0000000..9677bb2 --- /dev/null +++ b/tests/rbxl-lua-mvp.test.js @@ -0,0 +1,187 @@ +/** + * rbxl-lua-mvp.test.js — headless smoke-тест Roblox Lua API shim. + * + * НЕ запускает Worker (это требует браузерного Worker API). Вместо этого + * напрямую импортирует roblox-shim.js и инициализирует Lua в текущем потоке. + * + * Запуск: node --experimental-vm-modules tests/rbxl-lua-mvp.test.js + */ +import { LuaFactory } from 'wasmoon'; +import { registerRobloxApi } from '../src/engine/roblox-shim.js'; + +const FAKE_SCENE_SNAP = { + primitives: { + 1: { id: 1, type: 'cube', name: 'Floor', x: 0, y: 0, z: 0, sx: 10, sy: 1, sz: 10, + color: '#888888', material: 'glossy', anchored: true, canCollide: true, opacity: 1 }, + 2: { id: 2, type: 'cube', name: 'KillBrick', x: 5, y: 1, z: 0, sx: 2, sy: 1, sz: 2, + color: '#ff0000', material: 'neon', anchored: true, canCollide: true, opacity: 1 }, + }, +}; + +const SNIPPETS = [ + { + name: 'print hello', + lua: `print("Hello from Lua!")`, + expectLogs: [{ level: 'info', text: 'Hello from Lua!' }], + }, + { + name: 'Vector3 math', + lua: ` + local v = Vector3.new(3, 4, 0) + print("magnitude:", v.Magnitude) + local u = v.Unit + print("unit:", u.X, u.Y, u.Z) + `, + expectLogs: [ + { level: 'info', text: 'magnitude:\t5' }, + ], + }, + { + name: 'workspace iteration', + lua: ` + local children = workspace:GetChildren() + print("count:", #children) + for i, c in ipairs(children) do + print("child:", c.Name, "class:", c.ClassName) + end + `, + expectLogs: [ + { level: 'info', text: 'count:\t2' }, + ], + }, + { + name: 'FindFirstChild', + lua: ` + local kb = workspace:FindFirstChild("KillBrick") + if kb then print("found:", kb.Name) + else print("not found") end + `, + expectLogs: [{ level: 'info', text: 'found:\tKillBrick' }], + }, + { + name: 'Part.Position get', + lua: ` + local kb = workspace:FindFirstChild("KillBrick") + print("position:", kb.Position.X, kb.Position.Y, kb.Position.Z) + `, + expectLogs: [{ level: 'info', text: 'position:\t5\t1\t0' }], + }, + { + name: 'Part.Color set', + lua: ` + local kb = workspace:FindFirstChild("KillBrick") + kb.Color = Color3.new(0, 1, 0) + print("new color hex (via Position):", kb.Color.R, kb.Color.G, kb.Color.B) + `, + expectPartSet: { primId: 2, prop: 'color' }, + }, + { + name: 'CFrame.Angles', + lua: ` + local cf = CFrame.Angles(0, math.pi/2, 0) + print("lookvector:", cf.LookVector.X, cf.LookVector.Y, cf.LookVector.Z) + `, + expectLogs: [], + }, + { + name: 'Instance.new + Parent', + lua: ` + local f = Instance.new("Folder", workspace) + f.Name = "MyFolder" + print("folder name:", f.Name, "parent:", f.Parent.Name) + `, + expectLogs: [{ level: 'info', text: 'folder name:\tMyFolder\tparent:\tWorkspace' }], + }, + { + name: 'IsA hierarchy', + lua: ` + local kb = workspace:FindFirstChild("KillBrick") + print("isa Part:", kb:IsA("Part")) + print("isa BasePart:", kb:IsA("BasePart")) + print("isa Instance:", kb:IsA("Instance")) + print("isa Sound:", kb:IsA("Sound")) + `, + expectLogs: [ + { level: 'info', text: 'isa Part:\ttrue' }, + { level: 'info', text: 'isa BasePart:\ttrue' }, + { level: 'info', text: 'isa Instance:\ttrue' }, + { level: 'info', text: 'isa Sound:\tfalse' }, + ], + }, +]; + +async function runSnippet(snippet) { + const factory = new LuaFactory(); + const lua = await factory.createEngine(); + + const logs = []; + const sent = []; + const send = (cmd, payload) => sent.push({ cmd, payload }); + + registerRobloxApi(lua, { + getSceneSnap: () => FAKE_SCENE_SNAP, + targetPrimitiveId: 2, // как будто скрипт прикреплён к KillBrick + send, + }); + + // Перехват print через send('log', ...) + let errMsg = null; + try { + await lua.doString(snippet.lua); + } catch (e) { + errMsg = e && e.message ? e.message : String(e); + } + lua.global.close(); + + const captured = sent.filter(s => s.cmd === 'log'); + return { logs: captured.map(s => s.payload), partSets: sent.filter(s => s.cmd === 'partSet'), error: errMsg }; +} + +(async () => { + let passed = 0; + let failed = 0; + for (const s of SNIPPETS) { + const result = await runSnippet(s); + const ok = checkExpectations(s, result); + if (ok.success) { + console.log(`✓ ${s.name}`); + passed++; + } else { + console.log(`✗ ${s.name}`); + console.log(` error: ${result.error || 'none'}`); + console.log(` logs received:`); + for (const l of result.logs) console.log(` [${l.level}] ${JSON.stringify(l.text)}`); + if (result.partSets.length) { + console.log(` partSets:`, JSON.stringify(result.partSets)); + } + console.log(` reason: ${ok.reason}`); + failed++; + } + } + console.log(`\n${passed} passed, ${failed} failed`); + process.exit(failed > 0 ? 1 : 0); +})(); + +function checkExpectations(snippet, result) { + if (result.error) { + return { success: false, reason: `lua error: ${result.error}` }; + } + if (snippet.expectLogs) { + for (const exp of snippet.expectLogs) { + const found = result.logs.find(l => l.level === exp.level && l.text === exp.text); + if (!found) { + return { success: false, reason: `missing log: [${exp.level}] ${JSON.stringify(exp.text)}` }; + } + } + } + if (snippet.expectPartSet) { + const found = result.partSets.find(s => + s.payload.primId === snippet.expectPartSet.primId && + s.payload.prop === snippet.expectPartSet.prop + ); + if (!found) { + return { success: false, reason: `missing partSet ${JSON.stringify(snippet.expectPartSet)}` }; + } + } + return { success: true }; +} diff --git a/tests/rbxl-lua-services.test.js b/tests/rbxl-lua-services.test.js new file mode 100644 index 0000000..c913c25 --- /dev/null +++ b/tests/rbxl-lua-services.test.js @@ -0,0 +1,144 @@ +/** + * rbxl-lua-services.test.js — тесты Humanoid, RemoteEvent, DataStore, HttpService. + */ +import { LuaFactory } from 'wasmoon'; +import { registerRobloxApi } from '../src/engine/roblox-shim.js'; +import { RobloxScheduler } from '../src/engine/roblox-scheduler.js'; +import { installRobloxServices } from '../src/engine/roblox-services.js'; + +const SCENE = { primitives: {} }; + +const STORE = new Map(); + +async function run(luaSource, ticks = []) { + const factory = new LuaFactory(); + const lua = await factory.createEngine(); + const sent = []; + const send = (cmd, payload) => sent.push({ cmd, payload }); + let playerState = { x: 0, y: 5, z: 0 }; + + registerRobloxApi(lua, { getSceneSnap: () => SCENE, targetPrimitiveId: null, send }); + const sched = new RobloxScheduler(lua); + sched.install(); + installRobloxServices(lua, { + send, + getPlayerState: () => playerState, + loadSave: (k) => STORE.get(k), + saveSave: (k, v) => STORE.set(k, v), + removeSave: (k) => STORE.delete(k), + }); + + await sched.spawnMain(luaSource); + for (const dt of ticks) await sched.tick(dt); + lua.global.close(); + return { logs: sent.filter(s => s.cmd === 'log').map(s => s.payload), + playerCmds: sent.filter(s => s.cmd === 'playerCmd').map(s => s.payload), + broadcasts: sent.filter(s => s.cmd === 'broadcast').map(s => s.payload) }; +} + +const TESTS = [ + { + name: 'Players.LocalPlayer.Character.Humanoid существует', + lua: ` + local p = game:GetService("Players").LocalPlayer + local h = p.Character:WaitForChild("Humanoid") + print("hp:", h.Health, "ws:", h.WalkSpeed) + `, + expect: [{ level: 'info', text: 'hp:\t100\tws:\t16' }], + }, + { + name: 'Humanoid.WalkSpeed = 50 → playerCmd setWalkSpeed', + lua: ` + local h = game:GetService("Players").LocalPlayer.Character.Humanoid + h.WalkSpeed = 50 + `, + expectPlayerCmd: { method: 'setWalkSpeed', argsCheck: (a) => a[0] === 50 }, + }, + { + name: 'Humanoid:TakeDamage уменьшает HP', + lua: ` + local h = game:GetService("Players").LocalPlayer.Character.Humanoid + h:TakeDamage(30) + print("after damage:", h.Health) + `, + expect: [{ level: 'info', text: 'after damage:\t70' }], + }, + { + name: 'Humanoid.Health = 0 → Died fires', + lua: ` + local h = game:GetService("Players").LocalPlayer.Character.Humanoid + h.Died:Connect(function() print("DIED") end) + h.Health = 0 + `, + expect: [{ level: 'info', text: 'DIED' }], + }, + { + name: 'DataStoreService GetAsync/SetAsync', + lua: ` + local DSS = game:GetService("DataStoreService") + local store = DSS:GetDataStore("coins") + store:SetAsync("player1", 100) + print("got:", store:GetAsync("player1")) + `, + expect: [{ level: 'info', text: 'got:\t100' }], + }, + { + name: 'DataStoreService IncrementAsync', + lua: ` + local store = game:GetService("DataStoreService"):GetDataStore("score") + store:SetAsync("p1", 50) + store:IncrementAsync("p1", 25) + print("final:", store:GetAsync("p1")) + `, + expect: [{ level: 'info', text: 'final:\t75' }], + }, + { + name: 'HttpService:JSONEncode/Decode', + lua: ` + local HS = game:GetService("HttpService") + local s = HS:JSONEncode({a=1, b="two"}) + print("encoded len:", #s) + local d = HS:JSONDecode('{"x":42}') + print("decoded x:", d.x) + `, + expect: [{ level: 'info', text: 'decoded x:\t42' }], + }, + { + name: 'RemoteEvent FireServer + OnServerEvent', + lua: ` + local re = Instance.new("RemoteEvent", workspace) + re.Name = "MyEvent" + re.OnServerEvent:Connect(function(player, msg) + print("server got:", msg) + end) + re:FireServer("hello") + `, + expect: [{ level: 'info', text: 'server got:\thello' }], + }, +]; + +(async () => { + let passed = 0, failed = 0; + for (const t of TESTS) { + try { + const r = await run(t.lua, t.ticks); + let ok = true; let reason = ''; + for (const exp of (t.expect || [])) { + const found = r.logs.find(l => l.level === exp.level && l.text === exp.text); + if (!found) { ok = false; reason = `missing log: ${exp.text}; got: ${JSON.stringify(r.logs)}`; break; } + } + if (t.expectPlayerCmd) { + const found = r.playerCmds.find(c => c.method === t.expectPlayerCmd.method + && (!t.expectPlayerCmd.argsCheck || t.expectPlayerCmd.argsCheck(c.args))); + if (!found) { ok = false; reason = `missing playerCmd ${t.expectPlayerCmd.method}; got: ${JSON.stringify(r.playerCmds)}`; } + } + if (ok) { console.log(`✓ ${t.name}`); passed++; } + else { console.log(`✗ ${t.name} — ${reason}`); failed++; } + } catch (e) { + console.log(`✗ ${t.name} — exception: ${e.message || e}`); + failed++; + } + } + console.log(`\n${passed} passed, ${failed} failed`); + process.exit(failed > 0 ? 1 : 0); +})(); diff --git a/tests/rbxl-lua-tween.test.js b/tests/rbxl-lua-tween.test.js new file mode 100644 index 0000000..5dcdc0b --- /dev/null +++ b/tests/rbxl-lua-tween.test.js @@ -0,0 +1,89 @@ +/** + * rbxl-lua-tween.test.js — тесты TweenService. + */ +import { LuaFactory } from 'wasmoon'; +import { registerRobloxApi } from '../src/engine/roblox-shim.js'; +import { RobloxScheduler } from '../src/engine/roblox-scheduler.js'; +import { RobloxTweenManager } from '../src/engine/roblox-tween.js'; + +const SCENE = { + primitives: { + 1: { id: 1, type: 'cube', name: 'Movable', x: 0, y: 5, z: 0, sx: 1, sy: 1, sz: 1, + color: '#ffffff', material: 'glossy', anchored: false, canCollide: true, opacity: 1 }, + }, +}; + +async function run(luaSource, ticks = []) { + const factory = new LuaFactory(); + const lua = await factory.createEngine(); + const sent = []; + const send = (cmd, payload) => sent.push({ cmd, payload }); + registerRobloxApi(lua, { getSceneSnap: () => SCENE, targetPrimitiveId: 1, send }); + const sched = new RobloxScheduler(lua); + sched.install(); + const tweenMgr = new RobloxTweenManager(); + tweenMgr.install(lua); + + await sched.spawnMain(luaSource); + for (const dt of ticks) { + await sched.tick(dt); + tweenMgr.tick(dt); + } + lua.global.close(); + return { logs: sent.filter(s => s.cmd === 'log').map(s => s.payload), + partSets: sent.filter(s => s.cmd === 'partSet').map(s => s.payload) }; +} + +const TESTS = [ + { + name: 'TweenInfo создаётся', + lua: ` + local info = TweenInfo.new(2, Enum.EasingStyle.Linear, Enum.EasingDirection.Out) + print("time:", info.Time, "style:", info.EasingStyle) + `, + ticks: [], + expectLogs: [{ level: 'info', text: 'time:\t2\tstyle:\tLinear' }], + }, + { + name: 'TweenService:Create + Play (Linear)', + lua: ` + local TS = game:GetService("TweenService") + local p = workspace:FindFirstChild("Movable") + local info = TweenInfo.new(1, Enum.EasingStyle.Linear, Enum.EasingDirection.Out) + local tw = TS:Create(p, info, { Position = Vector3.new(10, 5, 0) }) + tw:Play() + print("started") + `, + ticks: [0.5, 0.5, 0.1], // больше 1 сек — должен завершиться + // Ожидаем что хотя бы один partSet с prop=position + expectPartSet: { primId: 1, prop: 'position' }, + }, +]; + +(async () => { + let passed = 0, failed = 0; + for (const t of TESTS) { + try { + const r = await run(t.lua, t.ticks); + let ok = true; + let reason = ''; + for (const exp of (t.expectLogs || [])) { + const found = r.logs.find(l => l.level === exp.level && l.text === exp.text); + if (!found) { ok = false; reason = `missing log: ${exp.text}`; break; } + } + if (t.expectPartSet) { + const found = r.partSets.find(p => p.primId === t.expectPartSet.primId && p.prop === t.expectPartSet.prop); + if (!found) { + ok = false; reason = `missing partSet: ${JSON.stringify(t.expectPartSet)}; got: ${JSON.stringify(r.partSets.slice(0,3))}`; + } + } + if (ok) { console.log(`✓ ${t.name}`); passed++; } + else { console.log(`✗ ${t.name} — ${reason}`); failed++; } + } catch (e) { + console.log(`✗ ${t.name} — exception: ${e}`); + failed++; + } + } + console.log(`\n${passed} passed, ${failed} failed`); + process.exit(failed > 0 ? 1 : 0); +})(); diff --git a/tests/rbxl-lua-wait.test.js b/tests/rbxl-lua-wait.test.js new file mode 100644 index 0000000..720de0b --- /dev/null +++ b/tests/rbxl-lua-wait.test.js @@ -0,0 +1,104 @@ +/** + * rbxl-lua-wait.test.js — тесты wait/task.wait через шедулер. + */ +import { LuaFactory } from 'wasmoon'; +import { registerRobloxApi } from '../src/engine/roblox-shim.js'; +import { RobloxScheduler } from '../src/engine/roblox-scheduler.js'; + +const SCENE = { primitives: {} }; + +async function run(luaSource, ticks = [0.5, 0.5, 0.5, 0.5, 0.5]) { + const factory = new LuaFactory(); + const lua = await factory.createEngine(); + const sent = []; + const send = (cmd, payload) => sent.push({ cmd, payload }); + registerRobloxApi(lua, { getSceneSnap: () => SCENE, targetPrimitiveId: null, send }); + const sched = new RobloxScheduler(lua); + sched.install(); + + await sched.spawnMain(luaSource); + for (const dt of ticks) { + await sched.tick(dt); + } + lua.global.close(); + return sent.filter(s => s.cmd === 'log').map(s => s.payload); +} + +const TESTS = [ + { + name: 'wait(0) — мгновенный', + lua: ` + print("before") + wait(0) + print("after") + `, + expect: ['before', 'after'], + }, + { + name: 'wait(1) — резюм после tick', + lua: ` + print("step1") + wait(1) + print("step2") + `, + ticks: [0.5, 0.5, 0.5], // 1.5 сек суммарно + expect: ['step1', 'step2'], + }, + { + name: 'task.wait(0.5)', + lua: ` + print("a") + task.wait(0.5) + print("b") + `, + ticks: [0.5, 0.5], + expect: ['a', 'b'], + }, + { + name: 'несколько wait подряд', + lua: ` + print("p1") + wait(0.5) + print("p2") + wait(0.5) + print("p3") + `, + ticks: [0.5, 0.5, 0.5, 0.5], // 2 сек + expect: ['p1', 'p2', 'p3'], + }, + { + name: 'task.delay (не блокирует)', + lua: ` + print("immediate") + task.delay(0.3, function() print("delayed") end) + print("after delay-call") + `, + ticks: [0.5], + expect: ['immediate', 'after delay-call', 'delayed'], + }, +]; + +(async () => { + let passed = 0, failed = 0; + for (const t of TESTS) { + try { + const logs = await run(t.lua, t.ticks); + const texts = logs.map(l => l.text); + const ok = JSON.stringify(texts) === JSON.stringify(t.expect); + if (ok) { + console.log(`✓ ${t.name}`); + passed++; + } else { + console.log(`✗ ${t.name}`); + console.log(` expected: ${JSON.stringify(t.expect)}`); + console.log(` got: ${JSON.stringify(texts)}`); + failed++; + } + } catch (e) { + console.log(`✗ ${t.name} — exception: ${e}`); + failed++; + } + } + console.log(`\n${passed} passed, ${failed} failed`); + process.exit(failed > 0 ? 1 : 0); +})();