894 lines
37 KiB
JavaScript
894 lines
37 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) { return this.FindFirstChild(name); },
|
||
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;
|
||
}
|
||
|
||
function newInstance(className, name) {
|
||
const m = makeInstanceMethods();
|
||
return {
|
||
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,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Создать 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.
|
||
// Формат payload должен соответствовать rbxl-lua-integration.js#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),
|
||
});
|
||
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']),
|
||
});
|
||
|
||
// === 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') });
|
||
});
|
||
|
||
// === 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;
|
||
localPlayer.DisplayName = 'Player';
|
||
players.Children.push(localPlayer);
|
||
players.LocalPlayer = localPlayer;
|
||
|
||
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);
|
||
});
|
||
|
||
makeService('Lighting').Ambient = new RbxColor3(0.5, 0.5, 0.5);
|
||
makeService('Chat');
|
||
makeService('SoundService');
|
||
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);
|
||
};
|
||
game.FindService = function (name) { return services[name] || null; };
|
||
|
||
global.set('game', game);
|
||
global.set('Game', game);
|
||
global.set('workspace', workspace);
|
||
global.set('Workspace', workspace);
|
||
|
||
// === Instance.new ===
|
||
// Счётчик для новых 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 {
|
||
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_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
|
||
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);
|
||
}
|
||
}
|
||
},
|
||
// Доступ к ключевым объектам (для тестов и отладки)
|
||
partById, localPlayer, humanoid, character, workspace, players, game,
|
||
};
|
||
}
|