Compare commits

...

4 Commits

Author SHA1 Message Date
min
60f0ba009d chore(player): удалён мёртвый worker-based Lua стек после миграции на LuaSharedSandbox (Фаза 4)
All checks were successful
CI / Lint (pull_request) Successful in 55s
CI / Build (pull_request) Successful in 1m27s
CI / Secret scan (pull_request) Successful in 26s
CI / PR size check (pull_request) Successful in 10s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
2026-06-10 00:15:54 +03:00
min
bbc82af819 feat(player): синхронизация JS-API + BabylonScene._meshToTarget(npc) + GUI cmd-handlers (Фаза 3)
- ScriptSandboxWorker: добавлены отсутствующие методы game.self.* (rotate, rotateY,
  setVisible, setCollide, setColor, setLabel, clearLabel) — критично для GUI-карточек
  и интерактивных объектов сцены.
- Добавлены namespace'ы game.remote (RemoteEvent), game.tools (custom Tool.create),
  game.items.define, game.leaderstats (define/set/add/get/onChange/me-shortcut),
  game.achievements (define/unlock/has/bindToStat/setButtonVisible/openPage).
- inventory: добавлены inv2-методы (give/take/open/closeUi/toggle/sort/setActiveHotbar).
- giveTool теперь принимает Tool-объект из tools.create (поле customToolId).
- Роутинг globalEvent: добавлены leaderstatsChange, achievementUnlocked, toolEquipped,
  toolUnequipped, remoteEvent; toolUse теперь вызывает per-tool onActivated.
- tween() нормализует ref через _normRef — теперь принимает не только строку,
  но и объект из scene.spawn/find.
- BabylonScene._meshToTarget: добавлен случай md.npcId != null → kind='npc'.
- BabylonScene._handlePlayClick: в 3-м лице (без pointer-lock) клик теперь
  пикает по реальным координатам мыши, а не из центра экрана. Это чинит
  клики по GUI/3D-карточкам и интерактивным объектам в третьем лице.

Не тронуты старые worker-файлы (roblox-shim.js и т.п.) — снос будет позже.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-10 00:09:49 +03:00
min
7389dfc660 feat(player): GameRuntime запускает Lua через LuaSharedSandbox + cmd-handlers (Фаза 2) 2026-06-10 00:02:52 +03:00
min
3478ffafd1 feat: перенос Lua-стека из студии (Фаза 1: shim + sandbox + LabelManager + rbxl-integration + HudOverlay) 2026-06-09 23:58:04 +03:00
15 changed files with 4037 additions and 2062 deletions

View File

@ -2828,6 +2828,7 @@ export class BabylonScene {
if (md.isBlock) { if (md.isBlock) {
return { kind: 'block', ref: { x: md.gridX, y: md.gridY, z: md.gridZ } }; return { kind: 'block', ref: { x: md.gridX, y: md.gridY, z: md.gridZ } };
} }
if (md.npcId != null) return { kind: 'npc', id: md.npcId };
if (md.isModel) return { kind: 'model', id: md.instanceId }; if (md.isModel) return { kind: 'model', id: md.instanceId };
if (md.isPrimitive) return { kind: 'primitive', id: md.primitiveId }; if (md.isPrimitive) return { kind: 'primitive', id: md.primitiveId };
return null; return null;
@ -3069,7 +3070,29 @@ export class BabylonScene {
} }
} }
const pick = this._pickFromCenter(); // В pointer-lock (1-е лицо) курсор скрыт в центре — пикаем центром.
// В 3-м лице (свободный курсор) — пикаем по реальным координатам клика.
const locked = (document.pointerLockElement === this.canvas);
let pick;
if (!locked && Number.isFinite(clickX) && Number.isFinite(clickY)) {
const pi = this.scene.pick(clickX, clickY, (mesh) => {
if (!mesh.isPickable) return false;
if (mesh.name && mesh.name.startsWith('gridLine')) return false;
return true;
});
if (pi?.hit) {
let m = pi.pickedMesh;
if (m?.metadata?._isBlockProto && this.blockManager) {
const proxy = this.blockManager.findProxyByPickInfo?.(pi);
if (proxy) m = proxy;
}
pick = { mesh: m, point: pi.pickedPoint, pickInfo: pi };
} else {
pick = null;
}
} else {
pick = this._pickFromCenter();
}
const target = pick?.mesh ? this._meshToTarget(pick.mesh) : null; const target = pick?.mesh ? this._meshToTarget(pick.mesh) : null;
const point = pick?.point ? { x: pick.point.x, y: pick.point.y, z: pick.point.z } : null; const point = pick?.point ? { x: pick.point.x, y: pick.point.y, z: pick.point.z } : null;
// 1) Self-onClick — только если target есть // 1) Self-onClick — только если target есть

View File

@ -17,6 +17,9 @@
import { Color3 } from '@babylonjs/core'; import { Color3 } from '@babylonjs/core';
import { ScriptSandbox } from './ScriptSandbox'; import { ScriptSandbox } from './ScriptSandbox';
import { STORYS_addres } from '../api/API'; import { STORYS_addres } from '../api/API';
import { handleLuaCommand, unpackRobloxLuaCode, parseRobloxLuaMeta } from './rbxl-lua-integration.js';
import { LuaSharedSandbox } from './lua/LuaSharedSandbox.js';
import { RbxlHudOverlay } from './RbxlHudOverlay.js';
export class GameRuntime { export class GameRuntime {
constructor(scene3d) { constructor(scene3d) {
@ -86,12 +89,53 @@ export class GameRuntime {
// (на старте) возвращает null → подписки obj.onTouch/find не работают. // (на старте) возвращает null → подписки obj.onTouch/find не работают.
let initialScene = null; let initialScene = null;
try { initialScene = this._buildSceneSnapshot(); } catch (e) { initialScene = null; } try { initialScene = this._buildSceneSnapshot(); } catch (e) { initialScene = null; }
// Фаза 2 синхронизации со студией: и user-Lua (language='lua'), и
// импортированные .rbxl-скрипты (с маркером // @roblox-lua) теперь
// идут через ОДИН LuaSharedSandbox в main thread (wasmoon один раз).
// Снимает WASM OOM лимит и устраняет race с worker'ом.
const luaUserBatch = [];
const runImportedRbxl = !(typeof window !== 'undefined' && window.__RBXL_SKIP_IMPORTED === true);
let rbxlSkipped = 0;
for (const s of scripts) { for (const s of scripts) {
// Roblox-Lua скрипты импортированные через rbxl-importer: // Roblox-Lua скрипты импортированные через rbxl-importer.
// отдельный sandbox с wasmoon Lua-VM и Roblox-API shim. if (s && typeof s.code === 'string' && s.code.startsWith('// @roblox-lua')) {
// Запускаем по флагу kind, обходя стандартный ScriptSandbox. if (!runImportedRbxl) { rbxlSkipped++; continue; }
if (s && s.kind === 'roblox-lua' && typeof s.lua_source === 'string' && s.lua_source.trim()) { const meta = parseRobloxLuaMeta(s.code);
this._startRobloxLuaScript(s); if (meta && meta.enabled === false) { rbxlSkipped++; continue; }
const sname = String(s.name || '').toLowerCase();
if (sname.startsWith('regenerate') || sname === 'regenerationscript') {
rbxlSkipped++; continue;
}
const luaSource = unpackRobloxLuaCode(s.code);
if (luaSource && (
/while\s+not\s+\w+[:.]FindFirstChild/.test(luaSource) ||
/ChildAdded:[Ww]ait\(\)/.test(luaSource) ||
/:[Gg]etChildren\(\)\s*\[\d/.test(luaSource)
)) {
rbxlSkipped++;
// eslint-disable-next-line no-console
console.warn(`[GameRuntime] skipped ${s.name}: tight-loop (WaitForChild/ChildAdded:wait)`);
continue;
}
if (luaSource && luaSource.trim()) {
let toolName = null;
if (s.target == null && /(script\.Parent|Tool)\.(Equipped|Unequipped|Activated|Deactivated)/.test(luaSource)) {
toolName = 'Tool';
}
luaUserBatch.push({
id: s.id,
name: s.name,
target: s.target,
toolName,
language: 'lua',
code: luaSource,
_rbxlImported: true,
});
}
continue;
}
if (s && s.language === 'lua') {
if (typeof s.code === 'string' && s.code.trim()) luaUserBatch.push(s);
continue; continue;
} }
if (!s || typeof s.code !== 'string' || !s.code.trim()) { if (!s || typeof s.code !== 'string' || !s.code.trim()) {
@ -123,6 +167,132 @@ export class GameRuntime {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log('[GameRuntime] sandbox started for script id=', s.id); console.log('[GameRuntime] sandbox started for script id=', s.id);
} }
// === Фаза 2: единый LuaSharedSandbox для user-Lua + импортированных .rbxl ===
let luaUserCount = 0;
if (luaUserBatch.length > 0) {
try {
const sb = new LuaSharedSandbox();
sb.setOnCommand(({ cmd, payload }) => {
if (cmd === 'partSet' || cmd === 'partVel' ||
cmd === 'sceneCreate' || cmd === 'sceneDelete') {
try {
handleLuaCommand(null, cmd, payload, this);
} catch (e) {
// eslint-disable-next-line no-console
console.error('[GameRuntime] handleLuaCommand failed:', cmd, payload, e);
}
} else if (cmd === 'toolRegistered') {
try { this._registerRbxlTool?.(payload); } catch (e) {
// eslint-disable-next-line no-console
console.warn('[GameRuntime] toolRegistered failed', e);
}
} else if (cmd === 'lightingTimeUpdate') {
try {
const baseHour = Number(payload?.hour);
if (baseHour >= 0 && baseHour < 24) {
if (this._lightBaseHour == null) {
this._lightBaseHour = baseHour;
this._lightStartReal = performance.now();
}
const dGame = baseHour - this._lightBaseHour;
const accel = 8;
const hour = ((this._lightBaseHour + dGame * accel) % 24 + 24) % 24;
this.scene3d?.setTimeOfDay?.(hour);
let targetPreset;
if (hour >= 6 && hour < 8) targetPreset = 'sunset';
else if (hour >= 8 && hour < 17) targetPreset = 'lowpoly-roblox';
else if (hour >= 17 && hour < 19) targetPreset = 'sunset';
else targetPreset = 'starry-night';
if (this._lightPreset !== targetPreset) {
this._lightPreset = targetPreset;
try {
const sky = this.scene3d?.skybox;
if (sky?.fadeTo) sky.fadeTo({ preset: targetPreset }, 2);
else this.scene3d?.setSkybox?.({ preset: targetPreset });
} catch (_) {}
}
}
} catch (_) {}
} else if (cmd === 'particleCreated') {
this._rbxlPendingParticles = this._rbxlPendingParticles || [];
this._rbxlPendingParticles.push(payload);
} else if (cmd === 'mouseIconChanged') {
try {
const canvas = this.scene3d?.engine?.getRenderingCanvas?.();
if (canvas) canvas.style.cursor = payload.cssCursor || 'default';
} catch (_) {}
} else if (cmd === 'hudMessage') {
try {
this._ensureRbxlHud();
if (payload.visible && payload.text) {
this._rbxlHud.showMessage(payload.text);
} else {
this._rbxlHud.hideMessage();
}
} catch (_) {}
} else if (cmd === 'killFeed') {
try {
this._ensureRbxlHud();
this._rbxlHud.addKillFeed(payload.killer, payload.victim, payload.weapon);
} catch (_) {}
} else if (cmd === 'winShow') {
try {
this._ensureRbxlHud();
this._rbxlHud.showWin(payload.text || 'WIN!');
} catch (_) {}
} else if (cmd === 'ui.showText') {
try {
this._ensureRbxlHud();
this._rbxlHud.showMessage(payload.text || '');
const dur = Number(payload.duration) || 2;
const t = payload.text || '';
setTimeout(() => {
try {
if (this._rbxlHud._lastMessage === t) {
this._rbxlHud.hideMessage();
}
} catch (_) {}
}, dur * 1000);
try { this._rbxlHud._lastMessage = t; } catch (_) {}
} catch (_) {}
} else if (cmd === 'leaderstatSet') {
try {
const lm = this.scene3d?.leaderstats;
if (lm) {
const statName = String(payload.statName || 'Stat');
if (!lm._defs.some(d => d.name === statName)) {
lm.define(statName, { initial: 0 });
}
lm.set(lm._meId || 'me', statName, Number(payload.value) || 0);
}
} catch (_) {}
} else {
this._handleCommand(null, cmd, payload);
}
});
try {
const snap = this._buildSceneSnapshot();
sb.sendSceneSnapshot(snap);
} catch (_) {}
for (const s of luaUserBatch) {
sb.addScript(s.id, s.code, s.target, s.name, { toolName: s.toolName });
}
sb.start();
this.sandboxes.push(sb);
this._luaUserSandbox = sb;
luaUserCount = luaUserBatch.length;
} catch (e) {
// eslint-disable-next-line no-console
console.error('[GameRuntime] Lua user runtime failed to init', e);
this._log('error', `Lua-runtime ошибка: ${e?.message || e}`);
}
}
if (rbxlSkipped > 0) {
this._log('info', `Импортированных .rbxl-скриптов пропущено: ${rbxlSkipped}`);
}
if (luaUserCount > 0) {
this._log('info', `Запущено Lua-скриптов (включая .rbxl): ${luaUserCount}`);
}
this._log('info', `Запущено скриптов: ${this.sandboxes.length}`); this._log('info', `Запущено скриптов: ${this.sandboxes.length}`);
// Подцепляемся на изменения HP игрока, чтобы шлать событие 'hpChange' // Подцепляемся на изменения HP игрока, чтобы шлать событие 'hpChange'
// во все sandbox'ы. Не перезаписываем существующий обработчик — // во все sandbox'ы. Не перезаписываем существующий обработчик —
@ -181,136 +351,6 @@ export class GameRuntime {
} }
} }
/**
* Запускает Roblox-Lua скрипт через RobloxLuaSandbox + wasmoon.
* Используется для скриптов импортированных из .rbxl файлов.
*
* Архитектура параллельно ScriptSandbox'у, в собственном Worker'е:
* 1. Lua-VM (wasmoon) в Worker
* 2. Roblox API shim (game, workspace, Vector3, CFrame, ...)
* 3. Scheduler для wait/task.wait/корутин
* 4. Services: Players, Humanoid, DataStore, RemoteEvent, Tween, BodyMover
*
* Маппинг команд из Lua-sandbox в наш runtime:
* partSet {primId, prop, value} applyPartProp(scriptId, primId, prop, value)
* partVel {primId, vx,vy,vz} applyPartVelocity(primId, ...)
* playerCmd {method, args} game.player API (teleport, setWalkSpeed, ...)
*/
async _startRobloxLuaScript(script) {
try {
const { RobloxLuaSandbox } = await import('./RobloxLuaSandbox.js');
// Worker создаётся через Vite ?worker import. Делаем динамику:
const WorkerModule = await import('./RobloxLuaWorker.js?worker');
const worker = new WorkerModule.default();
// Снапшот сцены для Lua (зеркало primitives для workspace:GetChildren)
const initialScene = this._buildRobloxLuaSceneSnap?.() || this._buildSceneSnapshot?.() || { primitives: {} };
const sb = new RobloxLuaSandbox(script.lua_source, script.target || null);
sb.scriptId = script.id;
sb.setInitialScene(initialScene);
sb.setOnCommand((cmd, payload) => {
this._handleRobloxLuaCommand(script.id, cmd, payload);
});
sb.start(worker);
this.sandboxes.push(sb);
// eslint-disable-next-line no-console
console.log(`[GameRuntime] roblox-lua sandbox started: ${script.name || script.id}`);
} catch (e) {
// eslint-disable-next-line no-console
console.warn(`[GameRuntime] failed to start roblox-lua script ${script.id}:`, e);
}
}
/**
* Обработчик команд от RobloxLuaSandbox.
* Маппит на существующие методы рантайма (как _handleCommand для обычного sandbox'а).
*/
_handleRobloxLuaCommand(scriptId, cmd, payload) {
if (cmd === 'log') {
// eslint-disable-next-line no-console
const fn = payload?.level === 'error' ? console.error
: payload?.level === 'warn' ? console.warn : console.log;
fn(`[rbxl-lua ${scriptId}] ${payload?.text || ''}`);
return;
}
if (cmd === 'partSet') {
// partSet: {primId, prop, value}
// prop: position | cframe | size | color | material | anchored | canCollide | opacity | rotation
try {
const pm = this.scene3d?._primitiveManager || this.scene3d?.primitiveManager;
if (!pm) return;
const { primId, prop, value } = payload || {};
const patch = {};
if (prop === 'position' && value) Object.assign(patch, { x: value.x, y: value.y, z: value.z });
else if (prop === 'cframe' && value) Object.assign(patch, {
x: value.x, y: value.y, z: value.z,
rotationX: value.rx, rotationY: value.ry, rotationZ: value.rz,
});
else if (prop === 'size' && value) Object.assign(patch, { sx: value.sx, sy: value.sy, sz: value.sz });
else if (prop === 'color') patch.color = value;
else if (prop === 'material') patch.material = value;
else if (prop === 'anchored') patch.anchored = value;
else if (prop === 'canCollide') patch.canCollide = value;
else if (prop === 'opacity') patch.opacity = value;
else if (prop === 'rotation' && value) Object.assign(patch, {
rotationX: value.rx, rotationY: value.ry, rotationZ: value.rz,
});
if (typeof pm.applyPatch === 'function') {
pm.applyPatch(primId, patch);
} else if (typeof pm.update === 'function') {
pm.update(primId, patch);
}
} catch (e) { /* swallow */ }
return;
}
if (cmd === 'partVel') {
try {
const pm = this.scene3d?._primitiveManager || this.scene3d?.primitiveManager;
if (!pm) return;
const { primId, vx, vy, vz } = payload || {};
if (typeof pm.setVelocity === 'function') pm.setVelocity(primId, vx, vy, vz);
} catch (e) {}
return;
}
if (cmd === 'playerCmd') {
try {
const p = this.game?.player;
if (!p) return;
const { method, args } = payload || {};
if (method === 'teleport' && Array.isArray(args)) p.teleport?.(args[0], args[1], args[2]);
else if (method === 'setWalkSpeed') p.setWalkSpeed?.(args[0]);
else if (method === 'setJumpPower') p.setJumpPower?.(args[0]);
else if (method === 'setHealth') p.setHealth?.(args[0]);
else if (method === 'die') p.die?.();
else if (method === 'damage' || method === 'takeDamage') p.damage?.(args[0]);
} catch (e) {}
return;
}
// broadcast/spawn/tweenStart — могут быть обработаны общим broadcast
}
/**
* Собирает snap сцены в формате удобном для Lua (primitives по id).
* Lua-VM ожидает { primitives: { id: { x,y,z, sx,sy,sz, color, material, ... } } }.
*/
_buildRobloxLuaSceneSnap() {
const out = { primitives: {} };
const data = this.projectData?.scene;
if (!data) return out;
for (const p of (data.primitives || [])) {
out.primitives[p.id] = {
id: p.id, type: p.type, name: p.name,
x: p.x, y: p.y, z: p.z,
sx: p.sx, sy: p.sy, sz: p.sz,
color: p.color, material: p.material,
anchored: !!p.anchored, canCollide: p.canCollide !== false,
opacity: typeof p.opacity === 'number' ? p.opacity : 1,
};
}
return out;
}
/** /**
* Разослать карту высот гладкого ландшафта всем sandbox'ам. * Разослать карту высот гладкого ландшафта всем sandbox'ам.
* Нужно для game.scene.surfaceY(x,z). Снимается raycast'ом по * Нужно для game.scene.surfaceY(x,z). Снимается raycast'ом по
@ -604,6 +644,14 @@ export class GameRuntime {
return null; return null;
} }
/** DOM-overlay для импортированных Roblox-карт (KillFeed/Message/WinGui). */
_ensureRbxlHud() {
if (this._rbxlHud) return;
const canvas = this.scene3d?.engine?.getRenderingCanvas?.();
const parent = canvas?.parentElement || document.body;
this._rbxlHud = new RbxlHudOverlay(parent);
}
stop() { stop() {
if (this.sandboxes.length > 0) { if (this.sandboxes.length > 0) {
this._log('info', 'Остановка скриптов'); this._log('info', 'Остановка скриптов');
@ -611,6 +659,11 @@ export class GameRuntime {
console.log('[GameRuntime] stopping', this.sandboxes.length, 'sandboxes'); console.log('[GameRuntime] stopping', this.sandboxes.length, 'sandboxes');
for (const sb of this.sandboxes) sb.stop(); for (const sb of this.sandboxes) sb.stop();
} }
// Очищаем Roblox HUD overlay (KillFeed/Message/WinGui) — Фаза 2.
try { this._rbxlHud?.dispose?.(); } catch (_) {}
this._rbxlHud = null;
this._rbxlPendingParticles = null;
this._luaUserSandbox = null;
// Удаляем все объекты, которые скрипты наспавнили через // Удаляем все объекты, которые скрипты наспавнили через
// game.scene.spawn/clone — иначе после Stop они остаются на сцене // game.scene.spawn/clone — иначе после Stop они остаются на сцене
// и накапливаются при повторных запусках. // и накапливаются при повторных запусках.
@ -743,7 +796,55 @@ export class GameRuntime {
tick(dt) { tick(dt) {
if (!this._isRunning || this.sandboxes.length === 0) return; if (!this._isRunning || this.sandboxes.length === 0) return;
const state = this._collectState(); const state = this._collectState();
// Реальная позиция игрока для Lua __rbxl_player_pos()
const playerObj = this.scene3d?.player;
let realPos = null;
if (playerObj?._pos) {
const halfH = playerObj.HALF_H ?? 0.9;
realPos = { x: playerObj._pos.x, y: playerObj._pos.y - halfH, z: playerObj._pos.z };
} else if (state?.player) {
realPos = { x: state.player.x, y: state.player.y, z: state.player.z };
}
// Позиции спавненных динамических примитивов (id >= 800000)
let spawnedPositions = null;
try {
const pm = this.scene3d?.primitiveManager;
if (pm && pm.instances) {
for (const [id, data] of pm.instances.entries()) {
if (id < 800000 || data.anchored !== false) continue;
if (!spawnedPositions) spawnedPositions = [];
spawnedPositions.push([id, data.x, data.y, data.z]);
}
}
} catch (_) {}
// Позиции NPC для Lua-shim
const npcPositions = [];
try {
const nm = this.scene3d?.npcManager;
if (nm && nm.npcs && this._localToReal) {
for (const [localRef, realRef] of this._localToReal.entries()) {
if (typeof realRef !== 'string' || !realRef.startsWith('npc:')) continue;
const npcId = Number(realRef.slice(4));
const npc = nm.npcs.get(npcId);
if (npc) npcPositions.push([localRef, npc.x, npc.y, npc.z]);
}
}
} catch (_) {}
for (const sb of this.sandboxes) { for (const sb of this.sandboxes) {
// Синк Lua-shim позиций (LuaSharedSandbox имеет sb.api.update*)
if (realPos && sb.api?.updatePlayerPos) {
try { sb.api.updatePlayerPos(realPos.x, realPos.y, realPos.z); } catch (_) {}
}
if (spawnedPositions && sb.api?.updateSpawnedPos) {
for (const [id, x, y, z] of spawnedPositions) {
try { sb.api.updateSpawnedPos(id, x, y, z); } catch (_) {}
}
}
if (npcPositions.length > 0 && sb.api?.updateNpcPos) {
for (const [ref, x, y, z] of npcPositions) {
try { sb.api.updateNpcPos(ref, x, y, z); } catch (_) {}
}
}
// Для скриптов с target — добавляем актуальную позицию self // Для скриптов с target — добавляем актуальную позицию self
const stateForSb = sb.target const stateForSb = sb.target
? { ...state, selfPosition: this._collectSelfPosition(sb.target) } ? { ...state, selfPosition: this._collectSelfPosition(sb.target) }

View File

@ -1,80 +1,385 @@
/** /**
* LabelManager billboard-метки (текст-плашки) над 3D-объектами. * LabelManager billboard-плашки (текст-надписи) над 3D-объектами.
* *
* Используется для game.scene.setLabel(ref, text) имена/HP над * game.scene.setLabel(ref, text, opts) имена/HP/таймеры/счётчики над
* персонажами, врагами, предметами. Метка всегда повёрнута лицом к камере * персонажами, врагами, предметами. По умолчанию плашка повёрнута лицом к
* (billboardMode=7), всегда поверх геометрии (renderingGroupId=1). * камере (billboardMode=7), всегда поверх геометрии (renderingGroupId=1).
* *
* Метка привязывается к мешу объекта (parent) и висит над ним. * Задача 10 расширенные стили: фон/обводка/скругление (пресеты gameui/
* warning/reward/boss-hp/plain), обводка текста, richText (<color>/<b>/<size>),
* faceMode billboard|fixed, attachPoint, maxDistance.
*
* Плашка привязывается к мешу объекта (parent) и висит над ним.
*/ */
import { DynamicTexture } from '@babylonjs/core/Materials/Textures/dynamicTexture'; import { DynamicTexture } from '@babylonjs/core/Materials/Textures/dynamicTexture';
import { StandardMaterial } from '@babylonjs/core/Materials/standardMaterial'; import { StandardMaterial } from '@babylonjs/core/Materials/standardMaterial';
import { MeshBuilder } from '@babylonjs/core/Meshes/meshBuilder'; import { MeshBuilder } from '@babylonjs/core/Meshes/meshBuilder';
import { Color3 } from '@babylonjs/core/Maths/math.color'; import { Color3 } from '@babylonjs/core/Maths/math.color';
import { Mesh } from '@babylonjs/core/Meshes/mesh';
// === Пресеты стилей плашки (фон/обводка/текст) ===
// gameui — жёлтая обводка + тёмно-синий фон + белый текст (как в Roblox-UI).
export const LABEL_PRESETS = {
plain: {
background: null, borderColor: null, borderWidth: 0, cornerRadius: 0,
color: '#ffffff', textStroke: { color: '#000', width: 8 },
},
gameui: {
background: '#1a3a8a', borderColor: '#f5c020', borderWidth: 10, cornerRadius: 28,
color: '#ffffff', textStroke: { color: '#0a1430', width: 6 },
},
warning: {
background: '#b01e1e', borderColor: '#ffce3a', borderWidth: 10, cornerRadius: 28,
color: '#ffffff', textStroke: { color: '#000', width: 6 },
},
reward: {
background: '#caa018', borderColor: '#fff0a0', borderWidth: 10, cornerRadius: 28,
color: '#fff8e0', textStroke: { color: '#6b4e00', width: 6 },
gradient: ['#f7d34a', '#c98a18'], // золотой градиент фона
},
'boss-hp': {
background: '#3a0a0a', borderColor: '#ff4040', borderWidth: 8, cornerRadius: 20,
color: '#ffd0d0', textStroke: { color: '#000', width: 6 },
gradient: ['#8a1414', '#3a0a0a'],
},
};
export class LabelManager { export class LabelManager {
constructor(scene) { constructor(scene) {
this.scene = scene; this.scene = scene;
// ref-строка объекта → { plane, tex, mat } // ref-строка объекта → { plane, tex, mat, lastKey, opts }
this.labels = new Map(); this.labels = new Map();
this._playerMesh = null; // для maxDistance — задаётся из BabylonScene
} }
/** Дать ссылку на меш игрока (для maxDistance-скрытия). */
setPlayerMesh(mesh) { this._playerMesh = mesh; }
/** /**
* Установить/обновить метку над объектом. * Установить/обновить плашку над объектом.
* ref ref-строка объекта (от scene.spawn / scene.find). * ref ref-строка объекта.
* anchorMesh Babylon-меш объекта (метка крепится к нему). * anchorMesh Babylon-меш объекта (плашка крепится к нему).
* text текст метки. * text текст (может содержать richText-теги если opts.richText).
* opts { color: '#fff', height: 2.5 (м над объектом), size: 1 } * opts см. LABEL_PRESETS + { color, height, size, background,
* borderColor, borderWidth, cornerRadius, padding, textStroke,
* fontWeight, faceMode, rotationY, attachPoint, preset,
* richText, maxDistance }
*/ */
setLabel(ref, anchorMesh, text, opts = {}) { setLabel(ref, anchorMesh, text, opts = {}) {
if (!anchorMesh) return; if (!anchorMesh) return;
const color = opts.color || '#ffffff'; text = String(text == null ? '' : text);
// Пресет → база, поверх — явные opts.
const preset = opts.preset && LABEL_PRESETS[opts.preset] ? LABEL_PRESETS[opts.preset] : null;
const st = { ...(preset || {}), ...opts };
const color = st.color || '#ffffff';
const heightAbove = Number.isFinite(opts.height) ? opts.height : 2.5; const heightAbove = Number.isFinite(opts.height) ? opts.height : 2.5;
const sizeMul = Number.isFinite(opts.size) ? opts.size : 1; const sizeMul = Number.isFinite(opts.size) ? opts.size : 1;
const richText = !!opts.richText;
// Диф-чек: если ничего не поменялось — не пересоздаём (важно для bindLabel).
const styleKey = JSON.stringify({ text, color, preset: opts.preset, bg: st.background,
bc: st.borderColor, bw: st.borderWidth, cr: st.cornerRadius, rich: richText,
fw: st.fontWeight, h: heightAbove, sz: sizeMul, fm: st.faceMode, ry: st.rotationY,
af: st.attachFace, tl: st.tilt, ap: typeof st.attachPoint === 'string' ? st.attachPoint : null });
const existing = this.labels.get(ref);
if (existing && existing.lastKey === styleKey && existing.plane.parent === anchorMesh) {
return; // ничего не изменилось
}
// Меняется только текст (тот же стиль/размер) → перерисуем canvas без
// пересоздания меша (дешевле). Иначе — полное пересоздание.
const sameStruct = existing && existing.styleStruct === this._structKey(st, richText, heightAbove, sizeMul);
if (sameStruct) {
this._drawCanvas(existing.tex, text, color, st, richText);
existing.tex.update(true);
existing.lastKey = styleKey;
existing.lastText = text;
return;
}
// Если метка уже есть — пересоздаём (текст/цвет могли измениться).
this.clearLabel(ref); this.clearLabel(ref);
// Размер текстуры: чем больше текста — тем шире, чтобы не растягивать.
const fontPx = 120;
const W = 1024, H = 256; const W = 1024, H = 256;
const tex = new DynamicTexture(`lblTex_${ref}_${Date.now()}`, const tex = new DynamicTexture(`lblTex_${ref}_${this._uid()}`,
{ width: W, height: H }, this.scene, true); { width: W, height: H }, this.scene, true);
tex.updateSamplingMode?.(3); // TRILINEAR tex.updateSamplingMode?.(3); // TRILINEAR
tex.anisotropicFilteringLevel = 8; tex.anisotropicFilteringLevel = 8;
const ctx = tex.getContext();
ctx.clearRect(0, 0, W, H);
ctx.font = 'bold 120px "Roboto Condensed", "Segoe UI", sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.lineWidth = 16;
ctx.lineJoin = 'round';
ctx.strokeStyle = '#000';
ctx.strokeText(String(text), W / 2, H / 2);
ctx.fillStyle = color;
ctx.fillText(String(text), W / 2, H / 2);
tex.update(true);
tex.hasAlpha = true; tex.hasAlpha = true;
this._drawCanvas(tex, text, color, st, richText);
tex.update(true);
// Соотношение плашки — 4:1 (1024×256). Крупная и читаемая (как в Roblox).
// ОДНОСТОРОННЯЯ плоскость (FRONTSIDE): лицо = нормаль +Z. Мы всегда
// разворачиваем плашку лицом к наблюдателю снаружи грани, поэтому текст
// читается прямо. DOUBLESIDE НЕ используем — задняя грань зеркалит UV
// (баг: «Ключ от тайника» наоборот). backFaceCulling выключен только
// чтобы плашка не пропадала при взгляде сзади (без отражённого текста).
const plane = MeshBuilder.CreatePlane(`lbl_${ref}`, const plane = MeshBuilder.CreatePlane(`lbl_${ref}`,
{ width: 2.2 * sizeMul, height: 0.55 * sizeMul }, this.scene); { width: 3.4 * sizeMul, height: 0.85 * sizeMul,
sideOrientation: Mesh.FRONTSIDE }, this.scene);
const mat = new StandardMaterial(`lblMat_${ref}`, this.scene); const mat = new StandardMaterial(`lblMat_${ref}`, this.scene);
mat.diffuseTexture = tex; mat.diffuseTexture = tex;
mat.diffuseTexture.hasAlpha = true; mat.diffuseTexture.hasAlpha = true;
mat.emissiveColor = new Color3(1, 1, 1); mat.emissiveColor = new Color3(1, 1, 1);
mat.diffuseColor = new Color3(0, 0, 0);
mat.disableLighting = true; mat.disableLighting = true;
// Геометрия двусторонняя (DOUBLESIDE) с прямыми backUVs — culling можно
// включить, дублей нет; текст читается с обеих сторон без зеркала.
mat.backFaceCulling = false; mat.backFaceCulling = false;
mat.disableDepthWrite = true; mat.disableDepthWrite = true;
mat.useAlphaFromDiffuseTexture = true;
plane.material = mat; plane.material = mat;
plane.billboardMode = 7; // всегда лицом к камере plane.renderingGroupId = 1;
plane.renderingGroupId = 1; // поверх геометрии
plane.isPickable = false; plane.isPickable = false;
// Крепим к объекту: метка висит над ним и двигается вместе с ним.
plane.parent = anchorMesh; plane.parent = anchorMesh;
plane.position.set(0, heightAbove, 0);
this.labels.set(ref, { plane, tex, mat }); // Полуразмеры объекта по каждой оси (для крепления над верхом ИЛИ на
// грань). Берём bounding box в ЛОКАЛЬНЫХ координатах меша (min/max), чтобы
// позиция плашки-ребёнка была верной при любом масштабе/вращении родителя.
let halfX = 0.5, halfY = 0.5, halfZ = 0.5;
try {
const bb = anchorMesh.getBoundingInfo?.().boundingBox;
if (bb && bb.minimum && bb.maximum) {
halfX = (bb.maximum.x - bb.minimum.x) / 2;
halfY = (bb.maximum.y - bb.minimum.y) / 2;
halfZ = (bb.maximum.z - bb.minimum.z) / 2;
} else if (anchorMesh.scaling) {
halfX = Math.abs(anchorMesh.scaling.x) / 2;
halfY = Math.abs(anchorMesh.scaling.y) / 2;
halfZ = Math.abs(anchorMesh.scaling.z) / 2;
}
} catch (e) { /* ignore */ }
const halfH = halfY;
const halfPlane = 0.45 * sizeMul; // полувысота самой плашки (3.4×0.85)
// attachFace — ПРИКРЕПИТЬ плашку НА ГРАНЬ объекта (как табличка на
// стене/полу): плашка лежит В ПЛОСКОСТИ грани, фиксированной ориентации,
// и перемещается/вращается ВМЕСТЕ с объектом (она его ребёнок). Это
// Roblox-style «надпись = часть постройки» (в отличие от billboard над
// верхом). Значения: 'top'|'bottom'|'front'|'back'|'left'|'right'
// (или сырые '+y'/'-y'/'+z'/'-z'/'+x'/'-x').
const FACE = { top: '+y', bottom: '-y', front: '+z', back: '-z',
right: '+x', left: '-x' };
let face = st.attachFace;
if (face && FACE[face]) face = FACE[face];
if (face) {
// На грань — всегда фиксированная ориентация (не billboard), иначе
// «связки с примитивом» не будет (плашка крутилась бы к камере).
plane.billboardMode = 0;
const gap = Number.isFinite(opts.height) ? opts.height : 0.05;
// ВАЖНО: Babylon CreatePlane (FRONTSIDE) лицевой стороной (где UV/текст
// не зеркалятся) смотрит в Z. Поэтому чтобы ЛИЦО таблички смотрело
// НАРУЖУ выбранной грани, поворачиваем плоскость так, чтобы её Z
// совпал с внешней нормалью грани. tiltSign — знак наклона tilt с
// учётом того, что для грани +z плоскость развёрнута на π.
let tiltSign = 1;
if (face === '+z') { plane.position.set(0, 0, halfZ + gap); plane.rotation.set(0, Math.PI, 0); tiltSign = -1; }
else if (face === '-z') { plane.position.set(0, 0, -(halfZ + gap)); plane.rotation.set(0, 0, 0); }
else if (face === '+x') { plane.position.set( halfX + gap, 0, 0); plane.rotation.set(0, -Math.PI / 2, 0); }
else if (face === '-x') { plane.position.set(-(halfX + gap), 0, 0); plane.rotation.set(0, Math.PI / 2, 0); }
else if (face === '+y') { plane.position.set(0, halfY + gap, 0); plane.rotation.set( Math.PI / 2, 0, 0); }
else if (face === '-y') { plane.position.set(0, -(halfY + gap), 0); plane.rotation.set(-Math.PI / 2, 0, 0); }
if (Number.isFinite(st.rotationY)) plane.rotation.y += st.rotationY;
// tilt — наклон таблички вокруг локальной X (ценник под ~45°, как на
// витрине Roblox). Знак нормализуем (tiltSign), чтобы «верх назад» был
// одинаковым для всех граней. Отрицательный tilt = верх отклоняется
// назад (от наблюдателя), как пюпитр.
if (Number.isFinite(st.tilt)) plane.rotation.x += st.tilt * tiltSign;
} else {
// faceMode: 'fixed' — фиксированная ориентация (вращается с объектом),
// но позиционируется как обычная плашка (над верхом/центром/низом).
if (st.faceMode === 'fixed') {
plane.billboardMode = 0;
if (Number.isFinite(st.rotationY)) plane.rotation.y = st.rotationY;
} else {
plane.billboardMode = 7; // всегда лицом к камере
}
// attachPoint: 'top'(default) — над верхом + небольшой зазор (height);
// 'center' — по центру; 'bottom' — у низа; {x,y,z} — явно.
const gap = Number.isFinite(opts.height) ? opts.height : 0.6;
let py = halfH + gap + halfPlane; // верх объекта + зазор + полувысота плашки
if (st.attachPoint === 'center') py = 0;
else if (st.attachPoint === 'bottom') py = -(halfH + gap);
else if (st.attachPoint && typeof st.attachPoint === 'object') {
plane.position.set(st.attachPoint.x || 0, st.attachPoint.y || 0, st.attachPoint.z || 0);
py = null;
}
if (py !== null) plane.position.set(0, py, 0);
} }
/** Убрать метку с объекта. */ this.labels.set(ref, {
plane, tex, mat,
lastKey: styleKey,
lastText: text,
styleStruct: this._structKey(st, richText, heightAbove, sizeMul),
maxDistance: Number.isFinite(opts.maxDistance) ? opts.maxDistance : null,
});
}
/** Ключ «структуры» (всё кроме текста) — для решения перерисовать ли canvas. */
_structKey(st, richText, h, sz) {
return JSON.stringify({ p: st.preset, bg: st.background, bc: st.borderColor,
bw: st.borderWidth, cr: st.cornerRadius, rich: richText, fw: st.fontWeight,
grad: st.gradient, ts: st.textStroke, h, sz, fm: st.faceMode,
af: st.attachFace, tl: st.tilt, ap: typeof st.attachPoint === 'string' ? st.attachPoint : null });
}
_uid() { this._seq = (this._seq || 0) + 1; return this._seq; }
/**
* Нарисовать плашку на canvas DynamicTexture.
* Фон (roundRect + gradient/fill) обводка border текст (с обводкой).
*/
_drawCanvas(tex, text, color, st, richText) {
const W = 1024, H = 256;
const ctx = tex.getContext();
ctx.clearRect(0, 0, W, H);
const hasBg = !!st.background || (Array.isArray(st.gradient) && st.gradient.length === 2);
const pad = Number.isFinite(st.padding) ? st.padding : 28;
const cr = Number.isFinite(st.cornerRadius) ? st.cornerRadius : 0;
const bw = Number.isFinite(st.borderWidth) ? st.borderWidth : 0;
const weight = st.fontWeight || 700;
const innerPad = (hasBg ? (bw + pad) : 24); // отступ от краёв (под рамку)
const maxTextW = W - innerPad * 2;
// Auto-fit: подбираем размер шрифта, чтобы текст влез по ширине (не обрезался).
let fontPx = 120;
if (!richText) {
ctx.font = `${weight} ${fontPx}px "Roboto Condensed", "Segoe UI", sans-serif`;
const tw = ctx.measureText(text).width;
if (tw > maxTextW) fontPx = Math.max(40, Math.floor(fontPx * maxTextW / tw));
}
ctx.font = `${weight} ${fontPx}px "Roboto Condensed", "Segoe UI", sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
// === Фон-плашка ===
if (hasBg) {
const m = bw / 2 + 4; // отступ рамки от края текстуры
const x = m, y = m, w = W - m * 2, h = H - m * 2;
this._roundRectPath(ctx, x, y, w, h, cr);
if (Array.isArray(st.gradient) && st.gradient.length === 2) {
const g = ctx.createLinearGradient(0, y, 0, y + h);
g.addColorStop(0, st.gradient[0]);
g.addColorStop(1, st.gradient[1]);
ctx.fillStyle = g;
} else {
ctx.fillStyle = st.background;
}
ctx.globalAlpha = Number.isFinite(st.backgroundOpacity) ? st.backgroundOpacity : 0.92;
ctx.fill();
ctx.globalAlpha = 1;
if (bw > 0 && st.borderColor) {
ctx.lineWidth = bw;
ctx.strokeStyle = st.borderColor;
ctx.stroke();
}
}
// === Текст ===
const ts = st.textStroke || { color: '#000', width: hasBg ? 6 : 10 };
if (richText) {
this._drawRichText(ctx, text, color, ts, W, H);
} else {
if (ts && ts.width > 0) {
ctx.lineWidth = ts.width;
ctx.lineJoin = 'round';
ctx.strokeStyle = ts.color || '#000';
ctx.strokeText(text, W / 2, H / 2 + 4);
}
ctx.fillStyle = color;
ctx.fillText(text, W / 2, H / 2 + 4);
}
}
/** Путь скруглённого прямоугольника (roundRect не везде есть). */
_roundRectPath(ctx, x, y, w, h, r) {
r = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
/**
* RichText: парсим теги <color=#hex>...</color>, <b>...</b>, <size=N>...</size>.
* Рисуем сегментами по горизонтали, центрируя всю строку. Вложенность не
* поддерживается (на MVP) берём последний открытый тег каждого типа.
*/
_drawRichText(ctx, text, baseColor, ts, W, H) {
const segs = this._parseRich(text, baseColor);
const fontPx = 120;
// Замер ширины каждого сегмента в его размере.
let total = 0;
for (const s of segs) {
ctx.font = `${s.bold ? 800 : 700} ${Math.round(fontPx * s.sizeMul)}px "Roboto Condensed","Segoe UI",sans-serif`;
s.w = ctx.measureText(s.text).width;
total += s.w;
}
let x = (W - total) / 2;
for (const s of segs) {
ctx.font = `${s.bold ? 800 : 700} ${Math.round(fontPx * s.sizeMul)}px "Roboto Condensed","Segoe UI",sans-serif`;
ctx.textAlign = 'left';
if (ts && ts.width > 0) {
ctx.lineWidth = ts.width;
ctx.lineJoin = 'round';
ctx.strokeStyle = ts.color || '#000';
ctx.strokeText(s.text, x, H / 2 + 4);
}
ctx.fillStyle = s.color;
ctx.fillText(s.text, x, H / 2 + 4);
x += s.w;
}
ctx.textAlign = 'center';
}
/** Простой парсер richText → [{text, color, bold, sizeMul}]. */
_parseRich(text, baseColor) {
const segs = [];
let color = baseColor, bold = false, sizeMul = 1;
// Разбиваем по тегам (открывающим/закрывающим).
const re = /<(\/?)(?:(color)=([#0-9a-fA-F]+)|(b)|(i)|(size)=(\d+))>|([^<]+)/g;
let m;
while ((m = re.exec(text)) !== null) {
const closing = m[1] === '/';
if (m[8] != null) {
// текстовый кусок
if (m[8]) segs.push({ text: m[8], color, bold, sizeMul });
} else if (m[2]) { // <color=...>
color = closing ? baseColor : m[3];
} else if (m[4]) { // <b>
bold = !closing;
} else if (m[6]) { // <size=N>
sizeMul = closing ? 1 : Math.max(0.4, Math.min(2, Number(m[7]) / 100));
}
// <i> игнорим визуально (italic в canvas через font-style — опускаем на MVP)
}
if (segs.length === 0) segs.push({ text: '', color: baseColor, bold: false, sizeMul: 1 });
return segs;
}
/** Каждый кадр: maxDistance-скрытие плашек дальше порога от игрока. */
update() {
if (!this._playerMesh) return;
const pp = this._playerMesh.position;
for (const rec of this.labels.values()) {
if (rec.maxDistance == null) continue;
const ap = rec.plane.getAbsolutePosition();
const dx = ap.x - pp.x, dy = ap.y - pp.y, dz = ap.z - pp.z;
const far = (dx * dx + dy * dy + dz * dz) > rec.maxDistance * rec.maxDistance;
rec.plane.setEnabled(!far);
}
}
/** Убрать плашку с объекта. */
clearLabel(ref) { clearLabel(ref) {
const rec = this.labels.get(ref); const rec = this.labels.get(ref);
if (!rec) return; if (!rec) return;
@ -84,7 +389,7 @@ export class LabelManager {
this.labels.delete(ref); this.labels.delete(ref);
} }
/** Удалить все метки (при выходе из Play). */ /** Удалить все плашки (при выходе из Play). */
clearAll() { clearAll() {
for (const ref of [...this.labels.keys()]) this.clearLabel(ref); for (const ref of [...this.labels.keys()]) this.clearLabel(ref);
} }

View File

@ -0,0 +1,177 @@
/**
* RbxlHudOverlay DOM-оверлей с HUD-элементами для импортированных
* Roblox-карт: KillFeed (правый верх), Message/Hint (центр), WinGui.
*
* Один контейнер `.rbxl-hud-overlay` на canvas.parentElement, в нём дочерние
* блоки по типу. Стили inline, ничего не зависит от CSS приложения.
*
* API:
* const hud = new RbxlHudOverlay(canvasParent);
* hud.addKillFeed(killer, victim, weapon)
* hud.showMessage(text, opts)
* hud.hideMessage()
* hud.showWin(text)
* hud.dispose()
*/
export class RbxlHudOverlay {
constructor(parent) {
this._parent = parent || document.body;
this._root = null;
this._killFeed = null;
this._message = null;
this._winBox = null;
this._killEntries = []; // [{el, expireAt}]
this._mount();
}
_mount() {
if (this._root) return;
const root = document.createElement('div');
root.className = 'rbxl-hud-overlay';
Object.assign(root.style, {
position: 'absolute',
inset: '0',
pointerEvents: 'none',
zIndex: '999',
fontFamily: 'system-ui, -apple-system, "Segoe UI", sans-serif',
});
this._parent.appendChild(root);
this._root = root;
// KillFeed — правый верхний угол
const kf = document.createElement('div');
Object.assign(kf.style, {
position: 'absolute',
top: '60px',
right: '12px',
display: 'flex',
flexDirection: 'column',
gap: '6px',
maxWidth: '320px',
pointerEvents: 'none',
});
root.appendChild(kf);
this._killFeed = kf;
// Message — центр сверху (Roblox Message по центру экрана,
// но в верхней трети чтобы не мешать игре)
const msg = document.createElement('div');
Object.assign(msg.style, {
position: 'absolute',
top: '15%',
left: '50%',
transform: 'translateX(-50%)',
padding: '10px 24px',
background: 'rgba(0,0,0,0.6)',
color: '#fff',
fontSize: '22px',
fontWeight: '600',
borderRadius: '6px',
textShadow: '0 2px 4px rgba(0,0,0,0.8)',
display: 'none',
pointerEvents: 'none',
});
root.appendChild(msg);
this._message = msg;
// WinGui — большая надпись по центру
const win = document.createElement('div');
Object.assign(win.style, {
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
padding: '24px 48px',
background: 'rgba(0,0,0,0.75)',
color: '#ffd86b',
fontSize: '48px',
fontWeight: '800',
borderRadius: '12px',
textShadow: '0 4px 8px rgba(0,0,0,0.8)',
display: 'none',
pointerEvents: 'none',
});
root.appendChild(win);
this._winBox = win;
// Тик для авто-исчезновения KillFeed entries (через 5с)
this._tickInterval = setInterval(() => this._cleanupKills(), 500);
}
addKillFeed(killer, victim, weapon) {
if (!this._killFeed) return;
const entry = document.createElement('div');
Object.assign(entry.style, {
background: 'rgba(0,0,0,0.55)',
color: '#fff',
padding: '6px 10px',
borderRadius: '4px',
fontSize: '13px',
display: 'flex',
gap: '6px',
alignItems: 'center',
animation: 'rbxlHudFadeIn 0.3s',
});
const killerEl = document.createElement('span');
killerEl.textContent = String(killer || '?');
killerEl.style.color = '#5bd1e8';
const arrow = document.createElement('span');
arrow.textContent = weapon ? `→ [${weapon}] →` : '→';
arrow.style.color = '#ff9a52';
const victimEl = document.createElement('span');
victimEl.textContent = String(victim || '?');
victimEl.style.color = '#f87a7a';
entry.appendChild(killerEl);
entry.appendChild(arrow);
entry.appendChild(victimEl);
this._killFeed.appendChild(entry);
this._killEntries.push({ el: entry, expireAt: performance.now() + 5000 });
// Keep only last 8
while (this._killEntries.length > 8) {
const old = this._killEntries.shift();
try { old.el.remove(); } catch (_) {}
}
}
_cleanupKills() {
const now = performance.now();
const keep = [];
for (const e of this._killEntries) {
if (e.expireAt < now) { try { e.el.remove(); } catch (_) {} }
else keep.push(e);
}
this._killEntries = keep;
}
showMessage(text, opts = {}) {
if (!this._message) return;
this._message.textContent = String(text || '');
this._message.style.display = text ? 'block' : 'none';
if (opts.duration) {
clearTimeout(this._msgTimer);
this._msgTimer = setTimeout(() => this.hideMessage(), opts.duration);
}
}
hideMessage() {
if (this._message) this._message.style.display = 'none';
}
showWin(text) {
if (!this._winBox) return;
this._winBox.textContent = String(text || '');
this._winBox.style.display = 'block';
// Auto-hide через 6с
clearTimeout(this._winTimer);
this._winTimer = setTimeout(() => { if (this._winBox) this._winBox.style.display = 'none'; }, 6000);
}
dispose() {
try { this._root?.remove(); } catch (_) {}
clearInterval(this._tickInterval);
clearTimeout(this._msgTimer);
clearTimeout(this._winTimer);
this._root = null;
}
}

View File

@ -1,133 +0,0 @@
/**
* RobloxLuaSandbox main-side обёртка над одним RobloxLuaWorker.
*
* Использование (по аналогии с ScriptSandbox):
* const sb = new RobloxLuaSandbox(luaSource, targetPrimitiveId);
* sb.setOnCommand((cmd, payload) => ...);
* sb.setInitialScene({primitives: {...}});
* sb.start();
* sb.tick(dt, sceneSnap);
* sb.fireEvent('touched', {primId, otherPrimId});
* sb.stop();
*
* Команды от Worker:
* { cmd: 'boot' } Lua-VM запущена
* { cmd: 'ready' } top-level код выполнен
* { cmd: 'log', payload: { level, text } }
* { cmd: 'partSet', payload: { primId, prop, value } }
* { cmd: 'partVel', payload: { primId, vx, vy, vz } }
* { cmd: 'playerCmd', payload: { method, args } }
* { cmd: 'tweenStart', payload: { ... } }
* { cmd: 'broadcast', payload: { msg, data } }
* { cmd: 'spawn', payload: { template, props, parentId } }
*/
let _workerUrl = null;
function getWorkerUrl() {
if (_workerUrl) return _workerUrl;
// Vite worker syntax — лучше через ?worker импорт; но мы можем
// динамически генерировать URL для ScriptSandboxWorker-style.
// Здесь упрощённо: загружаем worker как module через Vite ?worker&inline.
// Это будет настроено при интеграции в GameRuntime.
return null;
}
export class RobloxLuaSandbox {
constructor(luaSource, targetPrimitiveId = null) {
this.luaSource = luaSource || '';
this.targetPrimitiveId = targetPrimitiveId;
this.worker = null;
this._onCommand = null;
this._booted = false;
this._ready = false;
this._stopped = false;
this._pendingTicks = [];
this._pendingEvents = [];
this._initialScene = null;
}
setOnCommand(cb) { this._onCommand = cb; }
setInitialScene(snap) { this._initialScene = snap; }
/**
* @param {Worker} worker экземпляр Worker'а (предоставляется снаружи,
* так как Vite требует new Worker(new URL(...)) syntax который надо
* прописать в месте импорта)
*/
start(worker) {
if (this.worker) return;
if (!worker) throw new Error('RobloxLuaSandbox.start(worker): worker is required');
this.worker = worker;
this.worker.onmessage = (e) => this._handle(e);
this.worker.onerror = (err) => {
this._emit('log', { level: 'error', text: `Worker error: ${err.message || err}` });
};
this.worker.postMessage({
cmd: 'init',
payload: {
code: this.luaSource,
target: this.targetPrimitiveId,
sceneSnap: this._initialScene || { primitives: {} },
},
});
}
/** Передать кадр (snap сцены + dt). */
tick(dt, sceneSnap) {
if (!this.worker) return;
if (!this._ready) {
this._pendingTicks.push({ dt, sceneSnap });
return;
}
try { this.worker.postMessage({ cmd: 'tick', payload: { dt, sceneSnap } }); } catch (e) {}
}
/** Передать событие. */
fireEvent(kind, args, signalId) {
if (!this.worker) return;
if (!this._ready) {
this._pendingEvents.push({ kind, args, signalId });
return;
}
try { this.worker.postMessage({ cmd: 'event', payload: { kind, args, signalId } }); } catch (e) {}
}
stop() {
this._stopped = true;
try { this.worker?.postMessage({ cmd: 'stop' }); } catch (e) {}
try { this.worker?.terminate(); } catch (e) {}
this.worker = null;
}
_handle(ev) {
if (this._stopped) return;
const { cmd, payload } = ev.data || {};
if (cmd === 'boot') {
this._booted = true;
return;
}
if (cmd === 'ready') {
this._ready = true;
// флушим накопленное
for (const t of this._pendingTicks) {
try { this.worker.postMessage({ cmd: 'tick', payload: t }); } catch (e) {}
}
this._pendingTicks = [];
for (const e of this._pendingEvents) {
try { this.worker.postMessage({ cmd: 'event', payload: e }); } catch (e) {}
}
this._pendingEvents = [];
this._emit('ready', null);
return;
}
this._emit(cmd, payload);
}
_emit(cmd, payload) {
if (this._onCommand) {
try { this._onCommand(cmd, payload); } catch (e) {}
}
}
}

View File

@ -1,169 +0,0 @@
/* eslint-disable no-restricted-globals */
/**
* RobloxLuaWorker.js Web Worker, хостящий Lua 5.4 VM (wasmoon) для исполнения
* Roblox-Lua скриптов импортированных через rbxl-importer.
*
* Запускается из RobloxLuaSandbox.js (main thread).
*
* IPC (с main):
* <- init { code: string, target?: id, shim: 'full'|'mini', sceneSnap: object }
* <- tick { dt, sceneSnap } каждый кадр
* <- event { kind: 'touched'|'changed'|..., args } события сцены
* -> boot нет payload Worker запустился, Lua-VM ready
* -> ready нет payload top-level lua код исполнен
* -> log { level, text }
* -> partSet { primId, prop, value } изменение свойства Part'а
* -> partVel { primId, vx, vy, vz }
* -> playerCmd { method, args } методы game.player (teleport, damage, walkSpeed)
* -> tweenStart{ targetId, prop, from, to, durationSec, easing }
* -> broadcast { msg, data } RemoteEvent аналог
* -> spawn { template, props, parentId } Instance.new()
*
* Lua-runtime архитектура:
* - wasmoon = Lua 5.4 в WebAssembly, ~500KB, ~5x быстрее fengari.
* - Lua-VM глобалы: game, workspace, script, task, wait, print, warn, error.
* - Все Roblox-классы JS-объекты-прокси (см. roblox-shim.js, регистрируемые
* через factory.setProxy).
*
* Безопасность:
* - Worker изолирован от DOM.
* - Memory limit ~50 MB на VM (через wasmoon options).
* - На каждые N=10000 инструкций Lua hook возможность отменить (TODO).
*
* Воркер ДЕРЖИТ И ОБНОВЛЯЕТ snapshot сцены (зеркало того что в Babylon-сцене),
* чтобы Lua-код мог читать Position/Color без round-trip к main thread.
* Обновление от main: cmd='tick' с дельтой сцены.
*
* Это первый MVP-вариант. Полный shim API регистрируется в фазе 4.3-4.13.
*/
import { LuaFactory } from 'wasmoon';
import { registerRobloxApi } from './roblox-shim.js';
/**
* Worker-side state. Один Worker = один скрипт.
*/
const state = {
factory: null,
lua: null,
target: null, // id примитива к которому привязан script.Parent
sceneSnap: { primitives: {} },// зеркало
isStopped: false,
pendingEvents: [], // события до init
signals: new Map(), // signalId → [callbacks]
nextSignalId: 1,
};
/* ──────── IPC helpers ──────── */
function send(cmd, payload) {
self.postMessage({ cmd, payload });
}
function log(level, text) {
send('log', { level, text });
}
/* ──────── Worker entrypoint ──────── */
self.addEventListener('message', async (ev) => {
const { cmd, payload } = ev.data || {};
try {
if (cmd === 'init') {
await handleInit(payload);
} else if (cmd === 'tick') {
handleTick(payload);
} else if (cmd === 'event') {
handleEvent(payload);
} else if (cmd === 'stop') {
state.isStopped = true;
try { state.lua?.global?.close?.(); } catch (e) {}
}
} catch (err) {
log('error', `Worker error in ${cmd}: ${err && err.stack ? err.stack : err}`);
}
});
async function handleInit({ code, target, sceneSnap }) {
state.target = target;
state.sceneSnap = sceneSnap || { primitives: {} };
state.factory = new LuaFactory();
state.lua = await state.factory.createEngine({
injectObjects: true,
enableProxy: true,
traceAllocations: false,
});
// Регистрируем Roblox API.
registerRobloxApi(state.lua, {
getSceneSnap: () => state.sceneSnap,
targetPrimitiveId: state.target,
send,
registerSignal: (callback) => {
const id = state.nextSignalId++;
const list = state.signals.get(id) || [];
list.push(callback);
state.signals.set(id, list);
return id;
},
});
send('boot', null);
try {
// Запускаем Lua-код в защищённом блоке.
await state.lua.doString(code);
send('ready', null);
} catch (e) {
log('error', `Lua error: ${e && e.message ? e.message : e}`);
send('ready', null);
}
// После ready доставляем events которые накопились
for (const ev of state.pendingEvents) handleEvent(ev);
state.pendingEvents = [];
}
function handleTick({ dt, sceneSnap }) {
if (state.isStopped || !state.lua) return;
if (sceneSnap) state.sceneSnap = sceneSnap;
// Heartbeat — для всех подписанных
fireSignalByName('Heartbeat', [dt]);
// Stepped (старая API) — тоже даём
fireSignalByName('Stepped', [dt]);
// RenderStepped — отдельно (на клиенте между physics и render)
fireSignalByName('RenderStepped', [dt]);
}
function handleEvent({ kind, args, signalId }) {
if (!state.lua) {
state.pendingEvents.push({ kind, args, signalId });
return;
}
if (signalId != null) {
const list = state.signals.get(signalId) || [];
for (const cb of list) {
try { cb(...(args || [])); } catch (e) { log('error', `signal callback error: ${e}`); }
}
} else {
fireSignalByName(kind, args || []);
}
}
function fireSignalByName(name, args) {
// namedSignals регистрируются в roblox-shim как сильные строки
// (например 'Heartbeat'). Все callback'и под этим именем в signals.
// Без отдельной мапы — ищем линейно.
for (const [id, list] of state.signals.entries()) {
if (list.__name === name) {
for (const cb of list) {
try { cb(...args); } catch (e) { log('error', `${name} cb error: ${e}`); }
}
}
}
}
/* ──────── Helper export для тестов ──────── */
self.__rbxlState = state;

View File

@ -117,6 +117,13 @@ let _unlockedSkins = [];
let _currentSkin = null; let _currentSkin = null;
let _skinChangeHandlers = []; let _skinChangeHandlers = [];
let _skinCoins = 0; // локальная валюта магазина скинов (рублики проекта) let _skinCoins = 0; // локальная валюта магазина скинов (рублики проекта)
// Phase 6.4 / задача 20: custom tools, leaderstats, achievements, remote events
let _toolSeq = 0;
let _toolCallbacks = {}; // toolId → { activated, equipped, unequipped }
let _lsMirror = {}; // playerId('@me'|sid) → { statName: value }
let _lsChangeHandlers = [];
let _achUnlocked = {}; // id → true
let _remoteHandlers = {}; // remoteName → [fn]
// Подписки game.gui.onClick(id, fn) // Подписки game.gui.onClick(id, fn)
let _guiClickHandlers = {}; let _guiClickHandlers = {};
// Подписки game.gui.onSubmit(id, fn) — ввод в TextBox завершён (Enter) // Подписки game.gui.onSubmit(id, fn) — ввод в TextBox завершён (Enter)
@ -669,6 +676,50 @@ function _buildSelfApi() {
_send('self.move', { target: _target, x: nx, y: ny, z: nz }); _send('self.move', { target: _target, x: nx, y: ny, z: nz });
} }
}, },
/**
* Повернуть объект-носитель вокруг вертикальной оси Y на угол ry (радианы).
*/
rotate(ry) {
const r = Number(ry);
if (!Number.isFinite(r)) return;
const k = _target.kind;
const id = _target.id ?? _target.ref;
_send('scene.rotate', { kind: k, id, ref: (k && id != null) ? (k + ':' + id) : undefined, rotationY: r });
},
rotateY(ry) { this.rotate(ry); },
/** Показать/скрыть объект-носитель. */
setVisible(vis) {
const k = _target.kind;
const id = _target.id ?? _target.ref;
_send('scene.setVisible', { kind: k, id, ref: (k && id != null) ? (k + ':' + id) : undefined, visible: !!vis });
},
/** Включить/выключить столкновения объекта-носителя (проходимость). */
setCollide(can) {
const k = _target.kind;
const id = _target.id ?? _target.ref;
_send('scene.setCollide', { kind: k, id, ref: (k && id != null) ? (k + ':' + id) : undefined, canCollide: !!can });
},
/** Перекрасить объект-носитель (только примитив). */
setColor(hex) {
if (typeof hex !== 'string') return;
const k = _target.kind;
const id = _target.id ?? _target.ref;
_send('scene.setColor', { kind: k, id, ref: (k && id != null) ? (k + ':' + id) : undefined, color: hex });
},
/** Повесить текст-метку над объектом-носителем (имя/HP). */
setLabel(text, opts) {
const k = _target.kind;
const id = _target.id ?? _target.ref;
const ref = (k && id != null) ? (k + ':' + id) : undefined;
_send('scene.setLabel', { ref, text: String(text == null ? '' : text), opts: opts || {} });
},
/** Убрать метку с объекта-носителя. */
clearLabel() {
const k = _target.kind;
const id = _target.id ?? _target.ref;
const ref = (k && id != null) ? (k + ':' + id) : undefined;
_send('scene.clearLabel', { ref });
},
delete() { delete() {
_send('self.delete', { target: _target }); _send('self.delete', { target: _target });
}, },
@ -1101,6 +1152,18 @@ const game = {
* game.player.giveTool('blaster-blaster-a', { equip: true }); * game.player.giveTool('blaster-blaster-a', { equip: true });
*/ */
giveTool(toolType, opts) { giveTool(toolType, opts) {
// Phase 6.4: принимаем и Tool-объект (из game.tools.create), и строку.
if (toolType && typeof toolType === 'object' && toolType.id) {
_send('inventory.give', {
kind: toolType.kind || 'tool',
modelTypeId: toolType.modelTypeId || null,
name: toolType.name,
customToolId: toolType.id,
params: {},
equip: opts?.equip === true,
});
return;
}
if (typeof toolType !== 'string' || !toolType) return; if (typeof toolType !== 'string' || !toolType) return;
opts = opts || {}; opts = opts || {};
const isBlaster = toolType.indexOf('blaster') === 0; const isBlaster = toolType.indexOf('blaster') === 0;
@ -1215,7 +1278,8 @@ const game = {
* game.tween(coin, { sy: 1.4 }, { duration: 0.5, yoyo: true, repeat: -1 }); * game.tween(coin, { sy: 1.4 }, { duration: 0.5, yoyo: true, repeat: -1 });
*/ */
tween(ref, props, opts) { tween(ref, props, opts) {
if (typeof ref !== 'string' || !props || typeof props !== 'object') return null; ref = _normRef(ref);
if (!ref || !props || typeof props !== 'object') return null;
opts = opts || {}; opts = opts || {};
const id = ++_tweenSeq; const id = ++_tweenSeq;
if (typeof opts.onDone === 'function') _tweenCallbacks[id] = opts.onDone; if (typeof opts.onDone === 'function') _tweenCallbacks[id] = opts.onDone;
@ -1326,6 +1390,32 @@ const game = {
if (!sessionId) return; if (!sessionId) return;
_send('mp.sendTo', { sessionId, name, data }); _send('mp.sendTo', { sessionId, name, data });
}, },
/**
* Phase 6.6: RemoteEvent именованные сетевые события (как в Roblox).
* const ev = game.remote.create('PlayerShoot');
* ev.fireAllClients({ x: 10, y: 5 });
* ev.on(({ from, data }) => { ... });
*/
remote: {
create(name) {
const evName = String(name || '');
return {
get name() { return evName; },
fireAllClients(data) { _send('mp.remoteFire', { name: evName, target: 'all', data }); },
fireOthers(data) { _send('mp.remoteFire', { name: evName, target: 'others', data }); },
fireClient(player, data) {
const sid = typeof player === 'string' ? player : (player && player.sessionId);
if (!sid) return;
_send('mp.remoteFire', { name: evName, target: sid, data });
},
on(fn) {
if (typeof fn !== 'function') return;
(_remoteHandlers[evName] = _remoteHandlers[evName] || []).push(fn);
},
};
},
},
/** /**
* Подписаться на изменение HP игрока (получение урона / лечение / смерть). * Подписаться на изменение HP игрока (получение урона / лечение / смерть).
* fn(event) где event = { hp, maxHp, source, damaged, delta }. * fn(event) где event = { hp, maxHp, source, damaged, delta }.
@ -2741,7 +2831,96 @@ const game = {
clear() { clear() {
_send('inventory.clear', {}); _send('inventory.clear', {});
}, },
// === Задача 44: drag-drop инвентарь (сетка 8×5 + hotbar 9 + стаки) ===
give(itemId, count) { _send('inv2.add', { itemId, count: Number(count) || 1 }); },
take(itemId, count) { _send('inv2.remove', { itemId, count: Number(count) || 1 }); },
open() { _send('inv2.open', {}); },
closeUi() { _send('inv2.close', {}); },
toggle() { _send('inv2.toggle', {}); },
sort(by) { _send('inv2.sort', { by: by || 'rarity' }); },
setActiveHotbar(i) { _send('inv2.setActive', { i: Number(i) || 0 }); },
}, },
// === Phase 6.4: пользовательские tools (как Roblox Tool) ===
tools: {
create(name, opts) {
opts = opts || {};
_toolSeq++;
const toolId = 'custom:' + _toolSeq;
_toolCallbacks[toolId] = {};
const tool = {
get id() { return toolId; },
get name() { return String(name || ('Tool ' + _toolSeq)); },
get modelTypeId() { return opts.model || null; },
get kind() { return opts.kind || 'tool'; },
onActivated(fn) { if (typeof fn === 'function') _toolCallbacks[toolId].activated = fn; },
onEquipped(fn) { if (typeof fn === 'function') _toolCallbacks[toolId].equipped = fn; },
onUnequipped(fn) { if (typeof fn === 'function') _toolCallbacks[toolId].unequipped = fn; },
dropAt(pos) {
if (!pos || typeof pos !== 'object') return;
_send('tools.drop', {
toolId, name: String(name), model: opts.model || null,
params: opts.params || {},
x: Number(pos.x) || 0, y: Number(pos.y) || 0, z: Number(pos.z) || 0,
});
},
};
return tool;
},
},
// === Определения предметов (задача 44) ===
items: {
define(def) {
if (Array.isArray(def)) { for (const d of def) _send('items.define', { def: d }); return; }
_send('items.define', { def: def || {} });
},
},
// === Лидерборды (leaderstats) — задача 20 ===
leaderstats: {
define(name, opts) {
if (typeof name !== 'string' || !name) return;
_send('leaderstats.define', { name, opts: opts || {} });
},
set(playerId, name, value) {
_send('leaderstats.set', { playerId: playerId == null ? null : String(playerId), name, value: Number(value) || 0 });
const pid = playerId == null ? '@me' : String(playerId);
if (!_lsMirror[pid]) _lsMirror[pid] = {};
_lsMirror[pid][name] = Number(value) || 0;
},
add(playerId, name, delta) {
_send('leaderstats.add', { playerId: playerId == null ? null : String(playerId), name, delta: Number(delta) || 0 });
const pid = playerId == null ? '@me' : String(playerId);
if (!_lsMirror[pid]) _lsMirror[pid] = {};
_lsMirror[pid][name] = (_lsMirror[pid][name] || 0) + (Number(delta) || 0);
},
get(playerId, name) {
const pid = playerId == null ? '@me' : String(playerId);
return (_lsMirror[pid] && _lsMirror[pid][name]) || 0;
},
onChange(fn) { if (typeof fn === 'function') _lsChangeHandlers.push(fn); },
me: {
set(name, value) { game.leaderstats.set(null, name, value); },
add(name, delta) { game.leaderstats.add(null, name, delta); },
get(name) { return game.leaderstats.get(null, name); },
},
},
// === Достижения — задача 20 ===
achievements: {
define(list) { _send('achievements.define', { list: Array.isArray(list) ? list : [list] }); },
unlock(id, playerId) {
if (typeof id !== 'string') return;
_achUnlocked[id] = true;
_send('achievements.unlock', { id, playerId: playerId == null ? null : String(playerId) });
},
has(id) { return !!_achUnlocked[id]; },
bindToStat(id, statName, cond) { _send('achievements.bindToStat', { id, statName, cond: cond || {} }); },
setButtonVisible(v) { _send('achievements.setButtonVisible', { visible: !!v }); },
openPage() { _send('achievements.openPage', {}); },
},
/** /**
* Игроки комнаты (Фаза 4.3 мультиплеер). * Игроки комнаты (Фаза 4.3 мультиплеер).
* В одиночной игре (редактор) только локальный игрок. * В одиночной игре (редактор) только локальный игрок.
@ -3791,6 +3970,18 @@ self.onmessage = (e) => {
const t = payload?.type; const t = payload?.type;
if (t === 'click') { if (t === 'click') {
for (const fn of _globalClickHandlers) _safeCall(fn, payload, 'onClick'); for (const fn of _globalClickHandlers) _safeCall(fn, payload, 'onClick');
} else if (t === 'leaderstatsChange') {
// Задача 20: стат изменился на сервере/main — обновляем зеркало + onChange.
const pid = payload.playerId == null ? '@me' : String(payload.playerId);
if (!_lsMirror[pid]) _lsMirror[pid] = {};
_lsMirror[pid][payload.name] = payload.newValue;
if (payload.isMe) { if (!_lsMirror['@me']) _lsMirror['@me'] = {}; _lsMirror['@me'][payload.name] = payload.newValue; }
for (const fn of _lsChangeHandlers) {
try { fn(payload.playerId, payload.name, payload.newValue, payload.oldValue); }
catch (err) { _send('log', { level: 'error', text: 'leaderstats.onChange: ' + (err && err.message ? err.message : err) }); }
}
} else if (t === 'achievementUnlocked') {
_achUnlocked[payload.id] = true;
} else if (t === 'mouseMove') { } else if (t === 'mouseMove') {
for (const fn of _mouseMoveHandlers) { for (const fn of _mouseMoveHandlers) {
try { fn(payload.x, payload.y); } try { fn(payload.x, payload.y); }
@ -3845,13 +4036,34 @@ self.onmessage = (e) => {
for (const fn of arr) _safeCall(fn, ev, 'npc.onDeath'); for (const fn of arr) _safeCall(fn, ev, 'npc.onDeath');
} }
} else if (t === 'toolUse') { } else if (t === 'toolUse') {
// payload: { tool: {kind, modelTypeId, name}, point, target } // payload: { tool: {kind, modelTypeId, name, customToolId?}, point, target }
const ev = { const ev = {
tool: payload.tool || null, tool: payload.tool || null,
point: payload.point || null, point: payload.point || null,
target: payload.target || null, target: payload.target || null,
}; };
// Phase 6.4: per-tool callback из game.tools.create -> onActivated.
const customId = payload.tool && payload.tool.customToolId;
if (customId && _toolCallbacks[customId] && _toolCallbacks[customId].activated) {
_safeCall(_toolCallbacks[customId].activated, ev, 'tool.onActivated:' + customId);
}
for (const fn of _toolUseHandlers) _safeCall(fn, ev, 'onToolUse'); for (const fn of _toolUseHandlers) _safeCall(fn, ev, 'onToolUse');
} else if (t === 'toolEquipped') {
const customId = payload && payload.tool && payload.tool.customToolId;
if (customId && _toolCallbacks[customId] && _toolCallbacks[customId].equipped) {
_safeCall(_toolCallbacks[customId].equipped, payload, 'tool.onEquipped:' + customId);
}
} else if (t === 'toolUnequipped') {
const customId = payload && payload.tool && payload.tool.customToolId;
if (customId && _toolCallbacks[customId] && _toolCallbacks[customId].unequipped) {
_safeCall(_toolCallbacks[customId].unequipped, payload, 'tool.onUnequipped:' + customId);
}
} else if (t === 'remoteEvent') {
// Phase 6.6: RemoteEvent от сервера. payload: { from, name, data }
const arr = _remoteHandlers[payload.name] || [];
for (const fn of arr) {
_safeCall(fn, { from: payload.from, data: payload.data }, 'remote.on:' + payload.name);
}
} else if (t === 'cutsceneDone') { } else if (t === 'cutsceneDone') {
// Катсцена камеры завершилась (Фаза 5.7). // Катсцена камеры завершилась (Фаза 5.7).
for (const fn of _cutsceneDoneHandlers) _safeCall(fn, undefined, 'onCutsceneDone'); for (const fn of _cutsceneDoneHandlers) _safeCall(fn, undefined, 'onCutsceneDone');

View File

@ -0,0 +1,337 @@
/**
* LuaSharedSandbox (v3, main-thread) wasmoon-VM работает в MAIN потоке,
* без Web Worker. Это позволяет:
* - Видеть точные Lua-ошибки в DevTools (через console.error)
* - Использовать debugger / breakpoints прямо в RobloxShim.js
* - Не возиться с молчаливыми Worker-падениями
*
* Цена: тяжёлые Lua-скрипты могут блокировать UI. Для KillBrick-style
* скриптов это нестрашно они быстрые.
*
* API совместим с ScriptSandbox: setOnCommand / sendEvent / sendGlobalEvent /
* sendSceneSnapshot / sendGuiSnapshot / sendDataSnapshot / sendSkinsSnapshot /
* sendTerrainHeightmap / stop / tick / target.
*
* Что добавлено сверх ScriptSandbox:
* - addScript(id, code, target) добавить скрипт в общий VM. Можно
* до или после start().
* - start() асинхронен (createEngine), но возвращает сразу. После init
* стартует main loop (Heartbeat + scheduler).
*/
import { LuaFactory } from 'wasmoon';
import { registerRobloxShim } from './RobloxShim.js';
export class LuaSharedSandbox {
constructor() {
this.vm = null;
this.api = null;
this._onCommand = null;
this._isReady = false;
this._isStopped = false;
this._isKickedOff = false;
this._pendingScripts = []; // [{id, code, target, name}]
this._scriptsById = new Map();
this._scenes = null;
this._guiTree = null;
this._loopHandle = null;
this._lastTickAt = 0;
// Маркер для GameRuntime.routeEvent — этот sandbox принимает все
// события и сам маршрутизирует через shim.fireTargetEvent.
this._luaShared = true;
}
setOnCommand(cb) { this._onCommand = cb; }
get target() { return null; }
tick(_dt, _state) { /* no-op: main-loop запускается изнутри (setInterval) */ }
addScript(id, code, target, name, extra) {
const entry = {
id: String(id || `lua_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`),
code: String(code || ''),
target: target == null ? null : target,
name: name || null,
toolName: extra?.toolName || null,
};
this._scriptsById.set(entry.id, entry);
if (!this._isKickedOff) {
this._pendingScripts.push(entry);
} else {
this._startSingleScript(entry);
}
}
removeScript(id) {
this._scriptsById.delete(String(id));
}
/** Стартует VM, регистрирует shim, запускает main-loop. */
start() {
if (this.vm || this._isStopped) return;
// eslint-disable-next-line no-console
console.log('[LuaSharedSandbox v3] starting Lua VM in main thread...');
this._initAsync().catch((err) => {
// eslint-disable-next-line no-console
console.error('[LuaSharedSandbox] FATAL init error:', err);
this._emit('log', { level: 'error', text: `Lua-runtime init: ${err?.message || err}` });
});
}
async _initAsync() {
const factory = new LuaFactory();
this.vm = await factory.createEngine({ openStandardLibs: true });
// eslint-disable-next-line no-console
console.log('[LuaSharedSandbox] VM created. Registering Roblox shim...');
// Адаптер для shim'а: он ожидает send(cmd, payload), getSceneSnapshot, getGuiTree, scheduleWait.
const send = (cmd, payload) => this._emit(cmd, payload);
this.api = registerRobloxShim(this.vm, {
send,
getSceneSnapshot: () => this._scenes,
getGuiTree: () => this._guiTree,
scheduleWait: () => null,
});
// eslint-disable-next-line no-console
console.log('[LuaSharedSandbox] Shim registered. api keys:', Object.keys(this.api || {}));
// Применим snapshot если он есть
if (this._scenes && this.api?.onSceneSnapshot) {
try { this.api.onSceneSnapshot(this._scenes); } catch (e) {
console.error('[LuaSharedSandbox] onSceneSnapshot:', e);
}
}
this._isReady = true;
this._kickoff();
}
_kickoff() {
if (this._isKickedOff || this._isStopped) return;
this._isKickedOff = true;
// eslint-disable-next-line no-console
console.log(`[LuaSharedSandbox] kickoff: starting ${this._pendingScripts.length} scripts`);
const pending = this._pendingScripts;
this._pendingScripts = [];
// Запускаем main-loop сразу — он начнёт tick'ать как только будут coroutines.
this._lastTickAt = performance.now();
this._startMainLoop();
// Init батчами по 5 с задержкой 20мс между ними, чтобы UI отзывался.
const BATCH_SIZE = 5;
let idx = 0;
const initBatch = () => {
if (this._isStopped) return;
const end = Math.min(idx + BATCH_SIZE, pending.length);
for (let i = idx; i < end; i++) {
try { this._startSingleScript(pending[i]); }
catch (e) {
// eslint-disable-next-line no-console
console.error('[LuaSharedSandbox] init batch err:', e);
}
}
idx = end;
if (idx < pending.length) {
setTimeout(initBatch, 20);
} else {
// eslint-disable-next-line no-console
console.log(`[LuaSharedSandbox] all ${pending.length} scripts kicked off`);
// После того как все скрипты подключили хендлеры — фейрим
// events для уже существующих сущностей. Roblox-конвенция:
// если игрок уже на сервере когда скрипт подключается,
// Players.PlayerAdded не сработает повторно. Юзеру нужно
// делать ручной обход GetPlayers() — но это редко кто помнит.
// Мы дублируем событие через короткую задержку.
setTimeout(() => {
try {
if (this.api?.fireExistingPlayers) {
this.api.fireExistingPlayers();
}
} catch (e) {
console.warn('[LuaSharedSandbox] fireExistingPlayers failed:', e);
}
}, 100);
}
};
setTimeout(initBatch, 0);
}
_startSingleScript(entry) {
if (!this.vm || !entry || typeof entry.code !== 'string') return;
let primId = null;
if (typeof entry.target === 'number') primId = entry.target;
else if (entry.target && typeof entry.target === 'object') {
if (entry.target.kind === 'primitive') primId = entry.target.id ?? entry.target.ref;
}
const safeId = entry.id.replace(/[^a-zA-Z0-9_]/g, '_');
const scriptName = entry.name || `Script_${safeId}`;
// Скрипт оборачиваем в coroutine — это позволяет task.wait через yield.
// Резюмим coroutine из main-loop когда наступило время.
// Регистрируем coroutine в __rbxl_coroutines с id для возобновления.
// Скрипт оборачиваем в coroutine. task.wait()→coroutine.yield(sec) возвращает
// delay из resume → планируем следующий resume через scheduleResume.
// Fallback Parent: если скрипт связан с Tool (по имени Tool из metadata) —
// подсовываем виртуальный Tool как script.Parent. Иначе primitive по id,
// иначе workspace.
let parentExpr;
if (entry.toolName) {
// Tool создаётся в shim как Instance.new('Tool'). По имени достаём.
// Если не нашли — fallback на новый Tool того же имени.
const safeName = JSON.stringify(entry.toolName);
parentExpr = `(function()
local existing = __rbxl_get_tool_by_name(${safeName})
if existing then return existing end
local t = Instance.new("Tool")
t.Name = ${safeName}
return t
end)()`;
} else if (primId != null) {
parentExpr = `(__rbxl_get_part_by_id(${Number(primId)}) or workspace)`;
} else {
parentExpr = 'workspace';
}
const wrapped = `
do
-- Если parentExpr вернул primitive у него уже есть :FindFirstChild и пр.
-- Если ничего не вернёт workspace (всегда валидный).
-- script.Parent.Parent (Tool.Parent = StarterPack / Backpack / workspace).
local _scriptParent = ${parentExpr}
if _scriptParent == nil then _scriptParent = workspace end
if _scriptParent.Parent == nil then _scriptParent.Parent = workspace end
local script = setmetatable({
Name = ${JSON.stringify(scriptName)},
Parent = _scriptParent,
ClassName = "Script",
Disabled = false,
Source = nil,
}, {
-- Любой доступ к несуществующему полю workspace
-- (на случай script.Foo:Bar() в старом коде)
__index = function(t, k)
if k == "FindFirstChild" or k == "WaitForChild" or k == "GetChildren" then
return function() return nil end
end
return workspace[k]
end,
})
local co = coroutine.create(function()
-- WATCHDOG: каждые 100000 инструкций yield 1 кадр.
-- НЕ оборачиваем в pcall внутри C-call boundary yield
-- упадёт ошибкой, что прервёт скрипт. Это лучше чем виснуть.
debug.sethook(function()
coroutine.yield(0.016)
end, "", 20000)
-- pcall защищает от runtime-ошибок которые иначе крашат
-- coroutine и могут повредить WASM-стейт. Возвраты
-- handler'а намеренно поглощаются.
local ok_, err_ = pcall(function()
${entry.code}
end)
if not ok_ then
__rbxl_send_error(${JSON.stringify(entry.id)}, tostring(err_))
end
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(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
`;
try {
this.vm.doStringSync(wrapped);
// eslint-disable-next-line no-console
console.log(`[LuaSharedSandbox] script ${entry.id} initialized OK`);
} catch (err) {
// eslint-disable-next-line no-console
console.error(`[LuaSharedSandbox] script ${entry.id} init FAILED:`, err);
this._emit('log', { level: 'error', text: `[Lua ${entry.id}] ${err?.message || err}` });
}
}
_startMainLoop() {
const tick = () => {
if (this._isStopped) return;
try {
const now = performance.now();
const dt = Math.min(0.1, (now - this._lastTickAt) / 1000);
this._lastTickAt = now;
if (this.api?.tickScheduler) this.api.tickScheduler(dt);
if (this.api?.fireHeartbeat) this.api.fireHeartbeat(dt);
} catch (e) {
// eslint-disable-next-line no-console
console.error('[LuaSharedSandbox tick]', e);
}
this._loopHandle = setTimeout(tick, 16);
};
this._loopHandle = setTimeout(tick, 16);
}
_emit(cmd, payload) {
if (typeof this._onCommand === 'function') {
try { this._onCommand({ cmd, payload }); } catch (_) {}
}
}
// ----- API совместимый с ScriptSandbox -----
sendEvent(payload) {
if (!this.api?.fireTargetEvent || !this._isReady) return;
try { this.api.fireTargetEvent(payload); } catch (e) {
console.error('[LuaSharedSandbox] sendEvent:', e);
}
}
sendGlobalEvent(payload) {
if (!this.api?.fireGlobalEvent || !this._isReady) return;
try { this.api.fireGlobalEvent(payload); } catch (e) {
console.error('[LuaSharedSandbox] sendGlobalEvent:', e);
}
}
sendSceneSnapshot(snapshot) {
this._scenes = snapshot;
if (this.api?.onSceneSnapshot && this._isReady) {
try { this.api.onSceneSnapshot(snapshot); } catch (e) {
console.error('[LuaSharedSandbox] onSceneSnapshot:', e);
}
}
}
sendGuiSnapshot(snapshot) {
this._guiTree = snapshot;
if (this.api?.onGuiSnapshot && this._isReady) {
try { this.api.onGuiSnapshot(snapshot); } catch (_) {}
}
}
sendDataSnapshot(snapshot) {
if (this.api?.onDataSnapshot && this._isReady) {
try { this.api.onDataSnapshot(snapshot); } catch (_) {}
}
}
sendSkinsSnapshot(_) { /* no-op для Этапа 3 */ }
sendTerrainHeightmap(_) { /* no-op */ }
stop() {
this._isStopped = true;
if (this._loopHandle) {
clearTimeout(this._loopHandle);
this._loopHandle = null;
}
if (this.vm) {
try { this.vm.global.close(); } catch (_) {}
this.vm = null;
}
this.api = null;
}
}
export default LuaSharedSandbox;

2500
src/engine/lua/RobloxShim.js Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,210 @@
/**
* rbxl-lua-integration.js вспомогательные функции для импорта .rbxl-карт.
*
* Старый отдельный Worker-based runtime удалён (2026-06-08): импортированные
* Lua-скрипты теперь идут через тот же LuaSharedSandbox что и user-Lua
* (см. GameRuntime.start()). Этот файл оставлен только для:
* - unpackRobloxLuaCode() распаковка Lua из JS-комментария-обёртки;
* - handleLuaCommand() обработка partSet/sceneCreate/sceneDelete/playerCmd
* команд от Lua-VM в BabylonScene.
*/
/** Распаковка lua_source из packed-кода. */
export function unpackRobloxLuaCode(code) {
const openTag = '/[*] lua_source:\n'.replace('[*]', '*');
const i = code.indexOf(openTag);
if (i < 0) return null;
const start = i + openTag.length;
const closeIdx = code.lastIndexOf('\n*' + '/');
if (closeIdx < start) return null;
return code.slice(start, closeIdx);
}
/** Парсит JSON-метадату из 2-й строки packed-кода (`// {"roblox_class":..., "enabled": true}`). */
export function parseRobloxLuaMeta(code) {
if (typeof code !== 'string') return null;
const lines = code.split('\n');
if (lines.length < 2) return null;
const metaLine = lines[1];
if (!metaLine.startsWith('// ')) return null;
try {
return JSON.parse(metaLine.slice(3));
} catch (_) {
return null;
}
}
/** Сцена → snap для shim'а (workspace:GetChildren). */
export function buildLuaSceneSnap(primitives) {
const out = { primitives: {} };
if (!Array.isArray(primitives)) return out;
for (const p of primitives) {
out.primitives[p.id] = {
id: p.id, type: p.type, name: p.name,
x: p.x, y: p.y, z: p.z,
sx: p.sx, sy: p.sy, sz: p.sz,
color: p.color, material: p.material,
anchored: !!p.anchored, canCollide: p.canCollide !== false,
opacity: typeof p.opacity === 'number' ? p.opacity : 1,
};
}
return out;
}
/**
* GUI-tree для shim'а. Mapping origin __roblox_class.
* scene.gui массив элементов с {id, type, name, parentId, ...origin}.
* Возвращаем массив сохраняя порядок parent child (важно для tree-сборки).
*/
export function buildLuaGuiTree(guiElements) {
if (!Array.isArray(guiElements)) return [];
const out = [];
for (const el of guiElements) {
// origin = 'roblox-textbutton' → 'TextButton'
let rblClass = 'Frame';
const origin = el.origin || '';
if (origin.startsWith('roblox-')) {
const tail = origin.slice(7);
rblClass = tail.charAt(0).toUpperCase() + tail.slice(1);
// Camel-case "textbutton" → "TextButton"
if (rblClass.toLowerCase() === 'textbutton') rblClass = 'TextButton';
else if (rblClass.toLowerCase() === 'textlabel') rblClass = 'TextLabel';
else if (rblClass.toLowerCase() === 'imagebutton') rblClass = 'ImageButton';
else if (rblClass.toLowerCase() === 'imagelabel') rblClass = 'ImageLabel';
else if (rblClass.toLowerCase() === 'textbox') rblClass = 'TextBox';
else if (rblClass.toLowerCase() === 'scrollingframe') rblClass = 'ScrollingFrame';
else if (rblClass.toLowerCase() === 'frame') rblClass = 'Frame';
} else {
// Если origin не задан — гадаем по type
const t = el.type;
if (t === 'button') rblClass = 'TextButton';
else if (t === 'text') rblClass = 'TextLabel';
else if (t === 'image') rblClass = 'ImageLabel';
else if (t === 'textbox') rblClass = 'TextBox';
}
out.push({
id: el.id,
name: el.name || rblClass,
parentId: el.parentId || null,
visible: el.visible !== false,
text: el.text || '',
__roblox_class: rblClass,
});
}
return out;
}
/**
* Обработка IPC команд от worker'а мапим на действия в Babylon-сцене.
*/
export function handleLuaCommand(_scriptId, cmd, payload, runtime) {
if (cmd === 'log') {
const fn = payload?.level === 'error' ? console.error
: payload?.level === 'warn' ? console.warn : console.log;
fn('[rbxl-lua]', payload?.text || '');
return;
}
if (cmd === 'partSet') {
const pm = runtime.scene3d?.primitiveManager;
if (!pm) {
console.warn('[partSet] no primitiveManager. scene3d=', !!runtime.scene3d);
return;
}
const primId = payload?.primId;
const prop = payload?.prop;
const value = payload?.value;
const patch = {};
if (prop === 'position' && value) {
patch.x = value.x; patch.y = value.y; patch.z = value.z;
} else if (prop === 'cframe' && value) {
patch.x = value.x; patch.y = value.y; patch.z = value.z;
patch.rotationX = value.rx; patch.rotationY = value.ry; patch.rotationZ = value.rz;
} else if (prop === 'size' && value) {
patch.sx = value.sx; patch.sy = value.sy; patch.sz = value.sz;
} else if (prop === 'color') patch.color = value;
else if (prop === 'material') patch.material = value;
else if (prop === 'anchored') patch.anchored = value;
else if (prop === 'canCollide') patch.canCollide = value;
else if (prop === 'opacity') patch.opacity = value;
try {
if (typeof pm.updateInstance === 'function') pm.updateInstance(primId, patch);
else if (typeof pm.applyPatch === 'function') pm.applyPatch(primId, patch);
else if (typeof pm.update === 'function') pm.update(primId, patch);
} catch (e) {
console.error('[partSet] updateInstance failed:', 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);
// Если unanchored — регистрируем в физике на лету, иначе он не падает.
if (opts.anchored === false) {
try {
const dm = runtime.scene3d?.dynamics;
const data = pm.instances?.get?.(opts.id);
if (dm && data && typeof dm.registerPrimitive === 'function') {
dm.registerPrimitive(data);
}
} catch (e) {
console.warn('[sceneCreate] registerPrimitive failed', e);
}
}
} 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;
if (pm && typeof pm.setVelocity === 'function') {
pm.setVelocity(payload.primId, payload.vx, payload.vy, payload.vz);
}
} catch (e) {}
return;
}
if (cmd === 'playerCmd') {
try {
const p = runtime.game?.player;
if (!p) return;
const method = payload?.method;
const args = payload?.args || [];
if (method === 'teleport') p.teleport && p.teleport(args[0], args[1], args[2]);
else if (method === 'setWalkSpeed') p.setWalkSpeed && p.setWalkSpeed(args[0]);
else if (method === 'setJumpPower') p.setJumpPower && p.setJumpPower(args[0]);
else if (method === 'setHealth') p.setHealth && p.setHealth(args[0]);
else if (method === 'die') p.die && p.die();
else if (method === 'damage' || method === 'takeDamage') p.damage && p.damage(args[0]);
} catch (e) {}
return;
}
if (cmd === 'guiUpdate') {
// TODO: scripts setting Visible/Text/Color on GUI → передать в GuiManager
return;
}
}

View File

@ -1,216 +0,0 @@
/**
* roblox-physics.js эмуляция BodyMover / Constraint объектов Roblox.
*
* Roblox BodyMover'ы (старые, deprecated но массово используются):
* BodyVelocity поддерживает заданную линейную velocity
* BodyAngularVelocity поддерживает заданную угловую velocity
* BodyGyro пытается удержать ориентацию (Lookat)
* BodyForce постоянная сила
* BodyPosition пытается удержать позицию
* BodyThrust направленный импульс
*
* Constraint (новые):
* AlignPosition, AlignOrientation, LinearVelocity, AngularVelocity, Torque,
* VectorForce, Spring, RodConstraint, RopeConstraint, ...
*
* MVP: реализуем самые частые (BodyVelocity, BodyGyro, AlignPosition, VectorForce).
* Остальные заглушки + warning.
*
* Архитектура:
* - Когда Lua делает `Instance.new("BodyVelocity", part)`, мы создаём RbxBodyVelocity,
* прикрепляем к Part через .Parent.
* - На каждом tick шедулера обходим активные movers и отсылаем physForce в main.
* - Main применяет к Babylon physics impostor.
*/
import { RbxInstance, RbxSignal, RbxVector3 } from './roblox-shim.js';
class RbxBodyMoverBase extends RbxInstance {
constructor(className) {
super(className, { Name: className });
this._ctx = null; // { send, registerMover }
this.__parentPart = null;
}
/** Установить родителя и зарегистрироваться в physics-manager. */
setMoverParent(part) {
this.Parent = part;
if (part && part.__primId != null) {
this.__parentPart = part;
this._ctx?.registerMover?.(this);
}
}
}
export class RbxBodyVelocity extends RbxBodyMoverBase {
constructor() {
super('BodyVelocity');
this.Velocity = new RbxVector3(0, 0, 0);
this.MaxForce = new RbxVector3(4000, 4000, 4000);
this.P = 1250;
}
_step(_dt) {
if (!this.__parentPart || !this._ctx) return;
// posVel — желаемая velocity. Применяем как setVelocity.
this._ctx.send('partVel', {
primId: this.__parentPart.__primId,
vx: this.Velocity.X,
vy: this.Velocity.Y,
vz: this.Velocity.Z,
});
}
}
export class RbxBodyGyro extends RbxBodyMoverBase {
constructor() {
super('BodyGyro');
this.CFrame = null; // целевое вращение
this.MaxTorque = new RbxVector3(4000, 4000, 4000);
this.D = 500;
this.P = 3000;
}
_step(_dt) {
if (!this.__parentPart || !this._ctx || !this.CFrame) return;
const [rx, ry, rz] = this.CFrame.toEulerXYZ();
this._ctx.send('partSet', {
primId: this.__parentPart.__primId,
prop: 'rotation',
value: { rx, ry, rz },
});
}
}
export class RbxBodyPosition extends RbxBodyMoverBase {
constructor() {
super('BodyPosition');
this.Position = new RbxVector3(0, 0, 0);
this.MaxForce = new RbxVector3(4000, 4000, 4000);
this.D = 1250;
this.P = 10000;
}
_step(_dt) {
if (!this.__parentPart || !this._ctx) return;
this._ctx.send('partSet', {
primId: this.__parentPart.__primId,
prop: 'position',
value: { x: this.Position.X, y: this.Position.Y, z: this.Position.Z },
});
}
}
export class RbxBodyForce extends RbxBodyMoverBase {
constructor() {
super('BodyForce');
this.Force = new RbxVector3(0, 0, 0);
}
_step(dt) {
if (!this.__parentPart || !this._ctx) return;
this._ctx.send('partForce', {
primId: this.__parentPart.__primId,
fx: this.Force.X * dt, fy: this.Force.Y * dt, fz: this.Force.Z * dt,
});
}
}
export class RbxBodyAngularVelocity extends RbxBodyMoverBase {
constructor() {
super('BodyAngularVelocity');
this.AngularVelocity = new RbxVector3(0, 0, 0);
this.MaxTorque = new RbxVector3(4000, 4000, 4000);
}
_step(_dt) {
if (!this.__parentPart || !this._ctx) return;
this._ctx.send('partAngVel', {
primId: this.__parentPart.__primId,
wx: this.AngularVelocity.X, wy: this.AngularVelocity.Y, wz: this.AngularVelocity.Z,
});
}
}
/* ──────── New Constraints ──────── */
export class RbxAlignPosition extends RbxBodyMoverBase {
constructor() {
super('AlignPosition');
this.Position = new RbxVector3(0, 0, 0);
this.Attachment0 = null;
this.Attachment1 = null;
this.MaxForce = 1e6;
this.Enabled = true;
}
_step(_dt) {
if (!this.Enabled || !this.__parentPart || !this._ctx) return;
this._ctx.send('partSet', {
primId: this.__parentPart.__primId,
prop: 'position',
value: { x: this.Position.X, y: this.Position.Y, z: this.Position.Z },
});
}
}
export class RbxLinearVelocity extends RbxBodyMoverBase {
constructor() {
super('LinearVelocity');
this.VectorVelocity = new RbxVector3(0, 0, 0);
this.MaxForce = 1e6;
this.Enabled = true;
}
_step(_dt) {
if (!this.Enabled || !this.__parentPart || !this._ctx) return;
this._ctx.send('partVel', {
primId: this.__parentPart.__primId,
vx: this.VectorVelocity.X,
vy: this.VectorVelocity.Y,
vz: this.VectorVelocity.Z,
});
}
}
/* ──────── Manager ──────── */
export class RobloxPhysicsManager {
constructor(send) {
this._send = send;
this._movers = new Set();
}
install(lua) {
const self = this;
const ctx = {
send: this._send,
registerMover: (m) => self._movers.add(m),
};
// Подменяем Instance.new для физических классов
const origInstance = lua.global.get('Instance');
lua.global.set('Instance', {
new: (className, parent) => {
let inst = null;
switch (className) {
case 'BodyVelocity': inst = new RbxBodyVelocity(); break;
case 'BodyGyro': inst = new RbxBodyGyro(); break;
case 'BodyPosition': inst = new RbxBodyPosition(); break;
case 'BodyForce': inst = new RbxBodyForce(); break;
case 'BodyAngularVelocity':inst = new RbxBodyAngularVelocity(); break;
case 'AlignPosition': inst = new RbxAlignPosition(); break;
case 'LinearVelocity': inst = new RbxLinearVelocity(); break;
}
if (inst) {
inst._ctx = ctx;
if (parent) {
inst.setMoverParent(parent);
if (parent.Children) parent.Children.push(inst);
}
return inst;
}
return origInstance.new(className, parent);
},
});
}
tick(dt) {
for (const m of [...this._movers]) {
if (m.__destroyed || !m.__parentPart) { this._movers.delete(m); continue; }
try { m._step(dt); } catch (e) {}
}
}
}

View File

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

View File

@ -1,384 +0,0 @@
/**
* roblox-services.js расширения Roblox-API для сервисов:
* Players / Humanoid / UserInputService / RemoteEvent / RemoteFunction
* / DataStoreService / HttpService.
*
* Регистрируется ПОСЛЕ registerRobloxApi (см. roblox-shim.js).
*
* Поведение:
* - Players.LocalPlayer.Character.Humanoid.Health, WalkSpeed, JumpPower
* мапятся на game.player.* в Rublox через `playerCmd` IPC.
* - UserInputService.InputBegan/InputEnded пробрасываются из main
* по событию через fireEvent.
* - RemoteEvent:FireServer/FireClient broadcast.
* - DataStoreService:GetDataStore game.save.
*/
import { RbxInstance, RbxSignal, RbxVector3 } from './roblox-shim.js';
/* ──────── Humanoid ──────── */
class RbxHumanoid extends RbxInstance {
constructor(ctx) {
super('Humanoid', { Name: 'Humanoid' });
this._ctx = ctx; // { send, getPlayerState }
this._snap = {
Health: 100,
MaxHealth: 100,
WalkSpeed: 16,
JumpPower: 50,
JumpHeight: 7.2,
HipHeight: 0,
HumanoidStateType: 'GettingUp',
PlatformStand: false,
};
this.Died = new RbxSignal('Died');
this.HealthChanged = new RbxSignal('HealthChanged');
this.Touched = new RbxSignal('Touched');
this.Running = new RbxSignal('Running');
this.Jumping = new RbxSignal('Jumping');
this.StateChanged = new RbxSignal('StateChanged');
}
get Health() { return this._snap.Health; }
set Health(v) {
const old = this._snap.Health;
const nv = Math.max(0, +v || 0);
this._snap.Health = nv;
if (nv !== old) this.HealthChanged.Fire(nv);
if (nv <= 0 && old > 0) {
this.Died.Fire();
this._ctx.send?.('playerCmd', { method: 'die', args: [] });
} else {
this._ctx.send?.('playerCmd', { method: 'setHealth', args: [nv] });
}
}
get MaxHealth() { return this._snap.MaxHealth; }
set MaxHealth(v) {
this._snap.MaxHealth = +v || 100;
this._ctx.send?.('playerCmd', { method: 'setMaxHealth', args: [this._snap.MaxHealth] });
}
get WalkSpeed() { return this._snap.WalkSpeed; }
set WalkSpeed(v) {
this._snap.WalkSpeed = +v || 0;
this._ctx.send?.('playerCmd', { method: 'setWalkSpeed', args: [this._snap.WalkSpeed] });
}
get JumpPower() { return this._snap.JumpPower; }
set JumpPower(v) {
this._snap.JumpPower = +v || 0;
this._ctx.send?.('playerCmd', { method: 'setJumpPower', args: [this._snap.JumpPower] });
}
get JumpHeight() { return this._snap.JumpHeight; }
set JumpHeight(v) {
this._snap.JumpHeight = +v || 0;
this._ctx.send?.('playerCmd', { method: 'setJumpHeight', args: [this._snap.JumpHeight] });
}
get PlatformStand() { return !!this._snap.PlatformStand; }
set PlatformStand(v) {
this._snap.PlatformStand = !!v;
this._ctx.send?.('playerCmd', { method: 'setPlatformStand', args: [!!v] });
}
TakeDamage(amount) {
this.Health = Math.max(0, this.Health - (+amount || 0));
}
Move(direction, relative) {
if (direction instanceof RbxVector3) {
this._ctx.send?.('playerCmd', {
method: 'move',
args: [{ x: direction.X, y: direction.Y, z: direction.Z }, !!relative],
});
}
}
Jump() {
this._ctx.send?.('playerCmd', { method: 'jump', args: [] });
}
LoadAnimation(animation) {
// Animation объект — content rbxassetid. Возвращаем animation-track stub.
const aid = animation?.AnimationId || '';
return {
AnimationId: aid,
Length: 0,
IsPlaying: false,
Looped: false,
Play: () => this._ctx.send?.('playerCmd', { method: 'playAnim', args: [aid] }),
Stop: () => this._ctx.send?.('playerCmd', { method: 'stopAnim', args: [aid] }),
AdjustSpeed: (s) => this._ctx.send?.('playerCmd', { method: 'animSpeed', args: [aid, s] }),
GetTimeOfKeyframe: () => 0,
KeyframeReached: new RbxSignal('KeyframeReached'),
};
}
ChangeState(state) {
this._snap.HumanoidStateType = state;
this.StateChanged.Fire(state);
}
SetStateEnabled(_state, _enabled) { /* noop */ }
GetState() { return this._snap.HumanoidStateType; }
}
/* ──────── Character / Player ──────── */
class RbxCharacter extends RbxInstance {
constructor(ctx) {
super('Model', { Name: 'Character' });
// HumanoidRootPart — это «Position персонажа»
this.HumanoidRootPart = new RbxInstance('Part', { Name: 'HumanoidRootPart', Parent: this });
// mock Position через getter — берём текущую позицию из ctx
Object.defineProperty(this.HumanoidRootPart, 'Position', {
get: () => {
const p = ctx.getPlayerState?.() || { x: 0, y: 0, z: 0 };
return new RbxVector3(p.x, p.y, p.z);
},
set: (v) => {
if (v instanceof RbxVector3) {
ctx.send?.('playerCmd', { method: 'teleport', args: [v.X, v.Y, v.Z] });
}
},
});
Object.defineProperty(this.HumanoidRootPart, 'CFrame', {
get: () => {
const p = ctx.getPlayerState?.() || { x: 0, y: 0, z: 0 };
return { X: p.x, Y: p.y, Z: p.z, p: { X: p.x, Y: p.y, Z: p.z } };
},
set: (v) => {
if (v && typeof v === 'object') {
ctx.send?.('playerCmd', { method: 'teleport', args: [v.X || 0, v.Y || 0, v.Z || 0] });
}
},
});
this.Children.push(this.HumanoidRootPart);
this.Humanoid = new RbxHumanoid(ctx);
this.Humanoid.Parent = this;
this.Children.push(this.Humanoid);
}
}
class RbxPlayer extends RbxInstance {
constructor(ctx) {
super('Player', { Name: 'Player' });
this.UserId = 1;
this.DisplayName = 'Player';
this.Character = new RbxCharacter(ctx);
this.CharacterAdded = new RbxSignal('CharacterAdded');
this.CharacterRemoving = new RbxSignal('CharacterRemoving');
// На MVP — характер уже создан.
setTimeout(() => this.CharacterAdded.Fire(this.Character), 0);
this.leaderstats = new RbxInstance('Folder', { Name: 'leaderstats', Parent: this });
this.Children.push(this.leaderstats);
}
GetMouse() {
return { Hit: { Position: new RbxVector3(0, 0, 0) }, Target: null,
Button1Down: new RbxSignal('Button1Down'), Move: new RbxSignal('Move') };
}
Kick(reason) {
// в нашем плеере — просто log
return reason;
}
}
/* ──────── UserInputService ──────── */
class RbxUserInputService extends RbxInstance {
constructor() {
super('UserInputService', { Name: 'UserInputService' });
this.InputBegan = new RbxSignal('InputBegan');
this.InputEnded = new RbxSignal('InputEnded');
this.InputChanged = new RbxSignal('InputChanged');
this.JumpRequest = new RbxSignal('JumpRequest');
this.KeyboardEnabled = true;
this.MouseEnabled = true;
this.TouchEnabled = false;
}
GetMouseLocation() { return { X: 0, Y: 0 }; }
IsKeyDown(_keyCode) { return false; } // в MVP всегда false
}
/* ──────── RemoteEvent / RemoteFunction ──────── */
class RbxRemoteEvent extends RbxInstance {
constructor(ctx) {
super('RemoteEvent', { Name: 'RemoteEvent' });
this._ctx = ctx;
this.OnServerEvent = new RbxSignal('OnServerEvent');
this.OnClientEvent = new RbxSignal('OnClientEvent');
}
FireServer(...args) {
// singleplayer: server == client, просто отдаём в OnServerEvent
this.OnServerEvent.Fire(this._ctx.localPlayer, ...args);
this._ctx.send?.('broadcast', { msg: 'remoteEvent:' + this.Name, data: args });
}
FireClient(_player, ...args) {
this.OnClientEvent.Fire(...args);
this._ctx.send?.('broadcast', { msg: 'remoteEvent:' + this.Name, data: args });
}
FireAllClients(...args) {
this.OnClientEvent.Fire(...args);
this._ctx.send?.('broadcast', { msg: 'remoteEvent:' + this.Name, data: args });
}
}
class RbxRemoteFunction extends RbxInstance {
constructor(ctx) {
super('RemoteFunction', { Name: 'RemoteFunction' });
this._ctx = ctx;
this.OnServerInvoke = null; // function(player, ...args) → result
}
InvokeServer(...args) {
if (typeof this.OnServerInvoke === 'function') {
try { return this.OnServerInvoke(this._ctx.localPlayer, ...args); } catch (e) {}
}
return null;
}
InvokeClient(_player, ...args) {
if (typeof this.OnClientInvoke === 'function') {
try { return this.OnClientInvoke(...args); } catch (e) {}
}
return null;
}
}
/* ──────── DataStoreService ──────── */
class RbxDataStore {
constructor(name, ctx) {
this.name = name;
this._ctx = ctx;
}
GetAsync(key) {
try {
const data = this._ctx.loadSave?.(this.name + ':' + key);
return data ?? null;
} catch (e) { return null; }
}
SetAsync(key, value) {
this._ctx.saveSave?.(this.name + ':' + key, value);
return value;
}
UpdateAsync(key, updaterFn) {
const cur = this.GetAsync(key);
const next = updaterFn(cur);
if (next !== undefined) this.SetAsync(key, next);
return next;
}
IncrementAsync(key, delta) {
const cur = +this.GetAsync(key) || 0;
const next = cur + (+delta || 1);
this.SetAsync(key, next);
return next;
}
RemoveAsync(key) {
this._ctx.removeSave?.(this.name + ':' + key);
}
}
class RbxDataStoreService extends RbxInstance {
constructor(ctx) {
super('DataStoreService', { Name: 'DataStoreService' });
this._ctx = ctx;
this._stores = new Map();
}
GetDataStore(name) {
if (!this._stores.has(name)) this._stores.set(name, new RbxDataStore(name, this._ctx));
return this._stores.get(name);
}
GetGlobalDataStore() { return this.GetDataStore('__global__'); }
GetOrderedDataStore(name) { return this.GetDataStore('ordered:' + name); }
}
/* ──────── HttpService ──────── */
class RbxHttpService extends RbxInstance {
constructor(ctx) {
super('HttpService', { Name: 'HttpService' });
this._ctx = ctx;
this.HttpEnabled = false; // в нашем плеере по дефолту выкл, безопаснее
}
GenerateGUID(wrap) {
const c = () => Math.random().toString(16).slice(2, 6);
const guid = `${c()}${c()}-${c()}-${c()}-${c()}-${c()}${c()}${c()}`.toUpperCase();
return wrap === false ? guid : `{${guid}}`;
}
JSONEncode(value) { try { return JSON.stringify(value); } catch (e) { return ''; } }
JSONDecode(s) { try { return JSON.parse(s); } catch (e) { return null; } }
GetAsync(url) {
// CORS / sandbox: блокируем в MVP, возвращаем заглушку
this._ctx.send?.('log', { level: 'warn', text: `HttpService:GetAsync(${url}) blocked in MVP` });
return '';
}
PostAsync(url) {
this._ctx.send?.('log', { level: 'warn', text: `HttpService:PostAsync(${url}) blocked in MVP` });
return '';
}
}
/* ──────── install ──────── */
export function installRobloxServices(lua, ctx) {
// ctx: { send, getPlayerState, getSnapPlayer, loadSave, saveSave, removeSave }
const game = lua.global.get('game');
if (!game) return;
// Создаём LocalPlayer
const player = new RbxPlayer({
send: ctx.send,
getPlayerState: ctx.getPlayerState,
});
// Players service апгрейдим
const players = game.GetService('Players');
if (players) {
players.LocalPlayer = player;
// GetPlayers / GetPlayerFromCharacter
players.GetPlayers = () => [player];
players.GetPlayerFromCharacter = (c) => (c === player.Character ? player : null);
}
// UserInputService
const uis = new RbxUserInputService();
// RemoteEvent / DataStoreService / HttpService — выдаются через GetService
const dss = new RbxDataStoreService({
loadSave: ctx.loadSave,
saveSave: ctx.saveSave,
removeSave: ctx.removeSave,
});
const httpSvc = new RbxHttpService({ send: ctx.send });
// Подмена GetService — добавляем наши новые сервисы
const origGetService = game.GetService;
game.GetService = function(svc) {
if (svc === 'UserInputService') return uis;
if (svc === 'DataStoreService') return dss;
if (svc === 'HttpService') return httpSvc;
// ContextActionService — стаб
if (svc === 'ContextActionService') {
return {
ClassName: 'ContextActionService', Name: 'ContextActionService',
BindAction: (_name, fn, _gui, ...keys) => { /* в MVP — игнор */ },
UnbindAction: () => {},
};
}
return origGetService.call(this, svc);
};
// Instance.new('RemoteEvent') / 'RemoteFunction' — переопределяем фабрику
const origInstance = lua.global.get('Instance');
lua.global.set('Instance', {
new: (className, parent) => {
if (className === 'RemoteEvent') {
const r = new RbxRemoteEvent({ send: ctx.send, localPlayer: player });
if (parent) { r.Parent = parent; parent.Children.push(r); }
return r;
}
if (className === 'RemoteFunction') {
const r = new RbxRemoteFunction({ send: ctx.send, localPlayer: player });
if (parent) { r.Parent = parent; parent.Children.push(r); }
return r;
}
return origInstance.new(className, parent);
},
});
return { player, uis, dss, httpSvc };
}
export { RbxHumanoid, RbxCharacter, RbxPlayer, RbxUserInputService,
RbxRemoteEvent, RbxRemoteFunction, RbxDataStoreService, RbxHttpService };

View File

@ -1,575 +0,0 @@
/**
* roblox-shim.js регистрация Roblox API внутри Lua-VM (wasmoon).
*
* Используется из RobloxLuaWorker.js. Регистрирует глобалы:
* - game, workspace, script Instance-прокси
* - Vector3, Color3, CFrame, UDim, UDim2 конструкторы математических классов
* - Instance.new(class) фабрика
* - wait, task, tick, os, print, warn стандартные глобалы
* - Enum enum-таблица
*
* Архитектура:
* - JS-классы (RbxVector3, RbxCFrame, ...) обычные дата-объекты с
* перегруженными методами.
* - Instance прокси-объект который хранит { className, properties, children, parent }.
* Геттеры/сеттеры эмулируются через __index/__newindex (mt в wasmoon).
* - RBXScriptSignal JS-объект с Connect/Wait/Disconnect.
*
* Sandbox-side: при изменении Part.Position и т.п. отсылаем в main thread
* `partSet` main применит к Babylon-сцене.
*/
/* ──────── Math classes ──────── */
class RbxVector3 {
constructor(x, y, z) {
this.X = +x || 0;
this.Y = +y || 0;
this.Z = +z || 0;
}
get Magnitude() {
return Math.sqrt(this.X*this.X + this.Y*this.Y + this.Z*this.Z);
}
get Unit() {
const m = this.Magnitude || 1;
return new RbxVector3(this.X / m, this.Y / m, this.Z / m);
}
Dot(o) { return this.X*o.X + this.Y*o.Y + this.Z*o.Z; }
Cross(o) {
return new RbxVector3(
this.Y*o.Z - this.Z*o.Y,
this.Z*o.X - this.X*o.Z,
this.X*o.Y - this.Y*o.X,
);
}
Lerp(o, alpha) {
return new RbxVector3(
this.X + (o.X - this.X) * alpha,
this.Y + (o.Y - this.Y) * alpha,
this.Z + (o.Z - this.Z) * alpha,
);
}
add(o) { return new RbxVector3(this.X + o.X, this.Y + o.Y, this.Z + o.Z); }
sub(o) { return new RbxVector3(this.X - o.X, this.Y - o.Y, this.Z - o.Z); }
mul(scalar) {
if (typeof scalar === 'number') {
return new RbxVector3(this.X * scalar, this.Y * scalar, this.Z * scalar);
}
return new RbxVector3(this.X * scalar.X, this.Y * scalar.Y, this.Z * scalar.Z);
}
toString() { return `${this.X}, ${this.Y}, ${this.Z}`; }
}
class RbxColor3 {
constructor(r, g, b) {
this.R = +r || 0;
this.G = +g || 0;
this.B = +b || 0;
}
static fromRGB(r, g, b) {
return new RbxColor3((r||0)/255, (g||0)/255, (b||0)/255);
}
static fromHex(hex) {
const h = String(hex || '#000000').replace('#','');
return new RbxColor3(
parseInt(h.slice(0,2), 16)/255,
parseInt(h.slice(2,4), 16)/255,
parseInt(h.slice(4,6), 16)/255,
);
}
Lerp(o, alpha) {
return new RbxColor3(
this.R + (o.R - this.R) * alpha,
this.G + (o.G - this.G) * alpha,
this.B + (o.B - this.B) * alpha,
);
}
toHex() {
const h = (n) => Math.max(0, Math.min(255, Math.round(n * 255))).toString(16).padStart(2, '0');
return `#${h(this.R)}${h(this.G)}${h(this.B)}`;
}
toString() { return `${this.R}, ${this.G}, ${this.B}`; }
}
class RbxCFrame {
constructor(x, y, z, r00=1, r01=0, r02=0, r10=0, r11=1, r12=0, r20=0, r21=0, r22=1) {
this.X = +x || 0; this.Y = +y || 0; this.Z = +z || 0;
// Row-major 3x3
this.r00 = r00; this.r01 = r01; this.r02 = r02;
this.r10 = r10; this.r11 = r11; this.r12 = r12;
this.r20 = r20; this.r21 = r21; this.r22 = r22;
}
static new(x, y, z) {
if (x instanceof RbxVector3) return new RbxCFrame(x.X, x.Y, x.Z);
return new RbxCFrame(x || 0, y || 0, z || 0);
}
static Angles(rx, ry, rz) {
// Euler XYZ → 3x3 (intrinsic)
const cx = Math.cos(rx), sx = Math.sin(rx);
const cy = Math.cos(ry), sy = Math.sin(ry);
const cz = Math.cos(rz), sz = Math.sin(rz);
// R = Rx * Ry * Rz
const r00 = cy*cz, r01 = -cy*sz, r02 = sy;
const r10 = sx*sy*cz + cx*sz, r11 = -sx*sy*sz + cx*cz, r12 = -sx*cy;
const r20 = -cx*sy*cz + sx*sz, r21 = cx*sy*sz + sx*cz, r22 = cx*cy;
return new RbxCFrame(0, 0, 0, r00, r01, r02, r10, r11, r12, r20, r21, r22);
}
static fromEulerAnglesXYZ(rx, ry, rz) { return RbxCFrame.Angles(rx, ry, rz); }
get Position() { return new RbxVector3(this.X, this.Y, this.Z); }
get LookVector() { return new RbxVector3(-this.r02, -this.r12, -this.r22); }
get RightVector() { return new RbxVector3(this.r00, this.r10, this.r20); }
get UpVector() { return new RbxVector3(this.r01, this.r11, this.r21); }
Lerp(o, a) {
// Линейная интерполяция (без правильного slerp на матрицах — для MVP сойдёт)
return new RbxCFrame(
this.X + (o.X - this.X) * a,
this.Y + (o.Y - this.Y) * a,
this.Z + (o.Z - this.Z) * a,
this.r00, this.r01, this.r02,
this.r10, this.r11, this.r12,
this.r20, this.r21, this.r22,
);
}
Inverse() {
// Транспонируем 3x3 (для rotation matrix Inverse == Transpose)
return new RbxCFrame(
-this.X, -this.Y, -this.Z,
this.r00, this.r10, this.r20,
this.r01, this.r11, this.r21,
this.r02, this.r12, this.r22,
);
}
toEulerXYZ() {
const rx = Math.atan2(this.r21, this.r22);
const ry = Math.atan2(-this.r20, Math.sqrt(this.r21*this.r21 + this.r22*this.r22));
const rz = Math.atan2(this.r10, this.r00);
return [rx, ry, rz];
}
toString() { return `${this.X}, ${this.Y}, ${this.Z}`; }
}
class RbxUDim {
constructor(scale, offset) { this.Scale = +scale || 0; this.Offset = +offset | 0; }
toString() { return `${this.Scale}, ${this.Offset}`; }
}
class RbxUDim2 {
constructor(xs, xo, ys, yo) {
this.X = new RbxUDim(xs, xo);
this.Y = new RbxUDim(ys, yo);
}
static new(xs, xo, ys, yo) { return new RbxUDim2(xs, xo, ys, yo); }
static fromScale(xs, ys) { return new RbxUDim2(xs, 0, ys, 0); }
static fromOffset(xo, yo) { return new RbxUDim2(0, xo, 0, yo); }
toString() { return `${this.X.Scale}, ${this.X.Offset}, ${this.Y.Scale}, ${this.Y.Offset}`; }
}
/* ──────── RBXScriptSignal ──────── */
let _signalIdCounter = 1000;
class RbxSignal {
constructor(name) {
this.name = name;
this.id = _signalIdCounter++;
this.connections = [];
}
Connect(callback) {
const conn = { callback, connected: true };
this.connections.push(conn);
return {
Disconnect: () => { conn.connected = false; },
Connected: () => conn.connected,
};
}
Wait() {
// В рамках MVP — Wait не блокирует (т.к. wasmoon без корутин это сложно).
// Реальный Wait появится в 4.7 через task.wait.
return null;
}
Fire(...args) {
for (const c of this.connections) {
if (!c.connected) continue;
try { c.callback(...args); } catch (e) { /* swallow */ }
}
}
}
/* ──────── Instance прокси ──────── */
let _instanceCounter = 1;
class RbxInstance {
constructor(className, init = {}) {
this.__id = _instanceCounter++;
this.ClassName = className;
this.Name = init.Name || className;
this.Parent = init.Parent || null;
this.Children = [];
this.__props = {}; // raw properties (для Position и т.п.)
// Signals доступны как прямые свойства, плюс дублируются в __signals для serv-кода
this.Touched = new RbxSignal('Touched');
this.TouchEnded = new RbxSignal('TouchEnded');
this.Changed = new RbxSignal('Changed');
this.AncestryChanged = new RbxSignal('AncestryChanged');
this.ChildAdded = new RbxSignal('ChildAdded');
this.ChildRemoved = new RbxSignal('ChildRemoved');
this.__signals = {
Touched: this.Touched,
TouchEnded: this.TouchEnded,
Changed: this.Changed,
AncestryChanged: this.AncestryChanged,
ChildAdded: this.ChildAdded,
ChildRemoved: this.ChildRemoved,
};
this.__sceneState = null;
}
GetChildren() { return [...this.Children]; }
GetDescendants() {
const out = [];
const walk = (n) => {
for (const c of n.Children) { out.push(c); walk(c); }
};
walk(this);
return out;
}
FindFirstChild(name, recursive) {
for (const c of this.Children) {
if (c.Name === name) return c;
if (recursive) {
const found = c.FindFirstChild(name, true);
if (found) return found;
}
}
return null;
}
FindFirstChildOfClass(className) {
for (const c of this.Children) {
if (c.ClassName === className) return c;
}
return null;
}
FindFirstAncestor(name) {
let p = this.Parent;
while (p) {
if (p.Name === name) return p;
p = p.Parent;
}
return null;
}
WaitForChild(name, _timeout) {
// В MVP — синхронный поиск без ожидания. В 4.7 через корутины можно ждать.
return this.FindFirstChild(name);
}
IsA(className) {
if (this.ClassName === className) return true;
// Roblox class hierarchy: Part isA BasePart isA PVInstance isA Instance.
const hierarchy = {
'Part': ['BasePart', 'PVInstance', 'Instance'],
'WedgePart': ['BasePart', 'PVInstance', 'Instance'],
'CornerWedgePart': ['BasePart', 'PVInstance', 'Instance'],
'MeshPart': ['BasePart', 'PVInstance', 'Instance'],
'UnionOperation': ['PartOperation', 'BasePart', 'PVInstance', 'Instance'],
'TrussPart': ['BasePart', 'PVInstance', 'Instance'],
'SpawnLocation': ['Part', 'BasePart', 'PVInstance', 'Instance'],
'Script': ['BaseScript', 'LuaSourceContainer', 'Instance'],
'LocalScript': ['BaseScript', 'LuaSourceContainer', 'Instance'],
'ModuleScript': ['LuaSourceContainer', 'Instance'],
'Folder': ['Instance'],
'Model': ['PVInstance', 'Instance'],
'Sound': ['Instance'],
'PointLight': ['Light', 'Instance'],
'SpotLight': ['Light', 'Instance'],
'Humanoid': ['Instance'],
};
const ancestors = hierarchy[this.ClassName] || [];
return ancestors.includes(className);
}
Destroy() {
if (this.Parent && this.Parent.Children) {
const idx = this.Parent.Children.indexOf(this);
if (idx >= 0) this.Parent.Children.splice(idx, 1);
}
this.Parent = null;
this.__destroyed = true;
}
Clone() {
const cl = new RbxInstance(this.ClassName);
cl.Name = this.Name;
cl.__props = JSON.parse(JSON.stringify(this.__props));
for (const c of this.Children) {
const cc = c.Clone();
cc.Parent = cl;
cl.Children.push(cc);
}
return cl;
}
GetPropertyChangedSignal(propName) {
const sigName = `Changed:${propName}`;
if (!this.__signals[sigName]) this.__signals[sigName] = new RbxSignal(sigName);
return this.__signals[sigName];
}
}
/* ──────── Part — наследник Instance с реальными свойствами сцены ──────── */
class RbxPart extends RbxInstance {
constructor(primId, init = {}) {
super(init.ClassName || 'Part', init);
this.__primId = primId; // id примитива в Rublox-сцене
this.__sendFn = null; // setter из shim init
// Кешированные свойства (mirror'ятся через handleTick)
this._snap = init.snap || {};
}
get Position() {
return new RbxVector3(this._snap.x || 0, this._snap.y || 0, this._snap.z || 0);
}
set Position(v) {
if (v instanceof RbxVector3) {
this._snap.x = v.X; this._snap.y = v.Y; this._snap.z = v.Z;
this.__sendFn?.('partSet', { primId: this.__primId, prop: 'position', value: { x: v.X, y: v.Y, z: v.Z } });
}
}
get CFrame() {
return new RbxCFrame(this._snap.x || 0, this._snap.y || 0, this._snap.z || 0);
}
set CFrame(cf) {
if (cf instanceof RbxCFrame) {
this._snap.x = cf.X; this._snap.y = cf.Y; this._snap.z = cf.Z;
const [rx, ry, rz] = cf.toEulerXYZ();
this._snap.rx = rx; this._snap.ry = ry; this._snap.rz = rz;
this.__sendFn?.('partSet', { primId: this.__primId, prop: 'cframe', value: { x: cf.X, y: cf.Y, z: cf.Z, rx, ry, rz } });
}
}
get Size() {
return new RbxVector3(this._snap.sx || 1, this._snap.sy || 1, this._snap.sz || 1);
}
set Size(v) {
if (v instanceof RbxVector3) {
this._snap.sx = v.X; this._snap.sy = v.Y; this._snap.sz = v.Z;
this.__sendFn?.('partSet', { primId: this.__primId, prop: 'size', value: { sx: v.X, sy: v.Y, sz: v.Z } });
}
}
get Color() { return RbxColor3.fromHex(this._snap.color || '#cccccc'); }
set Color(c) {
if (c instanceof RbxColor3) {
const hex = c.toHex();
this._snap.color = hex;
this.__sendFn?.('partSet', { primId: this.__primId, prop: 'color', value: hex });
}
}
get BrickColor() { return { Color: this.Color, Name: 'Medium stone grey' }; }
set BrickColor(b) { if (b && b.Color) this.Color = b.Color; }
get Material() { return this._snap.material || 'glossy'; }
set Material(m) {
this._snap.material = m;
this.__sendFn?.('partSet', { primId: this.__primId, prop: 'material', value: m });
}
get Anchored() { return !!this._snap.anchored; }
set Anchored(v) {
this._snap.anchored = !!v;
this.__sendFn?.('partSet', { primId: this.__primId, prop: 'anchored', value: !!v });
}
get CanCollide() { return this._snap.canCollide !== false; }
set CanCollide(v) {
this._snap.canCollide = !!v;
this.__sendFn?.('partSet', { primId: this.__primId, prop: 'canCollide', value: !!v });
}
get Transparency() { return 1.0 - (this._snap.opacity ?? 1.0); }
set Transparency(v) {
this._snap.opacity = 1.0 - (+v || 0);
this.__sendFn?.('partSet', { primId: this.__primId, prop: 'opacity', value: this._snap.opacity });
}
get Velocity() { return new RbxVector3(0, 0, 0); }
set Velocity(v) {
if (v instanceof RbxVector3) {
this.__sendFn?.('partVel', { primId: this.__primId, vx: v.X, vy: v.Y, vz: v.Z });
}
}
}
/* ──────── Главный entry-point: регистрация в Lua-VM ──────── */
export function registerRobloxApi(lua, ctx) {
const { getSceneSnap, targetPrimitiveId, send } = ctx;
// 1. Math classes — как глобалы с .new factory
const wrap = (cls) => ({
new: (...args) => new cls(...args),
});
lua.global.set('Vector3', {
new: (x, y, z) => new RbxVector3(x, y, z),
zero: new RbxVector3(0, 0, 0),
one: new RbxVector3(1, 1, 1),
xAxis: new RbxVector3(1, 0, 0),
yAxis: new RbxVector3(0, 1, 0),
zAxis: new RbxVector3(0, 0, 1),
});
lua.global.set('Color3', {
new: (r, g, b) => new RbxColor3(r, g, b),
fromRGB: RbxColor3.fromRGB,
fromHex: RbxColor3.fromHex,
});
lua.global.set('CFrame', {
new: RbxCFrame.new,
Angles: RbxCFrame.Angles,
fromEulerAnglesXYZ: RbxCFrame.fromEulerAnglesXYZ,
});
lua.global.set('UDim', { new: (s, o) => new RbxUDim(s, o) });
lua.global.set('UDim2', {
new: RbxUDim2.new,
fromScale: RbxUDim2.fromScale,
fromOffset: RbxUDim2.fromOffset,
});
// 2. Сцена — собираем JS-структуру из snap'а
// Workspace — корень.
const workspace = new RbxInstance('Workspace', { Name: 'Workspace' });
const part_by_id = new Map();
const snap = getSceneSnap();
if (snap && snap.primitives) {
for (const [id, p] of Object.entries(snap.primitives)) {
const part = new RbxPart(+id, {
ClassName: p.type === 'wedge' ? 'WedgePart' :
p.type === 'cornerwedge' ? 'CornerWedgePart' : 'Part',
Name: p.name || 'Part',
snap: { ...p },
});
part.__sendFn = send;
part.Parent = workspace;
workspace.Children.push(part);
part_by_id.set(+id, part);
}
}
// 3. script — обёртка над текущим скриптом.
const scriptInst = new RbxInstance('LocalScript', { Name: 'Script' });
if (targetPrimitiveId != null && part_by_id.has(targetPrimitiveId)) {
const parentPart = part_by_id.get(targetPrimitiveId);
scriptInst.Parent = parentPart;
parentPart.Children.push(scriptInst);
}
lua.global.set('script', scriptInst);
// 4. game / game:GetService
const services = new Map();
const game = new RbxInstance('DataModel', { Name: 'Game' });
game.Children.push(workspace);
workspace.Parent = game;
// Builtin services:
const lighting = new RbxInstance('Lighting', { Name: 'Lighting' });
lighting.Parent = game;
game.Children.push(lighting);
services.set('Lighting', lighting);
const replicatedStorage = new RbxInstance('ReplicatedStorage', { Name: 'ReplicatedStorage' });
replicatedStorage.Parent = game;
game.Children.push(replicatedStorage);
services.set('ReplicatedStorage', replicatedStorage);
const runService = new RbxInstance('RunService', { Name: 'RunService' });
runService.Heartbeat = new RbxSignal('Heartbeat');
runService.Stepped = new RbxSignal('Stepped');
runService.RenderStepped = new RbxSignal('RenderStepped');
services.set('RunService', runService);
const playersService = new RbxInstance('Players', { Name: 'Players' });
playersService.PlayerAdded = new RbxSignal('PlayerAdded');
playersService.PlayerRemoving = new RbxSignal('PlayerRemoving');
// LocalPlayer заполнит фаза 4.9
playersService.LocalPlayer = null;
services.set('Players', playersService);
game.GetService = function(svc) {
if (services.has(svc)) return services.get(svc);
if (svc === 'Workspace') return workspace;
if (svc === 'Workspace') return workspace;
// Неизвестный сервис — создаём заглушку, чтобы не падало
const stub = new RbxInstance(svc, { Name: svc });
services.set(svc, stub);
return stub;
};
game.Workspace = workspace;
game.Lighting = lighting;
game.Players = playersService;
game.ReplicatedStorage = replicatedStorage;
lua.global.set('game', game);
lua.global.set('workspace', workspace);
lua.global.set('Workspace', workspace);
// 5. Instance.new
lua.global.set('Instance', {
new: (className, parent) => {
const inst = new RbxInstance(className);
if (parent && parent instanceof RbxInstance) {
inst.Parent = parent;
parent.Children.push(inst);
}
return inst;
},
});
// 6. wait / task / spawn — в фазе 4.7 заменим на корутинные.
// Сейчас — простой busy-wait через setTimeout не работает в Worker (sync).
// Поэтому MVP: wait это no-op, log warning.
lua.global.set('wait', (sec) => {
// TODO 4.7: реализовать через корутины
return [sec || 0, 0];
});
lua.global.set('task', {
wait: (sec) => sec || 0,
spawn: (fn, ...args) => { try { fn(...args); } catch (e) {} return null; },
delay: (sec, fn, ...args) => { try { fn(...args); } catch (e) {} return null; },
defer: (fn, ...args) => { try { fn(...args); } catch (e) {} return null; },
});
lua.global.set('spawn', (fn) => { try { fn(); } catch (e) {} });
lua.global.set('delay', (sec, fn) => { try { fn(); } catch (e) {} });
lua.global.set('tick', () => Date.now() / 1000);
lua.global.set('time', () => Date.now() / 1000);
lua.global.set('elapsedTime', () => Date.now() / 1000);
// 7. print / warn / error — пробрасываем в main как log
lua.global.set('print', (...args) => {
const text = args.map(a => luaToString(a)).join('\t');
send('log', { level: 'info', text });
});
lua.global.set('warn', (...args) => {
const text = args.map(a => luaToString(a)).join('\t');
send('log', { level: 'warn', text });
});
// 8. Enum — упрощённая заглушка для самых популярных enums
const enumTable = {
Material: { Plastic: { Value: 256, Name: 'Plastic' }, Neon: { Value: 784, Name: 'Neon' },
Metal: { Value: 512, Name: 'Metal' }, Glass: { Value: 1024, Name: 'Glass' },
Wood: { Value: 272, Name: 'Wood' }, SmoothPlastic: { Value: 496, Name: 'SmoothPlastic' } },
PartType: { Ball: { Value: 0, Name: 'Ball' }, Block: { Value: 1, Name: 'Block' },
Cylinder: { Value: 2, Name: 'Cylinder' } },
KeyCode: { Space: { Value: 32, Name: 'Space' }, W: { Value: 87, Name: 'W' },
A: { Value: 65, Name: 'A' }, S: { Value: 83, Name: 'S' }, D: { Value: 68, Name: 'D' } },
EasingStyle: { Linear: { Value: 0, Name: 'Linear' }, Quad: { Value: 1, Name: 'Quad' },
Sine: { Value: 5, Name: 'Sine' } },
EasingDirection: { In: { Value: 0, Name: 'In' }, Out: { Value: 1, Name: 'Out' },
InOut: { Value: 2, Name: 'InOut' } },
};
lua.global.set('Enum', enumTable);
return { workspace, game, part_by_id, services };
}
function luaToString(v) {
if (v == null) return 'nil';
if (typeof v === 'string') return v;
if (typeof v === 'number') return String(v);
if (typeof v === 'boolean') return String(v);
if (v.toString) return v.toString();
return '<object>';
}
export { RbxVector3, RbxColor3, RbxCFrame, RbxUDim, RbxUDim2, RbxInstance, RbxPart, RbxSignal };

View File

@ -1,204 +0,0 @@
/**
* roblox-tween.js TweenService для Roblox Lua-shim.
*
* Использование в Lua:
* local TS = game:GetService("TweenService")
* local info = TweenInfo.new(2, Enum.EasingStyle.Quad, Enum.EasingDirection.Out)
* local tween = TS:Create(part, info, {Position = Vector3.new(0, 10, 0)})
* tween:Play()
* tween.Completed:Connect(function() print("done") end)
*
* Реализация:
* - Все активные tween'ы держатся в этом модуле.
* - На каждом tick() прогрессируется alpha = (now - startTime) / duration.
* - Применяется easing-кривая, и обновляется свойство объекта через __sendFn.
* - При alpha >= 1 fire Completed signal и удаляем tween.
*/
import { RbxSignal, RbxVector3, RbxColor3, RbxCFrame, RbxUDim2 } from './roblox-shim.js';
/* ──────── EasingStyle / Direction ──────── */
const EASING_FNS = {
'Linear': (t) => t,
'Quad': (t) => t * t,
'Cubic': (t) => t * t * t,
'Quart': (t) => t * t * t * t,
'Quint': (t) => t * t * t * t * t,
'Sine': (t) => 1 - Math.cos((t * Math.PI) / 2),
'Bounce': (t) => {
const n1 = 7.5625, d1 = 2.75;
if (t < 1 / d1) return n1 * t * t;
if (t < 2 / d1) { t -= 1.5 / d1; return n1 * t * t + 0.75; }
if (t < 2.5 / d1) { t -= 2.25 / d1; return n1 * t * t + 0.9375; }
t -= 2.625 / d1; return n1 * t * t + 0.984375;
},
'Elastic': (t) => {
if (t === 0) return 0;
if (t === 1) return 1;
return -(2 ** (10 * (t - 1))) * Math.sin((t - 1.1) * 5 * Math.PI);
},
'Back': (t) => t * t * (2.70158 * t - 1.70158),
'Exponential': (t) => t === 0 ? 0 : 2 ** (10 * (t - 1)),
};
function applyDirection(t, direction) {
if (direction === 'In') return t;
if (direction === 'Out') return 1 - (1 - t);
if (direction === 'InOut') {
return t < 0.5 ? t * 2 : (1 - (1 - t) * 2);
}
return t;
}
function easeValue(alpha, style, direction) {
const styleFn = EASING_FNS[style] || EASING_FNS.Linear;
if (direction === 'In') return styleFn(alpha);
if (direction === 'Out') return 1 - styleFn(1 - alpha);
// InOut
if (alpha < 0.5) return styleFn(alpha * 2) / 2;
return 1 - styleFn((1 - alpha) * 2) / 2;
}
/* ──────── TweenInfo ──────── */
class RbxTweenInfo {
constructor(time = 1, easingStyle = 'Quad', easingDirection = 'Out',
repeatCount = 0, reverses = false, delayTime = 0) {
this.Time = +time || 0;
this.EasingStyle = typeof easingStyle === 'object' ? easingStyle.Name : easingStyle;
this.EasingDirection = typeof easingDirection === 'object' ? easingDirection.Name : easingDirection;
this.RepeatCount = repeatCount | 0;
this.Reverses = !!reverses;
this.DelayTime = +delayTime || 0;
}
}
/* ──────── Tween ──────── */
class RbxTween {
constructor(instance, info, goalProps, manager) {
this.Instance = instance;
this.TweenInfo = info;
this.GoalProps = goalProps;
this._manager = manager;
this._startTime = null;
this._fromProps = null;
this._playing = false;
this._completed = false;
this.Completed = new RbxSignal('Completed');
this.PlaybackState = 'Begin';
}
Play() {
if (this._playing) return;
// Снимок старых значений
this._fromProps = {};
for (const k of Object.keys(this.GoalProps)) {
this._fromProps[k] = this.Instance[k]; // через getter Part'а
}
this._startTime = this._manager.time;
this._playing = true;
this.PlaybackState = 'Playing';
this._manager._add(this);
}
Pause() { this._playing = false; this.PlaybackState = 'Paused'; }
Cancel() {
this._playing = false;
this.PlaybackState = 'Cancelled';
this._manager._remove(this);
}
/** internal — вызывается из manager.tick */
_step(now) {
if (!this._playing) return false;
const elapsed = now - this._startTime;
const dur = this.TweenInfo.Time || 0.001;
let alpha = Math.min(1, Math.max(0, elapsed / dur));
const ea = easeValue(alpha, this.TweenInfo.EasingStyle, this.TweenInfo.EasingDirection);
for (const k of Object.keys(this.GoalProps)) {
const from = this._fromProps[k];
const to = this.GoalProps[k];
const interp = interpolate(from, to, ea);
// Set через setter в Part — он отправит partSet в main
try { this.Instance[k] = interp; } catch (e) {}
}
if (alpha >= 1) {
this._playing = false;
this._completed = true;
this.PlaybackState = 'Completed';
this.Completed.Fire('Completed');
return true; // удалить из активных
}
return false;
}
}
function interpolate(from, to, a) {
if (from instanceof RbxVector3 && to instanceof RbxVector3) {
return from.Lerp(to, a);
}
if (from instanceof RbxColor3 && to instanceof RbxColor3) {
return from.Lerp(to, a);
}
if (from instanceof RbxCFrame && to instanceof RbxCFrame) {
return from.Lerp(to, a);
}
if (typeof from === 'number' && typeof to === 'number') {
return from + (to - from) * a;
}
// Иначе ничего не интерполируем
return a >= 1 ? to : from;
}
/* ──────── Manager ──────── */
export class RobloxTweenManager {
constructor() {
this.active = new Set();
this.time = 0;
}
install(lua) {
const self = this;
// TweenInfo конструктор
lua.global.set('TweenInfo', {
new: (time, style, direction, repeat_, reverses, delay_) =>
new RbxTweenInfo(time, style, direction, repeat_, reverses, delay_),
});
// Сервис: добавляем в services через game:GetService('TweenService')
// (services map передаётся в shim — но мы не имеем к нему доступа здесь;
// делаем по-другому: регистрируем сразу глобал TweenService который
// совместим с GetService('TweenService'))
const tweenService = {
ClassName: 'TweenService',
Name: 'TweenService',
Create(instance, info, goalProps) {
return new RbxTween(instance, info, goalProps, self);
},
};
lua.global.set('__tweenService', tweenService);
// и в game.GetService — мы делаем монки-патч если игра уже есть:
const game = lua.global.get('game');
if (game && typeof game.GetService === 'function') {
const origGetService = game.GetService;
game.GetService = function(svc) {
if (svc === 'TweenService') return tweenService;
return origGetService.call(this, svc);
};
}
}
_add(tween) { this.active.add(tween); }
_remove(tween) { this.active.delete(tween); }
tick(dtSec) {
this.time += +dtSec || 0;
for (const t of [...this.active]) {
const done = t._step(this.time);
if (done) this.active.delete(t);
}
}
}
export { RbxTweenInfo, RbxTween };