feat: 50 игр на Lua + импорт Roblox для всех + поддержка Lua в плеере #39
@ -124,7 +124,11 @@ export class LuaSharedSandbox {
|
||||
}
|
||||
const safeId = entry.id.replace(/[^a-zA-Z0-9_]/g, '_');
|
||||
const scriptName = entry.name || `Script_${safeId}`;
|
||||
// ВАЖНО: chunk_name прокидываем — wasmoon покажет его в traceback.
|
||||
// Скрипт оборачиваем в coroutine — это позволяет task.wait через yield.
|
||||
// Резюмим coroutine из main-loop когда наступило время.
|
||||
// Регистрируем coroutine в __rbxl_coroutines с id для возобновления.
|
||||
// Скрипт оборачиваем в coroutine. task.wait()→coroutine.yield(sec) возвращает
|
||||
// delay из resume → планируем следующий resume через scheduleResume.
|
||||
const wrapped = `
|
||||
do
|
||||
local script = {
|
||||
@ -134,11 +138,19 @@ export class LuaSharedSandbox {
|
||||
Disabled = false,
|
||||
Source = nil,
|
||||
}
|
||||
local ok, err = pcall(function()
|
||||
local co = coroutine.create(function()
|
||||
${entry.code}
|
||||
end)
|
||||
__rbxl_register_coroutine(${JSON.stringify(entry.id)}, co)
|
||||
local ok, ret = coroutine.resume(co)
|
||||
if not ok then
|
||||
__rbxl_send_error(${JSON.stringify(entry.id)}, tostring(err))
|
||||
__rbxl_send_error(${JSON.stringify(entry.id)}, tostring(ret))
|
||||
__rbxl_unregister_coroutine(${JSON.stringify(entry.id)})
|
||||
elseif type(ret) == 'number' then
|
||||
-- скрипт yield'нул с delay (через task.wait) — планируем resume
|
||||
__rbxl_schedule_resume(${JSON.stringify(entry.id)}, ret)
|
||||
elseif coroutine.status(co) == 'dead' then
|
||||
__rbxl_unregister_coroutine(${JSON.stringify(entry.id)})
|
||||
end
|
||||
end
|
||||
`;
|
||||
|
||||
@ -204,6 +204,10 @@ function makeInstanceMethods() {
|
||||
},
|
||||
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);
|
||||
@ -259,18 +263,99 @@ function newInstance(className, name) {
|
||||
function newPart(primData, sendFn) {
|
||||
const p = newInstance('Part', primData.name || `Part_${primData.id}`);
|
||||
p.__primId = primData.id;
|
||||
p.__sendFn = sendFn;
|
||||
p.__sendDestroy = (id) => sendFn('sceneDelete', { primId: id });
|
||||
p.Touched = makeSignal();
|
||||
p.TouchEnded = makeSignal();
|
||||
p.Position = new RbxVector3(primData.x || 0, primData.y || 0, primData.z || 0);
|
||||
p.Size = new RbxVector3(primData.sx || 1, primData.sy || 1, primData.sz || 1);
|
||||
p.Color = primData.color ? RbxColor3.fromHex(primData.color) : new RbxColor3(0.5, 0.5, 0.5);
|
||||
p.Anchored = !!primData.anchored;
|
||||
p.CanCollide = primData.canCollide !== false;
|
||||
p.Transparency = primData.opacity != null ? (1 - primData.opacity) : 0;
|
||||
p.Material = 'Plastic';
|
||||
p.BrickColor = { Color: p.Color, Name: 'Custom' };
|
||||
p.CFrame = new RbxCFrame(p.Position.X, p.Position.Y, p.Position.Z);
|
||||
|
||||
// Внутренний 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;
|
||||
}
|
||||
|
||||
@ -339,8 +424,13 @@ export function registerRobloxShim(lua, opts) {
|
||||
});
|
||||
|
||||
// === 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', {
|
||||
wait: (_) => undefined,
|
||||
spawn: (fn) => {
|
||||
try { if (typeof fn === 'function') fn(); } catch (e) {
|
||||
send('log', { level: 'error', text: `[task.spawn] ${e?.message || e}` });
|
||||
@ -365,7 +455,7 @@ export function registerRobloxShim(lua, opts) {
|
||||
synchronize: () => {},
|
||||
desynchronize: () => {},
|
||||
});
|
||||
global.set('wait', (_) => undefined);
|
||||
// task.wait и wait определяются через Lua coroutine.yield в prelude (см. ниже)
|
||||
|
||||
// === DataModel ===
|
||||
const game = newInstance('DataModel', 'game');
|
||||
@ -452,8 +542,59 @@ export function registerRobloxShim(lua, opts) {
|
||||
uis.InputChanged = makeSignal();
|
||||
uis.InputEnded = makeSignal();
|
||||
|
||||
// TweenService — реальная интерполяция через Heartbeat
|
||||
const tw = makeService('TweenService');
|
||||
tw.Create = function () { return { Play: () => {}, Pause: () => {}, Cancel: () => {} }; };
|
||||
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 '{}'; } };
|
||||
@ -503,21 +644,40 @@ export function registerRobloxShim(lua, opts) {
|
||||
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') {
|
||||
inst = newInstance(className, className);
|
||||
inst.Touched = makeSignal();
|
||||
inst.TouchEnded = makeSignal();
|
||||
inst.Position = new RbxVector3();
|
||||
inst.Size = new RbxVector3(4, 1, 2);
|
||||
inst.Color = new RbxColor3(0.5, 0.5, 0.5);
|
||||
inst.Anchored = false;
|
||||
inst.CanCollide = true;
|
||||
inst.Transparency = 0;
|
||||
inst.Material = 'Plastic';
|
||||
inst.CFrame = new RbxCFrame();
|
||||
// Реальный примитив на сцене: шлём 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();
|
||||
@ -555,6 +715,42 @@ export function registerRobloxShim(lua, opts) {
|
||||
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-код: определяем task.wait, wait, и обёртку для скрипта.
|
||||
// Этот код выполняется ВНУТРИ Lua VM.
|
||||
lua.doStringSync(`
|
||||
-- task.wait(sec) → coroutine.yield(sec); main-loop вернёт через delay sec
|
||||
local function rbx_wait(sec)
|
||||
sec = sec or 0
|
||||
coroutine.yield(sec)
|
||||
return sec
|
||||
end
|
||||
if type(task) == 'table' then
|
||||
task.wait = rbx_wait
|
||||
else
|
||||
task = { wait = rbx_wait }
|
||||
end
|
||||
wait = rbx_wait
|
||||
`);
|
||||
|
||||
// === Setter Part-свойств (Position/Size/Color/...) ===
|
||||
// Юзер пишет: part.Position = Vector3.new(0, 10, 0)
|
||||
// В Lua-side это просто rawset. Но мы хотим, чтобы JS-сторона узнала и применила.
|
||||
@ -578,7 +774,7 @@ export function registerRobloxShim(lua, opts) {
|
||||
partById.clear();
|
||||
for (const p of prims) {
|
||||
if (!p || p.id == null) continue;
|
||||
const part = newPart(p, send);
|
||||
const part = newPart(p, send); // setters внутри шлют через send
|
||||
part.Parent = workspace;
|
||||
workspace.Children.push(part);
|
||||
partById.set(Number(p.id), part);
|
||||
@ -593,8 +789,11 @@ export function registerRobloxShim(lua, opts) {
|
||||
onDataSnapshot() {},
|
||||
|
||||
tickScheduler(_dt) {
|
||||
// 0. Tweens
|
||||
_stepTweens(_dt);
|
||||
const now = SCHEDULER.now();
|
||||
if (SCHEDULER.sleeping.length === 0) return;
|
||||
// 1. task.delay / task.defer
|
||||
if (SCHEDULER.sleeping.length > 0) {
|
||||
const ready = [];
|
||||
const rest = [];
|
||||
for (const t of SCHEDULER.sleeping) {
|
||||
@ -604,6 +803,43 @@ export function registerRobloxShim(lua, opts) {
|
||||
for (const t of ready) {
|
||||
try { t.run(); } catch (_) {}
|
||||
}
|
||||
}
|
||||
// 2. Резюм coroutine'ов которые task.wait()
|
||||
// Скрипт-coroutine при первом запуске yield'ит с delay (от task.wait).
|
||||
// Мы регистрируем delay через __rbxl_schedule_resume? Нет — проще:
|
||||
// отслеживаем последний yield-результат через coroutine.resume → возвращает
|
||||
// delay. После init скрипта проверим всех coroutines: если status==suspended,
|
||||
// и им пора — резюмируем.
|
||||
for (const [coId, co] of coroutines) {
|
||||
let entry = waitingCoros.find(w => w.coId === coId);
|
||||
if (!entry) {
|
||||
// Первый раз видим этот co — нужно вытащить delay из yield-результата.
|
||||
// Это сделано в _onYield ниже.
|
||||
continue;
|
||||
}
|
||||
if (entry.wakeAt > now) continue;
|
||||
// Время резюмить
|
||||
waitingCoros.splice(waitingCoros.indexOf(entry), 1);
|
||||
try {
|
||||
const code = `
|
||||
local co = __rbxl_get_co(${JSON.stringify(coId)})
|
||||
if co and coroutine.status(co) == 'suspended' then
|
||||
local ok, ret = coroutine.resume(co)
|
||||
if not ok then
|
||||
__rbxl_send_error(${JSON.stringify(coId)}, tostring(ret))
|
||||
__rbxl_unregister_coroutine(${JSON.stringify(coId)})
|
||||
elseif type(ret) == 'number' then
|
||||
__rbxl_schedule_resume(${JSON.stringify(coId)}, ret)
|
||||
elseif coroutine.status(co) == 'dead' then
|
||||
__rbxl_unregister_coroutine(${JSON.stringify(coId)})
|
||||
end
|
||||
end
|
||||
`;
|
||||
lua.doStringSync(code);
|
||||
} catch (e) {
|
||||
send('log', { level: 'error', text: `[coroutine resume ${coId}] ${e?.message || e}` });
|
||||
}
|
||||
}
|
||||
},
|
||||
fireHeartbeat(dt) {
|
||||
try { HEARTBEAT_SIGNAL.Fire(dt); } catch (_) {}
|
||||
|
||||
@ -146,6 +146,38 @@ export function handleLuaCommand(_scriptId, cmd, payload, runtime) {
|
||||
} catch (e) {}
|
||||
return;
|
||||
}
|
||||
if (cmd === 'sceneCreate') {
|
||||
// Lua: Instance.new("Part") + part.Parent = workspace → создание примитива.
|
||||
// payload: { primId (предложенный id), type, x, y, z, sx, sy, sz, color, anchored }
|
||||
try {
|
||||
const pm = runtime.scene3d?.primitiveManager;
|
||||
if (!pm || typeof pm.addInstance !== 'function') return;
|
||||
const opts = {
|
||||
id: payload?.primId,
|
||||
x: payload?.x || 0, y: payload?.y || 0, z: payload?.z || 0,
|
||||
sx: payload?.sx || 1, sy: payload?.sy || 1, sz: payload?.sz || 1,
|
||||
color: payload?.color,
|
||||
anchored: payload?.anchored !== false,
|
||||
canCollide: payload?.canCollide !== false,
|
||||
};
|
||||
pm.addInstance(payload?.type || 'cube', opts);
|
||||
} catch (e) {
|
||||
console.error('[sceneCreate]', e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (cmd === 'sceneDelete') {
|
||||
// Lua: part:Destroy() → удаление примитива.
|
||||
try {
|
||||
const pm = runtime.scene3d?.primitiveManager;
|
||||
if (!pm || typeof pm.removeInstance !== 'function') return;
|
||||
const id = payload?.primId;
|
||||
if (id != null) pm.removeInstance(Number(id));
|
||||
} catch (e) {
|
||||
console.error('[sceneDelete]', e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (cmd === 'partVel') {
|
||||
try {
|
||||
const pm = runtime.scene3d?.primitiveManager;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user