diff --git a/src/editor/engine/lua/LuaSharedSandbox.js b/src/editor/engine/lua/LuaSharedSandbox.js index bbb0bf9..ead6242 100644 --- a/src/editor/engine/lua/LuaSharedSandbox.js +++ b/src/editor/engine/lua/LuaSharedSandbox.js @@ -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 `; diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index 4d6637c..7f68d7d 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -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) { diff --git a/src/editor/engine/rbxl-lua-integration.js b/src/editor/engine/rbxl-lua-integration.js index 7b6caa9..fe0c7f4 100644 --- a/src/editor/engine/rbxl-lua-integration.js +++ b/src/editor/engine/rbxl-lua-integration.js @@ -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;