Цель: запустить Roblox Tools (Zapper и подобные оружия) в плеере.
Архитектура:
1. RobloxShim: localPlayer.Backpack, localPlayer:GetMouse(), allTools registry,
equippedTool — внутренний учёт текущего Tool.
2. Instance.new('Tool') — теперь автоматически:
- создаёт виртуальный Handle (Part) внутри
- регистрирует Tool в allTools[]
- шлёт 'toolRegistered' в GameRuntime
3. fireGlobalEvent обработка новых событий из плеера:
- equipTool {index} → Tool.Equipped:Fire(playerMouse)
- unequipTool → Tool.Unequipped:Fire()
- toolActivated → Tool.Activated:Fire()
- mouseButton1Down {hit} → mouse.Hit.Position + mouse.Button1Down:Fire()
- keyDown {key} → mouse.KeyDown:Fire(key)
4. LuaSharedSandbox.addScript принимает toolName, в _startSingleScript
подсовывает виртуальный Tool как script.Parent (через
__rbxl_get_tool_by_name).
5. GameRuntime эвристика: скрипты с target=null и упоминанием
script.Parent.Equipped/Activated → toolName='Tool', группируются
в один Tool.
6. GameRuntime._registerRbxlTool: при получении toolRegistered кладёт
item в InventoryUI.hotbar, слушает смену слота → equipTool.
7. Клики canvas → mouseButton1Down с raycast Hit.Position.
Следующие шаги:
- HUD: индикатор экипированного Tool в плеере (Шаг 2)
- Leaderboard UI из leaderstats IntValue (Шаг 3)
1565 lines
70 KiB
JavaScript
1565 lines
70 KiB
JavaScript
/**
|
||
* RobloxShim v3 (для main-thread sandbox) — Roblox API + DataModel.
|
||
*
|
||
* Этап 3: добавлено виртуальное дерево DataModel поверх плоской сцены.
|
||
* - game.Workspace.Children = массив RbxPart обёрток над примитивами
|
||
* - script.Parent для target-скриптов = реальный RbxPart
|
||
* - RbxPart.Touched — RbxSignal который фейерится из BabylonScene при overlap
|
||
* - RbxPart.Position/Size/Color/Anchored/CanCollide — пишутся через setProp(part, ...)
|
||
* методы, которые шлют partSet в main thread (применяется к Babylon-сцене)
|
||
* - Humanoid с Health setter → playerSet команда
|
||
*
|
||
* ВАЖНО про wasmoon: не используем Object.defineProperty getters на JS-объектах
|
||
* передаваемых в Lua — wasmoon их некорректно оборачивает (js_promise). Вместо
|
||
* этого — обычные поля, которые юзер читает напрямую. Запись свойств происходит
|
||
* через `__rbxl_part_set(part, prop, value)` — она шлёт partSet и обновляет поле.
|
||
*/
|
||
|
||
// ---------- Scheduler (для task.delay/defer) ----------
|
||
const SCHEDULER = {
|
||
sleeping: [], // [{wakeAt, run}]
|
||
now: () => performance.now(),
|
||
};
|
||
|
||
// ---------- Базовые сигналы ----------
|
||
const HEARTBEAT_SIGNAL = makeSignal();
|
||
const STEPPED_SIGNAL = makeSignal();
|
||
|
||
function makeSignal() {
|
||
const sig = {
|
||
__isSignal: true,
|
||
connections: [],
|
||
};
|
||
sig.Connect = function (fn) {
|
||
if (typeof fn !== 'function') return { Disconnect() {}, disconnect() {}, Connected: false };
|
||
sig.connections.push(fn);
|
||
const conn = { Connected: true };
|
||
conn.Disconnect = function () {
|
||
const i = sig.connections.indexOf(fn);
|
||
if (i >= 0) sig.connections.splice(i, 1);
|
||
conn.Connected = false;
|
||
};
|
||
conn.disconnect = conn.Disconnect;
|
||
return conn;
|
||
};
|
||
sig.connect = sig.Connect;
|
||
sig.Fire = function (...args) {
|
||
for (const fn of [...sig.connections]) {
|
||
try { fn(...args); } catch (e) {
|
||
// eslint-disable-next-line no-console
|
||
console.error('[Signal handler]', e);
|
||
}
|
||
}
|
||
};
|
||
sig.fire = sig.Fire;
|
||
sig.Wait = () => null;
|
||
sig.wait = sig.Wait;
|
||
return sig;
|
||
}
|
||
|
||
// ---------- Vector3 / Color3 / UDim2 / Vector2 / CFrame ----------
|
||
class RbxVector3 {
|
||
constructor(x = 0, y = 0, z = 0) { this.X = +x; this.Y = +y; this.Z = +z; }
|
||
static new(x, y, z) { return new RbxVector3(x, y, z); }
|
||
get Magnitude() { return Math.hypot(this.X, this.Y, this.Z); }
|
||
get magnitude() { return Math.hypot(this.X, this.Y, this.Z); }
|
||
get Unit() {
|
||
const m = Math.hypot(this.X, this.Y, this.Z) || 1;
|
||
return new RbxVector3(this.X / m, this.Y / m, this.Z / m);
|
||
}
|
||
get unit() { return this.Unit; }
|
||
Normalize() { return this.Unit; }
|
||
Dot(b) { return this.X * b.X + this.Y * b.Y + this.Z * b.Z; }
|
||
Cross(b) {
|
||
return new RbxVector3(
|
||
this.Y * b.Z - this.Z * b.Y,
|
||
this.Z * b.X - this.X * b.Z,
|
||
this.X * b.Y - this.Y * b.X,
|
||
);
|
||
}
|
||
Lerp(b, t) {
|
||
return new RbxVector3(
|
||
this.X + (b.X - this.X) * t,
|
||
this.Y + (b.Y - this.Y) * t,
|
||
this.Z + (b.Z - this.Z) * t,
|
||
);
|
||
}
|
||
}
|
||
RbxVector3.zero = new RbxVector3(0, 0, 0);
|
||
RbxVector3.one = new RbxVector3(1, 1, 1);
|
||
RbxVector3.xAxis = new RbxVector3(1, 0, 0);
|
||
RbxVector3.yAxis = new RbxVector3(0, 1, 0);
|
||
RbxVector3.zAxis = new RbxVector3(0, 0, 1);
|
||
|
||
class RbxColor3 {
|
||
constructor(r = 0, g = 0, b = 0) { this.R = +r; this.G = +g; this.B = +b; }
|
||
static new(r, g, b) { return new RbxColor3(r, g, b); }
|
||
static fromRGB(r, g, b) { return new RbxColor3((r || 0) / 255, (g || 0) / 255, (b || 0) / 255); }
|
||
static fromHSV(h, s, v) {
|
||
const i = Math.floor(h * 6); const f = h * 6 - i;
|
||
const p = v * (1 - s); const q = v * (1 - f * s); const t = v * (1 - (1 - f) * s);
|
||
const [r, g, b] = [[v, t, p], [q, v, p], [p, v, t], [p, q, v], [t, p, v], [v, p, q]][i % 6];
|
||
return new RbxColor3(r, g, b);
|
||
}
|
||
static fromHex(hex) {
|
||
const s = String(hex || '').replace('#', '');
|
||
if (s.length !== 6) return new RbxColor3();
|
||
return new RbxColor3(
|
||
parseInt(s.slice(0, 2), 16) / 255,
|
||
parseInt(s.slice(2, 4), 16) / 255,
|
||
parseInt(s.slice(4, 6), 16) / 255,
|
||
);
|
||
}
|
||
Lerp(b, t) {
|
||
return new RbxColor3(
|
||
this.R + (b.R - this.R) * t,
|
||
this.G + (b.G - this.G) * t,
|
||
this.B + (b.B - this.B) * t,
|
||
);
|
||
}
|
||
toHex() {
|
||
const h = (n) => Math.round(Math.max(0, Math.min(1, n)) * 255).toString(16).padStart(2, '0');
|
||
return '#' + h(this.R) + h(this.G) + h(this.B);
|
||
}
|
||
}
|
||
|
||
class RbxUDim {
|
||
constructor(s = 0, o = 0) { this.Scale = +s; this.Offset = +o; }
|
||
static new(s, o) { return new RbxUDim(s, o); }
|
||
}
|
||
class RbxUDim2 {
|
||
constructor(sx = 0, ox = 0, sy = 0, oy = 0) {
|
||
this.X = new RbxUDim(sx, ox); this.Y = new RbxUDim(sy, oy);
|
||
}
|
||
static new(sx, ox, sy, oy) { return new RbxUDim2(sx, ox, sy, oy); }
|
||
static fromScale(sx, sy) { return new RbxUDim2(sx, 0, sy, 0); }
|
||
static fromOffset(ox, oy) { return new RbxUDim2(0, ox, 0, oy); }
|
||
}
|
||
class RbxVector2 {
|
||
constructor(x = 0, y = 0) { this.X = +x; this.Y = +y; }
|
||
static new(x, y) { return new RbxVector2(x, y); }
|
||
}
|
||
class RbxCFrame {
|
||
constructor(x = 0, y = 0, z = 0) {
|
||
this.X = +x; this.Y = +y; this.Z = +z;
|
||
this.Position = new RbxVector3(x, y, z);
|
||
this.p = this.Position;
|
||
}
|
||
static new(x, y, z) { return new RbxCFrame(x || 0, y || 0, z || 0); }
|
||
static lookAt(eye, _t) { return new RbxCFrame(eye?.X || 0, eye?.Y || 0, eye?.Z || 0); }
|
||
static Angles() { return new RbxCFrame(); }
|
||
static fromEulerAnglesXYZ() { return new RbxCFrame(); }
|
||
}
|
||
|
||
// ---------- Instance / Part ----------
|
||
let _instanceMethods = null;
|
||
function makeInstanceMethods() {
|
||
if (_instanceMethods) return _instanceMethods;
|
||
_instanceMethods = {
|
||
GetChildren: function () { return [...(this.Children || [])]; },
|
||
GetDescendants: function () {
|
||
const out = [];
|
||
const visit = (n) => {
|
||
for (const c of n.Children || []) { out.push(c); visit(c); }
|
||
};
|
||
visit(this);
|
||
return out;
|
||
},
|
||
FindFirstChild: function (name, recursive) {
|
||
for (const c of this.Children || []) {
|
||
if (c.Name === name) return c;
|
||
if (recursive) {
|
||
const f = c.FindFirstChild && c.FindFirstChild(name, true);
|
||
if (f) return f;
|
||
}
|
||
}
|
||
return undefined;
|
||
},
|
||
FindFirstChildOfClass: function (cls) {
|
||
for (const c of this.Children || []) {
|
||
if (c.ClassName === cls) return c;
|
||
}
|
||
return undefined;
|
||
},
|
||
FindFirstAncestor: function (name) {
|
||
let p = this.Parent;
|
||
while (p) { if (p.Name === name) return p; p = p.Parent; }
|
||
return undefined;
|
||
},
|
||
FindFirstAncestorOfClass: function (cls) {
|
||
let p = this.Parent;
|
||
while (p) { if (p.ClassName === cls) return p; p = p.Parent; }
|
||
return undefined;
|
||
},
|
||
WaitForChild: function (name) {
|
||
// В 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 null; };
|
||
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; };
|
||
lighting.SetMinutesAfterMidnight = function (m) {
|
||
lighting._minutes = (Number(m) || 0) % 1440;
|
||
lighting.ClockTime = lighting._minutes / 60;
|
||
};
|
||
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;
|
||
} 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;
|
||
},
|
||
});
|
||
|
||
// === 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-side helper для лога
|
||
global.set('__log', (level, text) => {
|
||
send('log', { level: String(level || 'info'), text: String(text || '') });
|
||
});
|
||
// Достаём ссылку на Lua-функцию один раз; вызовы безопасны (не doStringSync)
|
||
const luaResumeCo = lua.global.get('__rbxl_resume_co');
|
||
|
||
// === Setter Part-свойств (Position/Size/Color/...) ===
|
||
// Юзер пишет: part.Position = Vector3.new(0, 10, 0)
|
||
// В Lua-side это просто rawset. Но мы хотим, чтобы JS-сторона узнала и применила.
|
||
// Решение: каждые N секунд (или по требованию) — сканируем partById и сравниваем
|
||
// _state. Это дорого. ПРОСТЕЕ: научим юзера использовать part:SetPosition(v).
|
||
//
|
||
// Для Этапа 3 сделаем proxy через явный метод. В Этапе 4 — введём
|
||
// metatable на Lua-стороне (более чистый путь).
|
||
|
||
// Возвращаем api для main-loop
|
||
return {
|
||
onSceneSnapshot(snap) {
|
||
try {
|
||
const prims = snap?.primitives || [];
|
||
// Сохраняем Camera/Terrain
|
||
const kept = workspace.Children.filter(c =>
|
||
c.ClassName === 'Camera' || c.ClassName === 'Terrain'
|
||
);
|
||
workspace.Children.length = 0;
|
||
workspace.Children.push(...kept);
|
||
partById.clear();
|
||
for (const p of prims) {
|
||
if (!p || p.id == null) continue;
|
||
const part = newPart(p, send); // setters внутри шлют через send
|
||
part.Parent = workspace;
|
||
workspace.Children.push(part);
|
||
partById.set(Number(p.id), part);
|
||
}
|
||
// eslint-disable-next-line no-console
|
||
console.log(`[shim] DataModel: workspace has ${workspace.Children.length} children, ${partById.size} parts`);
|
||
} catch (e) {
|
||
send('log', { level: 'error', text: `[shim onSceneSnapshot] ${e?.message || e}` });
|
||
}
|
||
},
|
||
onGuiSnapshot() {},
|
||
onDataSnapshot() {},
|
||
|
||
tickScheduler(_dt) {
|
||
// 0. Tweens
|
||
_stepTweens(_dt);
|
||
const now = SCHEDULER.now();
|
||
// 1. task.delay / task.defer
|
||
if (SCHEDULER.sleeping.length > 0) {
|
||
const ready = [];
|
||
const rest = [];
|
||
for (const t of SCHEDULER.sleeping) {
|
||
if (t.wakeAt <= now) ready.push(t); else rest.push(t);
|
||
}
|
||
SCHEDULER.sleeping = rest;
|
||
for (const t of ready) {
|
||
try { t.run(); } catch (_) {}
|
||
}
|
||
}
|
||
// 2. Резюм coroutine'ов которые task.wait()
|
||
const dueCoros = [];
|
||
for (let i = waitingCoros.length - 1; i >= 0; i--) {
|
||
if (waitingCoros[i].wakeAt <= now) {
|
||
dueCoros.push(waitingCoros[i]);
|
||
waitingCoros.splice(i, 1);
|
||
}
|
||
}
|
||
for (const entry of dueCoros) {
|
||
const co = coroutines.get(entry.coId);
|
||
if (!co) continue;
|
||
try {
|
||
const result = luaResumeCo(co);
|
||
if (result === null || result === undefined) {
|
||
coroutines.delete(entry.coId);
|
||
} else if (typeof result === 'number') {
|
||
waitingCoros.push({
|
||
coId: entry.coId,
|
||
wakeAt: SCHEDULER.now() + result * 1000,
|
||
});
|
||
} else if (result === false) {
|
||
coroutines.delete(entry.coId);
|
||
}
|
||
} catch (e) {
|
||
send('log', { level: 'error', text: `[coroutine ${entry.coId}] ${e?.message || e}` });
|
||
coroutines.delete(entry.coId);
|
||
}
|
||
}
|
||
},
|
||
fireHeartbeat(dt) {
|
||
try { HEARTBEAT_SIGNAL.Fire(dt); } catch (_) {}
|
||
try { STEPPED_SIGNAL.Fire(performance.now() / 1000, dt); } catch (_) {}
|
||
},
|
||
fireTargetEvent(p) {
|
||
if (!p) return;
|
||
const id = p.primId ?? p.target;
|
||
const part = partById.get(Number(id));
|
||
if (!part) return;
|
||
if (p.kind === 'touch' || p.kind === 'touched') {
|
||
part.Touched.Fire(hrp);
|
||
} else if (p.kind === 'untouch' || p.kind === 'untouched') {
|
||
part.TouchEnded.Fire(hrp);
|
||
}
|
||
},
|
||
fireGlobalEvent(p) {
|
||
if (!p) return;
|
||
if (p.type === 'playerTouch' && p.target != null) {
|
||
let primId = null;
|
||
if (typeof p.target === 'number') primId = p.target;
|
||
else if (typeof p.target === 'string') {
|
||
const m = /^primitive:(\d+)$/.exec(p.target);
|
||
if (m) primId = +m[1];
|
||
} else if (typeof p.target === 'object') {
|
||
primId = p.target.id ?? p.target.ref ?? null;
|
||
}
|
||
if (primId != null) {
|
||
const part = partById.get(Number(primId));
|
||
if (part?.Touched) part.Touched.Fire(hrp);
|
||
if (humanoid.Touched) humanoid.Touched.Fire(part);
|
||
}
|
||
}
|
||
// 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,
|
||
};
|
||
}
|