""" 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}')