All checks were successful
Сегодня доведены до играбельного состояния:
- UI модалка импорта подключена в KubikonStudio (кнопка для МИНа в навигации)
- Converter: SCALE 0.35 (карта пропорциональна R15-персонажу),
playerModelType='skin_bacon-hair', Lua упакован в поле code с маркером
// @roblox-lua (storys API сохраняет только {id,code,target,name})
- vite.config: api+статика через rublox.pro/minecraftia-school.ru
- GameRuntime: распознаёт маркер, запускает через RobloxLuaSandbox
+ wasmoon Worker. Фильтрация: target!=null + lua<2500б +
лимит 50 sandbox'ов (WASM OOM при >50 VM)
- roblox-shim: nullStub (Proxy с no-op методами) вместо null
для FindFirstChild/WaitForChild — цепочки не падают
- require() заменён на nullStub
- RobloxLuaSandbox: совместимость с интерфейсом ScriptSandbox
(sendGlobalEvent/SceneSnapshot/etc — no-op заглушки)
- RobloxLuaWorker: pcall обёртка над user-кодом
- remoteDevlog.js + /devlog endpoint: автосбор browser-логов
- PlayerController._loadSkinManifest: dev-fallback на studio.rublox.pro
Тест на Easy Obby:
- 8205 instances → 2245 primitives + 742 Lua-scripts
- 50/742 Lua-VM запущены (KillBrick handlers и т.п.),
151 отфильтровано как admin/chat services, 541 пропущено по памяти
- Скин bacon-hair виден, FPS 20-25
- Сцена играется, можно ходить, прыгать
TODO (следующая итерация):
- Single-VM mode для wasmoon (один Lua-state на 742 скрипта,
убрать WASM OOM)
- Реализовать select/focus в иерархии для импортированных карт
- Touched events от Babylon impostor → Lua-shim сигналы
- Поддержка GUI (ScreenGui/Frame/TextLabel) в конвертере
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
210 lines
9.1 KiB
JavaScript
210 lines
9.1 KiB
JavaScript
/**
|
||
* roblox-scheduler.js — шедулер корутин для Roblox-Lua wait/task.
|
||
*
|
||
* Архитектура:
|
||
* - Каждый верхне-уровневый Lua-код оборачивается в coroutine.
|
||
* - wait(sec) / task.wait(sec) делают coroutine.yield(sec)
|
||
* - Шедулер запоминает: { coro, resumeAt: tick + sec }
|
||
* - На каждом handleTick из main thread шедулер ресюмит готовые корутины
|
||
*
|
||
* RBXScriptSignal.Wait() = аналогично, но wait не на время, а на event'е:
|
||
* - { coro, waitingForSignal: signalName }
|
||
* - При Fire() сигнала шедулер ресюмит все ждущие
|
||
*
|
||
* Использование:
|
||
* const sched = new RobloxScheduler(luaEngine);
|
||
* sched.spawnMain(luaSource);
|
||
* // Каждый кадр:
|
||
* sched.tick(dtSec);
|
||
* // При событии:
|
||
* sched.fireSignal('Heartbeat', dt);
|
||
*/
|
||
|
||
export class RobloxScheduler {
|
||
constructor(lua) {
|
||
this.lua = lua;
|
||
this.time = 0;
|
||
this.tasks = []; // [{ coro, resumeAt, waitForSignal?, signalArgsBuf? }]
|
||
this.signalWaiters = new Map(); // name → [task]
|
||
this._coroBox = null;
|
||
}
|
||
|
||
/**
|
||
* Регистрирует глобалы wait/task.wait/task.spawn/task.delay в Lua-VM.
|
||
* Должно вызываться ПОСЛЕ registerRobloxApi (т.к. перебивает заглушки).
|
||
*/
|
||
install() {
|
||
const self = this;
|
||
// wait(sec) — yield в корутине на sec секунд
|
||
this.lua.global.set('wait', (sec) => {
|
||
// Этот wait вызовется из Lua. Если мы внутри корутины (а мы внутри
|
||
// т.к. spawnMain обернул всё) — yield. Иначе вернём дельту времени
|
||
// как обычное wait в Roblox.
|
||
const s = +sec || 0;
|
||
self._currentYield = { kind: 'sleep', sec: s };
|
||
// Возврат тут — это значение которое получит await в Lua;
|
||
// wasmoon обработает yield извне.
|
||
return s;
|
||
});
|
||
this.lua.global.set('task', {
|
||
wait: (sec) => {
|
||
self._currentYield = { kind: 'sleep', sec: +sec || 0 };
|
||
return +sec || 0;
|
||
},
|
||
spawn: (fn, ...args) => {
|
||
self.spawnCoroutine(fn, args);
|
||
},
|
||
delay: (sec, fn, ...args) => {
|
||
self.tasks.push({
|
||
resumeAt: self.time + (+sec || 0),
|
||
runFn: () => { try { fn(...args); } catch (e) {} },
|
||
});
|
||
},
|
||
defer: (fn, ...args) => {
|
||
self.tasks.push({
|
||
resumeAt: self.time,
|
||
runFn: () => { try { fn(...args); } catch (e) {} },
|
||
});
|
||
},
|
||
});
|
||
this.lua.global.set('spawn', (fn) => { self.spawnCoroutine(fn, []); });
|
||
this.lua.global.set('delay', (sec, fn) => {
|
||
self.tasks.push({
|
||
resumeAt: self.time + (+sec || 0),
|
||
runFn: () => { try { fn(); } catch (e) {} },
|
||
});
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Запустить верхне-уровневый Lua-код как корутину.
|
||
* Возвращает Promise который резолвится когда код достиг ready (либо ушёл в первый yield).
|
||
*/
|
||
async spawnMain(luaSource) {
|
||
// Оборачиваем источник в coroutine.wrap(function() ... end)
|
||
// и сразу зовём — это даёт нам ручку на корутине через специальный
|
||
// приём: храним её в global _userCoro.
|
||
const wrapped = `
|
||
_userCoro = coroutine.create(function()
|
||
${luaSource}
|
||
end)
|
||
local ok, yieldVal = coroutine.resume(_userCoro)
|
||
if not ok then
|
||
error("user script error: " .. tostring(yieldVal))
|
||
end
|
||
return yieldVal
|
||
`;
|
||
try {
|
||
await this.lua.doString(wrapped);
|
||
const coroStatus = await this.lua.doString('return coroutine.status(_userCoro)');
|
||
if (coroStatus === 'suspended') {
|
||
// Ушла в yield — добавляем в шедулер
|
||
const yieldInfo = this._currentYield || { kind: 'sleep', sec: 0 };
|
||
this._currentYield = null;
|
||
this.tasks.push({
|
||
resumeAt: this.time + (yieldInfo.kind === 'sleep' ? yieldInfo.sec : 0),
|
||
waitForSignal: yieldInfo.kind === 'signal' ? yieldInfo.name : null,
|
||
coro: '_userCoro',
|
||
});
|
||
}
|
||
} catch (e) {
|
||
console.warn('spawnMain error:', e);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Запустить произвольную функцию как корутину (для task.spawn).
|
||
*/
|
||
spawnCoroutine(fn, args) {
|
||
// Создаём корутину на JS-стороне: просто вызываем fn() сразу,
|
||
// а если внутри неё дёрнут wait — yield не сработает (JS не делает
|
||
// sync yield в обычной функции). Поэтому task.spawn для JS-функций
|
||
// равен прямому вызову.
|
||
// В будущем (4.7.1) можно через Lua coroutine реализовать.
|
||
try { fn(...(args || [])); } catch (e) { /* swallow */ }
|
||
}
|
||
|
||
/**
|
||
* Продвинуть время на dt и резюмить готовые корутины.
|
||
* Также автоматически fire'ит RunService.Heartbeat / Stepped / RenderStepped.
|
||
*/
|
||
async tick(dtSec) {
|
||
const dt = +dtSec || 0;
|
||
this.time += dt;
|
||
// Heartbeat / Stepped / RenderStepped для RunService
|
||
const game = this.lua.global.get('game');
|
||
if (game && typeof game.GetService === 'function') {
|
||
const rs = game.GetService('RunService');
|
||
if (rs) {
|
||
if (rs.Heartbeat && rs.Heartbeat.Fire) rs.Heartbeat.Fire(dt);
|
||
if (rs.Stepped && rs.Stepped.Fire) rs.Stepped.Fire(this.time, dt);
|
||
if (rs.RenderStepped && rs.RenderStepped.Fire) rs.RenderStepped.Fire(dt);
|
||
}
|
||
}
|
||
// Резюмим всё что готово
|
||
const ready = this.tasks.filter(t => !t.waitForSignal && t.resumeAt <= this.time);
|
||
this.tasks = this.tasks.filter(t => !(ready.includes(t)));
|
||
for (const t of ready) {
|
||
await this._resumeTask(t);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Fire signal — разбудить все task'и ждущие этого сигнала.
|
||
*/
|
||
async fireSignal(name, ...args) {
|
||
const waiters = this.signalWaiters.get(name) || [];
|
||
this.signalWaiters.set(name, []);
|
||
for (const t of waiters) {
|
||
// Resume корутины передавая args как возврат :Wait()
|
||
await this._resumeTask(t, args);
|
||
}
|
||
}
|
||
|
||
async _resumeTask(task, resumeArgs = []) {
|
||
if (task.runFn) {
|
||
try {
|
||
const ret = task.runFn();
|
||
if (ret && typeof ret.then === 'function') await ret;
|
||
} catch (e) {}
|
||
return;
|
||
}
|
||
if (task.coro) {
|
||
try {
|
||
// resumeArgs идут как аргументы в coroutine.resume
|
||
const argsCode = resumeArgs.map((a, i) => {
|
||
if (typeof a === 'number') return String(a);
|
||
if (typeof a === 'string') return JSON.stringify(a);
|
||
return 'nil';
|
||
}).join(', ');
|
||
const code = `
|
||
local ok, val = coroutine.resume(${task.coro}${argsCode ? ', ' + argsCode : ''})
|
||
if not ok then
|
||
error("coro error: " .. tostring(val))
|
||
end
|
||
return val
|
||
`;
|
||
await this.lua.doString(code);
|
||
const status = await this.lua.doString(`return coroutine.status(${task.coro})`);
|
||
if (status === 'suspended') {
|
||
// Опять ушла в yield
|
||
const yi = this._currentYield || { kind: 'sleep', sec: 0 };
|
||
this._currentYield = null;
|
||
if (yi.kind === 'sleep') {
|
||
this.tasks.push({
|
||
resumeAt: this.time + yi.sec,
|
||
coro: task.coro,
|
||
});
|
||
} else if (yi.kind === 'signal') {
|
||
const list = this.signalWaiters.get(yi.name) || [];
|
||
list.push({ coro: task.coro });
|
||
this.signalWaiters.set(yi.name, list);
|
||
}
|
||
}
|
||
} catch (e) {
|
||
// Корутина завершилась с ошибкой — просто дропаем
|
||
}
|
||
}
|
||
}
|
||
}
|