/** * 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) { // Корутина завершилась с ошибкой — просто дропаем } } } }