studio/src/editor/engine/lua/RobloxShim.js
min 8a3405e34a feat(g20): полный паритет «Имена над врагами» + shim NPC API
JS:
- 3 NPC (Гоблин, Скелет, Орк) через spawnNpc('character-b')
- setLabel над каждым с HP, обновляется при уроне
- ЛКМ → бьём ближайшего врага в радиусе 4 (damage 30)
- onDeath → clearLabel + hit sound + при 0 врагов — победа+confetti

Расширения shim/runtime:
- __rbxl_spawn_npc уже было, добавил api._localToRealNpc Map
- __rbxl_npc_damage(ref, amount) → cmd 'npc.damage'
- __rbxl_set_label(ref, text, color, height) → cmd 'scene.setLabel'
- __rbxl_clear_label(ref) → cmd 'scene.clearLabel'
- __rbxl_npc_on_death(ref, fn) — регистрирует cb. shim слушает global
  event 'npcDeath' (resolveTweenTarget теперь поддерживает kind='npc')
  и зовёт зарегистрированные cb с подходящим ref (local или real).
- GameRuntime.npc.spawn.then синхронизирует _localToRealNpc в Lua-sb.

Lua-скрипт игры 20 (паритет):
- showText 'Победи всех врагов! Кликай по ним'
- 3 спавна с метками HP над головой
- UserInputService.InputBegan MouseButton1 → ближайший враг в r=4 → -30hp
- На смерть: clearLabel + при 0 — Победа + win Sound + confetti
2026-06-09 20:34:20 +03:00

2364 lines
111 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;
// Wait() возвращает -1 как маркер "yield 1 кадр" — наш Lua-prelude
// оборачивает все Signal:Wait через __rbxl_signal_wait который при
// получении -1 делает rbx_wait(0.05) (yield в coroutine).
sig.Wait = () => -1;
sig.wait = sig.Wait;
return sig;
}
// ---------- Vector3 / Color3 / UDim2 / Vector2 / CFrame ----------
class RbxVector3 {
constructor(x = 0, y = 0, z = 0) { this.X = +x; this.Y = +y; this.Z = +z; }
static new(x, y, z) { return new RbxVector3(x, y, z); }
get Magnitude() { return Math.hypot(this.X, this.Y, this.Z); }
get magnitude() { return Math.hypot(this.X, this.Y, this.Z); }
get Unit() {
const m = Math.hypot(this.X, this.Y, this.Z) || 1;
return new RbxVector3(this.X / m, this.Y / m, this.Z / m);
}
get unit() { return this.Unit; }
Normalize() { return this.Unit; }
Dot(b) { return this.X * b.X + this.Y * b.Y + this.Z * b.Z; }
Cross(b) {
return new RbxVector3(
this.Y * b.Z - this.Z * b.Y,
this.Z * b.X - this.X * b.Z,
this.X * b.Y - this.Y * b.X,
);
}
Lerp(b, t) {
return new RbxVector3(
this.X + (b.X - this.X) * t,
this.Y + (b.Y - this.Y) * t,
this.Z + (b.Z - this.Z) * t,
);
}
}
RbxVector3.zero = new RbxVector3(0, 0, 0);
RbxVector3.one = new RbxVector3(1, 1, 1);
RbxVector3.xAxis = new RbxVector3(1, 0, 0);
RbxVector3.yAxis = new RbxVector3(0, 1, 0);
RbxVector3.zAxis = new RbxVector3(0, 0, 1);
class RbxColor3 {
constructor(r = 0, g = 0, b = 0) { this.R = +r; this.G = +g; this.B = +b; }
static new(r, g, b) { return new RbxColor3(r, g, b); }
static fromRGB(r, g, b) { return new RbxColor3((r || 0) / 255, (g || 0) / 255, (b || 0) / 255); }
static fromHSV(h, s, v) {
const i = Math.floor(h * 6); const f = h * 6 - i;
const p = v * (1 - s); const q = v * (1 - f * s); const t = v * (1 - (1 - f) * s);
const [r, g, b] = [[v, t, p], [q, v, p], [p, v, t], [p, q, v], [t, p, v], [v, p, q]][i % 6];
return new RbxColor3(r, g, b);
}
static fromHex(hex) {
const s = String(hex || '').replace('#', '');
if (s.length !== 6) return new RbxColor3();
return new RbxColor3(
parseInt(s.slice(0, 2), 16) / 255,
parseInt(s.slice(2, 4), 16) / 255,
parseInt(s.slice(4, 6), 16) / 255,
);
}
Lerp(b, t) {
return new RbxColor3(
this.R + (b.R - this.R) * t,
this.G + (b.G - this.G) * t,
this.B + (b.B - this.B) * t,
);
}
toHex() {
const h = (n) => Math.round(Math.max(0, Math.min(1, n)) * 255).toString(16).padStart(2, '0');
return '#' + h(this.R) + h(this.G) + h(this.B);
}
}
class RbxUDim {
constructor(s = 0, o = 0) { this.Scale = +s; this.Offset = +o; }
static new(s, o) { return new RbxUDim(s, o); }
}
class RbxUDim2 {
constructor(sx = 0, ox = 0, sy = 0, oy = 0) {
this.X = new RbxUDim(sx, ox); this.Y = new RbxUDim(sy, oy);
}
static new(sx, ox, sy, oy) { return new RbxUDim2(sx, ox, sy, oy); }
static fromScale(sx, sy) { return new RbxUDim2(sx, 0, sy, 0); }
static fromOffset(ox, oy) { return new RbxUDim2(0, ox, 0, oy); }
}
class RbxVector2 {
constructor(x = 0, y = 0) { this.X = +x; this.Y = +y; }
static new(x, y) { return new RbxVector2(x, y); }
}
class RbxCFrame {
constructor(x = 0, y = 0, z = 0) {
this.X = +x; this.Y = +y; this.Z = +z;
this.Position = new RbxVector3(x, y, z);
this.p = this.Position;
}
static new(x, y, z) { return new RbxCFrame(x || 0, y || 0, z || 0); }
static lookAt(eye, _t) { return new RbxCFrame(eye?.X || 0, eye?.Y || 0, eye?.Z || 0); }
static Angles() { return new RbxCFrame(); }
static fromEulerAnglesXYZ() { return new RbxCFrame(); }
}
// ---------- Instance / Part ----------
let _instanceMethods = null;
function makeInstanceMethods() {
if (_instanceMethods) return _instanceMethods;
_instanceMethods = {
GetChildren: function () { return [...(this.Children || [])]; },
GetDescendants: function () {
const out = [];
const visit = (n) => {
for (const c of n.Children || []) { out.push(c); visit(c); }
};
visit(this);
return out;
},
FindFirstChild: function (name, recursive) {
for (const c of this.Children || []) {
if (c.Name === name) return c;
if (recursive) {
const f = c.FindFirstChild && c.FindFirstChild(name, true);
if (f) return f;
}
}
return undefined;
},
FindFirstChildOfClass: function (cls) {
for (const c of this.Children || []) {
if (c.ClassName === cls) return c;
}
return undefined;
},
FindFirstAncestor: function (name) {
let p = this.Parent;
while (p) { if (p.Name === name) return p; p = p.Parent; }
return undefined;
},
FindFirstAncestorOfClass: function (cls) {
let p = this.Parent;
while (p) { if (p.ClassName === cls) return p; p = p.Parent; }
return undefined;
},
WaitForChild: function (name) {
// В Roblox WaitForChild блокирует пока ребёнок не появится. У нас
// нет yield с произвольных JS-функций, поэтому возвращаем либо
// существующего ребёнка, либо ленивый stub-Folder чтобы избежать
// падений типа "attempt to index a nil value" в импортированных
// скриптах. Stub автоматически добавляется в Children.
const existing = this.FindFirstChild(name);
if (existing) return existing;
try {
const stub = newInstance('Folder', String(name));
stub.Parent = this;
if (this.Children) this.Children.push(stub);
return stub;
} catch (_) {
return undefined;
}
},
IsA: function (cls) { return this.ClassName === cls || cls === 'Instance'; },
GetFullName: function () {
const parts = [];
let p = this;
while (p && p.ClassName !== 'DataModel') {
parts.unshift(p.Name);
p = p.Parent;
}
return parts.join('.');
},
Destroy: function () {
this.Destroyed = true;
// Если это Part с примитивом — шлём sceneDelete
if (this.__primId != null && this.__sendDestroy) {
try { this.__sendDestroy(this.__primId); } catch (_) {}
}
if (this.Parent && this.Parent.Children) {
const i = this.Parent.Children.indexOf(this);
if (i >= 0) this.Parent.Children.splice(i, 1);
this.Parent = undefined;
}
},
Clone: function () {
// Поверхностный клон — достаточно для большинства Roblox-паттернов
// (Tool/Pellet/Bomb клонируются и parent'ятся в Workspace).
// Глубокий клон не делаем — Children копируются по ссылке (как в Roblox
// Clone() это deep copy, но у нас нет полной physical model).
try {
const copy = Object.assign({}, this);
copy.Children = (this.Children || []).slice();
copy.Parent = undefined;
return copy;
} catch (_) {
return undefined;
}
},
// Старый Roblox API: lowercase :clone()
clone: function () { return this.Clone && this.Clone(); },
// model:makeJoints() — заглушка (Welds мы не делаем)
MakeJoints: function () {},
makeJoints: function () {},
BreakJoints: function () {},
breakJoints: function () {},
Remove: function () { this.Parent = undefined; },
remove: function () { this.Parent = undefined; },
GetAttribute: function (n) { return (this.Attributes || {})[n]; },
SetAttribute: function (n, v) {
if (!this.Attributes) this.Attributes = {};
this.Attributes[n] = v;
},
GetPropertyChangedSignal: function () { return this.Changed; },
};
return _instanceMethods;
}
// Создаёт stub-signal который ничего не делает — для unknown свойств Instance
// которые скрипты пытаются использовать как сигнал (script.Parent.Selected:Connect).
function makeStubSignal() {
const sig = makeSignal();
// Помечаем чтобы знать что это stub (для возможной отладки)
sig.__stub = true;
return sig;
}
// Callable proxy: сам вызывается как function (ничего не делает), также имеет
// поля Connect/Disconnect и Fire/fire — то есть выглядит и как метод, и как
// сигнал, и как объект. Используется для unknown method-like свойств.
function makeStubCallable() {
const fn = function () { return undefined; };
fn.__stub = true;
fn.Connect = function () { return { Disconnect: () => {}, disconnect: () => {}, Connected: false }; };
fn.connect = fn.Connect;
fn.Fire = function () {};
fn.fire = fn.Fire;
fn.Wait = function () { return undefined; };
fn.wait = fn.Wait;
return fn;
}
// Эвристика: какие имена свойств вероятно сигналы?
// В Roblox сигналы заканчиваются на: Changed, Added, Removed, Began, Ended,
// Clicked, Activated, Touched, Selected, Deselected, Equipped, Unequipped, и т.д.
function isProbablySignalName(prop) {
if (typeof prop !== 'string') return false;
return /^(Mouse|Touch|Input|Render|Step|Heart|Render|On|Char|Player|Selected|Deselect|Equipped|Unequipped|Activated|Click|Changed|Added|Removed|Began|Ended|Died|Spawned|Reached|Loaded|Hover)/.test(prop)
|| /(Changed|Added|Removed|Began|Ended|Clicked|Activated|Touched|Died|Loaded|Hover|Connect|Event|Signal|Reached)$/.test(prop);
}
// Универсальный object-stub: ведёт себя как сигнал, как Instance, как Tool/Folder.
// НЕ function — иначе wasmoon мапит в Lua-function и Lua-индексация `.field`
// падает с "attempt to index a function value".
function makeObjectStub(name) {
const target = {
__stubName: name || 'stub',
// Signal API
Connect() { return { Disconnect() {}, disconnect() {}, Connected: false }; },
connect() { return this.Connect(); },
Wait() { return undefined; },
wait() { return undefined; },
Fire() {},
fire() {},
Disconnect() {},
disconnect() {},
// Instance read-API
FindFirstChild() { return undefined; },
FindFirstChildOfClass() { return undefined; },
FindFirstAncestor() { return undefined; },
FindFirstAncestorOfClass() { return undefined; },
GetChildren() { return []; },
GetDescendants() { return []; },
IsA() { return false; },
GetFullName() { return name || 'stub'; },
Destroy() {},
Clone() { return makeObjectStub(name); },
GetAttribute() { return undefined; },
SetAttribute() {},
GetPropertyChangedSignal() { return makeObjectStub('Changed'); },
// Tool/Animation/Sound — частые no-op методы
Activate() {}, Deactivate() {}, Equip() {}, Unequip() {},
Play() {}, Stop() {}, Pause() {}, Resume() {},
AdjustSpeed() {}, LoadAnimation() { return makeObjectStub('Animation'); },
TakeDamage() {}, MoveTo() {},
// Базовые поля
Parent: undefined,
Name: name || 'stub',
ClassName: 'Folder',
Children: [],
};
target.WaitForChild = function (childName) { return makeObjectStub(childName); };
return new Proxy(target, {
get(t, prop) {
if (Object.prototype.hasOwnProperty.call(t, prop) || prop in t) {
return t[prop];
}
if (typeof prop !== 'string') return undefined;
if (prop === 'then' || prop === 'catch' || prop === 'finally' ||
prop === 'toJSON' || prop === 'constructor' || prop === 'prototype' ||
prop.startsWith('__') || prop.startsWith('Symbol')) {
return undefined;
}
const child = makeObjectStub(prop);
t[prop] = child;
return child;
},
set(t, prop, value) { t[prop] = value; return true; },
});
}
function newInstance(className, name) {
const m = makeInstanceMethods();
const target = {
ClassName: className || 'Instance',
Name: name || className || 'Instance',
Parent: undefined,
Children: [],
Destroyed: false,
Attributes: {},
ChildAdded: makeSignal(),
ChildRemoved: makeSignal(),
AncestryChanged: makeSignal(),
Changed: makeSignal(),
GetChildren: m.GetChildren,
GetDescendants: m.GetDescendants,
FindFirstChild: m.FindFirstChild,
FindFirstChildOfClass: m.FindFirstChildOfClass,
FindFirstAncestor: m.FindFirstAncestor,
FindFirstAncestorOfClass: m.FindFirstAncestorOfClass,
WaitForChild: m.WaitForChild,
IsA: m.IsA,
GetFullName: m.GetFullName,
Destroy: m.Destroy,
Clone: m.Clone,
GetAttribute: m.GetAttribute,
SetAttribute: m.SetAttribute,
GetPropertyChangedSignal: m.GetPropertyChangedSignal,
};
// Proxy: для unknown свойств возвращаем stub чтобы скрипты не падали.
let proxyRef;
proxyRef = new Proxy(target, {
get(t, prop) {
// Существующее свойство всегда возвращаем как есть (включая методы)
if (Object.prototype.hasOwnProperty.call(t, prop) || prop in t) {
return t[prop];
}
// Не-строки и Symbol.* — undefined чтобы wasmoon не путался
if (typeof prop !== 'string') return undefined;
// wasmoon JS-internal ключи — undefined
if (prop === 'then' || prop === 'catch' || prop === 'finally' ||
prop === 'toJSON' || prop === 'toString' || prop === 'valueOf' ||
prop === 'constructor' || prop === 'prototype' ||
prop.startsWith('__') || prop.startsWith('Symbol')) {
return undefined;
}
// Object-stub: ведёт себя как сигнал (Connect), как Instance
// (WaitForChild, GetChildren), как Tool (Activate). НЕ function —
// иначе Lua упадёт с "attempt to index a function value".
const stub = makeObjectStub(prop);
t[prop] = stub;
return stub;
},
set(t, prop, value) {
// Авто-управление иерархией при `inst.Parent = X`:
// 1) удаляем себя из Children старого Parent
// 2) пушим в Children нового Parent
// 3) фейерим ChildAdded/ChildRemoved
if (prop === 'Parent') {
const oldP = t.Parent;
if (oldP && oldP.Children) {
const i = oldP.Children.indexOf(proxyRef);
if (i >= 0) {
oldP.Children.splice(i, 1);
try { oldP.ChildRemoved && oldP.ChildRemoved.Fire(proxyRef); } catch (_) {}
}
}
t[prop] = value;
if (value && value.Children && value.Children.indexOf(proxyRef) < 0) {
value.Children.push(proxyRef);
try { value.ChildAdded && value.ChildAdded.Fire(proxyRef); } catch (_) {}
}
// Спец-регистрация для ClickDetector — чтобы клик по Part
// мог сфейерить MouseClick через fireTargetEvent.
if (t.ClassName === 'ClickDetector' && value) {
try { value._clickDetector = proxyRef; } catch (_) {}
}
try { t.AncestryChanged && t.AncestryChanged.Fire(proxyRef, value); } catch (_) {}
return true;
}
t[prop] = value;
return true;
},
has(t, prop) {
// Для in-проверок отвечаем true почти всегда (чтобы Lua не падал на
// условиях вроде if obj.SomeField then ...)
return true;
},
});
return proxyRef;
}
/**
* Создать RbxPart-обёртку. Возвращает plain объект (без getter'ов) —
* запись свойств идёт через метод __SetProp, которое мы экспортируем
* глобально как `__rbxl_part_set(part, prop, value)`.
*/
function newPart(primData, sendFn) {
const p = newInstance('Part', primData.name || `Part_${primData.id}`);
p.__primId = primData.id;
p.__sendDestroy = (id) => sendFn('sceneDelete', { primId: id });
p.Touched = makeSignal();
p.TouchEnded = makeSignal();
p.Material = 'Plastic';
// Внутренний state: реальные значения хранятся здесь, в Lua через getter/setter.
p._state = {
Position: new RbxVector3(primData.x || 0, primData.y || 0, primData.z || 0),
Size: new RbxVector3(primData.sx || 1, primData.sy || 1, primData.sz || 1),
Color: primData.color ? RbxColor3.fromHex(primData.color) : new RbxColor3(0.5, 0.5, 0.5),
Anchored: !!primData.anchored,
CanCollide: primData.canCollide !== false,
Transparency: primData.opacity != null ? (1 - primData.opacity) : 0,
};
// Setter'ы шлют partSet → BabylonScene.primitiveManager через handleLuaCommand.
const send = (prop, value) => {
try { sendFn('partSet', { primId: p.__primId, prop, value }); } catch (_) {}
};
Object.defineProperty(p, 'Position', {
get() { return p._state.Position; },
set(v) {
if (!v) return;
const nv = new RbxVector3(v.X || 0, v.Y || 0, v.Z || 0);
p._state.Position = nv;
send('position', { x: nv.X, y: nv.Y, z: nv.Z });
},
enumerable: true, configurable: true,
});
Object.defineProperty(p, 'Size', {
get() { return p._state.Size; },
set(v) {
if (!v) return;
const nv = new RbxVector3(v.X || 1, v.Y || 1, v.Z || 1);
p._state.Size = nv;
send('size', { sx: nv.X, sy: nv.Y, sz: nv.Z });
},
enumerable: true, configurable: true,
});
Object.defineProperty(p, 'Color', {
get() { return p._state.Color; },
set(v) {
if (!v) return;
const nv = v instanceof RbxColor3 ? v : new RbxColor3(v.R || 0, v.G || 0, v.B || 0);
p._state.Color = nv;
// handleLuaCommand ожидает строку для color
send('color', nv.toHex());
},
enumerable: true, configurable: true,
});
Object.defineProperty(p, 'BrickColor', {
get() { return { Color: p._state.Color, Name: 'Custom' }; },
set(v) { if (v && v.Color) p.Color = v.Color; },
enumerable: true, configurable: true,
});
Object.defineProperty(p, 'Anchored', {
get() { return p._state.Anchored; },
set(v) {
p._state.Anchored = !!v;
send('anchored', !!v);
},
enumerable: true, configurable: true,
});
Object.defineProperty(p, 'CanCollide', {
get() { return p._state.CanCollide; },
set(v) {
p._state.CanCollide = !!v;
send('canCollide', !!v);
},
enumerable: true, configurable: true,
});
Object.defineProperty(p, 'Transparency', {
get() { return p._state.Transparency; },
set(v) {
const nv = Math.max(0, Math.min(1, Number(v) || 0));
p._state.Transparency = nv;
// handleLuaCommand ожидает number для opacity
send('opacity', 1 - nv);
},
enumerable: true, configurable: true,
});
Object.defineProperty(p, 'CFrame', {
get() {
const pos = p._state.Position;
return new RbxCFrame(pos.X, pos.Y, pos.Z);
},
set(v) {
if (v && v.Position) p.Position = v.Position;
else if (v && v.X != null) p.Position = new RbxVector3(v.X, v.Y, v.Z);
},
enumerable: true, configurable: true,
});
return p;
}
// ---------- Регистрация в Lua ----------
export function registerRobloxShim(lua, opts) {
const { send } = opts;
const global = lua.global;
// === Базовые типы ===
global.set('Vector3', {
new: (x, y, z) => new RbxVector3(x, y, z),
zero: RbxVector3.zero, one: RbxVector3.one,
xAxis: RbxVector3.xAxis, yAxis: RbxVector3.yAxis, zAxis: RbxVector3.zAxis,
FromNormalId: () => new RbxVector3(),
});
global.set('Color3', {
new: (r, g, b) => new RbxColor3(r, g, b),
fromRGB: (r, g, b) => RbxColor3.fromRGB(r, g, b),
fromHSV: (h, s, v) => RbxColor3.fromHSV(h, s, v),
fromHex: (hex) => RbxColor3.fromHex(hex),
});
// BrickColor — старая система цветов Roblox по имени
const BRICK_COLORS = {
'White': [1, 1, 1], 'Black': [0.1, 0.1, 0.1], 'Grey': [0.6, 0.6, 0.6],
'Bright red': [0.77, 0.2, 0.2], 'Bright blue': [0.05, 0.4, 0.7],
'Bright green': [0.3, 0.8, 0.2], 'Bright yellow': [1, 0.85, 0.1],
'Bright orange': [0.85, 0.5, 0.15], 'Bright violet': [0.45, 0.2, 0.65],
'Dark blue': [0.05, 0.15, 0.4], 'Dark green': [0.15, 0.4, 0.2],
'Dark red': [0.4, 0.1, 0.1], 'Lime green': [0.7, 0.95, 0.3],
'Pink': [1, 0.55, 0.7], 'Brown': [0.4, 0.25, 0.15],
'Reddish brown': [0.45, 0.2, 0.15], 'Sand red': [0.85, 0.6, 0.55],
'Medium blue': [0.4, 0.65, 0.85], 'Cyan': [0, 0.8, 0.8],
'Magenta': [0.85, 0, 0.85], 'Really red': [1, 0, 0], 'Really blue': [0, 0, 1],
'Really black': [0, 0, 0], 'Really white': [1, 1, 1],
};
function _brickToColor3(name) {
const rgb = BRICK_COLORS[name] || [0.5, 0.5, 0.5];
return new RbxColor3(rgb[0], rgb[1], rgb[2]);
}
global.set('BrickColor', {
new(nameOrR, g, b) {
// BrickColor.new("Bright red") или BrickColor.new(r, g, b)
const name = typeof nameOrR === 'string' ? nameOrR : 'White';
const c = typeof nameOrR === 'string'
? _brickToColor3(nameOrR)
: new RbxColor3(nameOrR, g, b);
return { Color: c, Name: name, Number: 1, R: c.R, G: c.G, B: c.B,
r: c.R, g: c.G, b: c.B };
},
random() { return { Color: new RbxColor3(Math.random(), Math.random(), Math.random()), Name: 'Random' }; },
White() { return this.new('White'); },
Black() { return this.new('Black'); },
Gray() { return this.new('Grey'); },
Red() { return this.new('Bright red'); },
Yellow() { return this.new('Bright yellow'); },
Green() { return this.new('Bright green'); },
Blue() { return this.new('Bright blue'); },
DarkGray() { return this.new('Dark stone grey'); },
palette(n) { return this.new('White'); },
});
// Ray — луч, используется в raycast
global.set('Ray', {
new(origin, direction) { return { Origin: origin, Direction: direction }; },
});
// Region3 — куб в пространстве
global.set('Region3', {
new(min, max) { return { Min: min, Max: max, CFrame: { Position: min }, Size: max }; },
});
global.set('UDim', { new: (s, o) => new RbxUDim(s, o) });
global.set('UDim2', {
new: (sx, ox, sy, oy) => new RbxUDim2(sx, ox, sy, oy),
fromScale: (sx, sy) => RbxUDim2.fromScale(sx, sy),
fromOffset: (ox, oy) => RbxUDim2.fromOffset(ox, oy),
});
global.set('Vector2', { new: (x, y) => new RbxVector2(x, y) });
global.set('CFrame', {
new: (x, y, z) => RbxCFrame.new(x, y, z),
lookAt: (e, t) => RbxCFrame.lookAt(e, t),
Angles: RbxCFrame.Angles,
fromEulerAnglesXYZ: RbxCFrame.fromEulerAnglesXYZ,
});
// === Enum ===
const mkE = (arr) => Object.fromEntries(arr.map(k => [k, { Name: k, Value: k }]));
global.set('Enum', {
KeyCode: mkE(['W','A','S','D','Space','LeftShift','LeftControl','F','E','Q','R','T','Y','U','I','O','P','G','H','J','K','L','Z','X','C','V','B','N','M','Tab','Return','Escape','Backspace','Up','Down','Left','Right','One','Two','Three','Four','Five','Six','Seven','Eight','Nine','Zero']),
UserInputType: mkE(['MouseButton1','MouseButton2','MouseButton3','MouseMovement','MouseWheel','Touch','Keyboard']),
Material: mkE(['Plastic','Wood','Metal','Neon','Glass','Sand','Ice','Grass','Concrete']),
HumanoidStateType: mkE(['Running','Jumping','Freefall','Landed','Dead','Climbing','Swimming','Seated']),
EasingStyle: mkE(['Linear','Sine','Quad','Cubic','Quart','Quint','Bounce','Elastic']),
EasingDirection: mkE(['In','Out','InOut']),
// Часто используемые в туториалах
InfoType: mkE(['Asset','BundleDetails','Subscription','GamePass','UserProductsInExperience']),
SortOrder: mkE(['Name','Custom','LayoutOrder']),
FillDirection: mkE(['Horizontal','Vertical']),
HorizontalAlignment: mkE(['Left','Center','Right']),
VerticalAlignment: mkE(['Top','Center','Bottom']),
Font: mkE(['Legacy','Arial','SourceSans','Code','Highway','SciFi','Cartoon','Gotham','GothamBold']),
TextXAlignment: mkE(['Left','Center','Right']),
TextYAlignment: mkE(['Top','Center','Bottom']),
ScaleType: mkE(['Stretch','Slice','Tile','Fit','Crop']),
AspectType: mkE(['FitWithinMaxSize','ScaleWithParentSize']),
DominantAxis: mkE(['Width','Height']),
BorderMode: mkE(['Outline','Middle','Inset']),
FormFactor: mkE(['Symmetric','Brick','Plate','Custom']),
PartType: mkE(['Ball','Block','Cylinder','Wedge','CornerWedge']),
SurfaceType: mkE(['Smooth','Glue','Weld','Studs','Inlet','Universal']),
ContextActionResult: mkE(['Pass','Sink']),
UserInputState: mkE(['Begin','Change','End','Cancel','None']),
});
// TweenInfo — конструктор объекта с параметрами анимации
// Сигнатура: TweenInfo.new(time, easingStyle, easingDirection, repeatCount, reverses, delayTime)
global.set('TweenInfo', {
new(time, easingStyle, easingDirection, repeatCount, reverses, delayTime) {
return {
Time: time || 1,
EasingStyle: easingStyle,
EasingDirection: easingDirection,
RepeatCount: repeatCount || 0,
Reverses: !!reverses,
DelayTime: delayTime || 0,
};
},
});
// NumberSequence, ColorSequence — упрощённые конструкторы для GUI-эффектов
global.set('NumberSequence', {
new(...args) { return { Keypoints: [], __ns: true }; },
});
global.set('ColorSequence', {
new(...args) { return { Keypoints: [], __cs: true }; },
});
global.set('NumberRange', {
new(min, max) { return { Min: min, Max: max == null ? min : max }; },
});
global.set('Rect', {
new(minX, minY, maxX, maxY) { return { Min: { X: minX, Y: minY }, Max: { X: maxX, Y: maxY } }; },
});
// === print / warn ===
const stringify = (v) => {
if (v == null) return 'nil';
if (typeof v === 'string') return v;
if (typeof v === 'number') return String(v);
if (typeof v === 'boolean') return v ? 'true' : 'false';
if (v instanceof RbxVector3) return `${v.X}, ${v.Y}, ${v.Z}`;
if (v instanceof RbxColor3) return `${v.R}, ${v.G}, ${v.B}`;
if (typeof v === 'object') {
if (v.Name) return String(v.Name);
return '[object]';
}
try { return String(v); } catch (_) { return '?'; }
};
global.set('print', (...args) => {
send('log', { level: 'info', text: args.map(stringify).join('\t') });
});
global.set('warn', (...args) => {
send('log', { level: 'warn', text: args.map(stringify).join('\t') });
});
// require(ModuleScript) — в Roblox загружает модуль. У нас модулей нет —
// возвращаем undefined (Lua nil) чтобы скрипты типа local mod = require(...)
// не падали. require строкой (стандартный Lua) перехватывать не будем.
global.set('require', (mod) => {
// Если передали Instance-stub — возвращаем сам stub (чтобы хоть
// что-то можно было сделать с возвращённым значением).
if (mod && typeof mod === 'object') return mod;
return undefined;
});
// === task.* + wait ===
// task.wait/wait — реальный yield через coroutines. Юзер пишет:
// while true do part.Position = ... ; task.wait(0.1) end
// Это работает потому что **скрипт сам запускается как coroutine**
// (см. LuaSharedSandbox._startSingleScript → мы оборачиваем код в pcall,
// НО для yield нам нужно завернуть в coroutine.create). Делаем это
// через Lua-prelude: глобальная функция `_run_in_coroutine(fn)`.
global.set('task', {
spawn: (fn) => {
try { if (typeof fn === 'function') fn(); } catch (e) {
send('log', { level: 'error', text: `[task.spawn] ${e?.message || e}` });
}
},
delay: (sec, fn) => {
if (typeof fn !== 'function') return;
SCHEDULER.sleeping.push({
wakeAt: SCHEDULER.now() + (Number(sec) || 0) * 1000,
run: () => { try { fn(); } catch (e) {
send('log', { level: 'error', text: `[task.delay] ${e?.message || e}` });
} },
});
},
defer: (fn) => {
if (typeof fn !== 'function') return;
SCHEDULER.sleeping.push({
wakeAt: SCHEDULER.now(),
run: () => { try { fn(); } catch (_) {} },
});
},
synchronize: () => {},
desynchronize: () => {},
});
// task.wait и wait определяются через Lua coroutine.yield в prelude (см. ниже)
// === DataModel ===
const game = newInstance('DataModel', 'game');
const workspace = newInstance('Workspace', 'Workspace');
workspace.Parent = game;
workspace.Gravity = 196.2;
workspace.CurrentCamera = newInstance('Camera', 'Camera');
workspace.CurrentCamera.Parent = workspace;
workspace.Children.push(workspace.CurrentCamera);
workspace.Terrain = newInstance('Terrain', 'Terrain');
workspace.Terrain.Parent = workspace;
workspace.Children.push(workspace.Terrain);
game.Children.push(workspace);
game.Workspace = workspace;
const players = newInstance('Players', 'Players');
players.Parent = game;
players.PlayerAdded = makeSignal();
players.PlayerRemoving = makeSignal();
game.Children.push(players);
game.Players = players;
const localPlayer = newInstance('Player', 'Player');
localPlayer.Parent = players;
localPlayer.UserId = 1;
// PlayerGui — контейнер для GUI принадлежащих игроку. В Rublox это no-op
// (overlay глобальный), но Roblox-скрипты часто делают gui.Parent = playerGui.
const playerGui = newInstance('PlayerGui', 'PlayerGui');
playerGui.Parent = localPlayer;
localPlayer.Children.push(playerGui);
localPlayer.PlayerGui = playerGui;
localPlayer.DisplayName = 'Player';
localPlayer.Name = 'Player';
localPlayer.Neutral = true; // не в команде по умолчанию
localPlayer.Team = undefined;
localPlayer.TeamColor = { Name: 'White', Color: new RbxColor3(1, 1, 1) };
localPlayer.Kick = function () {};
localPlayer.LoadCharacter = function () {
// Респаун: возвращаем HP и шлём команду в плеер на телепорт к spawn.
// Plus сбрасываем humanoid.Health на MaxHealth.
try {
if (humanoid && humanoid.MaxHealth) {
humanoid.Health = humanoid.MaxHealth;
}
send('playerSet', { prop: 'respawn', value: true });
} catch (_) {}
};
localPlayer.HasAppearanceLoaded = function () { return true; };
// Backpack — инвентарь, содержит Tools. В Roblox StarterPack автоматически
// клонируется в Backpack каждого спавнящегося игрока.
const backpack = newInstance('Backpack', 'Backpack');
backpack.Parent = localPlayer;
localPlayer.Children.push(backpack);
localPlayer.Backpack = backpack;
// Глобальный Mouse — единственный экземпляр на игрока, привязан к окну
// браузера. Реальные Button1Down/Hit фейерятся в GameRuntime.
const playerMouse = (function makePlayerMouse() {
const m = newInstance('Mouse', 'Mouse');
m.Button1Down = makeSignal();
m.Button1Up = makeSignal();
m.Button2Down = makeSignal();
m.Button2Up = makeSignal();
m.Move = makeSignal();
m.KeyDown = makeSignal();
m.KeyUp = makeSignal();
m.WheelForward = makeSignal();
m.WheelBackward = makeSignal();
m.Idle = makeSignal();
// m.Icon reactive — меняет CSS cursor на canvas
let _icon = '';
Object.defineProperty(m, 'Icon', {
get() { return _icon; },
set(v) {
_icon = String(v || '');
// rbxassetid → стрелочный курсор-прицел (наш дефолт)
const cssCursor = _icon && _icon.includes('rbxasset')
? 'crosshair' : (_icon ? 'crosshair' : 'default');
send('mouseIconChanged', { icon: _icon, cssCursor });
},
});
m.X = 0; m.Y = 0;
m.ViewSizeX = 1920; m.ViewSizeY = 1080;
m.Hit = { Position: new RbxVector3(0, 0, 0), p: new RbxVector3(0, 0, 0),
Lookvector: new RbxVector3(0, 0, -1) };
m.Origin = { Position: new RbxVector3(0, 5, 0) };
m.Target = undefined;
m.TargetFilter = undefined;
m.TargetSurface = 'Top';
return m;
})();
localPlayer.GetMouse = function () { return playerMouse; };
localPlayer.playerMouse = playerMouse;
players.Children.push(localPlayer);
players.LocalPlayer = localPlayer;
// === Tool registry ===
// Tracks все Tool-инстансы — для UI (hotbar) и equip-flow.
// GameRuntime читает API equipTool/unequipTool на main-loop.
const allTools = []; // [Tool, ...] в порядке создания (для hotbar 1-9)
let equippedTool = null;
const character = newInstance('Model', 'Player');
character.Parent = localPlayer;
localPlayer.Children.push(character);
localPlayer.Character = character;
localPlayer.CharacterAdded = makeSignal();
localPlayer.CharacterRemoving = makeSignal();
localPlayer.CharacterAppearanceLoaded = makeSignal();
const humanoid = newInstance('Humanoid', 'Humanoid');
humanoid.Parent = character;
let _hp = 100, _maxHp = 100, _ws = 16, _jp = 50;
Object.defineProperty(humanoid, 'Health', {
get() { return _hp; },
set(v) {
_hp = Math.max(0, Math.min(_maxHp, Number(v) || 0));
try { humanoid.HealthChanged.Fire(_hp); } catch (_) {}
send('playerSet', { prop: 'health', value: _hp });
},
});
Object.defineProperty(humanoid, 'MaxHealth', {
get() { return _maxHp; },
set(v) {
_maxHp = Math.max(1, Number(v) || 100);
if (_hp > _maxHp) humanoid.Health = _maxHp;
send('playerSet', { prop: 'maxHealth', value: _maxHp });
},
});
Object.defineProperty(humanoid, 'WalkSpeed', {
get() { return _ws; },
set(v) { _ws = Number(v) || 16; send('playerSet', { prop: 'walkSpeed', value: _ws }); },
});
Object.defineProperty(humanoid, 'JumpPower', {
get() { return _jp; },
set(v) { _jp = Number(v) || 50; send('playerSet', { prop: 'jumpPower', value: _jp }); },
});
humanoid.Died = makeSignal();
humanoid.HealthChanged = makeSignal();
humanoid.Touched = makeSignal();
humanoid.StateChanged = makeSignal();
humanoid.TakeDamage = function (n) {
const v = Math.max(0, (this.Health || 100) - (Number(n) || 0));
this.Health = v;
this.HealthChanged.Fire(v);
if (v === 0) {
// Creator-tag: ищем creator-ObjectValue в Humanoid.Children для kill feed
let killerName = null;
for (const c of (this.Children || [])) {
if (c && c.Name === 'creator' && c.Value) {
killerName = String(c.Value.Name || c.Value.DisplayName || '?');
break;
}
}
if (killerName) {
send('killFeed', { killer: killerName, victim: localPlayer.Name || 'Player', weapon: '' });
}
this.Died.Fire();
// В Roblox после Died игрок респавнится — у нас через playerSet=respawn
setTimeout(() => {
this.Health = this.MaxHealth || 100;
this.HealthChanged.Fire(this.Health);
send('playerSet', { prop: 'health', value: this.Health });
}, 2000);
}
send('playerSet', { prop: 'health', value: v });
};
humanoid.MoveTo = function () {};
humanoid.LoadAnimation = function () { return { Play: () => {}, Stop: () => {}, AdjustSpeed: () => {} }; };
character.Children.push(humanoid);
character.Humanoid = humanoid;
const hrp = newInstance('Part', 'HumanoidRootPart');
hrp.Parent = character;
hrp._position = new RbxVector3(0, 5, 0);
hrp.Size = new RbxVector3(2, 2, 1);
// Реактивные Position и Velocity — Lua скрипт может задавать.
Object.defineProperty(hrp, 'Position', {
get() { return hrp._position; },
set(v) {
if (!v) return;
hrp._position = new RbxVector3(v.X || 0, v.Y || 0, v.Z || 0);
try { send('playerSet', { prop: 'position',
value: { x: hrp._position.X, y: hrp._position.Y, z: hrp._position.Z } }); }
catch (_) {}
},
});
let _hrpCFrame = null;
Object.defineProperty(hrp, 'CFrame', {
get() { return _hrpCFrame || { Position: hrp._position, p: hrp._position }; },
set(v) {
if (!v) return;
_hrpCFrame = v;
const pos = v.Position || v.p || v;
if (pos && pos.X !== undefined) {
hrp._position = new RbxVector3(pos.X, pos.Y, pos.Z);
try { send('playerSet', { prop: 'position',
value: { x: pos.X, y: pos.Y, z: pos.Z } }); }
catch (_) {}
}
},
});
Object.defineProperty(hrp, 'Velocity', {
get() { return hrp._velocity || new RbxVector3(0, 0, 0); },
set(v) {
if (!v) return;
hrp._velocity = new RbxVector3(v.X || 0, v.Y || 0, v.Z || 0);
if (v.Y > 10) {
try { send('playerSet', { prop: 'jumpVelocity', value: v.Y * 0.3 }); }
catch (_) {}
}
},
});
character.Children.push(hrp);
character.HumanoidRootPart = hrp;
character.PrimaryPart = hrp;
// === Сервисы ===
const services = {};
const makeService = (name) => {
if (services[name]) return services[name];
const s = newInstance(name, name);
s.Parent = game;
game.Children.push(s);
services[name] = s;
game[name] = s;
return s;
};
makeService('ReplicatedStorage');
makeService('ServerStorage');
makeService('StarterGui');
makeService('StarterPack');
makeService('StarterPlayer');
// Teams сервис — PvP-команды (TeamBeacon Black/Blue/Red/Green в Roblox Battle)
const teams = makeService('Teams');
teams.Children = [];
teams.GetTeams = function () { return teams.Children.slice(); };
teams.GetChildren = function () { return teams.Children.slice(); };
const uis = makeService('UserInputService');
uis.InputBegan = makeSignal();
uis.InputChanged = makeSignal();
uis.InputEnded = makeSignal();
// TweenService — реальная интерполяция через Heartbeat
const tw = makeService('TweenService');
const activeTweens = []; // [{inst, props, duration, startAt, startVals, onDone}]
tw.Create = function (inst, info, propGoals) {
// info: TweenInfo (duration, EasingStyle, ...) — упрощённо берём только duration
const duration = (info && (info.Time || info.duration)) || 1;
const tween = {
__completed: makeSignal(),
Completed: undefined,
Play() {
if (!inst || !propGoals) return;
const startVals = {};
for (const k of Object.keys(propGoals)) {
try { startVals[k] = inst[k]; } catch (_) {}
}
activeTweens.push({
inst, props: propGoals, duration,
startAt: performance.now(),
startVals,
onDone: () => tween.__completed.Fire(),
});
},
Pause() {},
Cancel() {},
};
tween.Completed = tween.__completed;
return tween;
};
function _stepTweens(_dt) {
if (activeTweens.length === 0) return;
const now = performance.now();
for (let i = activeTweens.length - 1; i >= 0; i--) {
const t = activeTweens[i];
const elapsed = (now - t.startAt) / 1000;
const k = Math.min(1, elapsed / t.duration);
for (const prop of Object.keys(t.props)) {
const goal = t.props[prop];
const start = t.startVals[prop];
if (!start || !goal) continue;
if (start instanceof RbxVector3 && goal instanceof RbxVector3) {
try { t.inst[prop] = start.Lerp(goal, k); } catch (_) {}
} else if (start instanceof RbxColor3 && goal instanceof RbxColor3) {
try { t.inst[prop] = start.Lerp(goal, k); } catch (_) {}
} else if (typeof start === 'number' && typeof goal === 'number') {
try { t.inst[prop] = start + (goal - start) * k; } catch (_) {}
}
}
if (k >= 1) {
activeTweens.splice(i, 1);
try { t.onDone(); } catch (_) {}
}
}
}
const http = makeService('HttpService');
http.JSONEncode = function (v) { try { return JSON.stringify(v); } catch (_) { return '{}'; } };
http.JSONDecode = function (s) { try { return JSON.parse(s); } catch (_) { return undefined; } };
http.GenerateGUID = () => 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = Math.random() * 16 | 0; return (c === 'x' ? r : (r & 3 | 8)).toString(16);
});
const lighting = makeService('Lighting');
lighting.Ambient = new RbxColor3(0.5, 0.5, 0.5);
lighting.Brightness = 1;
lighting.ClockTime = 14;
lighting.TimeOfDay = "14:00:00";
lighting.OutdoorAmbient = new RbxColor3(0.5, 0.5, 0.5);
lighting.FogEnd = 100000;
lighting.FogStart = 0;
lighting.FogColor = new RbxColor3(0.75, 0.75, 0.75);
lighting._minutes = 14 * 60;
lighting.GetMinutesAfterMidnight = function () { return lighting._minutes; };
let _lastLightSent = 0;
lighting.SetMinutesAfterMidnight = function (m) {
lighting._minutes = (Number(m) || 0) % 1440;
lighting.ClockTime = lighting._minutes / 60;
// Тротлинг: не чаще раза в 250мс. Скрипты Day/Night обновляют это
// каждый кадр (100+ Hz), это убивает WASM.
const now = performance.now();
if (now - _lastLightSent < 250) return;
_lastLightSent = now;
send('lightingTimeUpdate', { hour: lighting.ClockTime });
};
lighting.GetMoonDirection = function () { return new RbxVector3(0, 1, 0); };
lighting.GetSunDirection = function () { return new RbxVector3(0, 1, 0); };
makeService('Chat');
const soundService = makeService('SoundService');
soundService.PlayLocalSound = function (sound) {
if (sound && typeof sound.Play === 'function') sound.Play();
};
makeService('PathfindingService');
// CollectionService — теги на инстансах
const cs = makeService('CollectionService');
const tagMap = new Map(); // tag → Set<instance>
const instTags = new WeakMap(); // instance → Set<tag>
const tagAddSignals = new Map(); // tag → Signal (InstanceAddedSignal)
const tagRemoveSignals = new Map(); // tag → Signal (InstanceRemovedSignal)
cs.AddTag = function (inst, tag) {
if (!inst || !tag) return;
let set = tagMap.get(tag);
if (!set) { set = new Set(); tagMap.set(tag, set); }
if (set.has(inst)) return;
set.add(inst);
let tags = instTags.get(inst);
if (!tags) { tags = new Set(); instTags.set(inst, tags); }
tags.add(tag);
const sig = tagAddSignals.get(tag);
if (sig) try { sig.Fire(inst); } catch (_) {}
};
cs.RemoveTag = function (inst, tag) {
const set = tagMap.get(tag);
if (set) set.delete(inst);
const tags = instTags.get(inst);
if (tags) tags.delete(tag);
const sig = tagRemoveSignals.get(tag);
if (sig) try { sig.Fire(inst); } catch (_) {}
};
cs.HasTag = function (inst, tag) {
const set = tagMap.get(tag);
return !!(set && set.has(inst));
};
cs.GetTagged = function (tag) {
const set = tagMap.get(tag);
return set ? [...set] : [];
};
cs.GetTags = function (inst) {
const tags = instTags.get(inst);
return tags ? [...tags] : [];
};
cs.GetInstanceAddedSignal = function (tag) {
let sig = tagAddSignals.get(tag);
if (!sig) { sig = makeSignal(); tagAddSignals.set(tag, sig); }
return sig;
};
cs.GetInstanceRemovedSignal = function (tag) {
let sig = tagRemoveSignals.get(tag);
if (!sig) { sig = makeSignal(); tagRemoveSignals.set(tag, sig); }
return sig;
};
// Debris — удаление инстансов через N секунд
const debris = makeService('Debris');
debris.AddItem = function (inst, lifetime) {
if (!inst || typeof inst.Destroy !== 'function') return;
const t = Math.max(0, Number(lifetime) || 0);
setTimeout(() => {
try { inst.Destroy(); } catch (_) {}
}, t * 1000);
};
makeService('MarketplaceService');
const ds = makeService('DataStoreService');
ds.GetDataStore = function () {
return {
GetAsync: () => undefined, SetAsync: () => {}, UpdateAsync: () => {},
RemoveAsync: () => {}, IncrementAsync: () => {},
};
};
const ctx = makeService('ContextActionService');
ctx.BindAction = () => {};
ctx.UnbindAction = () => {};
const runService = makeService('RunService');
runService.Heartbeat = HEARTBEAT_SIGNAL;
runService.Stepped = STEPPED_SIGNAL;
runService.RenderStepped = HEARTBEAT_SIGNAL;
runService.IsClient = () => true;
runService.IsServer = () => true;
runService.IsRunning = () => true;
runService.IsStudio = () => false;
game.GetService = function (name) {
if (name === 'Workspace') return workspace;
if (name === 'Players') return players;
return services[name] || makeService(name);
};
// Старый Roblox API: game:service(name) lowercase
game.service = game.GetService;
game.GetServiceFromName = game.GetService;
game.FindService = function (name) { return services[name] || null; };
game.JobId = '';
game.PlaceId = 0;
game.GameId = 0;
game.CreatorId = 0;
game.CreatorType = 'User';
// Players API extensions
players.GetPlayers = function () { return [players.LocalPlayer].filter(Boolean); };
players.GetPlayerFromCharacter = function (character) {
if (character && players.LocalPlayer && players.LocalPlayer.Character === character) {
return players.LocalPlayer;
}
return undefined;
};
players.playerFromCharacter = players.GetPlayerFromCharacter;
players.PlayerAdded = makeSignal();
players.PlayerRemoving = makeSignal();
players.ChildAdded = makeSignal();
global.set('game', game);
global.set('Game', game);
global.set('workspace', workspace);
global.set('Workspace', workspace);
// === Instance.new ===
// === Helper: создание GUI-элемента через game.gui.create ===
// Roblox: Frame/TextLabel/TextButton/ImageLabel/TextBox/ScrollingFrame.
// Шлём gui.create команду в main thread → GuiManager создаёт элемент.
// Возвращаем Lua-объект с setter'ами для основных свойств.
let _nextGuiLocalRef = 0;
function newGuiInstance(robloxClass) {
const localRef = `_gui_lua_${_nextGuiLocalRef++}`;
const inst = newInstance(robloxClass, robloxClass);
inst.__guiLocalRef = localRef;
inst.__guiClass = robloxClass;
// Маппим Roblox-класс на тип в GuiManager
const guiType = ({
Frame: 'frame',
TextLabel: 'text',
TextButton: 'button',
ImageLabel: 'image',
ImageButton: 'button',
TextBox: 'textbox',
ScrollingFrame: 'scroll',
})[robloxClass] || 'frame';
// Внутренние стейты
inst._gui = {
type: guiType,
text: '',
bgColor: '#3a2820',
bgOpacity: 1,
textColor: '#f0e6d8',
textSize: 16,
x: 50, y: 50, w: 20, h: 10,
visible: true,
};
// Шлём create при первом обращении (lazy) или сейчас — лучше сейчас, чтобы
// не было гонок при моментальной правке свойств после Instance.new.
send('gui.create', {
type: guiType,
opts: { ...inst._gui, _scriptCreated: true },
localRef,
});
// Сигналы (для кнопок)
if (robloxClass === 'TextButton' || robloxClass === 'ImageButton') {
inst.MouseButton1Click = makeSignal();
inst.MouseEnter = makeSignal();
inst.MouseLeave = makeSignal();
inst.Activated = inst.MouseButton1Click;
}
// Setters
const updateField = (field, value) => {
inst._gui[field] = value;
send('gui.update', { id: localRef, patch: { [field]: value } });
};
Object.defineProperty(inst, 'Text', {
get() { return inst._gui.text; },
set(v) { updateField('text', String(v ?? '')); },
enumerable: true,
});
Object.defineProperty(inst, 'Visible', {
get() { return inst._gui.visible; },
set(v) { updateField('visible', !!v); },
enumerable: true,
});
Object.defineProperty(inst, 'BackgroundColor3', {
get() { return RbxColor3.fromHex(inst._gui.bgColor); },
set(v) {
if (!v) return;
const hex = v.toHex ? v.toHex() : (v instanceof RbxColor3 ? v.toHex() : '#3a2820');
updateField('bgColor', hex);
},
enumerable: true,
});
Object.defineProperty(inst, 'BackgroundTransparency', {
get() { return 1 - (inst._gui.bgOpacity ?? 1); },
set(v) { updateField('bgOpacity', 1 - Math.max(0, Math.min(1, +v || 0))); },
enumerable: true,
});
Object.defineProperty(inst, 'TextColor3', {
get() { return RbxColor3.fromHex(inst._gui.textColor); },
set(v) {
if (!v) return;
const hex = v.toHex ? v.toHex() : '#f0e6d8';
updateField('textColor', hex);
},
enumerable: true,
});
Object.defineProperty(inst, 'TextSize', {
get() { return inst._gui.textSize; },
set(v) { updateField('textSize', Math.max(8, Math.min(72, +v || 16))); },
enumerable: true,
});
// Position: UDim2 → x,y проценты (Roblox-style: scale=%, offset=px)
// Упрощённо берём scale*100 как x/y; offset игнорируем.
Object.defineProperty(inst, 'Position', {
get() {
return new RbxUDim2(inst._gui.x / 100, 0, inst._gui.y / 100, 0);
},
set(v) {
if (!v) return;
const xPct = (v.X?.Scale || 0) * 100 + (v.X?.Offset || 0) / 10;
const yPct = (v.Y?.Scale || 0) * 100 + (v.Y?.Offset || 0) / 10;
inst._gui.x = xPct;
inst._gui.y = yPct;
send('gui.update', { id: localRef, patch: { x: xPct, y: yPct } });
},
enumerable: true,
});
Object.defineProperty(inst, 'Size', {
get() {
return new RbxUDim2(inst._gui.w / 100, 0, inst._gui.h / 100, 0);
},
set(v) {
if (!v) return;
const wPct = (v.X?.Scale || 0) * 100 + (v.X?.Offset || 0) / 10;
const hPct = (v.Y?.Scale || 0) * 100 + (v.Y?.Offset || 0) / 10;
inst._gui.w = wPct;
inst._gui.h = hPct;
send('gui.update', { id: localRef, patch: { w: wPct, h: hPct } });
},
enumerable: true,
});
// Destroy — удаление GUI
const origDestroy = inst.Destroy;
inst.Destroy = function () {
try { send('gui.remove', { id: localRef }); } catch (_) {}
origDestroy.call(inst);
};
return inst;
}
// Регистрация в guiByLocalRef для дальнейшей маршрутизации событий клика
const guiByLocalRef = new Map();
// Счётчик для новых Part'ов, создаваемых через Instance.new("Part"):
// primitiveManager.addInstance даст уникальный id, мы используем временный
// отрицательный id пока не пришёл sceneCreate ACK. Но проще: даём свой
// негативный id, primitiveManager заменит на свой. Для простоты — даём
// высокий positive id (10000+ random) и primitiveManager его использует
// если не занят.
let _nextNewPartId = 100000 + Math.floor(Math.random() * 10000);
global.set('Instance', {
new: (className, parent) => {
let inst;
if (className === 'Part' || className === 'WedgePart' || className === 'MeshPart') {
// Реальный примитив на сцене: шлём sceneCreate, регистрируем в partById
const newId = _nextNewPartId++;
const fakePrim = {
id: newId,
name: `Part_${newId}`,
x: 0, y: 0, z: 0,
sx: 4, sy: 1, sz: 2,
color: '#A0A0A0',
anchored: true,
canCollide: true,
};
send('sceneCreate', {
primId: newId,
type: className === 'WedgePart' ? 'wedge' : 'cube',
x: 0, y: 0, z: 0,
sx: 4, sy: 1, sz: 2,
color: '#A0A0A0',
anchored: true,
canCollide: true,
});
inst = newPart(fakePrim, send);
partById.set(newId, inst);
} else if (className === 'RemoteEvent') {
inst = newInstance('RemoteEvent', 'RemoteEvent');
inst.OnServerEvent = makeSignal();
inst.OnClientEvent = makeSignal();
inst.FireServer = function (...a) { this.OnServerEvent.Fire(localPlayer, ...a); };
inst.FireClient = function (_p, ...a) { this.OnClientEvent.Fire(...a); };
inst.FireAllClients = function (...a) { this.OnClientEvent.Fire(...a); };
} else if (className === 'SpecialMesh' || className === 'BlockMesh'
|| className === 'CylinderMesh' || className === 'FileMesh') {
inst = newInstance(className, className);
inst.MeshType = { Name: 'Brick', Value: 0 };
inst.MeshId = '';
inst.TextureId = '';
inst.Scale = new RbxVector3(1, 1, 1);
inst.Offset = new RbxVector3(0, 0, 0);
inst.VertexColor = new RbxVector3(1, 1, 1);
} else if (className === 'ClickDetector') {
// ClickDetector — клик по 3D-объекту (нужен Тиру и т.п.).
// Регистрация в part._clickDetector происходит автоматически
// через Proxy.set когда юзер делает clickDet.Parent = part.
inst = newInstance('ClickDetector', 'ClickDetector');
inst.MouseClick = makeSignal();
inst.MouseHoverEnter = makeSignal();
inst.MouseHoverLeave = makeSignal();
inst.MaxActivationDistance = 32;
} else if (className === 'BindableEvent') {
inst = newInstance('BindableEvent', 'BindableEvent');
inst.Event = makeSignal();
inst.Fire = function (...a) { this.Event.Fire(...a); };
} else if (className === 'BindableFunction') {
// BindableFunction — синхронный RPC внутри клиента.
// OnInvoke = single-callback; Invoke вызывает его и возвращает значение.
inst = newInstance('BindableFunction', 'BindableFunction');
inst.OnInvoke = undefined; // юзер ставит function
inst.Invoke = function (...args) {
if (typeof this.OnInvoke === 'function') {
try { return this.OnInvoke(...args); } catch (_) { return undefined; }
}
return undefined;
};
} else if (className === 'RemoteFunction') {
inst = newInstance('RemoteFunction', 'RemoteFunction');
inst.OnServerInvoke = undefined;
inst.OnClientInvoke = undefined;
inst.InvokeServer = function (...args) {
if (typeof this.OnServerInvoke === 'function') {
try { return this.OnServerInvoke(localPlayer, ...args); } catch (_) {}
}
return undefined;
};
inst.InvokeClient = function (_p, ...args) {
if (typeof this.OnClientInvoke === 'function') {
try { return this.OnClientInvoke(...args); } catch (_) {}
}
return undefined;
};
} else if (className === 'Message' || className === 'Hint') {
// Roblox Message — текстовая надпись по центру экрана,
// когда .Parent = workspace или nil. Hint — то же но мельче.
inst = newInstance(className, className);
let _txt = '';
Object.defineProperty(inst, 'Text', {
get() { return _txt; },
set(v) { _txt = String(v || ''); send('hudMessage', { kind: className, text: _txt, visible: !!inst.Parent }); },
});
// При смене Parent: nil → скрываем, workspace → показываем
const _origParent = Object.getOwnPropertyDescriptor(inst, 'Parent');
Object.defineProperty(inst, 'Parent', {
get() { return this._parent; },
set(v) {
this._parent = v;
send('hudMessage', { kind: className, text: _txt, visible: !!v });
},
});
} else if (className === 'Humanoid') {
inst = newInstance('Humanoid', 'Humanoid');
inst.Health = 100; inst.MaxHealth = 100;
inst.Died = makeSignal(); inst.HealthChanged = makeSignal();
inst.TakeDamage = function (n) { this.Health = Math.max(0, this.Health - (n || 0)); };
} else if (className === 'Sound') {
// Sound — процедурные звуки через _playSound.
// SoundId → имя процедурного звука (rbxassetid игнорится).
inst = newInstance('Sound', 'Sound');
inst.SoundId = '';
inst.Volume = 1;
inst.PlaybackSpeed = 1;
inst.Pitch = 1;
inst.Looped = false;
inst.IsPlaying = false;
inst.Played = makeSignal();
inst.Ended = makeSignal();
// Map SoundId/имя на встроенный звук (jump/pickup/win/lose/click/hit/coin).
const _mapSoundName = (idOrName) => {
if (!idOrName) return 'click';
const s = String(idOrName).toLowerCase();
// Прямые ключи имеют приоритет
if (['jump','pickup','win','lose','click','hit','coin'].indexOf(s) >= 0) return s;
// Эвристика по части строки (для Roblox AssetID)
if (s.includes('jump')) return 'jump';
if (s.includes('pickup') || s.includes('collect')) return 'pickup';
if (s.includes('win') || s.includes('victory')) return 'win';
if (s.includes('lose') || s.includes('death')) return 'lose';
if (s.includes('hit') || s.includes('damage')) return 'hit';
if (s.includes('coin') || s.includes('gem')) return 'coin';
return 'click';
};
inst.Play = function () {
const name = _mapSoundName(this.SoundId || this.Name);
const pitch = +this.PlaybackSpeed || +this.Pitch || 1;
const volume = +this.Volume || 1;
send('sound.play', { name, volume, pitch });
this.IsPlaying = true;
this.Played.Fire();
// Простая модель: считаем что звук длится 0.5с
SCHEDULER.sleeping.push({
wakeAt: SCHEDULER.now() + 500,
run: () => {
this.IsPlaying = false;
this.Ended.Fire();
if (this.Looped) this.Play();
},
});
};
inst.Stop = function () { this.IsPlaying = false; };
inst.Pause = function () { this.IsPlaying = false; };
inst.Resume = function () { if (!this.IsPlaying) this.Play(); };
} else if (className === 'ScreenGui') {
// ScreenGui — логический корень GUI. В Rublox overlay глобальный,
// поэтому ScreenGui это просто контейнер-no-op (без gui.create).
inst = newInstance('ScreenGui', 'ScreenGui');
inst.__isScreenGui = true;
inst.Enabled = true;
} else if (className === 'Frame' || className === 'TextLabel'
|| className === 'TextButton' || className === 'ImageLabel'
|| className === 'ImageButton' || className === 'TextBox'
|| className === 'ScrollingFrame') {
inst = newGuiInstance(className);
guiByLocalRef.set(inst.__guiLocalRef, inst);
} else if (className === 'Team') {
inst = newInstance('Team', 'Team');
inst.TeamColor = { Name: 'Bright red', Color: new RbxColor3(0.77, 0.2, 0.2) };
inst.Score = 0;
inst.AutoAssignable = true;
inst.PlayerAdded = makeSignal();
inst.PlayerRemoved = makeSignal();
inst.GetPlayers = function () {
return (players?.Children || []).filter(p => p.Team === this);
};
// Регистрация в teams сервисе при Parent = teams
Object.defineProperty(inst, 'Parent', {
get() { return this._parent; },
set(v) {
this._parent = v;
if (v === teams && !teams.Children.includes(this)) {
teams.Children.push(this);
}
},
});
} else if (className === 'Tool' || className === 'HopperBin') {
inst = newInstance(className, 'Tool');
inst.Equipped = makeSignal();
inst.Unequipped = makeSignal();
inst.Activated = makeSignal();
inst.Deactivated = makeSignal();
inst.GripForward = new RbxVector3(0, -1, 0);
inst.GripRight = new RbxVector3(1, 0, 0);
inst.GripUp = new RbxVector3(0, 1, 0);
inst.GripPos = new RbxVector3(0, 0, 0);
inst.CanBeDropped = true;
inst.Enabled = true;
inst.RequiresHandle = true;
inst.TextureId = '';
inst.ToolTip = '';
// Виртуальный Handle — Roblox-скрипты делают Tool.Handle.Position
const handle = newInstance('Part', 'Handle');
handle.Parent = inst;
handle.Position = new RbxVector3(0, 5, 0);
handle.Size = new RbxVector3(1, 1, 1);
inst.Handle = handle;
inst.Children = inst.Children || [];
inst.Children.push(handle);
// Регистрируем Tool, чтобы плеер показал его в hotbar
allTools.push(inst);
inst.__toolIndex = allTools.length;
send('toolRegistered', {
index: inst.__toolIndex,
name: inst.Name || `Tool ${inst.__toolIndex}`,
});
} else if (className === 'IntValue' || className === 'NumberValue'
|| className === 'BoolValue' || className === 'StringValue'
|| className === 'ObjectValue' || className === 'CFrameValue'
|| className === 'Vector3Value' || className === 'Color3Value'
|| className === 'BrickColorValue' || className === 'RayValue') {
inst = newInstance(className, className);
let _val = className === 'BoolValue' ? false
: className === 'StringValue' ? ''
: (className === 'IntValue' || className === 'NumberValue') ? 0
: undefined;
inst.Changed = makeSignal();
// Реактивное поле Value — фейерим Changed + обновляем leaderstats
// если этот *Value лежит внутри leaderstats-родителя (Roblox-pattern).
Object.defineProperty(inst, 'Value', {
get() { return _val; },
set(v) {
_val = v;
try { inst.Changed.Fire(v); } catch (_) {}
// Если этот IntValue — leaderstat (родитель Name=leaderstats):
if (inst.Parent && inst.Parent.Name === 'leaderstats') {
send('leaderstatSet', {
playerName: inst.Parent.Parent?.Name || 'Player',
statName: inst.Name || 'Stat',
value: Number(v) || 0,
});
}
},
});
} else if (className === 'BodyForce' || className === 'BodyVelocity'
|| className === 'BodyPosition' || className === 'BodyGyro'
|| className === 'BodyAngularVelocity' || className === 'BodyThrust') {
inst = newInstance(className, className);
let _vel = new RbxVector3(0, 0, 0);
Object.defineProperty(inst, 'velocity', {
get() { return _vel; },
set(v) {
_vel = v;
// Эвристика батута: BodyVelocity с +Y и Parent=Torso/HRP
// = "толкаем игрока вверх". Если это игрок — шлём jumpVelocity.
if (className === 'BodyVelocity' && v && v.Y > 10) {
const p = inst.Parent;
if (p && (p.Name === 'Torso' || p.Name === 'HumanoidRootPart' ||
p.Name === 'UpperTorso')) {
send('playerSet', { prop: 'jumpVelocity', value: v.Y * 0.3 });
}
}
},
});
Object.defineProperty(inst, 'Velocity', {
get() { return _vel; },
set(v) { inst.velocity = v; },
});
inst.force = new RbxVector3(0, 0, 0);
inst.Force = inst.force;
inst.MaxForce = new RbxVector3(0, 0, 0);
inst.P = 1000; inst.D = 100;
} else if (className === 'Weld' || className === 'WeldConstraint'
|| className === 'Motor6D' || className === 'Snap'
|| className === 'HingeConstraint' || className === 'BallSocketConstraint'
|| className === 'RopeConstraint' || className === 'SpringConstraint') {
inst = newInstance(className, className);
inst.Part0 = undefined; inst.Part1 = undefined;
inst.C0 = { Position: new RbxVector3(0, 0, 0) };
inst.C1 = { Position: new RbxVector3(0, 0, 0) };
inst.Enabled = true;
} else if (className === 'Sparkles' || className === 'ParticleEmitter'
|| className === 'Smoke' || className === 'Fire' || className === 'Trail'
|| className === 'Beam' || className === 'PointLight'
|| className === 'SurfaceLight' || className === 'SpotLight') {
inst = newInstance(className, className);
inst.Enabled = true;
inst.Color = new RbxColor3(1, 1, 1);
inst.Rate = 20;
inst.Lifetime = { Min: 1, Max: 1 };
inst.Brightness = 1;
inst.Range = 8;
inst.__particleKind = className.toLowerCase();
// Шлём событие "создан particle-effect" — GameRuntime может его
// привязать к мешу на сцене (например, рукам игрока).
send('particleCreated', {
kind: inst.__particleKind,
color: [inst.Color.R, inst.Color.G, inst.Color.B],
});
} else if (className === 'Mouse') {
inst = newInstance('Mouse', 'Mouse');
inst.Button1Down = makeSignal();
inst.Button1Up = makeSignal();
inst.Button2Down = makeSignal();
inst.Button2Up = makeSignal();
inst.Move = makeSignal();
inst.KeyDown = makeSignal();
inst.KeyUp = makeSignal();
inst.WheelForward = makeSignal();
inst.WheelBackward = makeSignal();
inst.Icon = '';
inst.Hit = { Position: new RbxVector3(0, 0, 0), p: new RbxVector3(0, 0, 0) };
inst.Target = undefined;
inst.TargetSurface = 'Top';
inst.X = 0; inst.Y = 0;
inst.ViewSizeX = 1920; inst.ViewSizeY = 1080;
} else {
inst = newInstance(className, className);
}
if (parent) {
inst.Parent = parent;
if (parent.Children) {
parent.Children.push(inst);
if (parent.ChildAdded) parent.ChildAdded.Fire(inst);
}
}
return inst;
},
});
// === Leaderboard scan ===
// Roblox-скрипт делает: Instance.new('IntValue').Name='leaderstats',
// stats.Parent = newPlayer, потом IntValue Reputation/Level внутри.
// Поскольку наш Lua не вызывает Children.push при Parent= (Lua делает rawset),
// мы периодически сканируем localPlayer на наличие leaderstats и шлём в плеер.
// === Helpers для скриптов ===
const partById = new Map();
global.set('__rbxl_get_part_by_id', (id) => partById.get(Number(id)) || undefined);
global.set('__rbxl_get_tool_by_name', (name) => allTools.find(t => t.Name === name) || undefined);
global.set('__rbxl_send_error', (id, errStr) => {
send('log', { level: 'error', text: `[Lua ${id}] ${errStr || 'unknown error'}` });
});
// === Coroutines registry + task.wait через yield ===
// Каждый скрипт стартует как coroutine. Когда юзер пишет task.wait(sec),
// мы делаем coroutine.yield(sec). Main-loop резюмирует когда время вышло.
const coroutines = new Map(); // id → coroutine
const waitingCoros = []; // [{coId, wakeAt}]
global.set('__rbxl_register_coroutine', (id, co) => {
coroutines.set(String(id), co);
});
global.set('__rbxl_unregister_coroutine', (id) => {
coroutines.delete(String(id));
});
global.set('__rbxl_get_co', (id) => coroutines.get(String(id)) || undefined);
global.set('__rbxl_schedule_resume', (coId, delaySec) => {
waitingCoros.push({
coId: String(coId),
wakeAt: SCHEDULER.now() + (Number(delaySec) || 0) * 1000,
});
});
// Lua-prelude: task.wait через coroutine.yield + готовая resume-функция.
// Главное: __rbxl_resume_co определена в Lua и вызывается из JS через
// lua.global.get('__rbxl_resume_co') — это безопаснее чем doStringSync
// потому что не парсит код заново и не создаёт re-entrant проблем.
// Lua-side helper для логов (используется в task.wait/resume для отладки)
global.set('__log', (level, text) => {
send('log', { level: String(level || 'info'), text: String(text || '') });
});
lua.doStringSync(`
local function rbx_wait(sec)
sec = sec or 0
-- Минимум 1 кадр (≈0.0166с). wait() и wait(0) в Roblox ждут до
-- следующего Heartbeat — без этого while true do wait() end
-- стал бы tight loop без yield и упёрся в WASM stack overflow.
if sec < 0.016 then sec = 0.016 end
local ret = coroutine.yield(sec)
return ret or sec
end
-- Глобальный безопасный yield для любых stub-сигналов / любых
-- "ждунов". Используется в Lua-обёртках вокруг WaitForChild и т.п.
function __rbxl_yield_frame()
coroutine.yield(0.05)
end
-- task — JS-object из shim ('userdata'/'table'). Сохраняем
-- существующие методы (delay/spawn/defer) и добавляем wait.
if type(task) == 'table' or type(task) == 'userdata' then
local existing = task
local jsDelay = existing.delay
local jsSpawn = existing.spawn
local jsDefer = existing.defer
task = {
wait = rbx_wait,
delay = jsDelay or function(_, fn) if fn then fn() end end,
spawn = jsSpawn or function(fn) if fn then fn() end end,
defer = jsDefer or function(fn) if fn then fn() end end,
synchronize = function() end,
desynchronize = function() end,
}
else
task = { wait = rbx_wait }
end
wait = rbx_wait
-- Roblox legacy globals
tick = function() return os.time() end -- секунды с epoch
time = function() return os.clock() * 1000 end -- ms аптайм
delay = function(sec, fn) -- delay(sec, fn) — задержка + вызов
if type(fn) ~= 'function' then return end
local co = coroutine.create(function()
rbx_wait(sec or 0)
pcall(fn)
end)
coroutine.resume(co)
end
spawn = function(fn) -- spawn(fn) — запуск в отдельной coroutine
if type(fn) ~= 'function' then return end
local co = coroutine.create(function() pcall(fn) end)
coroutine.resume(co)
end
-- LoadLibrary("RbxStamper"/"RbxUtility") — старый Roblox 2009.
-- Возвращаем пустую таблицу-стаб чтобы скрипт не упал.
LoadLibrary = function(name)
return setmetatable({}, { __index = function() return function() end end })
end
require = require or function(_) return {} end
function __rbxl_resume_co(co)
if not co or coroutine.status(co) ~= 'suspended' then return nil end
local ok, ret = coroutine.resume(co)
if not ok then return false, tostring(ret) end
if coroutine.status(co) == 'dead' then return nil end
if type(ret) == 'number' then return ret end
return 0
end
-- Запуск Lua-handler'а из очереди в собственной coroutine.
-- Вызывается из JS tickScheduler — мы УЖЕ вышли из C-callback,
-- так что wait() внутри handler'а — yield в свою coroutine.
__rbxl_next_handler_id = 0
function __rbxl_drain_handler(fn, a1, a2, a3, a4)
__rbxl_next_handler_id = __rbxl_next_handler_id + 1
local handlerId = "handler_" .. __rbxl_next_handler_id
local co = coroutine.create(function()
debug.sethook(function()
coroutine.yield(0.016)
end, "", 20000)
__log("warn", "[lua-handler] " .. handlerId .. " starting")
local ok, err = pcall(fn, a1, a2, a3, a4)
if ok then
__log("warn", "[lua-handler] " .. handlerId .. " finished OK")
else
__log("error", "[lua-handler] " .. handlerId .. " ERROR: " .. tostring(err))
end
return 1
end)
__rbxl_register_coroutine(handlerId, co)
pcall(coroutine.resume, co)
if coroutine.status(co) == 'dead' then
__rbxl_unregister_coroutine(handlerId)
end
return 1
end
`);
// Кешируем ссылку на Lua-функцию запуска handler'а
const luaDrainHandler = lua.global.get('__rbxl_drain_handler');
// Добавим Lua-side helper для лога
global.set('__log', (level, text) => {
send('log', { level: String(level || 'info'), text: String(text || '') });
});
// === Хелперы паритета с JS game.ui / game.scene ===
// Красивый центрированный текст без рамки (как game.ui.showText).
global.set('__rbxl_show_text', (text, duration, color) => {
send('ui.showText', {
text: String(text || ''),
duration: Number(duration) || 2,
color: color || '#ffffff',
});
});
// Установка/удаление HUD-плашки в фиксированной позиции — паритет с
// JS game.ui.set / game.ui.showInteractHint и аналогами.
// opts = {x, y, color, size} (x,y в процентах 0-100; color — hex)
global.set('__rbxl_hud_set', (id, text, x, y, color, size) => {
const payload = { id: String(id || ''), text: text || null };
if (text != null) {
payload.opts = {
x: Number(x) || 50,
y: Number(y) || 75,
color: color || '#ffe44a',
size: Number(size) || 20,
};
}
send('ui.set', payload);
});
// Спавн NPC — паритет с JS game.scene.spawnNpc(modelType, opts).
// Возвращает локальный ref (строку 'npc_lua_N'), который можно передавать
// в __rbxl_npc_say(ref, text, duration).
let _nextNpcRef = 0;
api._localToRealNpc = new Map();
global.set('__rbxl_spawn_npc', (modelType, x, y, z, name, hp, speed) => {
const ref = 'npc_lua_' + (_nextNpcRef++);
send('npc.spawn', {
modelType: String(modelType || 'character-a'),
ref,
x: +x || 0, y: +y || 0, z: +z || 0,
name: name ? String(name) : undefined,
hp: hp != null ? +hp : undefined,
speed: speed != null ? +speed : undefined,
});
return ref;
});
global.set('__rbxl_npc_say', (ref, text, duration) => {
send('npc.say', {
ref: String(ref || ''),
text: String(text || ''),
duration: +duration || 3,
});
});
global.set('__rbxl_npc_damage', (ref, amount) => {
send('npc.damage', {
ref: String(ref || ''),
amount: +amount || 0,
});
});
// Метка с именем/HP над NPC или примитивом — паритет с JS scene.setLabel.
global.set('__rbxl_set_label', (ref, text, color, height) => {
send('scene.setLabel', {
ref: String(ref || ''),
text: String(text || ''),
opts: {
color: color || '#ff5555',
height: Number(height) || 3,
},
});
});
global.set('__rbxl_clear_label', (ref) => {
send('scene.clearLabel', { ref: String(ref || '') });
});
// Регистрация коллбэка onDeath для NPC. GameRuntime шлёт globalEvent
// 'npcDeath' с {ref} при смерти. Shim фильтрует по ref и зовёт.
const _npcDeathCbs = new Map(); // ref → fn
global.set('__rbxl_npc_on_death', (ref, fn) => {
if (typeof fn === 'function') _npcDeathCbs.set(String(ref || ''), fn);
});
api._npcDeathCbs = _npcDeathCbs;
// Инвентарь invUI — паритет с JS game.inventory.add(itemId, count).
// Сначала определяем итем (один раз), потом добавляем.
const _localInventory = new Map();
const _definedItems = new Set();
global.set('__rbxl_inventory_define', (itemId, name, color) => {
const id = String(itemId || '');
if (!id || _definedItems.has(id)) return;
_definedItems.add(id);
send('items.define', {
def: {
id,
name: name ? String(name) : id,
color: color || '#ffd700',
stack: 99,
},
});
});
global.set('__rbxl_inventory_add', (itemId, count) => {
const id = String(itemId || '');
if (!id) return;
const c = Number(count) || 1;
_localInventory.set(id, (_localInventory.get(id) || 0) + c);
send('inv2.add', { itemId: id, count: c });
});
global.set('__rbxl_inventory_has', (itemId) => {
return (_localInventory.get(String(itemId || '')) || 0) > 0;
});
global.set('__rbxl_inventory_remove', (itemId, count) => {
const id = String(itemId || '');
const c = Number(count) || 1;
const cur = _localInventory.get(id) || 0;
const newCount = Math.max(0, cur - c);
if (newCount === 0) _localInventory.delete(id);
else _localInventory.set(id, newCount);
send('inv2.remove', { itemId: id, count: c });
});
// Урон игроку — паритет с JS game.player.damage(amount).
// У игрока есть i-frames (~0.5с), так что урон не каждый кадр.
global.set('__rbxl_damage_player', (amount) => {
send('player.damage', { amount: Number(amount) || 0 });
});
// Подброс игрока — паритет с JS game.player.boostJump(strength).
// 1.0 = обычный прыжок, 3.0 = втрое выше, и т.д.
global.set('__rbxl_boost_jump', (strength) => {
send('player.boostJump', { strength: Number(strength) || 1 });
});
// Эффекты частиц (confetti, sparks и т.п.) — как game.scene.spawnParticles.
// BabylonScene._spawnParticleEffect ждёт payload.type и payload.position.
global.set('__rbxl_spawn_particles', (kind, x, y, z, duration, count) => {
send('scene.particles', {
type: String(kind || 'confetti'),
position: { x: +x, y: +y, z: +z },
duration: Number(duration) || 2,
count: Number(count) || 1,
});
});
// Спавн примитива (паритет с JS game.scene.spawn) — кладёт в сцену
// примитив с указанным состоянием (включая anchored/canCollide). Возвращает
// id примитива (число) для дальнейших операций.
let _nextSpawnedId = 800000 + Math.floor(Math.random() * 10000);
global.set('__rbxl_spawn_part', (opts) => {
try {
const id = _nextSpawnedId++;
const o = opts || {};
send('sceneCreate', {
primId: id,
type: String(o.type || 'cube'),
x: +o.x || 0, y: +o.y || 0, z: +o.z || 0,
sx: +o.sx || 1, sy: +o.sy || 1, sz: +o.sz || 1,
color: o.color || '#A0A0A0',
anchored: o.anchored !== false,
canCollide: o.canCollide !== false,
});
// Создаём Lua-side представление для скриптов
const fakePrim = {
id, name: o.name || `Spawned_${id}`,
x: +o.x || 0, y: +o.y || 0, z: +o.z || 0,
sx: +o.sx || 1, sy: +o.sy || 1, sz: +o.sz || 1,
color: o.color || '#A0A0A0',
anchored: o.anchored !== false,
canCollide: o.canCollide !== false,
};
const part = newPart(fakePrim, send);
partById.set(id, part);
return part;
} catch (e) {
// eslint-disable-next-line no-console
console.warn('[__rbxl_spawn_part]', e?.message || e);
return null;
}
});
// Позиция игрока для удобства — отдельные функции для x/y/z, чтобы
// wasmoon не оборачивал результат в userdata-proxy.
global.set('__rbxl_player_x', () => {
const p = api._realPlayerPos || hrp._position || { X: 0 };
return Number(p.x ?? p.X) || 0;
});
global.set('__rbxl_player_y', () => {
const p = api._realPlayerPos || hrp._position || { Y: 0 };
return Number(p.y ?? p.Y) || 0;
});
global.set('__rbxl_player_z', () => {
const p = api._realPlayerPos || hrp._position || { Z: 0 };
return Number(p.z ?? p.Z) || 0;
});
// Совместимость: __rbxl_player_pos() возвращает 3 числа (x, y, z).
global.set('__rbxl_player_pos', () => {
const p = api._realPlayerPos || hrp._position || { X: 0, Y: 0, Z: 0 };
return {
x: Number(p.x ?? p.X) || 0,
y: Number(p.y ?? p.Y) || 0,
z: Number(p.z ?? p.Z) || 0,
};
});
// Достаём ссылку на Lua-функцию один раз; вызовы безопасны (не doStringSync)
const luaResumeCo = lua.global.get('__rbxl_resume_co');
// === Setter Part-свойств (Position/Size/Color/...) ===
// Юзер пишет: part.Position = Vector3.new(0, 10, 0)
// В Lua-side это просто rawset. Но мы хотим, чтобы JS-сторона узнала и применила.
// Решение: каждые N секунд (или по требованию) — сканируем partById и сравниваем
// _state. Это дорого. ПРОСТЕЕ: научим юзера использовать part:SetPosition(v).
//
// Для Этапа 3 сделаем proxy через явный метод. В Этапе 4 — введём
// metatable на Lua-стороне (более чистый путь).
// Возвращаем api для main-loop. api объявляется заранее, чтобы closures
// вроде __rbxl_player_pos и updatePlayerPos могли его видеть.
const api = {
_realPlayerPos: null,
onSceneSnapshot(snap) {
try {
const prims = snap?.primitives || [];
// Сохраняем Camera/Terrain
const kept = workspace.Children.filter(c =>
c.ClassName === 'Camera' || c.ClassName === 'Terrain'
);
workspace.Children.length = 0;
workspace.Children.push(...kept);
partById.clear();
for (const p of prims) {
if (!p || p.id == null) continue;
const part = newPart(p, send); // setters внутри шлют через send
part.Parent = workspace;
workspace.Children.push(part);
partById.set(Number(p.id), part);
}
// Teams из импорта .rbxl — создаём Team-инстансы в teams сервисе
const teamsList = snap?.teams || [];
if (teamsList.length > 0 && teams.Children.length === 0) {
for (const t of teamsList) {
const team = newInstance('Team', String(t.name || 'Team'));
team.TeamColor = {
Name: String(t.name || 'White'),
Color: RbxColor3.fromHex(t.color_hex || '#ffffff'),
};
team.Score = 0;
team.AutoAssignable = !!t.auto_assignable;
team.PlayerAdded = makeSignal();
team.PlayerRemoved = makeSignal();
team._parent = teams;
teams.Children.push(team);
}
// Авто-назначение игрока в первую auto_assignable команду
const first = teams.Children.find(t => t.AutoAssignable);
if (first) {
localPlayer.Team = first;
localPlayer.TeamColor = first.TeamColor;
localPlayer.Neutral = false;
}
}
// eslint-disable-next-line no-console
console.log(`[shim] DataModel: workspace has ${workspace.Children.length} children, ${partById.size} parts, ${teams.Children.length} teams`);
} catch (e) {
send('log', { level: 'error', text: `[shim onSceneSnapshot] ${e?.message || e}` });
}
},
onGuiSnapshot() {},
onDataSnapshot() {},
/** Фейр PlayerAdded для уже существующих игроков после того как
* скрипты успели подключить хендлеры. Roblox-конвенция:
* Players.PlayerAdded не срабатывает для игроков уже на сервере.
* Мы дублируем чтобы простые скрипты вроде
* Players.PlayerAdded:Connect(...) работали из коробки. */
fireExistingPlayers() {
try {
if (players?.PlayerAdded?.Fire) {
players.PlayerAdded.Fire(localPlayer);
}
// CharacterAdded — то же самое
if (localPlayer?.CharacterAdded?.Fire && character) {
localPlayer.CharacterAdded.Fire(character);
}
} catch (_) {}
},
tickScheduler(_dt) {
// 0a. Lua-handlers из очереди (signal.Fire отложил их сюда).
// Запускаем каждый в своей coroutine — wait() внутри безопасен.
if (_pendingHandlerQueue.length > 0) {
const queue = _pendingHandlerQueue.splice(0, _pendingHandlerQueue.length);
for (const h of queue) {
try {
// ПРЯМОЙ вызов JS-обёртки Lua-функции (без передачи fn
// обратно в Lua через luaDrainHandler — это создаёт
// wasmoon Promise-detection crash на null.then).
// wasmoon вернёт Promise — ловим через .catch.
const result = h.fn(...(h.args || []));
if (result && typeof result.then === 'function') {
result.catch((err) => {
// eslint-disable-next-line no-console
console.warn('[handler-async-err]', err?.message || err);
});
}
} catch (e) {
// eslint-disable-next-line no-console
console.warn('[handler-sync-err]', e?.message || e);
}
}
}
// 0b. Tweens
_stepTweens(_dt);
const now = SCHEDULER.now();
// 1. task.delay / task.defer
if (SCHEDULER.sleeping.length > 0) {
const ready = [];
const rest = [];
for (const t of SCHEDULER.sleeping) {
if (t.wakeAt <= now) ready.push(t); else rest.push(t);
}
SCHEDULER.sleeping = rest;
for (const t of ready) {
try { t.run(); } catch (_) {}
}
}
// 2. Резюм coroutine'ов которые task.wait()
const dueCoros = [];
for (let i = waitingCoros.length - 1; i >= 0; i--) {
if (waitingCoros[i].wakeAt <= now) {
dueCoros.push(waitingCoros[i]);
waitingCoros.splice(i, 1);
}
}
for (const entry of dueCoros) {
const co = coroutines.get(entry.coId);
if (!co) continue;
try {
const result = luaResumeCo(co);
if (result === null || result === undefined) {
coroutines.delete(entry.coId);
} else if (typeof result === 'number') {
waitingCoros.push({
coId: entry.coId,
wakeAt: SCHEDULER.now() + result * 1000,
});
} else if (result === false) {
coroutines.delete(entry.coId);
}
} catch (e) {
send('log', { level: 'error', text: `[coroutine ${entry.coId}] ${e?.message || e}` });
coroutines.delete(entry.coId);
}
}
},
fireHeartbeat(dt) {
try { HEARTBEAT_SIGNAL.Fire(dt); } catch (_) {}
try { STEPPED_SIGNAL.Fire(performance.now() / 1000, dt); } catch (_) {}
// Авто-детект Touched на спавненных частях (id >= 800000):
// Спавненные через __rbxl_spawn_part примитивы (падающие кубы,
// снаряды) Babylon не знает (target=null), поэтому делаем
// proximity-check игрок↔part прямо в shim каждый кадр.
//
// Используем РАСШИРЕННЫЙ радиус (не строгий AABB), потому что
// физтело куба отталкивается от игрока при контакте — куб может
// успеть отскочить ДО следующего кадра. Расширяем зону на 1.2
// единицы, чтобы поймать "почти-контакт".
try {
const pp = api._realPlayerPos;
if (!pp) return;
const phw = 0.4, phh = 0.9, phd = 0.4;
const SLACK = 1.2; // расширение зоны касания
for (const [id, part] of partById.entries()) {
if (id < 800000) continue;
if (!part || part.Destroyed) continue;
if (!part.Touched || part.Touched.connections.length === 0) continue;
const pos = part._state?.Position;
const size = part._state?.Size;
if (!pos || !size) continue;
const hw = size.X / 2 + SLACK;
const hh = size.Y / 2 + SLACK;
const hd = size.Z / 2 + SLACK;
const overlap =
pp.x + phw > pos.X - hw && pp.x - phw < pos.X + hw &&
pp.y + phh > pos.Y - hh && pp.y - phh < pos.Y + hh &&
pp.z + phd > pos.Z - hd && pp.z - phd < pos.Z + hd;
if (overlap && !part.__lastTouching) {
part.__lastTouching = true;
try { part.Touched.Fire(hrp); } catch (_) {}
} else if (!overlap && part.__lastTouching) {
part.__lastTouching = false;
try { part.TouchEnded.Fire(hrp); } catch (_) {}
}
}
} catch (_) {}
},
fireTargetEvent(p) {
if (!p) return;
const id = p.primId ?? p.target;
const part = partById.get(Number(id));
if (!part) return;
if (p.kind === 'touch' || p.kind === 'touched') {
part.Touched.Fire(hrp);
} else if (p.kind === 'untouch' || p.kind === 'untouched') {
part.TouchEnded.Fire(hrp);
} else if (p.kind === 'click') {
// ClickDetector — стрельба по 3D-объектам.
// Фейерим без аргумента (передача объектов в Lua через wasmoon
// может крашить с null.then).
try {
const cd = part._clickDetector;
if (cd && cd.MouseClick) cd.MouseClick.Fire();
} catch (_) {}
try {
if (part.Clicked) part.Clicked.Fire();
} catch (_) {}
}
},
fireGlobalEvent(p) {
if (!p) return;
if (p.type === 'playerTouch' && p.target != null) {
let primId = null;
if (typeof p.target === 'number') primId = p.target;
else if (typeof p.target === 'string') {
const m = /^primitive:(\d+)$/.exec(p.target);
if (m) primId = +m[1];
} else if (typeof p.target === 'object') {
primId = p.target.id ?? p.target.ref ?? null;
}
if (primId != null) {
const part = partById.get(Number(primId));
// НЕ фейерим part.Touched — это делает fireTargetEvent
// в routeEvent('touch'). Иначе двойной счёт.
if (part && humanoid.Touched) humanoid.Touched.Fire(part);
}
}
// NPC погиб — фейерим registered cb для конкретного локального ref.
if (p.type === 'npcDeath' && p.npcId != null) {
const realRef = 'npc:' + p.npcId;
// Ищем локальный ref по реальному
let localRef = null;
if (api._localToRealNpc) {
for (const [k, v] of api._localToRealNpc.entries()) {
if (v === realRef) { localRef = k; break; }
}
}
// Вызываем все cb с подходящим ref
if (_npcDeathCbs.size > 0) {
for (const [ref, fn] of _npcDeathCbs.entries()) {
if (ref === realRef || ref === localRef) {
_pendingHandlerQueue.push({ fn, args: [] });
}
}
}
}
// GUI-клик: GameRuntime шлёт {type:'guiClick', localId, id}
if (p.type === 'guiClick') {
const ref = p.localId || p.id;
const guiEl = guiByLocalRef.get(ref);
if (guiEl?.MouseButton1Click) {
guiEl.MouseButton1Click.Fire();
}
}
// Tool equip/unequip — клавиши 1-9 в плеере шлют
// {type:'equipTool', index:N}, {type:'unequipTool'}
if (p.type === 'equipTool') {
const idx = Number(p.index) - 1;
if (idx < 0 || idx >= allTools.length) return;
const tool = allTools[idx];
if (equippedTool === tool) return;
// Снимаем предыдущий
if (equippedTool) {
try { equippedTool.Unequipped.Fire(); } catch (_) {}
}
equippedTool = tool;
// В Roblox Tool при equip перемещается в Character
tool.Parent = character;
try { tool.Equipped.Fire(playerMouse); } catch (_) {}
}
if (p.type === 'unequipTool') {
if (!equippedTool) return;
try { equippedTool.Unequipped.Fire(); } catch (_) {}
equippedTool.Parent = backpack;
equippedTool = null;
}
if (p.type === 'toolActivated') {
if (!equippedTool) return;
try { equippedTool.Activated.Fire(); } catch (_) {}
}
if (p.type === 'toolDeactivated') {
if (!equippedTool) return;
try { equippedTool.Deactivated.Fire(); } catch (_) {}
}
// Mouse-события из плеера: клики, движение, клавиши при equipped Tool
if (p.type === 'mouseButton1Down') {
if (p.hit) {
playerMouse.Hit.Position = new RbxVector3(p.hit.x, p.hit.y, p.hit.z);
playerMouse.Hit.p = playerMouse.Hit.Position;
}
try { playerMouse.Button1Down.Fire(); } catch (_) {}
}
if (p.type === 'mouseButton1Up') {
try { playerMouse.Button1Up.Fire(); } catch (_) {}
}
if (p.type === 'keyDown' || p.type === 'keydown') {
const k = String(p.key || '').toLowerCase();
try { playerMouse.KeyDown.Fire(k); } catch (_) {}
// Также фейерим UserInputService.InputBegan с InputObject.
// KeyCode должна быть та же ссылка что и Enum.KeyCode.E,
// чтобы скрипт мог сравнивать input.KeyCode == Enum.KeyCode.E.
try {
const keyEnum = global.get('Enum')?.KeyCode || {};
const kc = keyEnum[k.toUpperCase()]
|| { Name: k.toUpperCase(), Value: k.toUpperCase() };
const inputObj = { UserInputType: 'Keyboard', KeyCode: kc };
uis.InputBegan.Fire(inputObj, false);
} catch (_) {}
}
if (p.type === 'keyUp' || p.type === 'keyup') {
const k = String(p.key || '').toLowerCase();
try { playerMouse.KeyUp.Fire(k); } catch (_) {}
try {
const keyEnum = global.get('Enum')?.KeyCode || {};
const kc = keyEnum[k.toUpperCase()]
|| { Name: k.toUpperCase(), Value: k.toUpperCase() };
const inputObj = { UserInputType: 'Keyboard', KeyCode: kc };
uis.InputEnded.Fire(inputObj, false);
} catch (_) {}
}
},
// Tool registry (для GameRuntime: какой Tool сделать script.Parent)
getToolByName(name) {
return allTools.find(t => t.Name === name);
},
getAllTools() { return allTools.slice(); },
// GameRuntime каждый кадр шлёт реальную позицию игрока сюда.
// __rbxl_player_pos() её возвращает Lua-скриптам.
updatePlayerPos(x, y, z) {
api._realPlayerPos = { x: +x, y: +y, z: +z };
},
// Синхронизация позиций спавненных физических частей (падающие кубы).
// GameRuntime каждый кадр зовёт это с актуальными координатами от
// pm.instances — иначе наш AABB-touched-check считает позиции
// устаревшими (на момент создания) и не ловит касание.
updateSpawnedPos(id, x, y, z) {
const part = partById.get(Number(id));
if (part && part._state && part._state.Position) {
part._state.Position = new RbxVector3(x, y, z);
}
},
// Доступ к ключевым объектам (для тестов и отладки)
partById, localPlayer, humanoid, character, workspace, players, game,
};
return api;
}