feat(player): ������������� �� ������� (Lua + JS-API + Roblox-������ + LoadingOverlay)
All checks were successful
CI / Lint (push) Successful in 54s
CI / Build (push) Successful in 1m30s
CI / Secret scan (push) Successful in 20s
CI / PR size check (push) Has been skipped
CI / Deploy to S1 + S2 (push) Successful in 2m56s

This commit is contained in:
min 2026-06-09 22:01:51 +00:00
parent 3330715781
commit a5e1558c2d
17 changed files with 4956 additions and 62 deletions

11
.WORKTREE_NOTICE.md Normal file
View File

@ -0,0 +1,11 @@
# Активная сессия: импорт Roblox .rbxl
Это **отдельный worktree** для разработки `feat/rbxl-import` — импорта Roblox-карт в Rublox.
**Не работайте здесь параллельно из других сессий!**
Ветка: `feat/rbxl-import`
Сервис на сервере: VM 130 на S1
Сопутствующий worktree: `Desktop/studio-rbxl-import`
Started: 2026-06-07

21
package-lock.json generated
View File

@ -18,7 +18,8 @@
"react": "18.3.1",
"react-dom": "18.3.1",
"react-router-dom": "7.4.0",
"socket.io-client": "^4.8.3"
"socket.io-client": "^4.8.3",
"wasmoon": "^1.16.0"
},
"devDependencies": {
"@types/react": "18.3.12",
@ -1427,6 +1428,12 @@
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
"license": "MIT"
},
"node_modules/@types/emscripten": {
"version": "1.39.10",
"resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.39.10.tgz",
"integrity": "sha512-TB/6hBkYQJxsZHSqyeuO1Jt0AB/bW6G7rHt9g7lML7SOF6lbgcHvw/Lr+69iqN0qxgXLhWKScAon73JNnptuDw==",
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@ -5206,6 +5213,18 @@
}
}
},
"node_modules/wasmoon": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/wasmoon/-/wasmoon-1.16.0.tgz",
"integrity": "sha512-FlRLb15WwAOz1A9OQDbf6oOKKSiefi5VK0ZRF2wgH9xk3o5SnU11tNPaOnQuAh1Ucr66cwwvVXaeVRaFdRBt5g==",
"license": "MIT",
"dependencies": {
"@types/emscripten": "1.39.10"
},
"bin": {
"wasmoon": "bin/wasmoon"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View File

@ -49,7 +49,8 @@
"react": "18.3.1",
"react-dom": "18.3.1",
"react-router-dom": "7.4.0",
"socket.io-client": "^4.8.3"
"socket.io-client": "^4.8.3",
"wasmoon": "^1.16.0"
},
"devDependencies": {
"@types/react": "18.3.12",

View File

@ -2863,6 +2863,7 @@ export class BabylonScene {
if (md.isBlock) {
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.isPrimitive) return { kind: 'primitive', id: md.primitiveId };
return null;
@ -3104,7 +3105,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 point = pick?.point ? { x: pick.point.x, y: pick.point.y, z: pick.point.z } : null;
// 1) Self-onClick — только если target есть

View File

@ -17,6 +17,9 @@
import { Color3 } from '@babylonjs/core';
import { ScriptSandbox } from './ScriptSandbox';
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';
import { LabelManager } from './LabelManager'; // задача: scene.setLabel (require крашит в браузере)
export class GameRuntime {
@ -101,7 +104,55 @@ export class GameRuntime {
// (на старте) возвращает null → подписки obj.onTouch/find не работают.
let 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) {
// Roblox-Lua скрипты импортированные через rbxl-importer.
if (s && typeof s.code === 'string' && s.code.startsWith('// @roblox-lua')) {
if (!runImportedRbxl) { rbxlSkipped++; continue; }
const meta = parseRobloxLuaMeta(s.code);
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;
}
if (!s || typeof s.code !== 'string' || !s.code.trim()) {
// eslint-disable-next-line no-console
console.warn('[GameRuntime] skipping invalid script entry', s);
@ -131,6 +182,132 @@ export class GameRuntime {
// eslint-disable-next-line no-console
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}`);
// Подцепляемся на изменения HP игрока, чтобы шлать событие 'hpChange'
// во все sandbox'ы. Не перезаписываем существующий обработчик —
@ -482,6 +659,14 @@ export class GameRuntime {
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() {
if (this.sandboxes.length > 0) {
this._log('info', 'Остановка скриптов');
@ -489,6 +674,11 @@ export class GameRuntime {
console.log('[GameRuntime] stopping', this.sandboxes.length, 'sandboxes');
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 они остаются на сцене
// и накапливаются при повторных запусках.
@ -621,7 +811,55 @@ export class GameRuntime {
tick(dt) {
if (!this._isRunning || this.sandboxes.length === 0) return;
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) {
// Синк 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
const stateForSb = 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 над
* персонажами, врагами, предметами. Метка всегда повёрнута лицом к камере
* (billboardMode=7), всегда поверх геометрии (renderingGroupId=1).
* game.scene.setLabel(ref, text, opts) имена/HP/таймеры/счётчики над
* персонажами, врагами, предметами. По умолчанию плашка повёрнута лицом к
* камере (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 { StandardMaterial } from '@babylonjs/core/Materials/standardMaterial';
import { MeshBuilder } from '@babylonjs/core/Meshes/meshBuilder';
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 {
constructor(scene) {
this.scene = scene;
// ref-строка объекта → { plane, tex, mat }
// ref-строка объекта → { plane, tex, mat, lastKey, opts }
this.labels = new Map();
this._playerMesh = null; // для maxDistance — задаётся из BabylonScene
}
/** Дать ссылку на меш игрока (для maxDistance-скрытия). */
setPlayerMesh(mesh) { this._playerMesh = mesh; }
/**
* Установить/обновить метку над объектом.
* ref ref-строка объекта (от scene.spawn / scene.find).
* anchorMesh Babylon-меш объекта (метка крепится к нему).
* text текст метки.
* opts { color: '#fff', height: 2.5 (м над объектом), size: 1 }
* Установить/обновить плашку над объектом.
* ref ref-строка объекта.
* anchorMesh Babylon-меш объекта (плашка крепится к нему).
* text текст (может содержать richText-теги если opts.richText).
* opts см. LABEL_PRESETS + { color, height, size, background,
* borderColor, borderWidth, cornerRadius, padding, textStroke,
* fontWeight, faceMode, rotationY, attachPoint, preset,
* richText, maxDistance }
*/
setLabel(ref, anchorMesh, text, opts = {}) {
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 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);
// Размер текстуры: чем больше текста — тем шире, чтобы не растягивать.
const fontPx = 120;
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);
tex.updateSamplingMode?.(3); // TRILINEAR
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;
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}`,
{ 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);
mat.diffuseTexture = tex;
mat.diffuseTexture.hasAlpha = true;
mat.emissiveColor = new Color3(1, 1, 1);
mat.diffuseColor = new Color3(0, 0, 0);
mat.disableLighting = true;
// Геометрия двусторонняя (DOUBLESIDE) с прямыми backUVs — culling можно
// включить, дублей нет; текст читается с обеих сторон без зеркала.
mat.backFaceCulling = false;
mat.disableDepthWrite = true;
mat.useAlphaFromDiffuseTexture = true;
plane.material = mat;
plane.billboardMode = 7; // всегда лицом к камере
plane.renderingGroupId = 1; // поверх геометрии
plane.renderingGroupId = 1;
plane.isPickable = false;
// Крепим к объекту: метка висит над ним и двигается вместе с ним.
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) {
const rec = this.labels.get(ref);
if (!rec) return;
@ -84,7 +389,7 @@ export class LabelManager {
this.labels.delete(ref);
}
/** Удалить все метки (при выходе из Play). */
/** Удалить все плашки (при выходе из Play). */
clearAll() {
for (const ref of [...this.labels.keys()]) this.clearLabel(ref);
}

View File

@ -35,7 +35,25 @@ function injectSpinnerCss() {
style.textContent =
'@keyframes kbn-ls-spin{from{transform:rotate(0)}to{transform:rotate(360deg)}}' +
'.kbn-ls-spinner{animation:kbn-ls-spin 0.9s linear infinite}' +
'@media (prefers-reduced-motion:reduce){.kbn-ls-spinner{animation:none}}';
// Ken Burns — медленный pan+zoom фона (задача 05).
'@keyframes kbn-ls-kenburns{' +
'0%{transform:scale(1.0) translate3d(0,0,0)}' +
'50%{transform:scale(1.10) translate3d(-3%,-2%,0)}' +
'100%{transform:scale(1.0) translate3d(-6%,0,0)}}' +
'.kbn-ls-kenburns{animation:kbn-ls-kenburns 24s ease-in-out infinite}' +
// particles — медленно всплывающие искры.
'@keyframes kbn-ls-rise{' +
'0%{transform:translateY(0) scale(1);opacity:0}' +
'10%{opacity:0.9}' +
'90%{opacity:0.7}' +
'100%{transform:translateY(-110vh) scale(1.4);opacity:0}}' +
'.kbn-ls-particle{animation:kbn-ls-rise linear infinite}' +
// лёгкий «дыхательный» glow карточки-превью.
'@keyframes kbn-ls-cardglow{' +
'0%,100%{box-shadow:0 18px 60px rgba(0,0,0,0.6),0 0 0 rgba(120,160,255,0)}' +
'50%{box-shadow:0 18px 60px rgba(0,0,0,0.6),0 0 40px rgba(120,160,255,0.35)}}' +
'.kbn-ls-cardglow{animation:kbn-ls-cardglow 4s ease-in-out infinite}' +
'@media (prefers-reduced-motion:reduce){.kbn-ls-spinner,.kbn-ls-kenburns,.kbn-ls-particle,.kbn-ls-cardglow{animation:none}}';
document.head.appendChild(style);
} catch { /* ignore */ }
}
@ -49,14 +67,17 @@ export class LoadingScreenOverlay {
// Мост наружу (GameRuntime подписывает) — id-based колбэки.
this._onSkipCb = null; // (id) => void
this._onCompleteCb = null; // (id) => void
this._onHideCb = null; // () => void — задача 05 (game.loading.onHide)
this._parallaxHandler = null;
// DOM-ссылки активного экрана:
this._els = null;
}
/** Колбэки-мост к GameRuntime (он рассылает globalEvent в sandbox'ы). */
setBridge(onSkip, onComplete) {
setBridge(onSkip, onComplete, onHide) {
this._onSkipCb = onSkip;
this._onCompleteCb = onComplete;
if (onHide) this._onHideCb = onHide;
}
/** Конфиг проекта (логотип/акцент по умолчанию) из BabylonScene._loadingConfig. */
@ -104,6 +125,15 @@ export class LoadingScreenOverlay {
logoCornerRadius: opts.logoCornerRadius != null ? Number(opts.logoCornerRadius) : 12,
// Текст под картинкой
text: opts.text != null ? String(opts.text) : '',
// --- Задача 05: Ken-Burns фон + карточка места ---
// style: 'ken-burns' | 'static' | 'parallax' | 'particles'
style: opts.style || cfg.style || 'ken-burns',
// фоновое размытое изображение (на весь экран); резолвится в _resolveCover.
background: opts.background != null ? opts.background : (cfg.background || null),
// карточка-витрина по центру (название места + автор), как в Roblox.
placeName: opts.placeName != null ? String(opts.placeName) : (cfg.placeName || ''),
studioName: opts.studioName != null ? String(opts.studioName) : (cfg.studioName || ''),
verified: opts.verified != null ? !!opts.verified : !!cfg.verified,
// Поведение
blockInput: opts.blockInput !== false,
pauseSimulation: opts.pauseSimulation !== false,
@ -163,20 +193,107 @@ export class LoadingScreenOverlay {
// (используем opacity всего root для fade, а bgOpacity — через rgba фон):
root.style.background = this._bgRgba(st.bgColor, st.bgOpacity);
// --- Cover (картинка по центру) ---
const coverUrl = this._resolveCover(cover);
const coverImg = document.createElement('div');
coverImg.style.cssText =
'max-width:min(62vw,860px);width:62vw;aspect-ratio:16/9;border-radius:16px;' +
'box-shadow:0 18px 60px rgba(0,0,0,0.6);background-size:cover;background-position:center;' +
'background-color:#1a1f2b;margin-bottom:140px;';
if (coverUrl) coverImg.style.backgroundImage = `url("${coverUrl}")`;
// --- Фоновый слой (Ken Burns / parallax / static) ---
// Размытое изображение игры на весь экран. Отдельный div под контентом,
// чтобы blur/анимация не трогали карточку и текст.
const bgUrl = this._resolveCover(st.background);
const bgLayer = document.createElement('div');
let bgClass = '';
if (bgUrl) {
if (st.style === 'ken-burns') bgClass = 'kbn-ls-kenburns';
bgLayer.className = bgClass;
bgLayer.style.cssText =
'position:absolute;inset:-8%;z-index:0;background-size:cover;background-position:center;' +
'filter:blur(8px) brightness(0.55);will-change:transform;' +
`background-image:url("${bgUrl}");`;
// parallax — лёгкий сдвиг по мыши (2 слоя имитируем одним + transform).
if (st.style === 'parallax') {
bgLayer.style.transition = 'transform 0.25s ease-out';
this._parallaxHandler = (e) => {
const cx = (e.clientX / window.innerWidth - 0.5) * 28;
const cy = (e.clientY / window.innerHeight - 0.5) * 18;
bgLayer.style.transform = `translate3d(${-cx}px,${-cy}px,0) scale(1.06)`;
};
window.addEventListener('mousemove', this._parallaxHandler);
}
root.appendChild(bgLayer);
}
// --- Текст под картинкой ---
// --- particles слой (медленные искры) ---
if (st.style === 'particles') {
const pLayer = document.createElement('div');
pLayer.style.cssText = 'position:absolute;inset:0;z-index:1;overflow:hidden;pointer-events:none;';
for (let i = 0; i < 26; i++) {
const sp = document.createElement('span');
sp.className = 'kbn-ls-particle';
const size = 2 + Math.round(Math.random() * 4);
const dur = 7 + Math.random() * 10;
sp.style.cssText =
`position:absolute;bottom:-10px;left:${(Math.random() * 100).toFixed(1)}%;` +
`width:${size}px;height:${size}px;border-radius:50%;` +
`background:rgba(${180 + Math.round(Math.random() * 70)},${190 + Math.round(Math.random() * 60)},255,0.85);` +
`box-shadow:0 0 ${size * 2}px rgba(140,170,255,0.7);` +
`animation-duration:${dur.toFixed(1)}s;animation-delay:${(-Math.random() * dur).toFixed(1)}s;`;
pLayer.appendChild(sp);
}
root.appendChild(pLayer);
}
// Обёртка контента (над фоном).
const content = document.createElement('div');
content.style.cssText =
'position:relative;z-index:2;display:flex;flex-direction:column;align-items:center;justify-content:center;width:100%;height:100%;';
// --- Cover (картинка-карточка по центру) ---
const coverUrl = this._resolveCover(cover);
// Режим карточки места (задача 05): квадрат + название + автор под ней.
const hasPlaceCard = !!(st.placeName || st.studioName);
const coverImg = document.createElement('div');
if (hasPlaceCard) {
coverImg.className = 'kbn-ls-cardglow';
coverImg.style.cssText =
'width:min(42vw,400px);aspect-ratio:1/1;border-radius:18px;' +
'background-size:cover;background-position:center;background-color:#1a1f2b;' +
'border:2px solid rgba(255,255,255,0.12);';
} else {
coverImg.style.cssText =
'max-width:min(62vw,860px);width:62vw;aspect-ratio:16/9;border-radius:16px;' +
'box-shadow:0 18px 60px rgba(0,0,0,0.6);background-size:cover;background-position:center;' +
'background-color:#1a1f2b;margin-bottom:140px;';
}
if (coverUrl) coverImg.style.backgroundImage = `url("${coverUrl}")`;
else if (bgUrl && hasPlaceCard) coverImg.style.backgroundImage = `url("${bgUrl}")`;
// --- Название места (крупный белый, под карточкой) ---
const placeEl = document.createElement('div');
placeEl.style.cssText =
'margin-top:22px;color:#fff;font-size:38px;font-weight:800;letter-spacing:0.5px;' +
'text-align:center;text-shadow:0 3px 14px rgba(0,0,0,0.7);' +
(st.placeName ? '' : 'display:none;');
placeEl.textContent = st.placeName || '';
// --- Автор + verified-галочка ---
const studioRow = document.createElement('div');
studioRow.style.cssText =
'margin-top:8px;display:flex;align-items:center;gap:7px;' +
'color:#cdd6e6;font-size:16px;font-weight:600;text-shadow:0 1px 4px rgba(0,0,0,0.6);' +
(st.studioName ? '' : 'display:none;');
const studioTxt = document.createElement('span');
studioTxt.textContent = st.studioName || '';
studioRow.appendChild(studioTxt);
if (st.verified) studioRow.appendChild(this._buildVerifiedBadge());
// --- Текст под картинкой (для не-карточного режима / mid-game) ---
const textEl = document.createElement('div');
textEl.style.cssText =
'position:absolute;left:50%;transform:translateX(-50%);bottom:170px;' +
'color:#e8edf5;font-size:18px;font-weight:500;text-align:center;text-shadow:0 1px 3px rgba(0,0,0,0.6);';
if (hasPlaceCard) {
textEl.style.cssText =
'margin-top:14px;color:#e8edf5;font-size:16px;font-weight:500;text-align:center;' +
'text-shadow:0 1px 3px rgba(0,0,0,0.6);' + (st.text ? '' : 'display:none;');
} else {
textEl.style.cssText =
'position:absolute;left:50%;transform:translateX(-50%);bottom:170px;' +
'color:#e8edf5;font-size:18px;font-weight:500;text-align:center;text-shadow:0 1px 3px rgba(0,0,0,0.6);';
}
textEl.textContent = st.text || '';
// --- Прогресс-бар ---
@ -245,8 +362,13 @@ export class LoadingScreenOverlay {
spinWrap.appendChild(spinTxt);
spinWrap.appendChild(spinCircle);
root.appendChild(coverImg);
root.appendChild(textEl);
// Центральная композиция (карточка + название + автор + текст) — в content.
content.appendChild(coverImg);
content.appendChild(placeEl);
content.appendChild(studioRow);
content.appendChild(textEl);
root.appendChild(content);
// Нижние/угловые элементы — абсолютно на root (поверх фона, рядом с content).
root.appendChild(barWrap);
root.appendChild(percent);
root.appendChild(skipBtn);
@ -255,7 +377,19 @@ export class LoadingScreenOverlay {
parent.appendChild(root);
this.root = root;
this._els = { root, coverImg, textEl, barWrap, bar, percent, skipBtn, logo, spinWrap };
this._els = { root, content, coverImg, placeEl, studioRow, textEl, barWrap, bar, percent, skipBtn, logo, spinWrap };
}
/** Verified-галочка (синий круг + белый чек) — inline SVG, без emoji-шрифта. */
_buildVerifiedBadge() {
const wrap = document.createElement('span');
wrap.style.cssText = 'display:inline-flex;align-items:center;';
wrap.innerHTML =
'<svg width="18" height="18" viewBox="0 0 24 24" aria-label="verified">' +
'<circle cx="12" cy="12" r="11" fill="#3897f0"/>' +
'<path d="M7 12.5l3 3 7-7" fill="none" stroke="#fff" stroke-width="2.4" ' +
'stroke-linecap="round" stroke-linejoin="round"/></svg>';
return wrap;
}
/** tick — fade-фазы, авто-duration, появление кнопки Пропустить. */
@ -329,6 +463,23 @@ export class LoadingScreenOverlay {
if (url) this._els.coverImg.style.backgroundImage = `url("${url}")`;
}
/** Задача 05: сменить фоновое (Ken-Burns) изображение на лету. */
setBackground(bg) {
if (!this._st || !this._els) return;
const url = this._resolveCover(bg);
if (!url) return;
this._st.background = bg;
// фоновый слой — первый ребёнок root с background-image; найдём его.
const layer = this._els.root.querySelector('.kbn-ls-kenburns')
|| this._els.root.firstElementChild;
if (layer && layer !== this._els.content) layer.style.backgroundImage = `url("${url}")`;
}
/** Задача 05: виден ли экран сейчас. */
isVisible() {
return !!(this._st && this._st.phase !== 'out');
}
/** Закрыть программно (с fadeOut). */
close() {
const st = this._st;
@ -361,6 +512,13 @@ export class LoadingScreenOverlay {
if (st.blockInput) { try { this.s.player?.setInputBlocked?.(false); } catch { /* ignore */ } }
if (st.pauseSimulation && this.s.gameRuntime) { try { this.s.gameRuntime.paused = false; } catch { /* ignore */ } }
}
// Снять parallax-listener (задача 05).
if (this._parallaxHandler) {
try { window.removeEventListener('mousemove', this._parallaxHandler); } catch { /* ignore */ }
this._parallaxHandler = null;
}
// onHide-мост (задача 05) — сообщаем скриптам что экран скрылся.
if (this._onHideCb) { try { this._onHideCb(); } catch { /* ignore */ } }
if (this.root) { try { this.root.remove(); } catch { /* ignore */ } }
this.root = null;
this._els = null;

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

@ -121,6 +121,13 @@ let _unlockedSkins = [];
let _currentSkin = null;
let _skinChangeHandlers = [];
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)
let _guiClickHandlers = {};
// Подписки game.gui.onSubmit(id, fn) — ввод в TextBox завершён (Enter)
@ -682,7 +689,9 @@ function _buildSelfApi() {
_send('self.move', { target: _target, x: nx, y: ny, z: nz });
}
},
/** Повернуть объект-носитель вокруг оси Y на угол ry (радианы). */
/**
* Повернуть объект-носитель вокруг вертикальной оси Y на угол ry (радианы).
*/
rotate(ry) {
const r = Number(ry);
if (!Number.isFinite(r)) return;
@ -697,7 +706,7 @@ function _buildSelfApi() {
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;
@ -710,13 +719,14 @@ function _buildSelfApi() {
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;
@ -1155,6 +1165,18 @@ const game = {
* game.player.giveTool('blaster-blaster-a', { equip: true });
*/
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;
opts = opts || {};
const isBlaster = toolType.indexOf('blaster') === 0;
@ -1269,7 +1291,8 @@ const game = {
* game.tween(coin, { sy: 1.4 }, { duration: 0.5, yoyo: true, repeat: -1 });
*/
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 || {};
const id = ++_tweenSeq;
if (typeof opts.onDone === 'function') _tweenCallbacks[id] = opts.onDone;
@ -1380,6 +1403,32 @@ const game = {
if (!sessionId) return;
_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 игрока (получение урона / лечение / смерть).
* fn(event) где event = { hp, maxHp, source, damaged, delta }.
@ -2807,7 +2856,7 @@ const game = {
clear() {
_send('inventory.clear', {});
},
// === Задача 44: drag-drop инвентарь ===
// === Задача 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', {}); },
@ -2816,12 +2865,87 @@ const game = {
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 мультиплеер).
* В одиночной игре (редактор) только локальный игрок.
@ -3935,12 +4059,15 @@ self.onmessage = (e) => {
if (t === 'click') {
for (const fn of _globalClickHandlers) _safeCall(fn, payload, 'onClick');
} else if (t === 'leaderstatsChange') {
// Задача 20: стат изменился на main — обновляем зеркало + onChange.
// Задача 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) }); } }
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') {
@ -3997,13 +4124,34 @@ self.onmessage = (e) => {
for (const fn of arr) _safeCall(fn, ev, 'npc.onDeath');
}
} else if (t === 'toolUse') {
// payload: { tool: {kind, modelTypeId, name}, point, target }
// payload: { tool: {kind, modelTypeId, name, customToolId?}, point, target }
const ev = {
tool: payload.tool || null,
point: payload.point || 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');
} 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') {
// Катсцена камеры завершилась (Фаза 5.7).
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

@ -0,0 +1,243 @@
/**
* rbxl-lua-integration.test.js реалистичные Roblox-сниппеты из obby/simulator карт.
*/
import { LuaFactory } from 'wasmoon';
import { registerRobloxApi } from '../src/engine/roblox-shim.js';
import { RobloxScheduler } from '../src/engine/roblox-scheduler.js';
import { installRobloxServices } from '../src/engine/roblox-services.js';
import { RobloxTweenManager } from '../src/engine/roblox-tween.js';
import { RobloxPhysicsManager } from '../src/engine/roblox-physics.js';
function makeScene() {
return {
primitives: {
10: { id: 10, type: 'cube', name: 'KillPart', x: 5, y: 1, z: 0, sx: 4, sy: 1, sz: 4,
color: '#ff0000', material: 'neon', anchored: true, canCollide: true, opacity: 1 },
11: { id: 11, type: 'cube', name: 'WinPart', x: 30, y: 1, z: 0, sx: 4, sy: 1, sz: 4,
color: '#00ff00', material: 'neon', anchored: true, canCollide: true, opacity: 1 },
12: { id: 12, type: 'cube', name: 'Conveyor', x: 15, y: 1, z: 0, sx: 8, sy: 0.5, sz: 4,
color: '#888888', material: 'metal', anchored: true, canCollide: true, opacity: 1 },
13: { id: 13, type: 'cube', name: 'Door', x: 20, y: 3, z: 0, sx: 2, sy: 6, sz: 4,
color: '#a0522d', material: 'matte', anchored: true, canCollide: true, opacity: 1 },
},
};
}
const STORE = new Map();
async function run(luaSource, targetPrimId = 10, ticks = []) {
const factory = new LuaFactory();
const lua = await factory.createEngine();
const sent = [];
const send = (cmd, payload) => sent.push({ cmd, payload });
let playerState = { x: 0, y: 5, z: 0, hp: 100 };
registerRobloxApi(lua, { getSceneSnap: makeScene, targetPrimitiveId: targetPrimId, send });
const sched = new RobloxScheduler(lua);
sched.install();
installRobloxServices(lua, {
send,
getPlayerState: () => playerState,
loadSave: (k) => STORE.get(k),
saveSave: (k, v) => STORE.set(k, v),
removeSave: (k) => STORE.delete(k),
});
const tween = new RobloxTweenManager();
tween.install(lua);
const phys = new RobloxPhysicsManager(send);
phys.install(lua);
await sched.spawnMain(luaSource);
for (const dt of ticks) {
await sched.tick(dt);
tween.tick(dt);
phys.tick(dt);
}
lua.global.close();
return {
logs: sent.filter(s => s.cmd === 'log').map(s => s.payload),
partSets: sent.filter(s => s.cmd === 'partSet').map(s => s.payload),
partVels: sent.filter(s => s.cmd === 'partVel').map(s => s.payload),
playerCmds: sent.filter(s => s.cmd === 'playerCmd').map(s => s.payload),
};
}
const TESTS = [
{
name: 'KillBrick (Touched → Humanoid.Health = 0)',
lua: `
local part = script.Parent
part.Touched:Connect(function(hit)
local hum = hit.Parent and hit.Parent:FindFirstChild("Humanoid")
if hum then hum.Health = 0 end
end)
print("kill brick armed")
`,
ticks: [],
check: (r) => r.logs.some(l => l.text === 'kill brick armed'),
},
{
name: 'WalkSpeed boost через trigger',
lua: `
local h = game:GetService("Players").LocalPlayer.Character.Humanoid
h.WalkSpeed = 32
print("speed boosted to", h.WalkSpeed)
`,
check: (r) => r.playerCmds.some(c => c.method === 'setWalkSpeed' && c.args[0] === 32)
&& r.logs.some(l => l.text.includes('speed boosted')),
},
{
name: 'Door open: TweenService двигает дверь вверх',
lua: `
local door = workspace:FindFirstChild("Door")
local TS = game:GetService("TweenService")
local goal = { Position = Vector3.new(door.Position.X, door.Position.Y + 10, door.Position.Z) }
local info = TweenInfo.new(1, Enum.EasingStyle.Linear, Enum.EasingDirection.Out)
local tw = TS:Create(door, info, goal)
tw:Play()
print("door opening")
`,
ticks: [0.5, 0.5, 0.1],
check: (r) => r.partSets.some(p => p.primId === 13 && p.prop === 'position'),
},
{
name: 'Конвейер: BodyVelocity толкает игрока',
lua: `
local conv = workspace:FindFirstChild("Conveyor")
local bv = Instance.new("BodyVelocity", conv)
bv.Velocity = Vector3.new(20, 0, 0)
bv.MaxForce = Vector3.new(4000, 0, 4000)
print("conveyor started")
`,
ticks: [0.1],
check: (r) => r.partVels.some(v => v.primId === 12 && v.vx === 20),
},
{
name: 'leaderstats (как в tycoon)',
lua: `
local Players = game:GetService("Players")
local plr = Players.LocalPlayer
local money = Instance.new("IntValue", plr.leaderstats)
money.Name = "Money"
money.Value = 100
print("money:", money.Value)
`,
check: (r) => r.logs.some(l => l.text === 'money:\t100'),
},
{
name: 'Checkpoint сохраняется в DataStore',
lua: `
local DSS = game:GetService("DataStoreService")
local store = DSS:GetDataStore("checkpoints")
store:SetAsync("player1", 5)
local cp = store:GetAsync("player1")
print("checkpoint:", cp)
`,
check: (r) => r.logs.some(l => l.text === 'checkpoint:\t5'),
},
{
name: 'Цикл с wait — подсчёт',
lua: `
for i = 1, 3 do
print("count:", i)
wait(0.3)
end
print("done")
`,
ticks: [0.3, 0.3, 0.3, 0.3],
check: (r) => {
const texts = r.logs.map(l => l.text);
return texts.includes('count:\t1') && texts.includes('count:\t2')
&& texts.includes('count:\t3') && texts.includes('done');
},
},
{
name: 'task.spawn — параллельные функции',
lua: `
task.spawn(function() print("parallel A") end)
task.spawn(function() print("parallel B") end)
print("main")
`,
check: (r) => {
const texts = r.logs.map(l => l.text);
return texts.includes('parallel A') && texts.includes('parallel B') && texts.includes('main');
},
},
{
name: 'Color3 + Material смена при Touched',
lua: `
local part = workspace:FindFirstChild("KillPart")
part.Touched:Connect(function()
part.Color = Color3.fromRGB(0, 0, 255)
part.Material = "Neon"
end)
-- симулируем touch
part.Touched:Fire(workspace)
`,
check: (r) => r.partSets.some(p => p.primId === 10 && p.prop === 'color')
&& r.partSets.some(p => p.primId === 10 && p.prop === 'material'),
},
{
name: 'RemoteEvent: client→server message',
lua: `
local re = Instance.new("RemoteEvent", workspace)
re.Name = "Coins"
re.OnServerEvent:Connect(function(player, amount)
print("server received:", amount)
end)
re:FireServer(50)
`,
check: (r) => r.logs.some(l => l.text === 'server received:\t50'),
},
{
name: 'Heartbeat: счётчик через RunService',
lua: `
local RS = game:GetService("RunService")
local count = 0
RS.Heartbeat:Connect(function(dt)
count = count + 1
if count == 3 then print("tick3") end
end)
`,
ticks: [0.1, 0.1, 0.1],
check: (r) => r.logs.some(l => l.text === 'tick3'),
},
{
name: 'Math: Vector3 arithmetic',
lua: `
local a = Vector3.new(1, 2, 3)
local b = Vector3.new(4, 5, 6)
local sum = a:add(b)
print("sum:", sum.X, sum.Y, sum.Z)
local d = a:Dot(b)
print("dot:", d)
`,
check: (r) => {
const texts = r.logs.map(l => l.text);
return texts.some(t => t === 'sum:\t5\t7\t9') && texts.some(t => t === 'dot:\t32');
},
},
];
(async () => {
let passed = 0, failed = 0;
for (const t of TESTS) {
try {
const r = await run(t.lua, t.targetPrimId, t.ticks || []);
const ok = t.check(r);
if (ok) { console.log(`${t.name}`); passed++; }
else {
console.log(`${t.name}`);
console.log(` logs: ${JSON.stringify(r.logs.map(l => l.text))}`);
if (r.partSets.length) console.log(` partSets: ${JSON.stringify(r.partSets)}`);
if (r.partVels.length) console.log(` partVels: ${JSON.stringify(r.partVels)}`);
if (r.playerCmds.length) console.log(` playerCmds: ${JSON.stringify(r.playerCmds)}`);
failed++;
}
} catch (e) {
console.log(`${t.name} — exception: ${e.message || e}`);
failed++;
}
}
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed > 0 ? 1 : 0);
})();

187
tests/rbxl-lua-mvp.test.js Normal file
View File

@ -0,0 +1,187 @@
/**
* rbxl-lua-mvp.test.js headless smoke-тест Roblox Lua API shim.
*
* НЕ запускает Worker (это требует браузерного Worker API). Вместо этого
* напрямую импортирует roblox-shim.js и инициализирует Lua в текущем потоке.
*
* Запуск: node --experimental-vm-modules tests/rbxl-lua-mvp.test.js
*/
import { LuaFactory } from 'wasmoon';
import { registerRobloxApi } from '../src/engine/roblox-shim.js';
const FAKE_SCENE_SNAP = {
primitives: {
1: { id: 1, type: 'cube', name: 'Floor', x: 0, y: 0, z: 0, sx: 10, sy: 1, sz: 10,
color: '#888888', material: 'glossy', anchored: true, canCollide: true, opacity: 1 },
2: { id: 2, type: 'cube', name: 'KillBrick', x: 5, y: 1, z: 0, sx: 2, sy: 1, sz: 2,
color: '#ff0000', material: 'neon', anchored: true, canCollide: true, opacity: 1 },
},
};
const SNIPPETS = [
{
name: 'print hello',
lua: `print("Hello from Lua!")`,
expectLogs: [{ level: 'info', text: 'Hello from Lua!' }],
},
{
name: 'Vector3 math',
lua: `
local v = Vector3.new(3, 4, 0)
print("magnitude:", v.Magnitude)
local u = v.Unit
print("unit:", u.X, u.Y, u.Z)
`,
expectLogs: [
{ level: 'info', text: 'magnitude:\t5' },
],
},
{
name: 'workspace iteration',
lua: `
local children = workspace:GetChildren()
print("count:", #children)
for i, c in ipairs(children) do
print("child:", c.Name, "class:", c.ClassName)
end
`,
expectLogs: [
{ level: 'info', text: 'count:\t2' },
],
},
{
name: 'FindFirstChild',
lua: `
local kb = workspace:FindFirstChild("KillBrick")
if kb then print("found:", kb.Name)
else print("not found") end
`,
expectLogs: [{ level: 'info', text: 'found:\tKillBrick' }],
},
{
name: 'Part.Position get',
lua: `
local kb = workspace:FindFirstChild("KillBrick")
print("position:", kb.Position.X, kb.Position.Y, kb.Position.Z)
`,
expectLogs: [{ level: 'info', text: 'position:\t5\t1\t0' }],
},
{
name: 'Part.Color set',
lua: `
local kb = workspace:FindFirstChild("KillBrick")
kb.Color = Color3.new(0, 1, 0)
print("new color hex (via Position):", kb.Color.R, kb.Color.G, kb.Color.B)
`,
expectPartSet: { primId: 2, prop: 'color' },
},
{
name: 'CFrame.Angles',
lua: `
local cf = CFrame.Angles(0, math.pi/2, 0)
print("lookvector:", cf.LookVector.X, cf.LookVector.Y, cf.LookVector.Z)
`,
expectLogs: [],
},
{
name: 'Instance.new + Parent',
lua: `
local f = Instance.new("Folder", workspace)
f.Name = "MyFolder"
print("folder name:", f.Name, "parent:", f.Parent.Name)
`,
expectLogs: [{ level: 'info', text: 'folder name:\tMyFolder\tparent:\tWorkspace' }],
},
{
name: 'IsA hierarchy',
lua: `
local kb = workspace:FindFirstChild("KillBrick")
print("isa Part:", kb:IsA("Part"))
print("isa BasePart:", kb:IsA("BasePart"))
print("isa Instance:", kb:IsA("Instance"))
print("isa Sound:", kb:IsA("Sound"))
`,
expectLogs: [
{ level: 'info', text: 'isa Part:\ttrue' },
{ level: 'info', text: 'isa BasePart:\ttrue' },
{ level: 'info', text: 'isa Instance:\ttrue' },
{ level: 'info', text: 'isa Sound:\tfalse' },
],
},
];
async function runSnippet(snippet) {
const factory = new LuaFactory();
const lua = await factory.createEngine();
const logs = [];
const sent = [];
const send = (cmd, payload) => sent.push({ cmd, payload });
registerRobloxApi(lua, {
getSceneSnap: () => FAKE_SCENE_SNAP,
targetPrimitiveId: 2, // как будто скрипт прикреплён к KillBrick
send,
});
// Перехват print через send('log', ...)
let errMsg = null;
try {
await lua.doString(snippet.lua);
} catch (e) {
errMsg = e && e.message ? e.message : String(e);
}
lua.global.close();
const captured = sent.filter(s => s.cmd === 'log');
return { logs: captured.map(s => s.payload), partSets: sent.filter(s => s.cmd === 'partSet'), error: errMsg };
}
(async () => {
let passed = 0;
let failed = 0;
for (const s of SNIPPETS) {
const result = await runSnippet(s);
const ok = checkExpectations(s, result);
if (ok.success) {
console.log(`${s.name}`);
passed++;
} else {
console.log(`${s.name}`);
console.log(` error: ${result.error || 'none'}`);
console.log(` logs received:`);
for (const l of result.logs) console.log(` [${l.level}] ${JSON.stringify(l.text)}`);
if (result.partSets.length) {
console.log(` partSets:`, JSON.stringify(result.partSets));
}
console.log(` reason: ${ok.reason}`);
failed++;
}
}
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed > 0 ? 1 : 0);
})();
function checkExpectations(snippet, result) {
if (result.error) {
return { success: false, reason: `lua error: ${result.error}` };
}
if (snippet.expectLogs) {
for (const exp of snippet.expectLogs) {
const found = result.logs.find(l => l.level === exp.level && l.text === exp.text);
if (!found) {
return { success: false, reason: `missing log: [${exp.level}] ${JSON.stringify(exp.text)}` };
}
}
}
if (snippet.expectPartSet) {
const found = result.partSets.find(s =>
s.payload.primId === snippet.expectPartSet.primId &&
s.payload.prop === snippet.expectPartSet.prop
);
if (!found) {
return { success: false, reason: `missing partSet ${JSON.stringify(snippet.expectPartSet)}` };
}
}
return { success: true };
}

View File

@ -0,0 +1,144 @@
/**
* rbxl-lua-services.test.js тесты Humanoid, RemoteEvent, DataStore, HttpService.
*/
import { LuaFactory } from 'wasmoon';
import { registerRobloxApi } from '../src/engine/roblox-shim.js';
import { RobloxScheduler } from '../src/engine/roblox-scheduler.js';
import { installRobloxServices } from '../src/engine/roblox-services.js';
const SCENE = { primitives: {} };
const STORE = new Map();
async function run(luaSource, ticks = []) {
const factory = new LuaFactory();
const lua = await factory.createEngine();
const sent = [];
const send = (cmd, payload) => sent.push({ cmd, payload });
let playerState = { x: 0, y: 5, z: 0 };
registerRobloxApi(lua, { getSceneSnap: () => SCENE, targetPrimitiveId: null, send });
const sched = new RobloxScheduler(lua);
sched.install();
installRobloxServices(lua, {
send,
getPlayerState: () => playerState,
loadSave: (k) => STORE.get(k),
saveSave: (k, v) => STORE.set(k, v),
removeSave: (k) => STORE.delete(k),
});
await sched.spawnMain(luaSource);
for (const dt of ticks) await sched.tick(dt);
lua.global.close();
return { logs: sent.filter(s => s.cmd === 'log').map(s => s.payload),
playerCmds: sent.filter(s => s.cmd === 'playerCmd').map(s => s.payload),
broadcasts: sent.filter(s => s.cmd === 'broadcast').map(s => s.payload) };
}
const TESTS = [
{
name: 'Players.LocalPlayer.Character.Humanoid существует',
lua: `
local p = game:GetService("Players").LocalPlayer
local h = p.Character:WaitForChild("Humanoid")
print("hp:", h.Health, "ws:", h.WalkSpeed)
`,
expect: [{ level: 'info', text: 'hp:\t100\tws:\t16' }],
},
{
name: 'Humanoid.WalkSpeed = 50 → playerCmd setWalkSpeed',
lua: `
local h = game:GetService("Players").LocalPlayer.Character.Humanoid
h.WalkSpeed = 50
`,
expectPlayerCmd: { method: 'setWalkSpeed', argsCheck: (a) => a[0] === 50 },
},
{
name: 'Humanoid:TakeDamage уменьшает HP',
lua: `
local h = game:GetService("Players").LocalPlayer.Character.Humanoid
h:TakeDamage(30)
print("after damage:", h.Health)
`,
expect: [{ level: 'info', text: 'after damage:\t70' }],
},
{
name: 'Humanoid.Health = 0 → Died fires',
lua: `
local h = game:GetService("Players").LocalPlayer.Character.Humanoid
h.Died:Connect(function() print("DIED") end)
h.Health = 0
`,
expect: [{ level: 'info', text: 'DIED' }],
},
{
name: 'DataStoreService GetAsync/SetAsync',
lua: `
local DSS = game:GetService("DataStoreService")
local store = DSS:GetDataStore("coins")
store:SetAsync("player1", 100)
print("got:", store:GetAsync("player1"))
`,
expect: [{ level: 'info', text: 'got:\t100' }],
},
{
name: 'DataStoreService IncrementAsync',
lua: `
local store = game:GetService("DataStoreService"):GetDataStore("score")
store:SetAsync("p1", 50)
store:IncrementAsync("p1", 25)
print("final:", store:GetAsync("p1"))
`,
expect: [{ level: 'info', text: 'final:\t75' }],
},
{
name: 'HttpService:JSONEncode/Decode',
lua: `
local HS = game:GetService("HttpService")
local s = HS:JSONEncode({a=1, b="two"})
print("encoded len:", #s)
local d = HS:JSONDecode('{"x":42}')
print("decoded x:", d.x)
`,
expect: [{ level: 'info', text: 'decoded x:\t42' }],
},
{
name: 'RemoteEvent FireServer + OnServerEvent',
lua: `
local re = Instance.new("RemoteEvent", workspace)
re.Name = "MyEvent"
re.OnServerEvent:Connect(function(player, msg)
print("server got:", msg)
end)
re:FireServer("hello")
`,
expect: [{ level: 'info', text: 'server got:\thello' }],
},
];
(async () => {
let passed = 0, failed = 0;
for (const t of TESTS) {
try {
const r = await run(t.lua, t.ticks);
let ok = true; let reason = '';
for (const exp of (t.expect || [])) {
const found = r.logs.find(l => l.level === exp.level && l.text === exp.text);
if (!found) { ok = false; reason = `missing log: ${exp.text}; got: ${JSON.stringify(r.logs)}`; break; }
}
if (t.expectPlayerCmd) {
const found = r.playerCmds.find(c => c.method === t.expectPlayerCmd.method
&& (!t.expectPlayerCmd.argsCheck || t.expectPlayerCmd.argsCheck(c.args)));
if (!found) { ok = false; reason = `missing playerCmd ${t.expectPlayerCmd.method}; got: ${JSON.stringify(r.playerCmds)}`; }
}
if (ok) { console.log(`${t.name}`); passed++; }
else { console.log(`${t.name}${reason}`); failed++; }
} catch (e) {
console.log(`${t.name} — exception: ${e.message || e}`);
failed++;
}
}
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed > 0 ? 1 : 0);
})();

View File

@ -0,0 +1,89 @@
/**
* rbxl-lua-tween.test.js тесты TweenService.
*/
import { LuaFactory } from 'wasmoon';
import { registerRobloxApi } from '../src/engine/roblox-shim.js';
import { RobloxScheduler } from '../src/engine/roblox-scheduler.js';
import { RobloxTweenManager } from '../src/engine/roblox-tween.js';
const SCENE = {
primitives: {
1: { id: 1, type: 'cube', name: 'Movable', x: 0, y: 5, z: 0, sx: 1, sy: 1, sz: 1,
color: '#ffffff', material: 'glossy', anchored: false, canCollide: true, opacity: 1 },
},
};
async function run(luaSource, ticks = []) {
const factory = new LuaFactory();
const lua = await factory.createEngine();
const sent = [];
const send = (cmd, payload) => sent.push({ cmd, payload });
registerRobloxApi(lua, { getSceneSnap: () => SCENE, targetPrimitiveId: 1, send });
const sched = new RobloxScheduler(lua);
sched.install();
const tweenMgr = new RobloxTweenManager();
tweenMgr.install(lua);
await sched.spawnMain(luaSource);
for (const dt of ticks) {
await sched.tick(dt);
tweenMgr.tick(dt);
}
lua.global.close();
return { logs: sent.filter(s => s.cmd === 'log').map(s => s.payload),
partSets: sent.filter(s => s.cmd === 'partSet').map(s => s.payload) };
}
const TESTS = [
{
name: 'TweenInfo создаётся',
lua: `
local info = TweenInfo.new(2, Enum.EasingStyle.Linear, Enum.EasingDirection.Out)
print("time:", info.Time, "style:", info.EasingStyle)
`,
ticks: [],
expectLogs: [{ level: 'info', text: 'time:\t2\tstyle:\tLinear' }],
},
{
name: 'TweenService:Create + Play (Linear)',
lua: `
local TS = game:GetService("TweenService")
local p = workspace:FindFirstChild("Movable")
local info = TweenInfo.new(1, Enum.EasingStyle.Linear, Enum.EasingDirection.Out)
local tw = TS:Create(p, info, { Position = Vector3.new(10, 5, 0) })
tw:Play()
print("started")
`,
ticks: [0.5, 0.5, 0.1], // больше 1 сек — должен завершиться
// Ожидаем что хотя бы один partSet с prop=position
expectPartSet: { primId: 1, prop: 'position' },
},
];
(async () => {
let passed = 0, failed = 0;
for (const t of TESTS) {
try {
const r = await run(t.lua, t.ticks);
let ok = true;
let reason = '';
for (const exp of (t.expectLogs || [])) {
const found = r.logs.find(l => l.level === exp.level && l.text === exp.text);
if (!found) { ok = false; reason = `missing log: ${exp.text}`; break; }
}
if (t.expectPartSet) {
const found = r.partSets.find(p => p.primId === t.expectPartSet.primId && p.prop === t.expectPartSet.prop);
if (!found) {
ok = false; reason = `missing partSet: ${JSON.stringify(t.expectPartSet)}; got: ${JSON.stringify(r.partSets.slice(0,3))}`;
}
}
if (ok) { console.log(`${t.name}`); passed++; }
else { console.log(`${t.name}${reason}`); failed++; }
} catch (e) {
console.log(`${t.name} — exception: ${e}`);
failed++;
}
}
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed > 0 ? 1 : 0);
})();

104
tests/rbxl-lua-wait.test.js Normal file
View File

@ -0,0 +1,104 @@
/**
* rbxl-lua-wait.test.js тесты wait/task.wait через шедулер.
*/
import { LuaFactory } from 'wasmoon';
import { registerRobloxApi } from '../src/engine/roblox-shim.js';
import { RobloxScheduler } from '../src/engine/roblox-scheduler.js';
const SCENE = { primitives: {} };
async function run(luaSource, ticks = [0.5, 0.5, 0.5, 0.5, 0.5]) {
const factory = new LuaFactory();
const lua = await factory.createEngine();
const sent = [];
const send = (cmd, payload) => sent.push({ cmd, payload });
registerRobloxApi(lua, { getSceneSnap: () => SCENE, targetPrimitiveId: null, send });
const sched = new RobloxScheduler(lua);
sched.install();
await sched.spawnMain(luaSource);
for (const dt of ticks) {
await sched.tick(dt);
}
lua.global.close();
return sent.filter(s => s.cmd === 'log').map(s => s.payload);
}
const TESTS = [
{
name: 'wait(0) — мгновенный',
lua: `
print("before")
wait(0)
print("after")
`,
expect: ['before', 'after'],
},
{
name: 'wait(1) — резюм после tick',
lua: `
print("step1")
wait(1)
print("step2")
`,
ticks: [0.5, 0.5, 0.5], // 1.5 сек суммарно
expect: ['step1', 'step2'],
},
{
name: 'task.wait(0.5)',
lua: `
print("a")
task.wait(0.5)
print("b")
`,
ticks: [0.5, 0.5],
expect: ['a', 'b'],
},
{
name: 'несколько wait подряд',
lua: `
print("p1")
wait(0.5)
print("p2")
wait(0.5)
print("p3")
`,
ticks: [0.5, 0.5, 0.5, 0.5], // 2 сек
expect: ['p1', 'p2', 'p3'],
},
{
name: 'task.delay (не блокирует)',
lua: `
print("immediate")
task.delay(0.3, function() print("delayed") end)
print("after delay-call")
`,
ticks: [0.5],
expect: ['immediate', 'after delay-call', 'delayed'],
},
];
(async () => {
let passed = 0, failed = 0;
for (const t of TESTS) {
try {
const logs = await run(t.lua, t.ticks);
const texts = logs.map(l => l.text);
const ok = JSON.stringify(texts) === JSON.stringify(t.expect);
if (ok) {
console.log(`${t.name}`);
passed++;
} else {
console.log(`${t.name}`);
console.log(` expected: ${JSON.stringify(t.expect)}`);
console.log(` got: ${JSON.stringify(texts)}`);
failed++;
}
} catch (e) {
console.log(`${t.name} — exception: ${e}`);
failed++;
}
}
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed > 0 ? 1 : 0);
})();