1096 lines
48 KiB
Python
1096 lines
48 KiB
Python
"""
|
||
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}')
|