studio/rbxl-importer/src/converter.py
min c375ae01ac
All checks were successful
CI / Lint (pull_request) Successful in 1m6s
CI / Build (pull_request) Successful in 2m2s
CI / Secret scan (pull_request) Successful in 26s
CI / PR size check (pull_request) Successful in 7s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
feat(rbxl-import): импорт Roblox .rbxl карт в Rublox-проекты
Тест-фича для МИНа. Полное описание в rbxl-importer/INFO_PROCESS.md.

Backend (rbxl-importer/ на VM 130 S1):
- Python-парсер Roblox Binary (28+ типов значений)
- Asset downloader через Marfusha proxy + .ROBLOSECURITY cookie
- Mesh→GLB конвертер (v1-v5)
- Converter Roblox-классов → project_data
- Flask API: /analyze + /create

Frontend:
- API.js + components/RbxlImportModal.jsx (drag-n-drop)

Тестовый импорт Easy Obby: project_id 2697,
2244 primitives + 742 lua-scripts + 5 ассетов.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 18:24:27 +03:00

794 lines
32 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
converter.py — RobloxModel → Rublox project_data.
Принимает дерево Instance'ов из parser.py и превращает в JSON-схему которую
понимает движок Rublox (студия и плеер).
Маппинг основных классов Roblox → Rublox:
Part(Shape=Block) → primitive: cube
Part(Shape=Ball) → primitive: sphere
Part(Shape=Cylinder) → primitive: cylinder
WedgePart → primitive: wedge
CornerWedgePart → primitive: cornerwedge
MeshPart → glbModels entry (ссылка на наш сконвертированный glb)
UnionOperation → glbModels entry (если есть CSG)
SpawnLocation → scene.spawnPoint
Script/LocalScript → scripts entry (kind='roblox-lua', raw lua source)
Lighting → scene.environment
Sound → scene.sounds entry
Folder/Model → scene.folders entry
Texture/Decal → не отдельные объекты, прикрепляются к Part'у
Координаты:
Roblox: правая Y-up, 1 stud ≈ 0.28 м.
Rublox/Babylon: правая Y-up, 1 unit = 1 м.
→ масштабируем все координаты на 0.28 (можно настроить через scale_factor).
CFrame → position + rotationX/Y/Z (Euler XYZ в радианах).
Скрипты: сохраняются как kind='roblox-lua' с raw lua_source. Lua-runtime
(asset_proxy + RobloxLuaWorker.js в плеере) исполняет их потом.
"""
from dataclasses import dataclass, field, asdict
from typing import List, Dict, Any, Optional, Tuple
import math
import logging
from rbxl_parser import RobloxModel, Instance
from rbxl_types import (
CFrame, Color3, Vector3, EnumValue, BrickColor,
OptionalCFrame, PhysicalProperties,
)
logger = logging.getLogger(__name__)
# ────── константы маппинга ──────
# Roblox stud → метры (примерно 0.28). Можно поменять при импорте.
DEFAULT_SCALE = 0.28
# Маппинг Material enum → Rublox material strings.
# Roblox Enum.Material:
# 0=Plastic, 256=Plastic (default), 272=Wood, 288=Slate, 304=Concrete,
# 320=CorrodedMetal, 336=DiamondPlate, 352=Foil, 368=Grass, 384=Ice,
# 400=Marble, 416=Granite, 432=Brick, 448=Pebble, 464=Sand, 480=Fabric,
# 496=SmoothPlastic, 512=Metal, 528=WoodPlanks, 784=Neon, 1024=Glass,
# 1280=ForceField, ...
# https://create.roblox.com/docs/reference/engine/enums/Material
ROBLOX_MATERIAL_TO_RUBLOX = {
0: 'glossy', # Plastic
256: 'glossy', # Plastic (legacy)
272: 'matte', # Wood
288: 'matte', # Slate
304: 'matte', # Concrete
320: 'metal', # CorrodedMetal
336: 'metal', # DiamondPlate
352: 'metal', # Foil
368: 'matte', # Grass
384: 'glass', # Ice
400: 'matte', # Marble
416: 'matte', # Granite
432: 'matte', # Brick
448: 'matte', # Pebble
464: 'matte', # Sand
480: 'matte', # Fabric
496: 'glossy', # SmoothPlastic
512: 'metal', # Metal
528: 'matte', # WoodPlanks
784: 'neon', # Neon
1024: 'glass', # Glass
1280: 'neon', # ForceField — синий полупрозрачный
1296: 'matte', # Cobblestone
1328: 'metal', # Aluminum
1344: 'matte', # CrackedLava
1360: 'metal', # Rubber
1376: 'matte', # Pavement
}
# Roblox Part.Shape enum (PartType): 0=Ball, 1=Block, 2=Cylinder, ...
SHAPE_TO_PRIMITIVE = {
0: 'sphere', # Ball
1: 'cube', # Block
2: 'cylinder', # Cylinder
3: 'cube', # Wedge — но WedgePart это отдельный класс
4: 'cornerwedge',
}
# ────── BrickColor таблица (упрощённая) ──────
# Roblox использует old BrickColor enum (числа 1-1032). Только распространённые:
BRICKCOLOR_TO_HEX = {
1: '#f2f3f3', 5: '#d9e4f7', 9: '#9c9e9c', 11: '#e8eaea',
21: '#c4281c', 23: '#0d69ac', 24: '#f5cd30', 26: '#27313e',
28: '#293f1a', 29: '#a3bb71', 37: '#4b9740', 38: '#ab5a32',
101: '#dab8a3', 102: '#82c1e9', 103: '#9b9696', 104: '#6b327a',
105: '#cf8b3e', 106: '#d97e29', 107: '#3a8fbf', 108: '#695b50',
111: '#a7a6a6', 119: '#aac84a', 125: '#e8b486', 138: '#8a8a76',
141: '#26462b', 153: '#9b605a', 192: '#5a3019', 194: '#9c9b91',
199: '#3c3e3f', 208: '#dbdcdc', 224: '#f3e3a5', 226: '#fff8a8',
1001: '#ffffff', 1002: '#cccccc', 1003: '#000000', 1004: '#ff0000',
1005: '#ff8000', 1006: '#ffff00', 1007: '#80c000', 1008: '#00ff00',
1009: '#00c080', 1010: '#0080ff', 1011: '#0000ff', 1012: '#5000ff',
1013: '#a000ff', 1014: '#ff00ff', 1015: '#ff0080', 1016: '#ff80c0',
1017: '#ff8080', 1018: '#ffc080', 1019: '#ffff80', 1020: '#80ff80',
}
# ──── вспомогательные функции ────
def cframe_to_pos_rot(cf: Optional[CFrame], scale: float) -> Tuple[Dict, Dict]:
"""Конвертит CFrame в позицию и Euler ротацию.
Roblox координаты: правая Y-up. Babylon: правая Y-up. То есть совпадают.
Возвращает ({x,y,z}, {rx,ry,rz}).
"""
if cf is None:
return ({'x': 0.0, 'y': 0.0, 'z': 0.0},
{'rx': 0.0, 'ry': 0.0, 'rz': 0.0})
pos = {'x': cf.position.x * scale, 'y': cf.position.y * scale, 'z': cf.position.z * scale}
rx, ry, rz = cf.to_euler_xyz()
rot = {'rx': rx, 'ry': ry, 'rz': rz}
return pos, rot
def color3_to_hex(c: Optional[Color3]) -> str:
if c is None:
return '#cccccc'
return c.to_hex()
def brickcolor_to_hex(b: Optional[BrickColor]) -> str:
if b is None:
return '#cccccc'
return BRICKCOLOR_TO_HEX.get(b.code, '#cccccc')
def material_to_string(m: Optional[EnumValue]) -> str:
if m is None:
return 'glossy'
return ROBLOX_MATERIAL_TO_RUBLOX.get(m.value, 'glossy')
def get_part_color(props: Dict[str, Any]) -> str:
"""Достаёт цвет Part: сначала Color3uint8, потом BrickColor, потом дефолт."""
if 'Color3uint8' in props:
return color3_to_hex(props['Color3uint8'])
if 'Color' in props:
return color3_to_hex(props['Color'])
if 'BrickColor' in props:
return brickcolor_to_hex(props['BrickColor'])
return '#cccccc'
def name_or_default(props: Dict[str, Any], default: str) -> str:
return str(props.get('Name', default))
# ────── Конвертеры классов ──────
@dataclass
class ConversionStats:
primitives_created: int = 0
glb_models_created: int = 0
scripts_collected: int = 0
scripts_skipped: int = 0
parts_dropped: int = 0
skipped_classes: Dict[str, int] = field(default_factory=dict)
warnings: List[str] = field(default_factory=list)
asset_ids_needed: List[int] = field(default_factory=list) # rbx_asset_id'ы для скачки
class Converter:
"""Главный конвертер. Работает на одной RobloxModel за раз."""
def __init__(
self,
model: RobloxModel,
scale: float = DEFAULT_SCALE,
asset_url_resolver=None, # callable(rbx_asset_id) -> str|None (public URL после скачки)
):
self.model = model
self.scale = scale
self.stats = ConversionStats()
self.asset_url_resolver = asset_url_resolver
self._next_primitive_id = 1
self._next_glb_id = 1
self._next_script_id = 1
self._instance_to_primitive_id: Dict[int, int] = {}
# ──── главный entry-point ────
def convert(self) -> Dict[str, Any]:
"""Возвращает project_data dict готовый к сохранению как JSON."""
scene = {
'blocks': [],
'models': [],
'primitives': [],
'userModels': [],
'terrain': [],
'robloxTerrain': self._default_roblox_terrain(),
'decorations': [],
'folders': [],
'gui': [],
'inventory': [],
'spawnPoint': {'x': 0, 'y': 2, 'z': 0},
'playerModelType': 'default',
'worldSize': 100,
'floorEnabled': True,
'jumpPowerMul': 1.0,
'cameraMode': 'thirdPerson',
'crosshair': 'default',
'shadowQuality': 'medium',
'environment': {
'preset': 'day',
'timeOfDay': 14,
'dayDurationMin': 5,
'nightDurationMin': 3,
'fogEnabled': False,
'fogColor': [0.7, 0.8, 0.9],
'fogDensity': 0.01,
},
'audio': {},
'assets': [],
'sounds': [],
'glbModels': [],
'scripts': [],
}
# Обходим все instances и конвертим
for inst in self.model.instances:
self._convert_one(inst, scene)
# Финальный отчёт о скипнутых классах
for cls, n in sorted(self.stats.skipped_classes.items(), key=lambda x: -x[1])[:30]:
self.stats.warnings.append(f"skipped {n}× {cls}")
return {
'version': 1,
'scene': scene,
'editorCamera': {'x': 20, 'y': 15, 'z': 20, 'targetX': 0, 'targetY': 0, 'targetZ': 0},
'settings': {
'isGd': False,
'importedFrom': 'roblox',
'importStats': asdict(self.stats),
},
}
# ──── per-class конвертеры ────
def _convert_one(self, inst: Instance, scene: Dict) -> None:
cls = inst.class_name
try:
if cls == 'Part':
self._convert_part(inst, scene)
elif cls == 'WedgePart':
self._convert_wedge(inst, scene)
elif cls == 'CornerWedgePart':
self._convert_cornerwedge(inst, scene)
elif cls == 'TrussPart':
self._convert_truss(inst, scene)
elif cls == 'MeshPart':
self._convert_meshpart(inst, scene)
elif cls == 'UnionOperation':
self._convert_union(inst, scene)
elif cls == 'SpecialMesh':
# SpecialMesh — child объект Part'а, меняет визуал родителя
# Обработка делается в _convert_part через children, тут не нужно
pass
elif cls == 'SpawnLocation':
self._convert_spawn(inst, scene)
elif cls == 'Script' or cls == 'LocalScript' or cls == 'ModuleScript':
self._convert_script(inst, scene)
elif cls == 'Sound':
self._convert_sound(inst, scene)
elif cls == 'PointLight' or cls == 'SpotLight' or cls == 'SurfaceLight':
self._convert_light(inst, scene)
elif cls == 'Folder' or cls == 'Model':
self._convert_folder(inst, scene)
elif cls in ('Decal', 'Texture'):
# Прикрепляются к Part'у — обрабатываются при конверте родителя
pass
elif cls == 'Lighting':
self._convert_lighting(inst, scene)
elif cls == 'Workspace':
# Workspace = root, его свойства мапим на scene.worldSize и т.п.
pass
elif cls in ('Camera', 'Terrain', 'StarterPlayer', 'StarterGui',
'StarterPack', 'StarterCharacterScripts', 'Players',
'ReplicatedStorage', 'ServerScriptService', 'ServerStorage',
'SoundService', 'TweenService', 'RunService',
'UserInputService', 'HttpService', 'DataStoreService',
'TeleportService', 'BadgeService', 'MarketplaceService',
'ContentProvider', 'NetworkClient', 'NetworkServer',
'Chat', 'Stats', 'Debris', 'AnalyticsService',
'CSGDictionaryService', 'NonReplicatedCSGDictionaryService'):
# Системные сервисы — игнорируем (Lua-runtime создаст mock'и)
pass
else:
self.stats.skipped_classes[cls] = self.stats.skipped_classes.get(cls, 0) + 1
except Exception as e:
self.stats.warnings.append(f"convert {cls} (referent={inst.referent}) failed: {e!r}")
self.stats.parts_dropped += 1
# ─── Part / WedgePart / etc ───
def _convert_part(self, inst: Instance, scene: Dict) -> None:
props = inst.properties
cf = props.get('CFrame')
size = props.get('size') or props.get('Size')
pos, rot = cframe_to_pos_rot(cf, self.scale)
# Roblox Part.Shape: 0=Ball, 1=Block, 2=Cylinder
# PartType enum value — берётся из props.get('shape') или props.get('Shape')
shape = props.get('Shape')
if isinstance(shape, EnumValue):
ptype = SHAPE_TO_PRIMITIVE.get(shape.value, 'cube')
else:
ptype = 'cube'
# Размер
if size:
sx = abs(size.x) * self.scale
sy = abs(size.y) * self.scale
sz = abs(size.z) * self.scale
else:
sx = sy = sz = 1.0 * self.scale
# Проверяем есть ли child SpecialMesh — он переопределяет визуал
for child in inst.children:
if child.class_name == 'SpecialMesh':
mesh_type = child.properties.get('MeshType')
if isinstance(mesh_type, EnumValue):
# MeshType: 0=Head, 1=Torso, 2=Wedge, 3=Sphere, 4=Cylinder, 5=FileMesh, 6=Brick
mt = mesh_type.value
if mt == 3: ptype = 'sphere'
elif mt == 4: ptype = 'cylinder'
prim_id = self._next_primitive_id
self._next_primitive_id += 1
self._instance_to_primitive_id[inst.referent] = prim_id
primitive = {
'id': prim_id,
'type': ptype,
'name': name_or_default(props, 'Part'),
'x': pos['x'], 'y': pos['y'], 'z': pos['z'],
'sx': sx, 'sy': sy, 'sz': sz,
'color': get_part_color(props),
'material': material_to_string(props.get('Material')),
'canCollide': bool(props.get('CanCollide', True)),
'visible': props.get('Transparency', 0) < 1.0 if isinstance(props.get('Transparency'), (int, float)) else True,
'opacity': max(0.0, 1.0 - (props.get('Transparency', 0) or 0)),
'anchored': bool(props.get('Anchored', False)),
'mass': 1.0,
'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'],
}
scene['primitives'].append(primitive)
self.stats.primitives_created += 1
def _convert_wedge(self, inst: Instance, scene: Dict) -> None:
props = inst.properties
cf = props.get('CFrame')
size = props.get('size') or props.get('Size')
pos, rot = cframe_to_pos_rot(cf, self.scale)
if size:
sx, sy, sz = abs(size.x)*self.scale, abs(size.y)*self.scale, abs(size.z)*self.scale
else:
sx = sy = sz = 1.0
prim_id = self._next_primitive_id
self._next_primitive_id += 1
self._instance_to_primitive_id[inst.referent] = prim_id
scene['primitives'].append({
'id': prim_id, 'type': 'wedge',
'name': name_or_default(props, 'Wedge'),
'x': pos['x'], 'y': pos['y'], 'z': pos['z'],
'sx': sx, 'sy': sy, 'sz': sz,
'color': get_part_color(props),
'material': material_to_string(props.get('Material')),
'canCollide': bool(props.get('CanCollide', True)),
'visible': True,
'anchored': bool(props.get('Anchored', False)),
'mass': 1.0,
'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'],
})
self.stats.primitives_created += 1
def _convert_cornerwedge(self, inst: Instance, scene: Dict) -> None:
props = inst.properties
cf = props.get('CFrame')
size = props.get('size') or props.get('Size')
pos, rot = cframe_to_pos_rot(cf, self.scale)
if size:
sx, sy, sz = abs(size.x)*self.scale, abs(size.y)*self.scale, abs(size.z)*self.scale
else:
sx = sy = sz = 1.0
prim_id = self._next_primitive_id
self._next_primitive_id += 1
self._instance_to_primitive_id[inst.referent] = prim_id
scene['primitives'].append({
'id': prim_id, 'type': 'cornerwedge',
'name': name_or_default(props, 'CornerWedge'),
'x': pos['x'], 'y': pos['y'], 'z': pos['z'],
'sx': sx, 'sy': sy, 'sz': sz,
'color': get_part_color(props),
'material': material_to_string(props.get('Material')),
'canCollide': bool(props.get('CanCollide', True)),
'visible': True,
'anchored': bool(props.get('Anchored', False)),
'mass': 1.0,
'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'],
})
self.stats.primitives_created += 1
def _convert_truss(self, inst: Instance, scene: Dict) -> None:
"""TrussPart — Roblox-специфичный леса. Конвертим в cube с пометкой."""
props = inst.properties
cf = props.get('CFrame')
size = props.get('size') or props.get('Size')
pos, rot = cframe_to_pos_rot(cf, self.scale)
if size:
sx, sy, sz = abs(size.x)*self.scale, abs(size.y)*self.scale, abs(size.z)*self.scale
else:
sx, sy, sz = 0.5, 5.0, 0.5
prim_id = self._next_primitive_id
self._next_primitive_id += 1
self._instance_to_primitive_id[inst.referent] = prim_id
scene['primitives'].append({
'id': prim_id, 'type': 'cube',
'name': name_or_default(props, 'Truss'),
'x': pos['x'], 'y': pos['y'], 'z': pos['z'],
'sx': sx, 'sy': sy, 'sz': sz,
'color': get_part_color(props),
'material': 'metal',
'canCollide': True,
'visible': True,
'anchored': bool(props.get('Anchored', True)),
'mass': 1.0,
'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'],
})
self.stats.primitives_created += 1
# ─── MeshPart / UnionOperation ───
def _convert_meshpart(self, inst: Instance, scene: Dict) -> None:
"""MeshPart → glbModels entry с ссылкой на сконвертированный GLB."""
props = inst.properties
cf = props.get('CFrame')
size = props.get('size') or props.get('Size')
pos, rot = cframe_to_pos_rot(cf, self.scale)
# MeshId — это rbxassetid:// строка
mesh_id_str = props.get('MeshID') or props.get('MeshId') or ''
rbx_id = self._parse_asset_id(mesh_id_str)
if rbx_id:
self.stats.asset_ids_needed.append(rbx_id)
glb_url = self.asset_url_resolver(rbx_id) if (rbx_id and self.asset_url_resolver) else None
if size:
sx, sy, sz = abs(size.x)*self.scale, abs(size.y)*self.scale, abs(size.z)*self.scale
else:
sx = sy = sz = 1.0
# Если GLB не доступен — fallback на bbox cube
if not glb_url:
prim_id = self._next_primitive_id
self._next_primitive_id += 1
self._instance_to_primitive_id[inst.referent] = prim_id
scene['primitives'].append({
'id': prim_id, 'type': 'cube',
'name': name_or_default(props, 'MeshPart'),
'x': pos['x'], 'y': pos['y'], 'z': pos['z'],
'sx': sx, 'sy': sy, 'sz': sz,
'color': get_part_color(props),
'material': material_to_string(props.get('Material')),
'canCollide': bool(props.get('CanCollide', True)),
'visible': True,
'anchored': bool(props.get('Anchored', False)),
'mass': 1.0,
'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'],
'note': f'MeshPart (no GLB) rbxid={rbx_id}',
})
self.stats.primitives_created += 1
self.stats.parts_dropped += 1
return
glb_id = self._next_glb_id
self._next_glb_id += 1
scene['glbModels'].append({
'id': glb_id,
'name': name_or_default(props, 'MeshPart'),
'url': glb_url,
'x': pos['x'], 'y': pos['y'], 'z': pos['z'],
'sx': sx, 'sy': sy, 'sz': sz,
'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'],
'color': get_part_color(props),
'canCollide': bool(props.get('CanCollide', True)),
'anchored': bool(props.get('Anchored', False)),
'origin': 'roblox-meshpart',
'rbxAssetId': rbx_id,
})
self.stats.glb_models_created += 1
def _convert_union(self, inst: Instance, scene: Dict) -> None:
"""UnionOperation — CSG объединение. Берём AssetId если есть, иначе bbox."""
props = inst.properties
cf = props.get('CFrame')
size = props.get('size') or props.get('Size')
pos, rot = cframe_to_pos_rot(cf, self.scale)
if size:
sx, sy, sz = abs(size.x)*self.scale, abs(size.y)*self.scale, abs(size.z)*self.scale
else:
sx = sy = sz = 1.0
# AssetId Union'а — это его CSG-mesh
asset_id_str = props.get('AssetId') or ''
rbx_id = self._parse_asset_id(asset_id_str)
if rbx_id:
self.stats.asset_ids_needed.append(rbx_id)
glb_url = self.asset_url_resolver(rbx_id) if (rbx_id and self.asset_url_resolver) else None
if not glb_url:
# Fallback: cube
prim_id = self._next_primitive_id
self._next_primitive_id += 1
self._instance_to_primitive_id[inst.referent] = prim_id
scene['primitives'].append({
'id': prim_id, 'type': 'cube',
'name': name_or_default(props, 'Union'),
'x': pos['x'], 'y': pos['y'], 'z': pos['z'],
'sx': sx, 'sy': sy, 'sz': sz,
'color': get_part_color(props),
'material': material_to_string(props.get('Material')),
'canCollide': bool(props.get('CanCollide', True)),
'visible': True,
'anchored': bool(props.get('Anchored', False)),
'mass': 1.0,
'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'],
'note': f'Union (no CSG GLB) rbxid={rbx_id}',
})
self.stats.primitives_created += 1
return
glb_id = self._next_glb_id
self._next_glb_id += 1
scene['glbModels'].append({
'id': glb_id,
'name': name_or_default(props, 'Union'),
'url': glb_url,
'x': pos['x'], 'y': pos['y'], 'z': pos['z'],
'sx': sx, 'sy': sy, 'sz': sz,
'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'],
'color': get_part_color(props),
'canCollide': bool(props.get('CanCollide', True)),
'anchored': bool(props.get('Anchored', False)),
'origin': 'roblox-union',
'rbxAssetId': rbx_id,
})
self.stats.glb_models_created += 1
# ─── Spawn ───
def _convert_spawn(self, inst: Instance, scene: Dict) -> None:
props = inst.properties
cf = props.get('CFrame')
pos, _ = cframe_to_pos_rot(cf, self.scale)
# Spawn должен быть чуть выше — Roblox SpawnLocation это плоская плита,
# юзер появляется на её верхней грани.
scene['spawnPoint'] = {
'x': pos['x'],
'y': pos['y'] + 1.5, # отступ вверх чтобы не залипнуть в плите
'z': pos['z'],
}
# ─── Scripts ───
def _convert_script(self, inst: Instance, scene: Dict) -> None:
"""Lua-script сохраняем raw с пометкой kind='roblox-lua'.
Plus, attached to конкретному примитиву если у скрипта есть Parent
с известным referent.
"""
props = inst.properties
source = props.get('Source', '')
if not source or not isinstance(source, str):
self.stats.scripts_skipped += 1
return
# Цель — primitive id предка если есть
target = None
if inst.parent_referent and inst.parent_referent in self._instance_to_primitive_id:
target = self._instance_to_primitive_id[inst.parent_referent]
script_id = f'rbx_{self._next_script_id}'
self._next_script_id += 1
scene['scripts'].append({
'id': script_id,
'name': name_or_default(props, inst.class_name),
'kind': 'roblox-lua',
'target': target,
'code': '', # JS-эквивалент пока нет (заполнит Lua-runtime в плеере)
'lua_source': source,
'roblox_class': inst.class_name, # Script | LocalScript | ModuleScript
'enabled': bool(props.get('Disabled', False) is False),
})
self.stats.scripts_collected += 1
# ─── Sound ───
def _convert_sound(self, inst: Instance, scene: Dict) -> None:
props = inst.properties
sound_id_str = props.get('SoundId') or ''
rbx_id = self._parse_asset_id(sound_id_str)
if rbx_id:
self.stats.asset_ids_needed.append(rbx_id)
url = self.asset_url_resolver(rbx_id) if (rbx_id and self.asset_url_resolver) else None
scene['sounds'].append({
'id': f'sound_{rbx_id or inst.referent}',
'name': name_or_default(props, 'Sound'),
'url': url or '',
'volume': float(props.get('Volume', 1.0) or 1.0),
'loop': bool(props.get('Looped', False)),
'autoplay': bool(props.get('Playing', False)),
'rbxAssetId': rbx_id,
})
# ─── Light ───
def _convert_light(self, inst: Instance, scene: Dict) -> None:
"""Roblox PointLight / SpotLight / SurfaceLight → primitive type='light'."""
props = inst.properties
# Position берём от parent Part (если есть)
x = y = z = 0.0
parent = self.model.by_referent.get(inst.parent_referent) if inst.parent_referent else None
if parent and 'CFrame' in parent.properties:
pcf = parent.properties['CFrame']
x, y, z = pcf.position.x * self.scale, pcf.position.y * self.scale, pcf.position.z * self.scale
prim_id = self._next_primitive_id
self._next_primitive_id += 1
scene['primitives'].append({
'id': prim_id, 'type': 'light',
'name': name_or_default(props, inst.class_name),
'x': x, 'y': y, 'z': z,
'sx': 1, 'sy': 1, 'sz': 1,
'color': color3_to_hex(props.get('Color')),
'material': 'glossy',
'canCollide': False,
'visible': True,
'anchored': True,
'mass': 0,
'rotationX': 0, 'rotationY': 0, 'rotationZ': 0,
'lightRange': float(props.get('Range', 8.0) or 8.0) * self.scale,
'lightBrightness': float(props.get('Brightness', 1.0) or 1.0),
})
self.stats.primitives_created += 1
# ─── Folder / Model ───
def _convert_folder(self, inst: Instance, scene: Dict) -> None:
props = inst.properties
scene['folders'].append({
'id': f'rbx_folder_{inst.referent}',
'name': name_or_default(props, inst.class_name),
'parent': inst.parent_referent,
'origin': 'roblox-' + inst.class_name.lower(),
})
# ─── Lighting ───
def _convert_lighting(self, inst: Instance, scene: Dict) -> None:
"""Roblox Lighting service — мапим на scene.environment."""
props = inst.properties
env = scene['environment']
ambient = props.get('Ambient')
if isinstance(ambient, Color3):
env['ambientColor'] = ambient.to_hex()
brightness = props.get('Brightness')
if isinstance(brightness, (int, float)):
env['brightness'] = float(brightness)
clock = props.get('ClockTime')
if isinstance(clock, (int, float)):
env['timeOfDay'] = float(clock)
fog_color = props.get('FogColor')
if isinstance(fog_color, Color3):
env['fogColor'] = [fog_color.r, fog_color.g, fog_color.b]
fog_end = props.get('FogEnd')
if isinstance(fog_end, (int, float)) and fog_end < 999999:
env['fogEnabled'] = True
env['fogDensity'] = 1.0 / max(fog_end, 1.0)
# ─── utility ───
def _default_roblox_terrain(self) -> Dict:
"""Минимальный плоский ландшафт чтобы импорт не падал.
Real Roblox terrain потребует отдельной конвертации (voxel grid)
— оставлю на потом.
"""
return {
'format': 'robloxterrain-v1',
'origin': {'x': -22, 'y': 0, 'z': -22},
'size': {'x': 44, 'y': 24, 'z': 44},
'palette': ['', 'grass', 'rock', 'sand'],
'mat': '',
'density': '',
}
def _parse_asset_id(self, s: Any) -> Optional[int]:
"""rbxassetid://12345 или rbxasset://12345 или просто 12345 → int."""
if not s:
return None
s = str(s).strip()
if not s:
return None
if s.startswith('rbxassetid://'):
s = s[len('rbxassetid://'):]
elif s.startswith('rbxasset://'):
s = s[len('rbxasset://'):]
elif s.startswith('http://www.roblox.com/asset/?id='):
s = s.split('=')[-1]
try:
return int(s)
except ValueError:
return None
# ────── CLI ──────
if __name__ == '__main__':
import sys
import json
sys.path.insert(0, '/opt/rbxl-importer/src')
from rbxl_parser import parse
path = sys.argv[1] if len(sys.argv) > 1 else '/opt/rbxl-importer/reference_files/easy_obby.rbxl'
out = sys.argv[2] if len(sys.argv) > 2 else '/tmp/converted.json'
with open(path, 'rb') as f:
blob = f.read()
logging.basicConfig(level=logging.INFO)
print(f'parsing {path} ({len(blob)} bytes)...')
model = parse(blob)
print(f'{model.instance_count} instances')
print(f'\nconverting...')
conv = Converter(model)
project_data = conv.convert()
with open(out, 'w', encoding='utf-8') as f:
json.dump(project_data, f, ensure_ascii=False, indent=2)
print(f'\nwritten to {out} ({sum(1 for _ in open(out))} lines)')
print('\n=== stats ===')
for k, v in asdict(conv.stats).items():
if k == 'warnings' and isinstance(v, list):
print(f' warnings: {len(v)} total')
for w in v[:5]:
print(f' - {w}')
elif k == 'skipped_classes':
top = sorted(v.items(), key=lambda x: -x[1])[:10]
print(f' skipped_classes (top 10):')
for c, n in top:
print(f' {n:>4d} {c}')
elif k == 'asset_ids_needed':
print(f' asset_ids_needed: {len(v)} (deduped: {len(set(v))})')
else:
print(f' {k}: {v}')