studio/src/editor/engine/lua/RobloxShim.js

894 lines
37 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

/**
* RobloxShim v3 (для main-thread sandbox) — Roblox API + DataModel.
*
* Этап 3: добавлено виртуальное дерево DataModel поверх плоской сцены.
* - game.Workspace.Children = массив RbxPart обёрток над примитивами
* - script.Parent для target-скриптов = реальный RbxPart
* - RbxPart.Touched — RbxSignal который фейерится из BabylonScene при overlap
* - RbxPart.Position/Size/Color/Anchored/CanCollide — пишутся через setProp(part, ...)
* методы, которые шлют partSet в main thread (применяется к Babylon-сцене)
* - Humanoid с Health setter → playerSet команда
*
* ВАЖНО про wasmoon: не используем Object.defineProperty getters на JS-объектах
* передаваемых в Lua — wasmoon их некорректно оборачивает (js_promise). Вместо
* этого — обычные поля, которые юзер читает напрямую. Запись свойств происходит
* через `__rbxl_part_set(part, prop, value)` — она шлёт partSet и обновляет поле.
*/
// ---------- Scheduler (для task.delay/defer) ----------
const SCHEDULER = {
sleeping: [], // [{wakeAt, run}]
now: () => performance.now(),
};
// ---------- Базовые сигналы ----------
const HEARTBEAT_SIGNAL = makeSignal();
const STEPPED_SIGNAL = makeSignal();
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,
};
}