feat: 50 игр на Lua + импорт Roblox для всех + поддержка Lua в плеере #39

Merged
min merged 215 commits from feat/lua-50-games-bundle into main 2026-06-09 21:59:25 +00:00
3 changed files with 315 additions and 35 deletions
Showing only changes of commit 8ac2637615 - Show all commits

View File

@ -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
`;

View File

@ -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,16 +789,56 @@ export function registerRobloxShim(lua, opts) {
onDataSnapshot() {},
tickScheduler(_dt) {
// 0. Tweens
_stepTweens(_dt);
const now = SCHEDULER.now();
if (SCHEDULER.sleeping.length === 0) return;
const ready = [];
const rest = [];
for (const t of SCHEDULER.sleeping) {
if (t.wakeAt <= now) ready.push(t); else rest.push(t);
// 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 (_) {}
}
}
SCHEDULER.sleeping = rest;
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) {

View File

@ -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;