/** * 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; api._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_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); }); api._npcDeathCbs = _npcDeathCbs; // Инвентарь 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.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, 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); } } // NPC погиб — фейерим registered cb для конкретного локального ref. if (p.type === 'npcDeath' && p.npcId != null) { const realRef = 'npc:' + p.npcId; // Ищем локальный ref по реальному let localRef = null; if (api._localToRealNpc) { for (const [k, v] of api._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 if (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 (_) {} } if (p.type === 'mouseButton1Up') { try { playerMouse.Button1Up.Fire(); } 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; }