studio/rbxl-importer/src/converter.py
min f7441b0bd6
Some checks failed
CI / Lint (push) Failing after 1m8s
CI / Build (push) Successful in 1m58s
CI / Secret scan (push) Successful in 23s
CI / PR size check (push) Has been skipped
CI / Deploy to S1 + S2 (push) Successful in 3m28s
feat: 50 ��� �� Lua + ������ Roblox ��� ���� + ������ ����
2026-06-09 21:59:19 +00:00

1096 lines
48 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 json
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 → unit Rublox-движка.
# R15-персонаж в Rublox ~5.5м, Roblox-персонаж ~5stud высотой. Чтобы карта
# была пропорциональна персонажу — scale 0.35 (платформа 4stud → 1.4 unit,
# как стандартная Rublox-платформа).
DEFAULT_SCALE = 0.35
# Маппинг 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', 2: '#a1a5a2', 3: '#f9e999', 5: '#d9e4f7',
9: '#9c9e9c', 11: '#e8eaea', 18: '#cc8e69', 21: '#c4281c',
23: '#0d69ac', 24: '#f5cd30', 26: '#1b2a35', 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',
115: '#c7d23c', 116: '#56fff0', 118: '#b4d2e4', 119: '#aac84a',
120: '#d4f0a6', 123: '#cf6b6f', 124: '#9c54a6', 125: '#e8b486',
126: '#a6c2e3', 127: '#deb87b', 128: '#a37e5b', 131: '#9ba19d',
133: '#cc7c39', 134: '#de8b5f', 135: '#74859c', 136: '#876a7a',
137: '#e6a262', 138: '#8a8a76', 140: '#234770', 141: '#26462b',
143: '#bdc3e3', 145: '#5c8aa1', 146: '#75718b', 147: '#9a8a64',
148: '#5a605a', 149: '#1b2a47', 150: '#9ea1a3',
# ВАЖНО: 151 — Earth green (тёмная трава Crossroads)
151: '#7c9b53',
153: '#9b605a', 154: '#7a2d2d', 157: '#f5e09c', 158: '#b58c9c',
168: '#3c3a37', 176: '#a39989', 178: '#aa724c', 180: '#cc9555',
190: '#f7b830', 191: '#e69138',
192: '#5a3019', 193: '#f59d24', 194: '#9c9b91', 195: '#447ba6',
196: '#283970', 198: '#7b4b85', 199: '#3c3e3f', 200: '#7a854b',
208: '#dbdcdc', 209: '#a4733f', 210: '#7d8a8e', 211: '#9da3b3',
212: '#a5cce0', 213: '#6584b5', 215: '#7c8aa4', 216: '#8a5040',
217: '#7a5443', 218: '#94748a', 219: '#5c5a8a', 220: '#a3a8c4',
221: '#cc4488', 222: '#e8a8e0', 223: '#dd7790', 224: '#f3e3a5',
225: '#e8b685', 226: '#fff8a8', 232: '#bce0f0', 268: '#3c2e74',
301: '#73584b',
# Бипалитра 1001-1032 — стандартные яркие цвета
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',
1021: '#80c0ff', 1022: '#80ffff', 1023: '#80ff00', 1024: '#00ff80',
1025: '#ff4040', 1026: '#8a0028', 1027: '#001f80', 1028: '#4d4d4d',
1029: '#9d9d9d', 1030: '#5e3923', 1031: '#7a4f30', 1032: '#cca5a5',
}
# ──── вспомогательные функции ────
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},
# Стандартный R15-скин bacon-hair как во всех новых проектах студии.
# 'default' — невалидный typeId, PlayerController на нём падает.
'playerModelType': 'skin_bacon-hair',
'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': [],
# Команды PvP (Roblox Battle): {id, name, color_hex, auto_assignable}
'teams': [],
# Spawn-точки команд (для SpawnLocation.TeamColor)
'team_spawns': [], # {team_color_hex, x, y, z}
}
# Эвристика для Roblox Battle: Model с именем "TeamBeacon X" →
# команда X. PvP-карты часто используют этот паттерн вместо Team-инстансов.
TEAM_BEACON_COLORS = {
'Black': '#1f1f1f', 'Blue': '#0d69ac', 'Red': '#c4281c',
'Green': '#4b9740', 'White': '#f2f3f3', 'Yellow': '#f5cd30',
'Orange': '#d97e29', 'Purple': '#6b327a',
}
for inst in self.model.instances:
name = inst.properties.get('Name', '')
if (inst.class_name == 'Model' and isinstance(name, str)
and name.startswith('TeamBeacon ')):
team_name = name.replace('TeamBeacon ', '').strip()
color = TEAM_BEACON_COLORS.get(team_name, '#cccccc')
scene['teams'].append({
'id': f'team_{len(scene["teams"]) + 1}',
'name': team_name,
'color_hex': color,
'auto_assignable': True,
})
# Обходим все instances и конвертим
for inst in self.model.instances:
self._convert_one(inst, scene)
# Spawn fallback: если SpawnLocation в карте НЕ был (или дефолт 0,2,0
# остался) — поднимаем выше самой высокой Part'ы. Иначе игрок
# появляется внутри Anchored=True геометрии и не может двигаться.
sp = scene.get('spawnPoint', {'x': 0, 'y': 2, 'z': 0})
if sp.get('x') == 0 and sp.get('y') == 2 and sp.get('z') == 0:
prims = scene.get('primitives', [])
if prims:
max_top = max(
(p['y'] + p.get('sy', 1) / 2) for p in prims
if isinstance(p.get('y'), (int, float))
)
scene['spawnPoint'] = {'x': 0, 'y': max_top + 5, 'z': 0}
# Финальный отчёт о скипнутых классах
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 ('ScreenGui', 'BillboardGui', 'SurfaceGui'):
# Контейнер — сам по себе ничего не рендерит, дети идут в scene.gui
# как top-level элементы с parentId=None.
self._convert_screen_gui(inst, scene)
elif cls in ('Frame', 'ScrollingFrame', 'TextLabel', 'TextButton',
'ImageLabel', 'ImageButton', 'TextBox'):
self._convert_gui_element(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 == 'Team':
# PvP-команда: имя + цвет в scene.teams[].
self._convert_team(inst, scene)
elif cls in ('Camera', 'Terrain', 'StarterPlayer', 'StarterGui',
'StarterPack', 'StarterCharacterScripts', 'Players',
'Teams',
'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)),
# FORCE-ANCHORED — Welds импортируем как заглушки, без них
# физика 700+ unanchored Part'ов = карта рассыпается.
'anchored': True,
'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,
# FORCE-ANCHORED — Welds импортируем как заглушки, без них
# физика 700+ unanchored Part'ов = карта рассыпается.
'anchored': True,
'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,
# FORCE-ANCHORED — Welds импортируем как заглушки, без них
# физика 700+ unanchored Part'ов = карта рассыпается.
'anchored': True,
'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,
# FORCE-ANCHORED — Welds импортируем как заглушки, без них
# физика 700+ unanchored Part'ов = карта рассыпается.
'anchored': True,
'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)),
# FORCE-ANCHORED — Welds импортируем как заглушки, без них
# физика 700+ unanchored Part'ов = карта рассыпается.
'anchored': True,
'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,
# FORCE-ANCHORED — Welds импортируем как заглушки, без них
# физика 700+ unanchored Part'ов = карта рассыпается.
'anchored': True,
'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)),
# FORCE-ANCHORED — Welds импортируем как заглушки, без них
# физика 700+ unanchored Part'ов = карта рассыпается.
'anchored': True,
'origin': 'roblox-union',
'rbxAssetId': rbx_id,
})
self.stats.glb_models_created += 1
# ─── Spawn ───
def _convert_team(self, inst: Instance, scene: Dict) -> None:
"""Roblox Team → scene.teams[]."""
props = inst.properties
name = str(props.get('Name', 'Team'))
# TeamColor — BrickColor код, мапим в hex через существующую таблицу
team_color = props.get('TeamColor')
color_hex = '#ffffff'
if isinstance(team_color, BrickColor):
color_hex = BRICKCOLOR_TO_HEX.get(team_color.code, '#cccccc')
scene['teams'].append({
'id': f'team_{len(scene["teams"]) + 1}',
'name': name,
'color_hex': color_hex,
'auto_assignable': bool(props.get('AutoAssignable', True)),
})
def _convert_spawn(self, inst: Instance, scene: Dict) -> None:
props = inst.properties
cf = props.get('CFrame')
pos, _ = cframe_to_pos_rot(cf, self.scale)
# TeamColor (если есть) → spawn для команды.
team_color = props.get('TeamColor')
if isinstance(team_color, BrickColor):
color_hex = BRICKCOLOR_TO_HEX.get(team_color.code, '#cccccc')
scene['team_spawns'].append({
'team_color_hex': color_hex,
'x': pos['x'], 'y': pos['y'] + 1.5, 'z': pos['z'],
'neutral': not bool(props.get('Neutral', True)) and team_color.code != 0,
})
# Spawn должен быть значительно выше — старые Roblox-карты часто имеют
# толстый Floor выше плиты, юзер появляется внутри стены/пола если
# не дать запас. +5 единиц достаточно — гравитация уронит на пол.
scene['spawnPoint'] = {
'x': pos['x'],
'y': pos['y'] + 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
# 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({
'id': script_id,
'name': name_or_default(props, inst.class_name),
'target': target,
'code': packed_code,
})
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(),
})
# ─── GUI ───
def _convert_screen_gui(self, inst: Instance, scene: Dict) -> None:
# ScreenGui сам не рендерится — он контейнер. Дети получают parentId=None
# при конверте. Сохраняем referent чтобы _gui_parent_id() видел.
# Также сохраняем Enabled-свойство: если ScreenGui.Enabled=false →
# все дети должны быть скрыты (Roblox прячет всю иерархию).
if not hasattr(self, '_screen_gui_refs'):
self._screen_gui_refs = set()
self._screen_gui_enabled = {}
self._screen_gui_kind = {} # ref → 'screen' | 'billboard' | 'surface'
self._screen_gui_refs.add(inst.referent)
enabled = inst.properties.get('Enabled', True)
self._screen_gui_enabled[inst.referent] = bool(enabled) if enabled is not None else True
# Сохраняем тип контейнера — потом отфильтруем 3D-GUI если выбрано screen-only
kind = {'ScreenGui': 'screen', 'BillboardGui': 'billboard', 'SurfaceGui': 'surface'}.get(inst.class_name, 'screen')
self._screen_gui_kind[inst.referent] = kind
def _gui_parent_id(self, parent_ref) -> Optional[str]:
if parent_ref is None:
return None
if hasattr(self, '_screen_gui_refs') and parent_ref in self._screen_gui_refs:
return None # top-level в ScreenGui = parentId=None в Rublox
return f'rbx_gui_{parent_ref}'
def _udim_to_percent(self, udim, axis: str = 'x') -> float:
"""Roblox UDim(scale, offset) → процент (0..100) для Rublox GUI.
Rublox использует проценты от viewport. Конвертация:
- scale (0..1) → scale * 100
- offset (px) → offset / viewport_size * 100 (1280×720 reference)
"""
if udim is None:
return 0.0
ref = 1280.0 if axis == 'x' else 720.0
if isinstance(udim, dict):
scale = udim.get('scale', 0) or 0
offset = udim.get('offset', 0) or 0
else:
scale = getattr(udim, 'scale', 0) or 0
offset = getattr(udim, 'offset', 0) or 0
return scale * 100.0 + (offset / ref) * 100.0
def _udim2_pair(self, udim2) -> Tuple[float, float]:
"""UDim2 → (x%, y%). Поддерживает dataclass UDim2 и dict."""
if udim2 is None:
return (0.0, 0.0)
if isinstance(udim2, dict):
x_obj = udim2.get('x')
y_obj = udim2.get('y')
else:
x_obj = getattr(udim2, 'x', None)
y_obj = getattr(udim2, 'y', None)
return (self._udim_to_percent(x_obj, 'x'), self._udim_to_percent(y_obj, 'y'))
def _color3_to_hex(self, c3) -> str:
if c3 is None:
return '#ffffff'
try:
if hasattr(c3, 'to_hex'):
return c3.to_hex()
r = int(round(getattr(c3, 'r', 1) * 255))
g = int(round(getattr(c3, 'g', 1) * 255))
b = int(round(getattr(c3, 'b', 1) * 255))
return f'#{r:02x}{g:02x}{b:02x}'
except Exception:
return '#ffffff'
def _convert_gui_element(self, inst: Instance, scene: Dict) -> None:
props = inst.properties
cls = inst.class_name
# type-маппинг Roblox → Rublox GUI
if cls in ('Frame', 'ScrollingFrame'):
r_type = 'frame'
elif cls == 'TextLabel':
r_type = 'text'
elif cls in ('TextButton', 'ImageButton'):
r_type = 'button'
elif cls == 'ImageLabel':
r_type = 'image'
elif cls == 'TextBox':
r_type = 'textbox'
else:
r_type = 'frame'
pos_x, pos_y = self._udim2_pair(props.get('Position'))
size_x, size_y = self._udim2_pair(props.get('Size'))
# В процентах. Если размер не указан — дефолт 20%×10%.
if size_x <= 0: size_x = 20.0
if size_y <= 0: size_y = 10.0
# Округляем до 0.1% для читаемости JSON
pos_x = round(pos_x, 2)
pos_y = round(pos_y, 2)
size_x = round(size_x, 2)
size_y = round(size_y, 2)
# Фильтр Roblox CDN URL'ов: rbxasset://, rbxassetid://, rbxhttp:// —
# браузер их не поймёт, даём пустую строку. В будущем asset_downloader
# может подменить на cached_url.
raw_image = str(props.get('Image', '') or '')
if raw_image.startswith(('rbxasset://', 'rbxassetid://', 'rbxhttp://', 'rbxthumb://')):
raw_image = ''
# Видимость: если родитель — ScreenGui.Enabled=false, скрываем весь элемент.
own_visible = props.get('Visible', True)
if own_visible is None:
own_visible = True
# Поднимаемся по родителям пока не найдём ScreenGui — если он Disabled,
# элемент тоже невидим.
parent_ref = inst.parent_referent
screen_enabled = True
container_kind = 'screen' # default
if hasattr(self, '_screen_gui_refs'):
cur = parent_ref
depth = 0
while cur is not None and depth < 50:
if cur in self._screen_gui_refs:
screen_enabled = self._screen_gui_enabled.get(cur, True)
container_kind = self._screen_gui_kind.get(cur, 'screen')
break
# Поиск родителя cur в instances (если есть)
cur_inst = self.model.by_referent.get(cur) if hasattr(self, 'model') else None
cur = cur_inst.parent_referent if cur_inst else None
depth += 1
effective_visible = bool(own_visible) and screen_enabled
# Эвристика: HDAdmin/Chat/Leaderboard модалки в Roblox показываются
# отдельными Lua-скриптами по триггеру. Без работающих скриптов они
# показываются ВСЕ сразу и наслаиваются. Скрываем их по имени.
gui_name = name_or_default(props, cls)
ADMIN_HIDDEN = ('HDAdmin', 'Cmdbar', 'Console', 'TeleportTo',
'Notifications', 'Settings', 'Promo', 'PlayerList',
'BanList', 'Admin', 'CommandBar')
# Поднимаемся по родителям проверяя их имена
cur = inst.parent_referent
depth = 0
while cur is not None and depth < 10:
cur_inst = self.model.by_referent.get(cur)
if not cur_inst: break
pn = cur_inst.properties.get('Name') or cur_inst.class_name
if any(h.lower() in str(pn).lower() for h in ADMIN_HIDDEN):
effective_visible = False
break
cur = cur_inst.parent_referent
depth += 1
element = {
'id': f'rbx_gui_{inst.referent}',
'type': r_type,
'name': name_or_default(props, cls),
'parentId': self._gui_parent_id(inst.parent_referent),
'x': pos_x,
'y': pos_y,
'w': size_x,
'h': size_y,
'anchor': 'top-left', # Roblox по умолчанию top-left
'visible': effective_visible,
'bgColor': self._color3_to_hex(props.get('BackgroundColor3')),
'bgOpacity': max(0.0, min(1.0, 1.0 - float(props.get('BackgroundTransparency', 0) or 0))),
'borderColor': self._color3_to_hex(props.get('BorderColor3')),
'borderWidth': int(props.get('BorderSizePixel', 0) or 0),
'borderRadius': 0,
'text': str(props.get('Text', '') or ''),
'textColor': self._color3_to_hex(props.get('TextColor3')),
'textSize': int(props.get('TextSize', 14) or 14),
'textAlign': 'center',
'fontWeight': 700 if cls in ('TextButton',) else 500,
'imageUrl': raw_image,
'imageAsset': None,
'zIndex': int(props.get('ZIndex', 1) or 1),
'origin': 'roblox-' + cls.lower(),
# 'screen' — обычный HUD; 'billboard' — 3D-табличка над частью;
# 'surface' — на грани Part. Last 2 рендерятся в 3D-сцене и
# сильно тормозят если их сотни.
'gui_container_kind': container_kind,
}
scene['gui'].append(element)
# ─── 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}')