/** * 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(); 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]) { try { fn(...args); } catch (e) { // eslint-disable-next-line no-console console.error('[Signal handler]', e); } } }; sig.fire = sig.Fire; sig.Wait = () => null; 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) { return this.FindFirstChild(name); }, 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 () { return 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; } function newInstance(className, name) { const m = makeInstanceMethods(); return { 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, }; } /** * Создать 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. // Формат payload должен соответствовать rbxl-lua-integration.js#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), }); 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']), }); // === 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') }); }); // === 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; localPlayer.DisplayName = 'Player'; players.Children.push(localPlayer); players.LocalPlayer = localPlayer; const character = newInstance('Model', 'Player'); character.Parent = localPlayer; localPlayer.Children.push(character); localPlayer.Character = character; const humanoid = newInstance('Humanoid', 'Humanoid'); humanoid.Parent = character; humanoid.Health = 100; humanoid.MaxHealth = 100; humanoid.WalkSpeed = 16; humanoid.JumpPower = 50; 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) this.Died.Fire(); 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); 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'); 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); }); makeService('Lighting').Ambient = new RbxColor3(0.5, 0.5, 0.5); makeService('Chat'); makeService('SoundService'); makeService('PathfindingService'); makeService('CollectionService'); 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); }; game.FindService = function (name) { return services[name] || null; }; global.set('game', game); global.set('Game', game); global.set('workspace', workspace); global.set('Workspace', workspace); // === Instance.new === // Счётчик для новых 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 === 'BindableEvent') { inst = newInstance('BindableEvent', 'BindableEvent'); inst.Event = makeSignal(); inst.Fire = function (...a) { this.Event.Fire(...a); }; } 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 { 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; }, }); // === Helpers для скриптов === const partById = new Map(); global.set('__rbxl_get_part_by_id', (id) => partById.get(Number(id)) || 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 local ret = coroutine.yield(sec) return ret or sec end if type(task) == 'table' then task.wait = rbx_wait else task = { wait = rbx_wait } end wait = rbx_wait 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-side helper для лога global.set('__log', (level, text) => { send('log', { level: String(level || 'info'), text: String(text || '') }); }); // Достаём ссылку на 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 return { 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); } // eslint-disable-next-line no-console console.log(`[shim] DataModel: workspace has ${workspace.Children.length} children, ${partById.size} parts`); } catch (e) { send('log', { level: 'error', text: `[shim onSceneSnapshot] ${e?.message || e}` }); } }, onGuiSnapshot() {}, onDataSnapshot() {}, tickScheduler(_dt) { // 0. 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 (_) {} }, 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); } }, 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)); if (part?.Touched) part.Touched.Fire(hrp); if (humanoid.Touched) humanoid.Touched.Fire(part); } } }, // Доступ к ключевым объектам (для тестов и отладки) partById, localPlayer, humanoid, character, workspace, players, game, }; }