studio/src/editor/engine/lua/RobloxShim.js
min 6bec44d778 fix(lua): троттлинг lightingTimeUpdate до 250мс на shim-стороне
Day/Night скрипт в Roblox: while true do wait(0.01); SetMinutes(+0.1) end
= 100+ Hz обновлений Lighting.ClockTime. Каждое слало lightingTimeUpdate
через send() из coroutine, что (вероятно) вызывает WASM access crash.

Тротлинг прямо в SetMinutesAfterMidnight — не чаще раза в 250мс.
Lua-сторона продолжает делать высокочастотные обновления _minutes/ClockTime
(скрипт работает корректно), но в JS уходит только 4 раза в секунду.
2026-06-08 15:49:04 +03:00

1638 lines
75 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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,
};
}