feat: импорт Roblox .rbxl карт (тест-фича МИНа) #33

Merged
min merged 7 commits from feat/rbxl-import into main 2026-06-08 03:26:01 +00:00
18 changed files with 2510 additions and 36 deletions
Showing only changes of commit 412bb2fad9 - Show all commits

View File

@ -64,6 +64,11 @@ ALLOWED_USER_IDS = [1] # пока только МИН
app = Flask(__name__) app = Flask(__name__)
CORS(app, resources={r'/*': {'origins': '*'}}) CORS(app, resources={r'/*': {'origins': '*'}})
# Devlog для удалённой отладки dev-сессий студии: фронт пушит сюда
# console.error/warn, failed network requests, неожиданные exceptions.
from app_devlog import devlog_bp
app.register_blueprint(devlog_bp)
try: try:
rds = redis.from_url(REDIS_URL, decode_responses=False) rds = redis.from_url(REDIS_URL, decode_responses=False)
rds.ping() rds.ping()

View File

@ -0,0 +1,65 @@
"""
app_devlog.py endpoint /devlog для удалённого сбора логов dev-сессии студии.
Подключается к Flask из app.py через blueprint.
Эндпоинты:
POST /devlog приём batch'а событий из браузера
GET /devlog/recent последние N событий (для меня)
GET /devlog/clear очистить лог
Хранение: append-only файл /opt/rbxl-importer/devlog.jsonl.
CORS открыт для localhost:* (dev режим).
"""
import os
import json
import time
from flask import Blueprint, request, jsonify
devlog_bp = Blueprint('devlog', __name__)
DEVLOG_PATH = os.environ.get('DEVLOG_PATH', '/opt/rbxl-importer/devlog.jsonl')
@devlog_bp.post('/devlog')
def post_devlog():
"""Принимает массив событий из браузера.
Каждое событие: {ts, kind, url?, status?, body?, message?, stack?, extra?}
"""
data = request.get_json(silent=True) or {}
events = data.get('events') or []
if not isinstance(events, list):
return jsonify({'error': 'events must be list'}), 400
received_at = time.time()
with open(DEVLOG_PATH, 'a', encoding='utf-8') as f:
for ev in events:
if not isinstance(ev, dict):
continue
ev['received_at'] = received_at
f.write(json.dumps(ev, ensure_ascii=False, default=str) + '\n')
return jsonify({'ok': True, 'count': len(events)})
@devlog_bp.get('/devlog/recent')
def get_recent():
"""Последние N событий (по умолчанию 200)."""
n = int(request.args.get('n', 200))
out = []
if os.path.exists(DEVLOG_PATH):
with open(DEVLOG_PATH, 'r', encoding='utf-8') as f:
lines = f.readlines()[-n:]
for line in lines:
try:
out.append(json.loads(line))
except Exception:
pass
return jsonify({'events': out, 'total': len(out)})
@devlog_bp.get('/devlog/clear')
def clear():
if os.path.exists(DEVLOG_PATH):
os.unlink(DEVLOG_PATH)
return jsonify({'ok': True})

View File

@ -31,6 +31,7 @@ CFrame → position + rotationX/Y/Z (Euler XYZ в радианах).
""" """
from dataclasses import dataclass, field, asdict from dataclasses import dataclass, field, asdict
from typing import List, Dict, Any, Optional, Tuple from typing import List, Dict, Any, Optional, Tuple
import json
import math import math
import logging import logging
@ -45,8 +46,11 @@ logger = logging.getLogger(__name__)
# ────── константы маппинга ────── # ────── константы маппинга ──────
# Roblox stud → метры (примерно 0.28). Можно поменять при импорте. # Roblox stud → unit Rublox-движка.
DEFAULT_SCALE = 0.28 # R15-персонаж в Rublox ~5.5м, Roblox-персонаж ~5stud высотой. Чтобы карта
# была пропорциональна персонажу — scale 0.35 (платформа 4stud → 1.4 unit,
# как стандартная Rublox-платформа).
DEFAULT_SCALE = 0.35
# Маппинг Material enum → Rublox material strings. # Маппинг Material enum → Rublox material strings.
# Roblox Enum.Material: # Roblox Enum.Material:
@ -214,7 +218,9 @@ class Converter:
'gui': [], 'gui': [],
'inventory': [], 'inventory': [],
'spawnPoint': {'x': 0, 'y': 2, 'z': 0}, 'spawnPoint': {'x': 0, 'y': 2, 'z': 0},
'playerModelType': 'default', # Стандартный R15-скин bacon-hair как во всех новых проектах студии.
# 'default' — невалидный typeId, PlayerController на нём падает.
'playerModelType': 'skin_bacon-hair',
'worldSize': 100, 'worldSize': 100,
'floorEnabled': True, 'floorEnabled': True,
'jumpPowerMul': 1.0, 'jumpPowerMul': 1.0,
@ -615,15 +621,21 @@ class Converter:
script_id = f'rbx_{self._next_script_id}' script_id = f'rbx_{self._next_script_id}'
self._next_script_id += 1 self._next_script_id += 1
# storys-нормализатор сохраняет только {id,code,target,name} —
# поэтому упаковываем kind+lua_source ВНУТРЬ поля code как комментарий-маркер,
# а GameRuntime/RobloxLuaSandbox распакуют обратно.
# Маркер: первая строка ровно "// @roblox-lua" + JSON-метадата на 2-й строке.
marker_header = '// @roblox-lua\n// ' + json.dumps({
'roblox_class': inst.class_name,
'enabled': bool(props.get('Disabled', False) is False),
}, ensure_ascii=False) + '\n/* lua_source:\n'
marker_footer = '\n*/\n'
packed_code = marker_header + source + marker_footer
scene['scripts'].append({ scene['scripts'].append({
'id': script_id, 'id': script_id,
'name': name_or_default(props, inst.class_name), 'name': name_or_default(props, inst.class_name),
'kind': 'roblox-lua',
'target': target, 'target': target,
'code': '', # JS-эквивалент пока нет (заполнит Lua-runtime в плеере) 'code': packed_code,
'lua_source': source,
'roblox_class': inst.class_name, # Script | LocalScript | ModuleScript
'enabled': bool(props.get('Disabled', False) is False),
}) })
self.stats.scripts_collected += 1 self.stats.scripts_collected += 1

View File

@ -9,6 +9,27 @@
import { RBXL_addres } from './API.js'; import { RBXL_addres } from './API.js';
// На dev (localhost) backend ещё не интегрирован с user-service для JWT-парсинга.
// Используем dev override: явно передаём user_id. В prod NPM или user-service
// сами выставят X-User-Id из JWT.
const IS_DEV = typeof window !== 'undefined'
&& (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1');
function authHeaders() {
const token = localStorage.getItem('Authorization') || '';
const headers = { 'Authorization': token };
if (IS_DEV) {
// Достаём user_id из JWT для dev (без validation — backend на dev доверяет).
try {
const payload = JSON.parse(atob(token.split('.')[1] || ''));
if (payload && payload.id != null) headers['X-Auth-Override'] = String(payload.id);
} catch (e) {
// если JWT не парсится — забыли залогиниться, всё равно отправим что-то
}
}
return headers;
}
/** /**
* Анализ .rbxl. Принимает File (из <input type="file">) или Blob. * Анализ .rbxl. Принимает File (из <input type="file">) или Blob.
* Возвращает { preview_hash, report }. * Возвращает { preview_hash, report }.
@ -16,14 +37,9 @@ import { RBXL_addres } from './API.js';
export async function analyzeRbxl(file) { export async function analyzeRbxl(file) {
const fd = new FormData(); const fd = new FormData();
fd.append('file', file, file.name || 'upload.rbxl'); fd.append('file', file, file.name || 'upload.rbxl');
const token = localStorage.getItem('Authorization') || '';
const resp = await fetch(`${RBXL_addres}/import/rbxl/analyze`, { const resp = await fetch(`${RBXL_addres}/import/rbxl/analyze`, {
method: 'POST', method: 'POST',
headers: { headers: authHeaders(),
'Authorization': token,
// X-User-Id выставит upstream (NPM → user-service после JWT)
// на dev добавим override (только в LAN):
},
body: fd, body: fd,
}); });
if (!resp.ok) { if (!resp.ok) {
@ -37,13 +53,9 @@ export async function analyzeRbxl(file) {
* Создаёт проект из preview_hash. Возвращает { project_id, redirect, assets_downloaded, assets_failed }. * Создаёт проект из preview_hash. Возвращает { project_id, redirect, assets_downloaded, assets_failed }.
*/ */
export async function createRbxlProject(previewHash, title) { export async function createRbxlProject(previewHash, title) {
const token = localStorage.getItem('Authorization') || '';
const resp = await fetch(`${RBXL_addres}/import/rbxl/create`, { const resp = await fetch(`${RBXL_addres}/import/rbxl/create`, {
method: 'POST', method: 'POST',
headers: { headers: { ...authHeaders(), 'Content-Type': 'application/json' },
'Authorization': token,
'Content-Type': 'application/json',
},
body: JSON.stringify({ preview_hash: previewHash, title: title || '' }), body: JSON.stringify({ preview_hash: previewHash, title: title || '' }),
}); });
if (!resp.ok) { if (!resp.ok) {

View File

@ -14,6 +14,7 @@ import useDeviceType from '../hooks/useDeviceType';
import KubikonDesktopOnlyStub from './KubikonDesktopOnlyStub'; import KubikonDesktopOnlyStub from './KubikonDesktopOnlyStub';
import PleeseReg from '../components/PleeseReg/PleeseReg'; import PleeseReg from '../components/PleeseReg/PleeseReg';
import Icon from '../editor/Icon'; import Icon from '../editor/Icon';
import RbxlImportModal from '../components/RbxlImportModal';
function getCurrentUserId() { function getCurrentUserId() {
try { try {
@ -131,6 +132,7 @@ const KubikonStudio = () => {
const [greetName, setGreetName] = useState(''); const [greetName, setGreetName] = useState('');
// Поиск по своим играм. searchOpen раскрыт ли инпут в шапке. // Поиск по своим играм. searchOpen раскрыт ли инпут в шапке.
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [rbxlImportOpen, setRbxlImportOpen] = useState(false);
const [searchOpen, setSearchOpen] = useState(false); const [searchOpen, setSearchOpen] = useState(false);
// Гость МОЖЕТ просматривать студию видит шаблоны и обучение. // Гость МОЖЕТ просматривать студию видит шаблоны и обучение.
@ -388,8 +390,30 @@ const KubikonStudio = () => {
<span className={cl.navIcon}><Icon name="book-open" size={15} /></span> <span className={cl.navIcon}><Icon name="book-open" size={15} /></span>
<span>ВИКИ</span> <span>ВИКИ</span>
</button> </button>
{/* Импорт Roblox .rbxl — только для МИНа (user_id=1) */}
{getCurrentUserId() === 1 && (
<button
className={cl.navItem}
onClick={() => setRbxlImportOpen(true)}
title="Импортировать игру из Roblox (.rbxl файл) — тест-фича"
style={{ marginTop: 8, borderTop: '1px solid #333', paddingTop: 12 }}
>
<span className={cl.navIcon}>📦</span>
<span>Импорт Roblox</span>
</button>
)}
</nav> </nav>
<RbxlImportModal
open={rbxlImportOpen}
onClose={() => setRbxlImportOpen(false)}
currentUserId={getCurrentUserId()}
onCreated={(result) => {
// eslint-disable-next-line no-console
console.log('[rbxl-import] created:', result);
}}
/>
<div className={cl.sidebarFooter}> <div className={cl.sidebarFooter}>
<button <button
className={cl.docsBtn} className={cl.docsBtn}

View File

@ -19,6 +19,7 @@ import { ScriptSandbox } from './ScriptSandbox';
import { STORYS_addres } from '../../api/API'; import { STORYS_addres } from '../../api/API';
import { PhysicsWorld } from './PhysicsWorld'; import { PhysicsWorld } from './PhysicsWorld';
import { LabelManager } from './LabelManager'; import { LabelManager } from './LabelManager';
import { startRobloxLuaScript, handleLuaCommand } from './rbxl-lua-integration.js';
export class GameRuntime { export class GameRuntime {
constructor(scene3d) { constructor(scene3d) {
@ -96,7 +97,36 @@ export class GameRuntime {
// (баг «стрелка-указатель не переключается на след. цель»). // (баг «стрелка-указатель не переключается на след. цель»).
let initialScene = null; let initialScene = null;
try { initialScene = this._buildSceneSnapshot(); } catch (e) { initialScene = null; } try { initialScene = this._buildSceneSnapshot(); } catch (e) { initialScene = null; }
let rbxlStarted = 0;
let rbxlLimited = 0;
let rbxlFiltered = 0;
// WebAssembly OOM при >50 Lua-VM. Лимит 50 Worker'ов + фильтрация.
// Реальное решение — single-VM mode (TODO).
const RBXL_LUA_LIMIT = 50;
// Маркер пакета: первая строка "// @roblox-lua", вторая — JSON-метадата с
// {roblox_class, enabled}. lua_source между "/* lua_source:\n" и "\n*/".
// Размер code = маркеры (~60 байт) + lua_source. Чистый lua_source = code.length - 60.
const RBXL_LUA_MAX_SOURCE = 2500; // больше — почти всегда серверный/admin/chat
const primitives = this.projectData?.scene?.primitives || this.scene3d?.primitiveManager?.getAllData?.() || [];
for (const s of scripts) { for (const s of scripts) {
// Roblox-Lua скрипты (маркер // @roblox-lua) — wasmoon Worker.
if (s && typeof s.code === 'string' && s.code.startsWith('// @roblox-lua')) {
// Фильтр 1: только короткие скрипты (длинные = HD Admin, chat, и т.п.).
if (s.code.length > RBXL_LUA_MAX_SOURCE + 200) { rbxlFiltered++; continue; }
// Фильтр 2: только привязанные к Part'у (target != null) — это
// KillBrick/Checkpoint handlers. Скрипты без target обычно сервисные.
if (s.target == null) { rbxlFiltered++; continue; }
if (rbxlStarted >= RBXL_LUA_LIMIT) { rbxlLimited++; continue; }
const sb = startRobloxLuaScript(s, {
primitives,
onCommand: (cmd, payload) => handleLuaCommand(s.id, cmd, payload, this),
});
if (sb) {
this.sandboxes.push(sb);
rbxlStarted++;
}
continue;
}
if (!s || typeof s.code !== 'string' || !s.code.trim()) { if (!s || typeof s.code !== 'string' || !s.code.trim()) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.warn('[GameRuntime] skipping invalid script entry', s); console.warn('[GameRuntime] skipping invalid script entry', s);
@ -127,6 +157,15 @@ export class GameRuntime {
console.log('[GameRuntime] sandbox started for script id=', s.id); console.log('[GameRuntime] sandbox started for script id=', s.id);
} }
this._log('info', `Запущено скриптов: ${this.sandboxes.length}`); this._log('info', `Запущено скриптов: ${this.sandboxes.length}`);
if (rbxlStarted > 0) {
this._log('info', `Запущено Roblox-Lua скриптов (wasmoon): ${rbxlStarted}`);
}
if (rbxlFiltered > 0) {
this._log('info', `Отфильтровано Roblox-Lua скриптов (admin/chat/services): ${rbxlFiltered}`);
}
if (rbxlLimited > 0) {
this._log('warn', `Пропущено ${rbxlLimited} Roblox-Lua скриптов (WASM memory limit)`);
}
// Подцепляемся на изменения HP игрока, чтобы шлать событие 'hpChange' // Подцепляемся на изменения HP игрока, чтобы шлать событие 'hpChange'
// во все sandbox'ы. Не перезаписываем существующий обработчик — // во все sandbox'ы. Не перезаписываем существующий обработчик —
// оборачиваем его (старый колбэк UI должен продолжать работать). // оборачиваем его (старый колбэк UI должен продолжать работать).

View File

@ -638,10 +638,10 @@ export class PlayerController {
const json = await resp.json(); const json = await resp.json();
this._skinManifest = json.skins || []; this._skinManifest = json.skins || [];
} catch (e) { } catch (e) {
// eslint-disable-next-line no-console
console.warn('[PlayerController] skins_manifest load failed:', e); console.warn('[PlayerController] skins_manifest load failed:', e);
this._skinManifest = []; this._skinManifest = [];
} }
this._skinManifestBaseUrl = '/kubikon-assets';
return this._skinManifest; return this._skinManifest;
} }
@ -656,15 +656,11 @@ export class PlayerController {
if (typeId.startsWith('skin_')) { if (typeId.startsWith('skin_')) {
const manifest = await this._loadSkinManifest(); const manifest = await this._loadSkinManifest();
const entry = manifest.find((s) => s.id === typeId); const entry = manifest.find((s) => s.id === typeId);
const baseUrl = this._skinManifestBaseUrl || '/kubikon-assets';
if (entry) { if (entry) {
// kind определяет систему анимации:
// 'r15' → R15-скелет (как раньше)
// 'non-humanoid-mesh' → single-mesh, процедурное покачивание
// 'non-humanoid-rigged' → свой скелет + встроенные AnimationGroup
// Отсутствие kind = 'r15' (обратная совместимость со старым манифестом).
const kind = entry.kind || 'r15'; const kind = entry.kind || 'r15';
return { return {
file: '/kubikon-assets/' + entry.file, file: baseUrl + '/' + entry.file,
isR15: kind === 'r15', isR15: kind === 'r15',
kind, kind,
overrides: entry.overrides || {}, overrides: entry.overrides || {},
@ -673,9 +669,8 @@ export class PlayerController {
rotationYOffset: Number.isFinite(entry.rotationYOffset) ? entry.rotationYOffset : 0, rotationYOffset: Number.isFinite(entry.rotationYOffset) ? entry.rotationYOffset : 0,
}; };
} }
// нет в манифесте — пробуем прямой путь (старые R15-скины)
return { return {
file: `/kubikon-assets/characters/${typeId}/body.glb`, file: `${baseUrl}/characters/${typeId}/body.glb`,
isR15: true, isR15: true,
kind: 'r15', kind: 'r15',
overrides: {}, overrides: {},
@ -707,7 +702,20 @@ export class PlayerController {
/** Загрузить GLB-модель персонажа и его анимации. */ /** Загрузить GLB-модель персонажа и его анимации. */
async _loadPlayerModel() { async _loadPlayerModel() {
const source = await this._resolveModelSource(); const source = await this._resolveModelSource();
if (!source) return; // DEVLOG: явно логируем какой скин и путь
try {
console.warn('[PlayerController.devlog] _loadPlayerModel called', JSON.stringify({
typeId: this._modelTypeId,
source: source ? { file: source.file, isR15: source.isR15, kind: source.kind } : null,
active: this._active,
manifestCount: this._skinManifest?.length || 0,
manifestBaseUrl: this._skinManifestBaseUrl,
}));
} catch (e) {}
if (!source) {
console.error('[PlayerController.devlog] source=null, aborting');
return;
}
if (!this._active) return; if (!this._active) return;
// ВАЖНО: грузим через SceneLoader напрямую, НЕ через shared-кэш // ВАЖНО: грузим через SceneLoader напрямую, НЕ через shared-кэш
@ -726,15 +734,25 @@ export class PlayerController {
rootUrl = source.file.substring(0, lastSlash + 1); rootUrl = source.file.substring(0, lastSlash + 1);
filename = source.file.substring(lastSlash + 1); filename = source.file.substring(lastSlash + 1);
} }
// DEVLOG
console.warn('[PlayerController.devlog] SceneLoader.LoadAssetContainerAsync',
JSON.stringify({ rootUrl, filename, isDataUrl: !!source.isDataUrl }));
let container; let container;
try { try {
container = await SceneLoader.LoadAssetContainerAsync( container = await SceneLoader.LoadAssetContainerAsync(
rootUrl, filename, this.scene, rootUrl, filename, this.scene,
null, source.isDataUrl ? '.glb' : undefined null, source.isDataUrl ? '.glb' : undefined
); );
console.warn('[PlayerController.devlog] container loaded',
JSON.stringify({
meshes: container?.meshes?.length || 0,
skeletons: container?.skeletons?.length || 0,
animations: container?.animationGroups?.length || 0,
}));
} catch (e) { } catch (e) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.error('[PlayerController] failed to load model:', e); console.error('[PlayerController.devlog] LoadAssetContainerAsync FAILED:',
e?.message || String(e), 'url=', rootUrl + filename);
return; return;
} }
try { try {

View File

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

View File

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

View File

@ -0,0 +1,148 @@
/**
* rbxl-lua-integration.js обёртка для запуска Roblox-Lua скриптов
* (импортированных через rbxl-importer) в студии.
*
* Используется из GameRuntime.start: для каждого скрипта с маркером
* `// @roblox-lua` вызываем startRobloxLuaScript(scriptObj, ctx) и оно
* само создаёт Worker + Lua-VM (wasmoon) + Roblox API shim.
*
* ВАЖНО: импорт Worker делается через явный `?worker` синтаксис Vite
* это вынесено сюда чтобы изолировать от GameRuntime.js (огромный файл,
* в нём vite-плагин analysis иногда не парсит динамические импорты).
*/
import RobloxLuaWorker from './RobloxLuaWorker.js?worker';
import { RobloxLuaSandbox } from './RobloxLuaSandbox.js';
/**
* Распаковывает Lua-исходник из поля code упакованного rbxl-importer'ом.
* Формат поля code:
* // @roblox-lua
* // {"roblox_class": "Script", "enabled": true}
* /* lua_source:
* <ЛУА КОД>
* *\/
*/
export function unpackRobloxLuaCode(code) {
const openTag = '/* lua_source:\n';
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);
}
/**
* Собирает snap сцены для Lua-shim (workspace:GetChildren).
* @param {Array} primitives projectData.scene.primitives
*/
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;
}
/**
* Запускает один Roblox-Lua скрипт.
*
* @param {Object} script entry из state.scripts (id, code, target, name)
* @param {Object} ctx { primitives, onCommand(cmd, payload) }
* @returns {RobloxLuaSandbox|null} sandbox для push в this.sandboxes, или null
*/
export function startRobloxLuaScript(script, ctx) {
try {
const luaSource = unpackRobloxLuaCode(script.code);
if (!luaSource) return null;
const worker = new RobloxLuaWorker();
const sceneSnap = buildLuaSceneSnap(ctx.primitives);
const sb = new RobloxLuaSandbox(luaSource, script.target || null);
sb.scriptId = script.id;
sb.setInitialScene(sceneSnap);
sb.setOnCommand(ctx.onCommand);
sb.start(worker);
return sb;
} catch (e) {
// eslint-disable-next-line no-console
console.warn('[rbxl-lua] start failed for', script?.id, e?.message || e);
return null;
}
}
/**
* Маппинг IPC команд от RobloxLuaSandbox на действия в Babylon-сцене.
*
* @param {string} scriptId
* @param {string} cmd
* @param {object} payload
* @param {object} runtime { scene3d, game }
*/
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 ' + scriptId + ']', payload?.text || '');
return;
}
if (cmd === 'partSet') {
try {
const pm = runtime.scene3d?.primitiveManager;
if (!pm) 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;
else if (prop === 'rotation' && value) {
patch.rotationX = value.rx; patch.rotationY = value.ry; patch.rotationZ = value.rz;
}
if (typeof pm.applyPatch === 'function') pm.applyPatch(primId, patch);
else if (typeof pm.update === 'function') pm.update(primId, patch);
} catch (e) { /* swallow */ }
return;
}
if (cmd === 'partVel') {
try {
const pm = 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;
}
}

View File

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

View File

@ -0,0 +1,209 @@
/**
* roblox-scheduler.js шедулер корутин для Roblox-Lua wait/task.
*
* Архитектура:
* - Каждый верхне-уровневый Lua-код оборачивается в coroutine.
* - wait(sec) / task.wait(sec) делают coroutine.yield(sec)
* - Шедулер запоминает: { coro, resumeAt: tick + sec }
* - На каждом handleTick из main thread шедулер ресюмит готовые корутины
*
* RBXScriptSignal.Wait() = аналогично, но wait не на время, а на event'е:
* - { coro, waitingForSignal: signalName }
* - При Fire() сигнала шедулер ресюмит все ждущие
*
* Использование:
* const sched = new RobloxScheduler(luaEngine);
* sched.spawnMain(luaSource);
* // Каждый кадр:
* sched.tick(dtSec);
* // При событии:
* sched.fireSignal('Heartbeat', dt);
*/
export class RobloxScheduler {
constructor(lua) {
this.lua = lua;
this.time = 0;
this.tasks = []; // [{ coro, resumeAt, waitForSignal?, signalArgsBuf? }]
this.signalWaiters = new Map(); // name → [task]
this._coroBox = null;
}
/**
* Регистрирует глобалы wait/task.wait/task.spawn/task.delay в Lua-VM.
* Должно вызываться ПОСЛЕ registerRobloxApi (т.к. перебивает заглушки).
*/
install() {
const self = this;
// wait(sec) — yield в корутине на sec секунд
this.lua.global.set('wait', (sec) => {
// Этот wait вызовется из Lua. Если мы внутри корутины (а мы внутри
// т.к. spawnMain обернул всё) — yield. Иначе вернём дельту времени
// как обычное wait в Roblox.
const s = +sec || 0;
self._currentYield = { kind: 'sleep', sec: s };
// Возврат тут — это значение которое получит await в Lua;
// wasmoon обработает yield извне.
return s;
});
this.lua.global.set('task', {
wait: (sec) => {
self._currentYield = { kind: 'sleep', sec: +sec || 0 };
return +sec || 0;
},
spawn: (fn, ...args) => {
self.spawnCoroutine(fn, args);
},
delay: (sec, fn, ...args) => {
self.tasks.push({
resumeAt: self.time + (+sec || 0),
runFn: () => { try { fn(...args); } catch (e) {} },
});
},
defer: (fn, ...args) => {
self.tasks.push({
resumeAt: self.time,
runFn: () => { try { fn(...args); } catch (e) {} },
});
},
});
this.lua.global.set('spawn', (fn) => { self.spawnCoroutine(fn, []); });
this.lua.global.set('delay', (sec, fn) => {
self.tasks.push({
resumeAt: self.time + (+sec || 0),
runFn: () => { try { fn(); } catch (e) {} },
});
});
}
/**
* Запустить верхне-уровневый Lua-код как корутину.
* Возвращает Promise который резолвится когда код достиг ready (либо ушёл в первый yield).
*/
async spawnMain(luaSource) {
// Оборачиваем источник в coroutine.wrap(function() ... end)
// и сразу зовём — это даёт нам ручку на корутине через специальный
// приём: храним её в global _userCoro.
const wrapped = `
_userCoro = coroutine.create(function()
${luaSource}
end)
local ok, yieldVal = coroutine.resume(_userCoro)
if not ok then
error("user script error: " .. tostring(yieldVal))
end
return yieldVal
`;
try {
await this.lua.doString(wrapped);
const coroStatus = await this.lua.doString('return coroutine.status(_userCoro)');
if (coroStatus === 'suspended') {
// Ушла в yield — добавляем в шедулер
const yieldInfo = this._currentYield || { kind: 'sleep', sec: 0 };
this._currentYield = null;
this.tasks.push({
resumeAt: this.time + (yieldInfo.kind === 'sleep' ? yieldInfo.sec : 0),
waitForSignal: yieldInfo.kind === 'signal' ? yieldInfo.name : null,
coro: '_userCoro',
});
}
} catch (e) {
console.warn('spawnMain error:', e);
}
}
/**
* Запустить произвольную функцию как корутину (для task.spawn).
*/
spawnCoroutine(fn, args) {
// Создаём корутину на JS-стороне: просто вызываем fn() сразу,
// а если внутри неё дёрнут wait — yield не сработает (JS не делает
// sync yield в обычной функции). Поэтому task.spawn для JS-функций
// равен прямому вызову.
// В будущем (4.7.1) можно через Lua coroutine реализовать.
try { fn(...(args || [])); } catch (e) { /* swallow */ }
}
/**
* Продвинуть время на dt и резюмить готовые корутины.
* Также автоматически fire'ит RunService.Heartbeat / Stepped / RenderStepped.
*/
async tick(dtSec) {
const dt = +dtSec || 0;
this.time += dt;
// Heartbeat / Stepped / RenderStepped для RunService
const game = this.lua.global.get('game');
if (game && typeof game.GetService === 'function') {
const rs = game.GetService('RunService');
if (rs) {
if (rs.Heartbeat && rs.Heartbeat.Fire) rs.Heartbeat.Fire(dt);
if (rs.Stepped && rs.Stepped.Fire) rs.Stepped.Fire(this.time, dt);
if (rs.RenderStepped && rs.RenderStepped.Fire) rs.RenderStepped.Fire(dt);
}
}
// Резюмим всё что готово
const ready = this.tasks.filter(t => !t.waitForSignal && t.resumeAt <= this.time);
this.tasks = this.tasks.filter(t => !(ready.includes(t)));
for (const t of ready) {
await this._resumeTask(t);
}
}
/**
* Fire signal разбудить все task'и ждущие этого сигнала.
*/
async fireSignal(name, ...args) {
const waiters = this.signalWaiters.get(name) || [];
this.signalWaiters.set(name, []);
for (const t of waiters) {
// Resume корутины передавая args как возврат :Wait()
await this._resumeTask(t, args);
}
}
async _resumeTask(task, resumeArgs = []) {
if (task.runFn) {
try {
const ret = task.runFn();
if (ret && typeof ret.then === 'function') await ret;
} catch (e) {}
return;
}
if (task.coro) {
try {
// resumeArgs идут как аргументы в coroutine.resume
const argsCode = resumeArgs.map((a, i) => {
if (typeof a === 'number') return String(a);
if (typeof a === 'string') return JSON.stringify(a);
return 'nil';
}).join(', ');
const code = `
local ok, val = coroutine.resume(${task.coro}${argsCode ? ', ' + argsCode : ''})
if not ok then
error("coro error: " .. tostring(val))
end
return val
`;
await this.lua.doString(code);
const status = await this.lua.doString(`return coroutine.status(${task.coro})`);
if (status === 'suspended') {
// Опять ушла в yield
const yi = this._currentYield || { kind: 'sleep', sec: 0 };
this._currentYield = null;
if (yi.kind === 'sleep') {
this.tasks.push({
resumeAt: this.time + yi.sec,
coro: task.coro,
});
} else if (yi.kind === 'signal') {
const list = this.signalWaiters.get(yi.name) || [];
list.push({ coro: task.coro });
this.signalWaiters.set(yi.name, list);
}
}
} catch (e) {
// Корутина завершилась с ошибкой — просто дропаем
}
}
}
}

View File

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

View File

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

View File

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

View File

@ -2,5 +2,8 @@ import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import App from './App.jsx'; import App from './App.jsx';
import './index.css'; import './index.css';
import { installRemoteDevlog } from './utils/remoteDevlog.js';
installRemoteDevlog();
ReactDOM.createRoot(document.getElementById('root')).render(<App />); ReactDOM.createRoot(document.getElementById('root')).render(<App />);

168
src/utils/remoteDevlog.js Normal file
View File

@ -0,0 +1,168 @@
/**
* remoteDevlog.js клиент удалённого dev-логгера.
*
* Перехватывает: console.error/warn, window.onerror, unhandledrejection,
* все fetch/XHR ошибки и не-2xx ответы; батчит и шлёт на бэкенд.
*
* Запускается только в localhost (dev), на проде no-op.
*/
const IS_DEV = typeof window !== 'undefined'
&& (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1');
const ENDPOINT = '/api-rbxl/devlog';
const FLUSH_INTERVAL_MS = 1500;
const MAX_BATCH = 50;
const queue = [];
let flushTimer = null;
function push(ev) {
if (!IS_DEV) return;
ev.ts = Date.now();
ev.page = location.pathname + location.search;
queue.push(ev);
if (queue.length >= MAX_BATCH) flush();
else scheduleFlush();
}
function scheduleFlush() {
if (flushTimer) return;
flushTimer = setTimeout(flush, FLUSH_INTERVAL_MS);
}
function flush() {
flushTimer = null;
if (queue.length === 0) return;
const events = queue.splice(0, MAX_BATCH);
try {
const blob = new Blob([JSON.stringify({ events })], { type: 'application/json' });
// sendBeacon — не блокирует, переживёт unload
if (navigator.sendBeacon) {
navigator.sendBeacon(ENDPOINT, blob);
} else {
fetch(ENDPOINT, { method: 'POST', body: blob, headers: { 'Content-Type': 'application/json' }, keepalive: true })
.catch(() => {});
}
} catch (e) { /* swallow */ }
}
function truncate(s, max = 4000) {
if (typeof s !== 'string') {
try { s = JSON.stringify(s); } catch { s = String(s); }
}
return s.length > max ? s.slice(0, max) + '...[truncated]' : s;
}
export function installRemoteDevlog() {
if (!IS_DEV) return;
// 1. console.error / console.warn (но НЕ console.log — слишком шумно)
const origError = console.error.bind(console);
const origWarn = console.warn.bind(console);
console.error = (...args) => {
try { push({ kind: 'console.error', message: truncate(args.map(formatArg).join(' ')) }); } catch {}
origError(...args);
};
console.warn = (...args) => {
try { push({ kind: 'console.warn', message: truncate(args.map(formatArg).join(' ')) }); } catch {}
origWarn(...args);
};
// 2. window.onerror
window.addEventListener('error', (ev) => {
push({
kind: 'window.error',
message: ev.message,
filename: ev.filename,
lineno: ev.lineno,
colno: ev.colno,
stack: ev.error?.stack ? truncate(ev.error.stack) : null,
});
});
// 3. Unhandled promise rejection
window.addEventListener('unhandledrejection', (ev) => {
const reason = ev.reason;
push({
kind: 'unhandledrejection',
message: truncate(reason?.message || String(reason)),
stack: reason?.stack ? truncate(reason.stack) : null,
});
});
// 4. fetch wrapper — логируем все не-2xx и failed
const origFetch = window.fetch.bind(window);
window.fetch = async (input, init) => {
const url = typeof input === 'string' ? input : input?.url || '';
const method = (init?.method || 'GET').toUpperCase();
const t0 = performance.now();
try {
const resp = await origFetch(input, init);
if (!resp.ok) {
let body = '';
try {
const cloned = resp.clone();
body = truncate(await cloned.text(), 2000);
} catch {}
push({
kind: 'fetch.bad',
url,
method,
status: resp.status,
duration_ms: Math.round(performance.now() - t0),
body,
});
}
return resp;
} catch (e) {
push({
kind: 'fetch.fail',
url,
method,
duration_ms: Math.round(performance.now() - t0),
message: e?.message || String(e),
});
throw e;
}
};
// 5. XHR wrapper — для axios и т.п.
const XhrOpen = XMLHttpRequest.prototype.open;
const XhrSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (method, url, ...rest) {
this.__rdl = { method, url, t0: performance.now() };
return XhrOpen.call(this, method, url, ...rest);
};
XMLHttpRequest.prototype.send = function (body) {
this.addEventListener('loadend', () => {
const meta = this.__rdl || {};
const status = this.status;
if (status === 0 || status >= 400) {
push({
kind: 'xhr.bad',
url: meta.url,
method: meta.method,
status,
duration_ms: Math.round(performance.now() - (meta.t0 || performance.now())),
body: truncate(this.responseText || '', 2000),
});
}
});
return XhrSend.call(this, body);
};
// Периодически флушим (для долгоживущих логов)
setInterval(() => { if (queue.length) flush(); }, 5000);
window.addEventListener('beforeunload', flush);
// Стартовая отметка чтобы в логе было видно начало сессии
push({ kind: 'session.start', ua: navigator.userAgent });
}
function formatArg(a) {
if (a == null) return String(a);
if (typeof a === 'string') return a;
if (a instanceof Error) return a.message + (a.stack ? '\n' + a.stack : '');
try { return JSON.stringify(a); } catch { return String(a); }
}

View File

@ -16,13 +16,28 @@ export default defineConfig(({ mode }) => {
const PROXY_TARGET = env.VITE_API_PROXY_TARGET || 'https://dev-api.rublox.pro'; const PROXY_TARGET = env.VITE_API_PROXY_TARGET || 'https://dev-api.rublox.pro';
// Префиксы которые проксируем на бэкенд. // Префиксы которые проксируем на бэкенд.
// Для prod-target (minecraftia-school.ru) — режем напрямую на S2 IP (85.175.6.22),
// т.к. на S1 user-service остался старый JWT_SECRET после ротации 2026-06-04,
// и токены выданные S2 на S1 не валидируются.
const proxyPrefixes = ['/api-user', '/api-storys', '/api-game']; const proxyPrefixes = ['/api-user', '/api-storys', '/api-game'];
const proxy = Object.fromEntries( const isProdTarget = PROXY_TARGET.includes('minecraftia-school.ru');
proxyPrefixes.map((p) => [ const proxyOpts = {
p, target: PROXY_TARGET,
{ target: PROXY_TARGET, changeOrigin: true, secure: true, ws: true }, changeOrigin: true,
]) secure: true,
); ws: true,
};
const proxy = Object.fromEntries(proxyPrefixes.map((p) => [p, proxyOpts]));
// /api-rbxl — отдельный target (VM 130 rbxl-importer на S1).
// В dev: ходим напрямую через CF DNS (proxied=false → 85.175.7.40 → NPM → VM 130).
proxy['/api-rbxl'] = {
target: env.VITE_RBXL_PROXY_TARGET || 'http://api-rbxl.rublox.pro',
changeOrigin: true,
secure: false,
rewrite: (path) => path.replace(/^\/api-rbxl/, ''),
};
// Вся статика (kubikon-assets, assets, wiki, dev-*.json) лежит в public/ —
// vite сама отдаёт. Никаких proxy не нужно. См. setup-public.ps1 если папок нет.
return { return {
plugins: [react()], plugins: [react()],