/** * 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; sig.Wait = () => undefined; 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 () { 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; } // Создаёт 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 чтобы скрипты не падали. return 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) { t[prop] = value; return true; }, has(t, prop) { // Для in-проверок отвечаем true почти всегда (чтобы Lua не падал на // условиях вроде if obj.SomeField then ...) return true; }, }); } /** * Создать 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'; // 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 = ''; 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; 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); }); 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'); 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); }; // Старый 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 === '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 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 === '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); inst.Value = className === 'BoolValue' ? false : className === 'StringValue' ? '' : (className === 'IntValue' || className === 'NumberValue') ? 0 : undefined; inst.Changed = makeSignal(); } else if (className === 'BodyForce' || className === 'BodyVelocity' || className === 'BodyPosition' || className === 'BodyGyro' || className === 'BodyAngularVelocity' || className === 'BodyThrust') { inst = newInstance(className, className); inst.force = new RbxVector3(0, 0, 0); inst.Force = inst.force; inst.Velocity = new RbxVector3(0, 0, 0); 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 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-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 -- Оборачиваем call в pcall чтобы поглотить return value handler'а -- (RayGun возвращает :connect(...) объект как последнее выражение, -- что приводит к wasmoon promise-detection crash). pcall возвращает -- (ok, ret1, ret2, ...) — мы их не используем. local co = coroutine.create(function() pcall(fn, a1, a2, a3, a4) end) __rbxl_register_coroutine(handlerId, co) local ok, ret = coroutine.resume(co) if not ok then __rbxl_send_error(handlerId, tostring(ret)) __rbxl_unregister_coroutine(handlerId) elseif type(ret) == 'number' then __rbxl_schedule_resume(handlerId, ret) elseif coroutine.status(co) == 'dead' then __rbxl_unregister_coroutine(handlerId) end -- Явно ничего не возвращаем чтобы wasmoon не оборачивал nil 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 || '') }); }); // Достаём ссылку на 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) { // 0a. Lua-handlers из очереди (signal.Fire отложил их сюда). // Запускаем каждый в своей coroutine — wait() внутри безопасен. if (_pendingHandlerQueue.length > 0) { const queue = _pendingHandlerQueue.splice(0, _pendingHandlerQueue.length); for (const h of queue) { try { // Только реальное число аргументов. wasmoon не любит // undefined/null — может попытаться обернуть как promise. const a = h.args || []; if (a.length === 0) luaDrainHandler(h.fn); else if (a.length === 1) luaDrainHandler(h.fn, a[0]); else if (a.length === 2) luaDrainHandler(h.fn, a[0], a[1]); else if (a.length === 3) luaDrainHandler(h.fn, a[0], a[1], a[2]); else luaDrainHandler(h.fn, a[0], a[1], a[2], a[3]); } catch (e) { console.error('[handler-drain]', 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 (_) {} }, 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); } } // 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') { try { playerMouse.KeyDown.Fire(String(p.key || '').toLowerCase()); } catch (_) {} } if (p.type === 'keyUp') { try { playerMouse.KeyUp.Fire(String(p.key || '').toLowerCase()); } catch (_) {} } }, // Tool registry (для GameRuntime: какой Tool сделать script.Parent) getToolByName(name) { return allTools.find(t => t.Name === name); }, getAllTools() { return allTools.slice(); }, // Доступ к ключевым объектам (для тестов и отладки) partById, localPlayer, humanoid, character, workspace, players, game, }; }