1602 lines
55 KiB
Python
1602 lines
55 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
TH1 Dashboard Data Exporter
|
|
Parses Unity YAML .asset files and exports JSON for the dashboard.
|
|
|
|
Usage:
|
|
python export_data.py
|
|
|
|
Output:
|
|
data/units.json - Unit type stats
|
|
data/skills.json - Skill info
|
|
data/techs.json - Tech tree data
|
|
data/players.json - Player/faction info
|
|
data/heroes.json - Hero data
|
|
data/summary.json - Project summary stats
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import sys
|
|
from pathlib import Path
|
|
from datetime import datetime
|
|
|
|
# Paths
|
|
SCRIPT_DIR = Path(__file__).parent
|
|
TOOLS_DIR = SCRIPT_DIR.parent
|
|
UNITY_DIR = TOOLS_DIR.parent / "Unity"
|
|
ASSETS_DIR = UNITY_DIR / "Assets" / "BundleResources" / "DataAssets"
|
|
OUTPUT_DIR = SCRIPT_DIR / "data"
|
|
ASSETS_OUT_DIR = SCRIPT_DIR / "assets"
|
|
PLAN_FILE = TOOLS_DIR / "Design" / "Plan" / "plan.md"
|
|
ART_DIR = UNITY_DIR / "Assets" / "BundleResources" / "ArtResources"
|
|
OSS_DATA_SRC = TOOLS_DIR / "OSS" / "Data"
|
|
OSS_JSON_SRC = OSS_DATA_SRC / "JsonExport"
|
|
OSS_DATA_DST = OUTPUT_DIR / "oss"
|
|
|
|
# ============ Enum mappings (from C# source) ============
|
|
|
|
UNIT_TYPE_NAMES = {
|
|
0: "None", 1: "Warrior", 2: "Rider", 3: "Archer", 4: "Defender",
|
|
5: "Knights", 6: "Catapult", 7: "Swordsman", 8: "Cloak", 9: "Minder",
|
|
10: "Boat", 11: "Ship", 12: "RammerShip", 13: "BomberShip", 14: "Giant",
|
|
15: "BigGuy", 16: "Phantom", 17: "Dabber", 18: "Juggernaut",
|
|
19: "GiantJuggernaut", 20: "KaguyaFrenchAnimalWarrior", 21: "KaguyaFrenchWarrior",
|
|
22: "KaguyaFrenchCatapult", 23: "KaguyaFrenchMokouEgg", 24: "KaguyaFrenchReisenIllusion",
|
|
25: "KaguyaFrenchWolf", 26: "RemiliaEgyptianKoakuma", 27: "RemiliaEgyptianKoakumaLion",
|
|
28: "MoriyaRider", 29: "MoriyaKnight", 30: "MoriyaHebi", 31: "WolfJuggernaut",
|
|
32: "BonePile", 33: "KomeijiIndianBigGuy", 34: "KomeijiIndianRider",
|
|
35: "KomeijiIndianKnight", 36: "KomeijiIndianArcher", 37: "KomeijiIndianCatapult",
|
|
38: "KomeijiIndianShip", 39: "KomeijiIndianBomberShip", 40: "KomeijiIndianJuggernaut",
|
|
}
|
|
|
|
UNIT_TYPE_CN = {
|
|
0: "-", 1: "步兵", 2: "骑兵", 3: "弓箭手", 4: "盾兵",
|
|
5: "重骑兵", 6: "炮手", 7: "剑士", 8: "间谍", 9: "脑控",
|
|
10: "小船", 11: "远战船", 12: "近战船", 13: "战舰", 14: "英雄",
|
|
15: "巨汉", 16: "幻影", 17: "Dabber", 18: "巨人战船",
|
|
19: "英雄战船", 20: "竹林兽兵", 21: "竹林步兵",
|
|
22: "竹林炮手", 23: "妹红蛋", 24: "月兔幻象",
|
|
25: "竹林狼", 26: "小恶魔", 27: "小恶魔狮",
|
|
28: "守矢骑兵", 29: "守矢重骑", 30: "守矢蛇", 31: "狼战船",
|
|
32: "骨堆", 33: "古明地巨汉", 34: "古明地骑兵",
|
|
35: "古明地重骑", 36: "古明地弓手", 37: "古明地炮手",
|
|
38: "古明地战船", 39: "古明地远船", 40: "古明地巨人船",
|
|
}
|
|
|
|
GIANT_TYPE_NAMES = {
|
|
0: "None",
|
|
1: "Flandre", 2: "Remilia", 3: "Sakuya", 4: "Meiling", 5: "Patchouli",
|
|
6: "Kaguya", 7: "Reisen", 8: "Tewi", 9: "Eirin", 10: "Mokou",
|
|
11: "Kanako", 12: "Suwako", 13: "Sanae", 14: "Aya", 15: "Momiji",
|
|
16: "Satori", 17: "Koishi", 18: "Utsuho", 19: "Yuugi", 20: "Rin",
|
|
21: "Reimu", 22: "Sumireko", 23: "Kasen", 24: "Aunn", 25: "Suika",
|
|
26: "Byakuren",
|
|
31: "Miko",
|
|
36: "Zanmu",
|
|
}
|
|
|
|
CIV_NAMES = {
|
|
0: "Common", 1: "Egyptian", 2: "French", 3: "Germany", 4: "Indian",
|
|
5: "Norway", 6: "Britain", 7: "Persian", 8: "Byzantine",
|
|
9: "Sumerian", 10: "Mayan", 11: "Malian", 12: "Greek",
|
|
13: "Khmer", 14: "Aztec", 15: "Incan", 16: "Mongolian", 17: "Arabian",
|
|
}
|
|
|
|
CIV_CN = {
|
|
0: "通用", 1: "埃及", 2: "法国", 3: "德国", 4: "印度",
|
|
5: "挪威", 6: "英国", 7: "波斯", 8: "拜占庭",
|
|
9: "苏美尔", 10: "玛雅", 11: "马里", 12: "希腊",
|
|
13: "高棉", 14: "阿兹特克", 15: "印加", 16: "蒙古", 17: "阿拉伯",
|
|
}
|
|
|
|
FORCE_NAMES = {
|
|
0: "Common", 1: "Remilia", 2: "Kaguya", 3: "Kanako", 4: "Satori",
|
|
5: "Reimu", 6: "Byakuren", 7: "Miko", 8: "Zanmu",
|
|
9: "Yuyuko", 10: "Hecatia", 11: "Megumu", 12: "Cirno",
|
|
13: "Yorihime", 14: "Tenshi", 15: "Ubame", 16: "Seija", 17: "Marisa",
|
|
}
|
|
|
|
LAND_TYPE = {0: "None", 1: "Land", 2: "Water", 3: "Both"}
|
|
SKILL_VIEW_TYPE = {0: "Normal", 1: "Special", 2: "Unique", 3: "Negative", 4: "Positive"}
|
|
|
|
ACTION_TYPE_NAMES = {
|
|
0: "None", 1: "Wonder", 2: "Resource", 3: "Feature", 4: "Unit",
|
|
5: "UnitAction", 6: "CityLevelUp", 7: "GridMisc", 8: "Skill",
|
|
9: "Tech", 10: "PlayerAction", 11: "AIParam", 12: "CultureCard",
|
|
}
|
|
|
|
TERRAIN_TYPE_NAMES = {0: "None", 1: "Plain", 2: "ShallowSea", 3: "DeepSea"}
|
|
TERRAIN_TYPE_CN = {0: "-", 1: "平原", 2: "浅海", 3: "深海"}
|
|
|
|
CULTURE_TYPE_NAMES = {0: "None", 1: "Political", 2: "Religious", 3: "Military", 4: "Economic"}
|
|
CULTURE_TYPE_CN = {0: "-", 1: "政治", 2: "宗教", 3: "军事", 4: "经济"}
|
|
|
|
TECH_TYPE_NAMES = {
|
|
0: "None", 1: "Climbing", 2: "Meditation", 3: "Mining", 4: "Philosophy",
|
|
5: "Smithery", 6: "Organization", 7: "Strategy", 8: "Farming",
|
|
9: "Diplomacy", 10: "Construction", 11: "Riding", 12: "FreeSpirit",
|
|
13: "Chivalry", 14: "Roads", 15: "Trade", 16: "Hunting", 17: "Forestry",
|
|
18: "Archery", 19: "Mathematics", 20: "Spiritualism", 21: "Fishing",
|
|
22: "Ramming", 23: "Sailing", 24: "Aquatism", 25: "Navigation",
|
|
26: "EgyptFlandre", 27: "EgyptRemilia", 28: "EgyptMeiling",
|
|
29: "EgyptPatchouli", 30: "EgyptSakuya",
|
|
31: "KaguyaHunting", 32: "KaguyaArcher", 33: "KaguyaSpiritual",
|
|
34: "KaguyaForestry", 35: "KaguyaMath", 36: "KaguyaRoad",
|
|
37: "KaguyaTrade", 38: "KaguyaConstruction",
|
|
39: "NoUseRemiliaConstruction", 40: "RemiliaFarming",
|
|
41: "NoUseRemiliaAuatism", 42: "RemiliaRamming",
|
|
43: "NoUseRemiliaChivalry", 44: "RemiliaFreeSpirit",
|
|
45: "KanakoClimbing", 46: "KanakoMeditation", 47: "KanakoMining",
|
|
48: "KanakoPhilosophy", 49: "KanakoSmithery",
|
|
50: "KanakoRiding", 51: "KanakoFreeSpirit", 52: "KanakoChivalry",
|
|
53: "KanakoRoads", 54: "KanakoTrade", 55: "KanakoNavigation",
|
|
56: "KomeijiIndianMuladhara", 57: "KomeijiIndianArchery",
|
|
58: "KomeijiIndianMethematics",
|
|
60: "KomeijiIndianSailing", 61: "KomeijiIndianNavigation",
|
|
62: "KomeijiIndianRiding", 64: "KomeijiIndianChivalry",
|
|
}
|
|
|
|
|
|
# ============ YAML Parsing ============
|
|
|
|
def decode_unicode_escapes(s):
|
|
"""Decode \\uXXXX style escapes in YAML string values."""
|
|
if not s or not isinstance(s, str):
|
|
return s
|
|
s = s.strip('"').strip("'")
|
|
try:
|
|
return s.encode('raw_unicode_escape').decode('unicode_escape')
|
|
except (UnicodeDecodeError, UnicodeEncodeError):
|
|
return s
|
|
|
|
|
|
def parse_hex_int_list(hex_str):
|
|
"""Parse Unity's packed hex int list (little-endian 4-byte ints)."""
|
|
if not hex_str or not isinstance(hex_str, str):
|
|
return []
|
|
hex_str = hex_str.strip()
|
|
if not hex_str or not all(c in '0123456789abcdefABCDEF' for c in hex_str):
|
|
return []
|
|
result = []
|
|
for i in range(0, len(hex_str), 8):
|
|
chunk = hex_str[i:i+8]
|
|
if len(chunk) < 8:
|
|
break
|
|
val = int(chunk[6:8] + chunk[4:6] + chunk[2:4] + chunk[0:2], 16)
|
|
result.append(val)
|
|
return result
|
|
|
|
|
|
def parse_int_list(value):
|
|
"""Parse Unity int lists from either YAML arrays or packed hex strings."""
|
|
if isinstance(value, list):
|
|
result = []
|
|
for item in value:
|
|
if isinstance(item, dict):
|
|
continue
|
|
result.append(safe_int(item))
|
|
return result
|
|
if isinstance(value, str):
|
|
return parse_hex_int_list(value)
|
|
return []
|
|
|
|
|
|
def parse_unit_full_type_list(value):
|
|
"""Normalize UnitFullType YAML list entries for dashboard consumers."""
|
|
if not isinstance(value, list):
|
|
return []
|
|
result = []
|
|
for item in value:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
result.append({
|
|
"unitType": safe_int(item.get('UnitType')),
|
|
"giantType": safe_int(item.get('GiantType')),
|
|
"unitLevel": safe_int(item.get('UnitLevel')),
|
|
})
|
|
return result
|
|
|
|
|
|
def get_indent(line):
|
|
"""Get the indentation level of a line."""
|
|
return len(line) - len(line.lstrip(' '))
|
|
|
|
|
|
def parse_unity_yaml(filepath):
|
|
"""
|
|
Parse a Unity YAML .asset file.
|
|
Returns the MonoBehaviour dict with all fields.
|
|
"""
|
|
with open(filepath, 'r', encoding='utf-8') as f:
|
|
lines = f.readlines()
|
|
|
|
# Find the MonoBehaviour block start
|
|
start = 0
|
|
for i, line in enumerate(lines):
|
|
if line.strip() == 'MonoBehaviour:':
|
|
start = i + 1
|
|
break
|
|
|
|
if start == 0:
|
|
return {}
|
|
|
|
# All fields under MonoBehaviour are at indent=2
|
|
result = {}
|
|
i = start
|
|
while i < len(lines):
|
|
line = lines[i]
|
|
stripped = line.strip()
|
|
|
|
if not stripped or stripped.startswith('#'):
|
|
i += 1
|
|
continue
|
|
|
|
indent = get_indent(line)
|
|
|
|
# We're looking for top-level fields (indent=2)
|
|
if indent < 2:
|
|
break # Left MonoBehaviour block
|
|
|
|
if indent == 2 and not stripped.startswith('- '):
|
|
# This is a field: " FieldName: value"
|
|
match = re.match(r'^ (\w+):\s*(.*)', line)
|
|
if match:
|
|
key = match.group(1)
|
|
val = match.group(2).strip()
|
|
|
|
if val and not val == '':
|
|
# Inline value
|
|
result[key] = _parse_val(val)
|
|
i += 1
|
|
else:
|
|
# Empty value — check next line for list or sub-block
|
|
i += 1
|
|
if i < len(lines):
|
|
next_stripped = lines[i].strip()
|
|
next_indent = get_indent(lines[i])
|
|
if next_stripped.startswith('- ') and next_indent >= 2:
|
|
# It's a list (Unity YAML puts list items at same indent as key)
|
|
result[key], i = _parse_list(lines, i, next_indent)
|
|
elif next_indent > 2:
|
|
# Sub-block
|
|
result[key], i = _parse_block(lines, i, next_indent)
|
|
else:
|
|
result[key] = ''
|
|
else:
|
|
result[key] = ''
|
|
else:
|
|
i += 1
|
|
elif indent == 2 and stripped.startswith('- '):
|
|
# Top-level list at indent 2 (shouldn't happen for MonoBehaviour, skip)
|
|
i += 1
|
|
else:
|
|
i += 1
|
|
|
|
return result
|
|
|
|
|
|
def _parse_block(lines, start, min_indent):
|
|
"""Parse a YAML block (dict) at given indentation."""
|
|
result = {}
|
|
i = start
|
|
while i < len(lines):
|
|
line = lines[i]
|
|
stripped = line.strip()
|
|
|
|
if not stripped:
|
|
i += 1
|
|
continue
|
|
|
|
indent = get_indent(line)
|
|
if indent < min_indent:
|
|
break
|
|
|
|
if indent == min_indent and not stripped.startswith('- '):
|
|
match = re.match(r'^(\s*)(\w+):\s*(.*)', line)
|
|
if match:
|
|
key = match.group(2)
|
|
val = match.group(3).strip()
|
|
if val and val != '':
|
|
result[key] = _parse_val(val)
|
|
i += 1
|
|
else:
|
|
i += 1
|
|
if i < len(lines):
|
|
ni = get_indent(lines[i])
|
|
ns = lines[i].strip()
|
|
if ni > min_indent and ns.startswith('- '):
|
|
result[key], i = _parse_list(lines, i, ni)
|
|
elif ni > min_indent:
|
|
result[key], i = _parse_block(lines, i, ni)
|
|
else:
|
|
result[key] = ''
|
|
else:
|
|
result[key] = ''
|
|
else:
|
|
i += 1
|
|
else:
|
|
i += 1
|
|
|
|
return result, i
|
|
|
|
|
|
def _parse_list(lines, start, list_indent):
|
|
"""Parse a YAML list at given indentation."""
|
|
items = []
|
|
i = start
|
|
while i < len(lines):
|
|
line = lines[i]
|
|
stripped = line.strip()
|
|
|
|
if not stripped:
|
|
i += 1
|
|
continue
|
|
|
|
indent = get_indent(line)
|
|
if indent < list_indent:
|
|
break
|
|
# A non-list line at list indent means the list has ended
|
|
if indent == list_indent and not stripped.startswith('- '):
|
|
break
|
|
|
|
if indent == list_indent and stripped.startswith('- '):
|
|
# New list item
|
|
item = {}
|
|
# Parse the "- key: val" part
|
|
rest = stripped[2:]
|
|
match = re.match(r'(\w+):\s*(.*)', rest)
|
|
if match:
|
|
key = match.group(1)
|
|
val = match.group(2).strip()
|
|
if val:
|
|
item[key] = _parse_val(val)
|
|
else:
|
|
item[key] = ''
|
|
i += 1
|
|
|
|
# Parse continuation fields of this list item (indent > list_indent)
|
|
item_indent = list_indent + 2 # Typically 2 more
|
|
while i < len(lines):
|
|
cline = lines[i]
|
|
cstripped = cline.strip()
|
|
|
|
if not cstripped:
|
|
i += 1
|
|
continue
|
|
|
|
cindent = get_indent(cline)
|
|
if cindent < list_indent + 1:
|
|
break
|
|
if cindent == list_indent and cstripped.startswith('- '):
|
|
break # Next list item
|
|
|
|
cmatch = re.match(r'^(\s*)(\w+):\s*(.*)', cline)
|
|
if cmatch:
|
|
ckey = cmatch.group(2)
|
|
cval = cmatch.group(3).strip()
|
|
if cval and cval != '':
|
|
item[ckey] = _parse_val(cval)
|
|
i += 1
|
|
else:
|
|
i += 1
|
|
if i < len(lines):
|
|
ni = get_indent(lines[i])
|
|
ns = lines[i].strip()
|
|
if ns.startswith('- ') and ni >= cindent:
|
|
item[ckey], i = _parse_list(lines, i, ni)
|
|
elif ni > cindent:
|
|
item[ckey], i = _parse_block(lines, i, ni)
|
|
else:
|
|
item[ckey] = ''
|
|
else:
|
|
item[ckey] = ''
|
|
else:
|
|
i += 1
|
|
|
|
items.append(item)
|
|
else:
|
|
i += 1
|
|
|
|
return items, i
|
|
|
|
|
|
def _parse_val(val):
|
|
"""Parse a single YAML value."""
|
|
if val is None:
|
|
return None
|
|
val = str(val).strip()
|
|
if val == '':
|
|
return ''
|
|
# Inline reference like {fileID: 0}
|
|
if val.startswith('{'):
|
|
return val
|
|
# Inline list like []
|
|
if val == '[]':
|
|
return []
|
|
# Unity serializes packed integer arrays as long hex strings. Some lists can
|
|
# contain only numeric hex digits, so keep those as strings instead of ints.
|
|
if not val.startswith('"') and len(val) > 8 and len(val) % 8 == 0 and re.fullmatch(r'[0-9a-fA-F]+', val):
|
|
return val
|
|
# Boolean-ish
|
|
# Number
|
|
try:
|
|
if '.' in val and not val.startswith('"'):
|
|
return float(val)
|
|
if not val.startswith('"'):
|
|
return int(val)
|
|
except ValueError:
|
|
pass
|
|
# Quoted string
|
|
if val.startswith('"') and val.endswith('"'):
|
|
return val[1:-1]
|
|
return val
|
|
|
|
|
|
# ============ Helper Functions ============
|
|
|
|
def safe_int(val, default=0):
|
|
if val is None or val == '':
|
|
return default
|
|
try:
|
|
return int(val)
|
|
except (ValueError, TypeError):
|
|
return default
|
|
|
|
|
|
def safe_float(val, default=0.0):
|
|
if val is None or val == '':
|
|
return default
|
|
try:
|
|
return float(val)
|
|
except (ValueError, TypeError):
|
|
return default
|
|
|
|
|
|
# ============ Export Functions ============
|
|
|
|
def export_units():
|
|
"""Export UnitTypeDataAssets → units.json"""
|
|
print("Exporting units...")
|
|
filepath = ASSETS_DIR / "UnitTypeDataAssets.asset"
|
|
data = parse_unity_yaml(filepath)
|
|
|
|
units = []
|
|
raw_list = data.get('UnitTypeList', [])
|
|
if not isinstance(raw_list, list):
|
|
print(f" Warning: UnitTypeList type={type(raw_list)}")
|
|
raw_list = []
|
|
|
|
for item in raw_list:
|
|
unit_type = safe_int(item.get('UnitType'))
|
|
giant_type = safe_int(item.get('GiantType'))
|
|
unit_level = safe_int(item.get('UnitLevel'))
|
|
|
|
skills_hex = str(item.get('Skills', ''))
|
|
skills_ids = parse_hex_int_list(skills_hex)
|
|
|
|
name_raw = item.get('Name', '')
|
|
if isinstance(name_raw, str):
|
|
name_raw = decode_unicode_escapes(name_raw)
|
|
|
|
desc_raw = item.get('Desc', '')
|
|
if isinstance(desc_raw, str):
|
|
desc_raw = decode_unicode_escapes(desc_raw)
|
|
|
|
giant_empire = item.get('GiantEmpire', {})
|
|
if isinstance(giant_empire, dict):
|
|
civ = safe_int(giant_empire.get('Civ'))
|
|
force = safe_int(giant_empire.get('Force'))
|
|
else:
|
|
civ = 0
|
|
force = 0
|
|
|
|
unit = {
|
|
"unitType": unit_type,
|
|
"unitTypeName": UNIT_TYPE_NAMES.get(unit_type, f"Unknown({unit_type})"),
|
|
"unitTypeCN": UNIT_TYPE_CN.get(unit_type, ""),
|
|
"giantType": giant_type,
|
|
"giantName": GIANT_TYPE_NAMES.get(giant_type, ""),
|
|
"unitLevel": unit_level,
|
|
"chessType": safe_int(item.get('ChessType')),
|
|
"empire": {
|
|
"civ": civ,
|
|
"civName": CIV_NAMES.get(civ, ""),
|
|
"force": force,
|
|
"forceName": FORCE_NAMES.get(force, ""),
|
|
},
|
|
"name": name_raw,
|
|
"desc": desc_raw,
|
|
"landType": LAND_TYPE.get(safe_int(item.get('LandType')), ""),
|
|
"maxHealth": safe_int(item.get('MaxHealth')),
|
|
"attack": safe_float(item.get('Attack')),
|
|
"defense": safe_float(item.get('Defense')),
|
|
"moveRange": safe_int(item.get('MoveRange')),
|
|
"attackRange": safe_int(item.get('AttackRange')),
|
|
"sightRange": safe_int(item.get('SightRange')),
|
|
"cost": safe_int(item.get('Cost')),
|
|
"skillIds": skills_ids,
|
|
}
|
|
units.append(unit)
|
|
|
|
print(f" Exported {len(units)} units")
|
|
return units
|
|
|
|
|
|
def export_skills():
|
|
"""Export SkillDataAssets → skills.json"""
|
|
print("Exporting skills...")
|
|
filepath = ASSETS_DIR / "SkillDataAssets.asset"
|
|
data = parse_unity_yaml(filepath)
|
|
|
|
skills = []
|
|
raw_list = data.get('SkillInfoList', [])
|
|
if not isinstance(raw_list, list):
|
|
raw_list = []
|
|
|
|
for item in raw_list:
|
|
skill_type = safe_int(item.get('SkillType'))
|
|
name = item.get('SkillName', '')
|
|
if isinstance(name, str):
|
|
name = decode_unicode_escapes(name)
|
|
desc = item.get('SkillDesc', '')
|
|
if isinstance(desc, str):
|
|
desc = decode_unicode_escapes(desc)
|
|
|
|
skill = {
|
|
"skillType": skill_type,
|
|
"viewType": SKILL_VIEW_TYPE.get(safe_int(item.get('SkillViewType')), "Normal"),
|
|
"name": name,
|
|
"desc": desc,
|
|
"notShow": safe_int(item.get('NotShow')) == 1,
|
|
"hasShowList": safe_int(item.get('HasShowList')) == 1,
|
|
"priority": "Origin" if safe_int(item.get('skillPriority')) == 1 else "Normal",
|
|
"reserveOnCarry": safe_int(item.get('ReserveOnCarry')) == 1,
|
|
"reserveLeaveCarry": safe_int(item.get('ReserveLeaveCarry')) == 1,
|
|
"reserveGiantUpgrade": safe_int(item.get('ReserveGiantUpgrade')) == 1,
|
|
}
|
|
skills.append(skill)
|
|
|
|
print(f" Exported {len(skills)} skills")
|
|
return skills
|
|
|
|
|
|
def export_techs():
|
|
"""Export TechDataAssets → techs.json"""
|
|
print("Exporting techs...")
|
|
filepath = ASSETS_DIR / "TechDataAssets.asset"
|
|
data = parse_unity_yaml(filepath)
|
|
|
|
techs = []
|
|
raw_list = data.get('TechList', [])
|
|
if not isinstance(raw_list, list):
|
|
raw_list = []
|
|
|
|
for item in raw_list:
|
|
tech_type = safe_int(item.get('TechType'))
|
|
name = item.get('TechName', '')
|
|
if isinstance(name, str):
|
|
name = decode_unicode_escapes(name)
|
|
desc = item.get('Description', '')
|
|
if isinstance(desc, str):
|
|
desc = decode_unicode_escapes(desc)
|
|
|
|
fathers_hex = str(item.get('FatherTechList', ''))
|
|
father_ids = parse_hex_int_list(fathers_hex)
|
|
|
|
atoms_hex = str(item.get('TechAtomList', ''))
|
|
atom_ids = parse_hex_int_list(atoms_hex)
|
|
|
|
tech = {
|
|
"techType": tech_type,
|
|
"techTypeName": TECH_TYPE_NAMES.get(tech_type, f"Unknown({tech_type})"),
|
|
"name": name,
|
|
"desc": desc,
|
|
"costLevel": safe_int(item.get('CostLevel')),
|
|
"fatherTechs": father_ids,
|
|
"fatherNames": [TECH_TYPE_NAMES.get(f, f"?{f}") for f in father_ids],
|
|
"techAtoms": atom_ids,
|
|
}
|
|
techs.append(tech)
|
|
|
|
print(f" Exported {len(techs)} techs")
|
|
return techs
|
|
|
|
|
|
def export_players():
|
|
"""Export PlayerDataAssets → players.json"""
|
|
print("Exporting players...")
|
|
filepath = ASSETS_DIR / "PlayerDataAssets.asset"
|
|
data = parse_unity_yaml(filepath)
|
|
|
|
players = []
|
|
raw_list = data.get('PlayerDataList', [])
|
|
if not isinstance(raw_list, list):
|
|
raw_list = []
|
|
|
|
for item in raw_list:
|
|
civ_id = safe_int(item.get('CivId'))
|
|
force_id = safe_int(item.get('ForceId'))
|
|
|
|
civ_name = item.get('CivName', '')
|
|
if isinstance(civ_name, str):
|
|
civ_name = decode_unicode_escapes(civ_name)
|
|
force_name = item.get('ForceName', '')
|
|
if isinstance(force_name, str):
|
|
force_name = decode_unicode_escapes(force_name)
|
|
leader_name = item.get('LeaderName', '')
|
|
if isinstance(leader_name, str):
|
|
leader_name = decode_unicode_escapes(leader_name)
|
|
|
|
# TechPool can be hex-packed list or parsed list
|
|
tech_pool_raw = item.get('TechPool', '')
|
|
if isinstance(tech_pool_raw, list):
|
|
tech_pool = [safe_int(t) for t in tech_pool_raw]
|
|
elif isinstance(tech_pool_raw, str) and len(tech_pool_raw) > 2:
|
|
tech_pool = parse_hex_int_list(tech_pool_raw)
|
|
else:
|
|
tech_pool = []
|
|
|
|
tech_start_raw = item.get('TechStart', '')
|
|
if isinstance(tech_start_raw, list):
|
|
tech_start = [safe_int(t) for t in tech_start_raw]
|
|
elif isinstance(tech_start_raw, str) and len(tech_start_raw) > 2:
|
|
tech_start = parse_hex_int_list(tech_start_raw)
|
|
else:
|
|
tech_start = []
|
|
|
|
player = {
|
|
"civId": civ_id,
|
|
"civName": CIV_NAMES.get(civ_id, f"Unknown({civ_id})"),
|
|
"civNameLocal": civ_name,
|
|
"forceId": force_id,
|
|
"forceName": FORCE_NAMES.get(force_id, f"Unknown({force_id})"),
|
|
"forceNameLocal": force_name,
|
|
"leaderName": leader_name,
|
|
"diff": safe_int(item.get('Diff')),
|
|
"musicName": str(item.get('MusicName', '')),
|
|
"techPoolCount": len(tech_pool),
|
|
"techPool": tech_pool,
|
|
"techPoolNames": [TECH_TYPE_NAMES.get(t, f"?{t}") for t in tech_pool],
|
|
"techStart": tech_start,
|
|
"techStartNames": [TECH_TYPE_NAMES.get(t, f"?{t}") for t in tech_start],
|
|
}
|
|
players.append(player)
|
|
|
|
print(f" Exported {len(players)} players/factions")
|
|
return players
|
|
|
|
|
|
def export_heroes():
|
|
"""Export HeroDataAssets → heroes.json"""
|
|
print("Exporting heroes...")
|
|
filepath = ASSETS_DIR / "HeroDataAssets.asset"
|
|
data = parse_unity_yaml(filepath)
|
|
|
|
heroes = []
|
|
raw_list = data.get('HeroInfoList', [])
|
|
if not isinstance(raw_list, list):
|
|
raw_list = []
|
|
|
|
for item in raw_list:
|
|
giant_type = safe_int(item.get('GiantType'))
|
|
|
|
tasks = []
|
|
task_list = item.get('TaskList', [])
|
|
if isinstance(task_list, list):
|
|
for task in task_list:
|
|
skill_name = task.get('SkillName', '')
|
|
if isinstance(skill_name, str):
|
|
skill_name = decode_unicode_escapes(skill_name)
|
|
desc = task.get('Desc', '')
|
|
if isinstance(desc, str):
|
|
desc = decode_unicode_escapes(desc)
|
|
tasks.append({
|
|
"taskContentType": safe_int(task.get('taskContentType')),
|
|
"param": safe_int(task.get('Param')),
|
|
"skillParam": safe_int(task.get('SkillParam')),
|
|
"spType": safe_int(task.get('SpType')),
|
|
"skillList": parse_int_list(task.get('SkillList')),
|
|
"unitFullTypes": parse_unit_full_type_list(task.get('UnitFullTypes', [])),
|
|
"targetBuff": parse_int_list(task.get('TargetBuff')),
|
|
"skillName": skill_name,
|
|
"desc": desc,
|
|
})
|
|
|
|
hero = {
|
|
"giantType": giant_type,
|
|
"giantName": GIANT_TYPE_NAMES.get(giant_type, f"Unknown({giant_type})"),
|
|
"taskCount": len(tasks),
|
|
"tasks": tasks,
|
|
}
|
|
heroes.append(hero)
|
|
|
|
print(f" Exported {len(heroes)} heroes")
|
|
return heroes
|
|
|
|
|
|
def export_civs():
|
|
"""Export CivDataAssets -> civs.json"""
|
|
print("Exporting civs...")
|
|
filepath = ASSETS_DIR / "CivDataAssets.asset"
|
|
data = parse_unity_yaml(filepath)
|
|
|
|
civs = []
|
|
raw_list = data.get('CivDataList', [])
|
|
if not isinstance(raw_list, list):
|
|
raw_list = []
|
|
|
|
for item in raw_list:
|
|
civ_id = safe_int(item.get('CivId'))
|
|
civ_enum = safe_int(item.get('Civ'))
|
|
civ_name = item.get('CivName', '')
|
|
if isinstance(civ_name, str):
|
|
civ_name = decode_unicode_escapes(civ_name)
|
|
|
|
cities = []
|
|
city_list = item.get('CityInfoList', [])
|
|
if isinstance(city_list, list):
|
|
for c in city_list:
|
|
cn = c.get('CityName', '')
|
|
if isinstance(cn, str):
|
|
cn = decode_unicode_escapes(cn)
|
|
cd = c.get('CityDescription', '')
|
|
if isinstance(cd, str):
|
|
cd = decode_unicode_escapes(cd)
|
|
cities.append({
|
|
"nameEnum": safe_int(c.get('CityNameEnum')),
|
|
"name": cn,
|
|
"desc": cd,
|
|
})
|
|
|
|
civs.append({
|
|
"civId": civ_id,
|
|
"civEnum": civ_enum,
|
|
"civName": civ_name,
|
|
"civNameEn": CIV_NAMES.get(civ_id, ""),
|
|
"cityCount": len(cities),
|
|
"cities": cities,
|
|
})
|
|
|
|
print(f" Exported {len(civs)} civs")
|
|
return civs
|
|
|
|
|
|
def export_actions():
|
|
"""Export ActionDataAssets -> actions.json"""
|
|
print("Exporting actions...")
|
|
filepath = ASSETS_DIR / "ActionDataAssets.asset"
|
|
data = parse_unity_yaml(filepath)
|
|
|
|
actions = []
|
|
raw_list = data.get('ActionList', [])
|
|
if not isinstance(raw_list, list):
|
|
raw_list = []
|
|
|
|
for item in raw_list:
|
|
action_id = item.get('ActionId', {})
|
|
if not isinstance(action_id, dict):
|
|
action_id = {}
|
|
|
|
action_type = safe_int(action_id.get('ActionType'))
|
|
unit_type = safe_int(action_id.get('UnitType'))
|
|
giant_type = safe_int(action_id.get('GiantType'))
|
|
tech_type = safe_int(action_id.get('TechType'))
|
|
skill_type = safe_int(action_id.get('SkillType'))
|
|
wonder_type = safe_int(action_id.get('WonderType'))
|
|
resource_type = safe_int(action_id.get('ResourceType'))
|
|
feature_type = safe_int(action_id.get('FeatureType'))
|
|
culture_card_type = safe_int(action_id.get('CultureCardType'))
|
|
|
|
name = item.get('ActionName', '')
|
|
if isinstance(name, str):
|
|
name = decode_unicode_escapes(name)
|
|
desc = item.get('Desc', '')
|
|
if isinstance(desc, str):
|
|
desc = decode_unicode_escapes(desc)
|
|
|
|
actions.append({
|
|
"actionType": action_type,
|
|
"actionTypeName": ACTION_TYPE_NAMES.get(action_type, f"?{action_type}"),
|
|
"unitType": unit_type,
|
|
"unitTypeName": UNIT_TYPE_NAMES.get(unit_type, ""),
|
|
"giantType": giant_type,
|
|
"giantName": GIANT_TYPE_NAMES.get(giant_type, ""),
|
|
"techType": tech_type,
|
|
"skillType": skill_type,
|
|
"wonderType": wonder_type,
|
|
"resourceType": resource_type,
|
|
"featureType": feature_type,
|
|
"cultureCardType": culture_card_type,
|
|
"name": name,
|
|
"desc": desc,
|
|
"cost": safe_int(item.get('Cost')),
|
|
"cityExp": safe_int(item.get('CityExp')),
|
|
"noNeedTech": safe_int(item.get('NoNeedTech')) == 1,
|
|
})
|
|
|
|
print(f" Exported {len(actions)} actions")
|
|
return actions
|
|
|
|
|
|
def export_grids():
|
|
"""Export GridAndResourceDataAssets -> grids.json"""
|
|
print("Exporting grids/resources...")
|
|
filepath = ASSETS_DIR / "GridAndResourceDataAssets.asset"
|
|
data = parse_unity_yaml(filepath)
|
|
|
|
result = {"terrains": [], "resources": []}
|
|
|
|
# Terrains
|
|
terrain_list = data.get('TerrainInfoList', [])
|
|
if isinstance(terrain_list, list):
|
|
for item in terrain_list:
|
|
tt = safe_int(item.get('TerrainType'))
|
|
name = item.get('ResourceName', '')
|
|
if isinstance(name, str):
|
|
name = decode_unicode_escapes(name)
|
|
desc = item.get('ResourceDesc', '')
|
|
if isinstance(desc, str):
|
|
desc = decode_unicode_escapes(desc)
|
|
result["terrains"].append({
|
|
"terrainType": tt,
|
|
"terrainName": TERRAIN_TYPE_CN.get(tt, ""),
|
|
"name": name,
|
|
"desc": desc,
|
|
})
|
|
|
|
# Resources
|
|
resource_list = data.get('ResourceInfoList', [])
|
|
if isinstance(resource_list, list):
|
|
for item in resource_list:
|
|
rt = safe_int(item.get('ResourceType', 0))
|
|
name = item.get('ResourceName', '')
|
|
if isinstance(name, str):
|
|
name = decode_unicode_escapes(name)
|
|
desc = item.get('ResourceDesc', '')
|
|
if isinstance(desc, str):
|
|
desc = decode_unicode_escapes(desc)
|
|
result["resources"].append({
|
|
"resourceType": rt,
|
|
"name": name,
|
|
"desc": desc,
|
|
})
|
|
|
|
# Features
|
|
feature_list = data.get('FeatureInfoList', [])
|
|
if isinstance(feature_list, list):
|
|
for item in feature_list:
|
|
ft = safe_int(item.get('FeatureType', 0))
|
|
name = item.get('ResourceName', '')
|
|
if isinstance(name, str):
|
|
name = decode_unicode_escapes(name)
|
|
desc = item.get('ResourceDesc', '')
|
|
if isinstance(desc, str):
|
|
desc = decode_unicode_escapes(desc)
|
|
result["resources"].append({
|
|
"resourceType": 100 + ft,
|
|
"name": name,
|
|
"desc": desc,
|
|
"isFeature": True,
|
|
})
|
|
|
|
total = len(result["terrains"]) + len(result["resources"])
|
|
print(f" Exported {len(result['terrains'])} terrains, {len(result['resources'])} resources/features")
|
|
return result
|
|
|
|
|
|
def export_culture_cards():
|
|
"""Export CultureCardDataAssets -> culture_cards.json"""
|
|
print("Exporting culture cards...")
|
|
filepath = ASSETS_DIR / "CultureCardDataAssets.asset"
|
|
data = parse_unity_yaml(filepath)
|
|
|
|
cards = []
|
|
raw_list = data.get('CultureCardDataList', [])
|
|
if not isinstance(raw_list, list):
|
|
raw_list = []
|
|
|
|
for item in raw_list:
|
|
card_type = safe_int(item.get('CardType'))
|
|
culture_type = safe_int(item.get('CultureType'))
|
|
name = item.get('Name', '')
|
|
if isinstance(name, str):
|
|
name = decode_unicode_escapes(name)
|
|
desc = item.get('Description', '')
|
|
if isinstance(desc, str):
|
|
desc = decode_unicode_escapes(desc)
|
|
|
|
req_techs_hex = str(item.get('RequiredTechs', ''))
|
|
req_techs = parse_hex_int_list(req_techs_hex)
|
|
prereq_hex = str(item.get('PrerequisiteCards', ''))
|
|
prereq = parse_hex_int_list(prereq_hex)
|
|
|
|
cards.append({
|
|
"cardType": card_type,
|
|
"cultureType": culture_type,
|
|
"cultureTypeName": CULTURE_TYPE_CN.get(culture_type, ""),
|
|
"isActive": safe_int(item.get('IsActive')) == 1,
|
|
"name": name,
|
|
"desc": desc,
|
|
"cost": safe_int(item.get('Cost')),
|
|
"maxCount": safe_int(item.get('MaxCount')),
|
|
"priority": safe_int(item.get('Priority')),
|
|
"requiredTechs": req_techs,
|
|
"requiredTechNames": [TECH_TYPE_NAMES.get(t, f"?{t}") for t in req_techs],
|
|
"prerequisiteCards": prereq,
|
|
})
|
|
|
|
print(f" Exported {len(cards)} culture cards")
|
|
return cards
|
|
|
|
|
|
def export_library():
|
|
"""Export LibraryDataAssets -> library_heroes.json + library_wonders.json"""
|
|
print("Exporting library...")
|
|
filepath = ASSETS_DIR / "LibraryDataAssets.asset"
|
|
data = parse_unity_yaml(filepath)
|
|
|
|
heroes = []
|
|
raw_list = data.get('LibraryGiantList', [])
|
|
if not isinstance(raw_list, list):
|
|
raw_list = []
|
|
|
|
for item in raw_list:
|
|
giant_type = safe_int(item.get('GiantType'))
|
|
name = item.get('Name', '')
|
|
if isinstance(name, str):
|
|
name = decode_unicode_escapes(name)
|
|
desc = item.get('Desc', '')
|
|
if isinstance(desc, str):
|
|
desc = decode_unicode_escapes(desc)
|
|
diag = item.get('Diag', '')
|
|
if isinstance(diag, str):
|
|
diag = decode_unicode_escapes(diag)
|
|
en_name = item.get('EnglishName', '')
|
|
if isinstance(en_name, str):
|
|
en_name = decode_unicode_escapes(en_name)
|
|
|
|
diag_color = item.get('DiagColor', {})
|
|
if isinstance(diag_color, dict):
|
|
color_r = safe_float(diag_color.get('r'))
|
|
color_g = safe_float(diag_color.get('g'))
|
|
color_b = safe_float(diag_color.get('b'))
|
|
else:
|
|
color_r = color_g = color_b = 1.0
|
|
|
|
heroes.append({
|
|
"giantType": giant_type,
|
|
"giantName": GIANT_TYPE_NAMES.get(giant_type, f"Unknown({giant_type})"),
|
|
"name": name,
|
|
"englishName": en_name,
|
|
"desc": desc,
|
|
"diag": diag,
|
|
"diagColor": f"rgb({int(color_r*255)},{int(color_g*255)},{int(color_b*255)})",
|
|
"achivePreId": safe_int(item.get('AchivePreId')),
|
|
})
|
|
|
|
wonders = []
|
|
raw_list = data.get('LibraryWonderList', [])
|
|
if not isinstance(raw_list, list):
|
|
raw_list = []
|
|
|
|
for item in raw_list:
|
|
wid = safe_int(item.get('WonderLibraryID'))
|
|
name = item.get('Name', '')
|
|
if isinstance(name, str):
|
|
name = decode_unicode_escapes(name)
|
|
desc = item.get('Desc', '')
|
|
if isinstance(desc, str):
|
|
desc = decode_unicode_escapes(desc)
|
|
|
|
diags = []
|
|
raw_diags = item.get('Diags', [])
|
|
if isinstance(raw_diags, list):
|
|
for d in raw_diags:
|
|
lines = d.get('Diag', [])
|
|
if isinstance(lines, list):
|
|
decoded_lines = []
|
|
for ln in lines:
|
|
if isinstance(ln, str):
|
|
decoded_lines.append(decode_unicode_escapes(ln))
|
|
else:
|
|
decoded_lines.append(str(ln))
|
|
giant_hex = str(d.get('Giant', ''))
|
|
giant_ids = parse_hex_int_list(giant_hex)
|
|
diags.append({
|
|
"lines": decoded_lines,
|
|
"giants": giant_ids,
|
|
"giantNames": [GIANT_TYPE_NAMES.get(g, "") for g in giant_ids],
|
|
})
|
|
|
|
wonders.append({
|
|
"wonderId": wid,
|
|
"name": name,
|
|
"desc": desc,
|
|
"diagCount": len(diags),
|
|
"diags": diags,
|
|
"achivePreId": safe_int(item.get('AchivePreId')),
|
|
})
|
|
|
|
print(f" Exported {len(heroes)} library heroes, {len(wonders)} wonders")
|
|
return {"heroes": heroes, "wonders": wonders}
|
|
|
|
|
|
def export_stories():
|
|
"""Export StoryDataAssets -> stories.json"""
|
|
print("Exporting stories...")
|
|
filepath = ASSETS_DIR / "StoryDataAssets.asset"
|
|
data = parse_unity_yaml(filepath)
|
|
|
|
# Character map
|
|
chars = {}
|
|
char_list = data.get('CharDataList', [])
|
|
if isinstance(char_list, list):
|
|
for item in char_list:
|
|
cid = safe_int(item.get('CharName'))
|
|
chars[cid] = cid
|
|
|
|
sheets = []
|
|
raw_list = data.get('SheetData', [])
|
|
if not isinstance(raw_list, list):
|
|
raw_list = []
|
|
|
|
for item in raw_list:
|
|
sheet_name = item.get('SheetName', '')
|
|
if isinstance(sheet_name, str):
|
|
sheet_name = decode_unicode_escapes(sheet_name)
|
|
|
|
diag_list = item.get('DiagList', [])
|
|
dialogues = []
|
|
if isinstance(diag_list, list):
|
|
for d in diag_list:
|
|
char_id = safe_int(d.get('CharName'))
|
|
text = d.get('Diag', '')
|
|
if isinstance(text, str):
|
|
text = decode_unicode_escapes(text)
|
|
dialogues.append({
|
|
"charId": char_id,
|
|
"text": text,
|
|
})
|
|
|
|
sheets.append({
|
|
"sheetName": sheet_name,
|
|
"dialogueCount": len(dialogues),
|
|
"dialogues": dialogues,
|
|
})
|
|
|
|
print(f" Exported {len(sheets)} story sheets, {len(chars)} characters")
|
|
return {"charIds": list(chars.keys()), "sheets": sheets}
|
|
|
|
|
|
def export_moments():
|
|
"""Export MomentDataAssets -> moments.json"""
|
|
print("Exporting moments...")
|
|
filepath = ASSETS_DIR / "MomentDataAssets.asset"
|
|
data = parse_unity_yaml(filepath)
|
|
|
|
moments = []
|
|
raw_list = data.get('MomentMainDataList', [])
|
|
if not isinstance(raw_list, list):
|
|
raw_list = []
|
|
|
|
for item in raw_list:
|
|
moment_type = safe_int(item.get('MomentType'))
|
|
title = item.get('Title', '')
|
|
if isinstance(title, str):
|
|
title = decode_unicode_escapes(title)
|
|
|
|
# Colors
|
|
title_bg = item.get('TitleBG', {})
|
|
if isinstance(title_bg, dict):
|
|
tbg = f"rgb({int(safe_float(title_bg.get('r'))*255)},{int(safe_float(title_bg.get('g'))*255)},{int(safe_float(title_bg.get('b'))*255)})"
|
|
else:
|
|
tbg = "rgb(128,128,128)"
|
|
|
|
desc_bg = item.get('DescBG', {})
|
|
if isinstance(desc_bg, dict):
|
|
dbg = f"rgb({int(safe_float(desc_bg.get('r'))*255)},{int(safe_float(desc_bg.get('g'))*255)},{int(safe_float(desc_bg.get('b'))*255)})"
|
|
else:
|
|
dbg = "rgb(100,100,100)"
|
|
|
|
# Image packs
|
|
image_packs = []
|
|
raw_packs = item.get('ImagePacks', [])
|
|
if isinstance(raw_packs, list):
|
|
for pack in raw_packs:
|
|
empire = pack.get('Empire', {})
|
|
if isinstance(empire, dict):
|
|
civ = safe_int(empire.get('Civ'))
|
|
force = safe_int(empire.get('Force'))
|
|
else:
|
|
civ = 0
|
|
force = 0
|
|
image_packs.append({
|
|
"civ": civ,
|
|
"civName": CIV_NAMES.get(civ, ""),
|
|
"force": force,
|
|
"forceName": FORCE_NAMES.get(force, ""),
|
|
})
|
|
|
|
moments.append({
|
|
"momentType": moment_type,
|
|
"title": title,
|
|
"titleBG": tbg,
|
|
"descBG": dbg,
|
|
"imagePackCount": len(image_packs),
|
|
"imagePacks": image_packs,
|
|
})
|
|
|
|
# Sub-moments
|
|
sub_moments = []
|
|
sub_list = data.get('MomentSubDataList', [])
|
|
if isinstance(sub_list, list):
|
|
for item in sub_list:
|
|
mt = safe_int(item.get('MomentType'))
|
|
sub_type = safe_int(item.get('SubType'))
|
|
title = item.get('Title', '')
|
|
if isinstance(title, str):
|
|
title = decode_unicode_escapes(title)
|
|
desc = item.get('Desc', '')
|
|
if isinstance(desc, str):
|
|
desc = decode_unicode_escapes(desc)
|
|
sub_moments.append({
|
|
"momentType": mt,
|
|
"subType": sub_type,
|
|
"title": title,
|
|
"desc": desc,
|
|
})
|
|
|
|
print(f" Exported {len(moments)} main moments, {len(sub_moments)} sub-moments")
|
|
return {"main": moments, "sub": sub_moments}
|
|
|
|
|
|
def export_chat_bubbles():
|
|
"""Extract chat bubble data from PlayerDataAssets -> chat_bubbles.json"""
|
|
print("Exporting chat bubbles...")
|
|
filepath = ASSETS_DIR / "PlayerDataAssets.asset"
|
|
data = parse_unity_yaml(filepath)
|
|
|
|
players = []
|
|
raw_list = data.get('PlayerDataList', [])
|
|
if not isinstance(raw_list, list):
|
|
raw_list = []
|
|
|
|
for item in raw_list:
|
|
force_id = safe_int(item.get('ForceId'))
|
|
civ_id = safe_int(item.get('CivId'))
|
|
leader_name = item.get('LeaderName', '')
|
|
if isinstance(leader_name, str):
|
|
leader_name = decode_unicode_escapes(leader_name)
|
|
force_name = item.get('ForceName', '')
|
|
if isinstance(force_name, str):
|
|
force_name = decode_unicode_escapes(force_name)
|
|
|
|
def decode_bubble_list(raw):
|
|
if isinstance(raw, list):
|
|
result = []
|
|
for s in raw:
|
|
if isinstance(s, str):
|
|
result.append(decode_unicode_escapes(s))
|
|
else:
|
|
result.append(str(s))
|
|
return result
|
|
return []
|
|
|
|
start = decode_bubble_list(item.get('StartChatBubble', []))
|
|
meet = decode_bubble_list(item.get('MeetChatBubble', []))
|
|
lose = decode_bubble_list(item.get('LoseChatBubble', []))
|
|
win = decode_bubble_list(item.get('WinChatBubble', []))
|
|
|
|
total = len(start) + len(meet) + len(lose) + len(win)
|
|
if total == 0:
|
|
continue
|
|
|
|
players.append({
|
|
"forceId": force_id,
|
|
"forceName": FORCE_NAMES.get(force_id, ""),
|
|
"forceNameLocal": force_name,
|
|
"civId": civ_id,
|
|
"civName": CIV_NAMES.get(civ_id, ""),
|
|
"leaderName": leader_name,
|
|
"startChat": start,
|
|
"meetChat": meet,
|
|
"loseChat": lose,
|
|
"winChat": win,
|
|
"totalLines": total,
|
|
})
|
|
|
|
print(f" Exported chat bubbles for {len(players)} players")
|
|
return players
|
|
|
|
|
|
def generate_summary(units, skills, techs, players, heroes):
|
|
"""Generate project summary stats."""
|
|
basic_units = [u for u in units if u['unitType'] <= 13 and u['giantType'] == 0 and u['unitLevel'] == 0]
|
|
giant_units = [u for u in units if u['unitType'] == 14]
|
|
|
|
return {
|
|
"exportTime": datetime.now().isoformat(),
|
|
"counts": {
|
|
"totalUnits": len(units),
|
|
"basicUnits": len(basic_units),
|
|
"giantUnits": len(giant_units),
|
|
"skills": len(skills),
|
|
"visibleSkills": len([s for s in skills if not s['notShow']]),
|
|
"techs": len(techs),
|
|
"players": len(players),
|
|
"heroes": len(heroes),
|
|
},
|
|
"civs": list(CIV_NAMES.values()),
|
|
"forces": list(FORCE_NAMES.values()),
|
|
}
|
|
|
|
|
|
def export_versions():
|
|
"""Parse VersionConfig.asset and export version history to versions.json.
|
|
Uses custom parsing because descriptions are multi-line quoted YAML strings."""
|
|
print("Exporting versions...")
|
|
version_file = ASSETS_DIR / "VersionConfig.asset"
|
|
if not version_file.exists():
|
|
print(f" Warning: VersionConfig not found: {version_file}")
|
|
return []
|
|
|
|
with open(version_file, 'r', encoding='utf-8') as f:
|
|
content = f.read()
|
|
|
|
# Extract CurVersionId
|
|
cur_match = re.search(r'CurVersionId:\s*(\d+)', content)
|
|
cur_version_id = int(cur_match.group(1)) if cur_match else 0
|
|
|
|
# Split into version blocks: each starts with " - MajorVersion:"
|
|
blocks = re.split(r'\n - MajorVersion:', content)
|
|
blocks = blocks[1:] # skip everything before first version
|
|
|
|
versions = []
|
|
for block in blocks:
|
|
block = 'MajorVersion:' + block # restore the key
|
|
|
|
def get_field(name):
|
|
m = re.search(rf'{name}:\s*(\d+)', block)
|
|
return int(m.group(1)) if m else 0
|
|
|
|
major = get_field('MajorVersion')
|
|
minor = get_field('MinorVersion')
|
|
patch = get_field('PatchVersion')
|
|
fourth = get_field('FourthVersion')
|
|
version_id = major * 1000000 + minor * 10000 + patch * 100 + fourth
|
|
|
|
fourth_letter = chr(ord('a') + fourth) if fourth > 0 else ''
|
|
full_version = f"{major}.{minor}.{patch}{fourth_letter}"
|
|
|
|
# Extract the full multi-line Description string
|
|
desc_match = re.search(r'Description:\s*"(.*?)(?:"\s*$|\"\s*\n\s*\w+:)', block, re.DOTALL)
|
|
if desc_match:
|
|
desc_raw = desc_match.group(1)
|
|
# Remove YAML line continuation whitespace (newline + spaces = single space)
|
|
desc_raw = re.sub(r'\n\s+', '', desc_raw)
|
|
desc = decode_unicode_escapes(desc_raw)
|
|
else:
|
|
desc = ''
|
|
|
|
# Extract date from description
|
|
release_date = ''
|
|
date_match = re.search(r'发布日期\s*(\d{2,4})[.\-/](\d{1,2})[.\-/](\d{1,2})', desc)
|
|
if date_match:
|
|
y, m, d = date_match.group(1), date_match.group(2), date_match.group(3)
|
|
year = int(y)
|
|
if year < 100:
|
|
year += 2000
|
|
release_date = f"{year}-{int(m):02d}-{int(d):02d}"
|
|
|
|
versions.append({
|
|
'versionId': version_id,
|
|
'version': full_version,
|
|
'major': major,
|
|
'minor': minor,
|
|
'patch': patch,
|
|
'fourth': fourth,
|
|
'description': desc,
|
|
'releaseDate': release_date,
|
|
'isCurrent': version_id == cur_version_id,
|
|
})
|
|
|
|
versions.sort(key=lambda x: x['versionId'], reverse=True)
|
|
print(f" Exported {len(versions)} versions (current: {cur_version_id})")
|
|
return versions
|
|
|
|
|
|
def export_plan():
|
|
"""Parse plan.md and export milestone data to plan.json"""
|
|
print("Exporting plan...")
|
|
if not PLAN_FILE.exists():
|
|
print(f" Warning: Plan file not found: {PLAN_FILE}")
|
|
return {"milestones": []}
|
|
|
|
with open(PLAN_FILE, 'r', encoding='utf-8') as f:
|
|
content = f.read()
|
|
|
|
milestones = []
|
|
# Parse markdown table rows: | Date | Milestone | Status | Description | Highlight? |
|
|
for line in content.split('\n'):
|
|
line = line.strip()
|
|
if not line.startswith('|') or '---' in line:
|
|
continue
|
|
cells = [c.strip() for c in line.split('|')]
|
|
# cells[0] and cells[-1] are empty due to leading/trailing |
|
|
cells = [c for c in cells if c != '']
|
|
if len(cells) < 4:
|
|
continue
|
|
date_str = cells[0]
|
|
if not re.match(r'\d{4}-\d{2}-\d{2}', date_str):
|
|
continue
|
|
m = {
|
|
"date": date_str,
|
|
"name": cells[1],
|
|
"status": cells[2],
|
|
"description": cells[3],
|
|
}
|
|
# Optional 5th column: highlight
|
|
if len(cells) >= 5 and cells[4].lower() in ('true', 'yes', '1'):
|
|
m["highlight"] = True
|
|
milestones.append(m)
|
|
|
|
print(f" Exported {len(milestones)} milestones")
|
|
return {"milestones": milestones}
|
|
|
|
|
|
def sync_file(src, dst):
|
|
"""Copy src to dst only if src is newer or dst doesn't exist."""
|
|
if not src.exists():
|
|
return False
|
|
if dst.exists() and dst.stat().st_mtime >= src.stat().st_mtime:
|
|
return False
|
|
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(src, dst)
|
|
return True
|
|
|
|
|
|
def export_art_assets():
|
|
"""Copy core art assets to the dashboard assets directory."""
|
|
print("Exporting art assets...")
|
|
if not ART_DIR.exists():
|
|
print(f" Warning: ArtResources not found: {ART_DIR}")
|
|
return 0
|
|
|
|
copied = 0
|
|
|
|
# 1. Logo
|
|
for f in (ART_DIR / "TH1UI" / "Main").glob("*.png"):
|
|
if sync_file(f, ASSETS_OUT_DIR / "logo" / f.name):
|
|
copied += 1
|
|
for f in (ART_DIR / "TH1UI" / "Main").glob("*.jpg"):
|
|
if sync_file(f, ASSETS_OUT_DIR / "logo" / f.name):
|
|
copied += 1
|
|
|
|
# 2. Forces logos
|
|
for f in (ART_DIR / "TH1ForcesLogo").glob("*.png"):
|
|
if sync_file(f, ASSETS_OUT_DIR / "forces" / f.name):
|
|
copied += 1
|
|
|
|
# 3. Hero / Giant sprites
|
|
giant_dir = ART_DIR / "TH1Units" / "Giant"
|
|
if giant_dir.exists():
|
|
for f in giant_dir.glob("*.png"):
|
|
if sync_file(f, ASSETS_OUT_DIR / "heroes" / f.name):
|
|
copied += 1
|
|
|
|
# 4. Character illustrations (per-force subdirs, may have civ nesting)
|
|
illust_dir = ART_DIR / "TH1CharIllustrations"
|
|
if illust_dir.exists():
|
|
for sub in illust_dir.iterdir():
|
|
if sub.is_dir():
|
|
for f in sub.glob("*.png"):
|
|
if sync_file(f, ASSETS_OUT_DIR / "illustrations" / sub.name / f.name):
|
|
copied += 1
|
|
for civ_sub in sub.iterdir():
|
|
if civ_sub.is_dir():
|
|
for f in civ_sub.glob("*.png"):
|
|
if sync_file(f, ASSETS_OUT_DIR / "illustrations" / sub.name / f.name):
|
|
copied += 1
|
|
|
|
# 5. Unit sprites (per force, may have civ subdirectory)
|
|
units_dir = ART_DIR / "TH1Units"
|
|
if units_dir.exists():
|
|
for sub in units_dir.iterdir():
|
|
if sub.is_dir() and sub.name != "Giant":
|
|
# Direct pngs
|
|
for f in sub.glob("*.png"):
|
|
if sync_file(f, ASSETS_OUT_DIR / "units" / sub.name / f.name):
|
|
copied += 1
|
|
# Nested civ subdirectories (e.g. RemiliaForces/Egyptian/)
|
|
for civ_sub in sub.iterdir():
|
|
if civ_sub.is_dir():
|
|
for f in civ_sub.glob("*.png"):
|
|
if sync_file(f, ASSETS_OUT_DIR / "units" / sub.name / f.name):
|
|
copied += 1
|
|
|
|
# 6. Key UI assets (all sub-dirs)
|
|
ui_dir = ART_DIR / "TH1UI"
|
|
if ui_dir.exists():
|
|
for ui_sub in ui_dir.iterdir():
|
|
if ui_sub.is_dir():
|
|
for f in list(ui_sub.glob("*.png")) + list(ui_sub.glob("*.jpg")):
|
|
if sync_file(f, ASSETS_OUT_DIR / "ui" / ui_sub.name / f.name):
|
|
copied += 1
|
|
|
|
# 7. Buildings (all sub-dirs)
|
|
build_dir = ART_DIR / "TH1Buildings"
|
|
if build_dir.exists():
|
|
for sub in build_dir.iterdir():
|
|
if sub.is_dir():
|
|
for f in sub.glob("*.png"):
|
|
if sync_file(f, ASSETS_OUT_DIR / "buildings" / sub.name / f.name):
|
|
copied += 1
|
|
# Nested civ subdirectories
|
|
for civ_sub in sub.iterdir():
|
|
if civ_sub.is_dir():
|
|
for f in civ_sub.glob("*.png"):
|
|
if sync_file(f, ASSETS_OUT_DIR / "buildings" / sub.name / f.name):
|
|
copied += 1
|
|
|
|
# 8. Comic art
|
|
comic_dir = ART_DIR / "Comic"
|
|
if comic_dir.exists():
|
|
for sub in comic_dir.iterdir():
|
|
if sub.is_dir():
|
|
for f in sub.glob("*.png"):
|
|
if sync_file(f, ASSETS_OUT_DIR / "comic" / sub.name / f.name):
|
|
copied += 1
|
|
|
|
print(f" Synced {copied} art files")
|
|
return copied
|
|
|
|
|
|
def export_art_catalog():
|
|
"""Scan synced art assets and build a JSON catalog for the dashboard."""
|
|
print("Building art catalog...")
|
|
catalog = {}
|
|
|
|
categories = [
|
|
("illustrations", "角色立绘", "illustrations"),
|
|
("heroes", "英雄立绘", "heroes"),
|
|
("units", "兵种精灵", "units"),
|
|
("buildings", "建筑", "buildings"),
|
|
("forces", "阵营图标", "forces"),
|
|
("comic", "游戏插图", "comic"),
|
|
("ui", "UI素材", "ui"),
|
|
("logo", "Logo", "logo"),
|
|
]
|
|
|
|
for key, label, folder in categories:
|
|
base = ASSETS_OUT_DIR / folder
|
|
if not base.exists():
|
|
catalog[key] = {"label": label, "count": 0, "groups": {}}
|
|
continue
|
|
|
|
groups = {}
|
|
for f in sorted(base.rglob("*.png")):
|
|
rel = f.relative_to(base)
|
|
parts = rel.parts
|
|
group = parts[0] if len(parts) > 1 else "_root"
|
|
if group not in groups:
|
|
groups[group] = []
|
|
# Path relative to dashboard root (for <img src>)
|
|
groups[group].append(f"assets/{folder}/{rel.as_posix()}")
|
|
|
|
# Also include jpg
|
|
for f in sorted(base.rglob("*.jpg")):
|
|
rel = f.relative_to(base)
|
|
parts = rel.parts
|
|
group = parts[0] if len(parts) > 1 else "_root"
|
|
if group not in groups:
|
|
groups[group] = []
|
|
groups[group].append(f"assets/{folder}/{rel.as_posix()}")
|
|
|
|
total = sum(len(v) for v in groups.values())
|
|
catalog[key] = {"label": label, "count": total, "groups": groups}
|
|
print(f" {label}: {total} files in {len(groups)} groups")
|
|
|
|
return catalog
|
|
|
|
|
|
def sync_oss_data():
|
|
"""Sync OSS match JSON data from Tools/OSS/Data/JsonExport to Dashboard data/oss/.
|
|
|
|
The JSON files are pre-converted from MemoryPack .dat by Unity Editor
|
|
(menu: Tools/OSS 导出 JSON). Structure: version/steamId/timestamp.json
|
|
Only copies new files (skips existing).
|
|
"""
|
|
if not OSS_JSON_SRC.exists():
|
|
print(f" OSS JSON source not found: {OSS_JSON_SRC}")
|
|
print(f" Run Unity Editor menu 'Tools/OSS 导出 JSON (Dashboard)' first")
|
|
return
|
|
|
|
OSS_DATA_DST.mkdir(parents=True, exist_ok=True)
|
|
copied = 0
|
|
skipped = 0
|
|
|
|
for json_file in OSS_JSON_SRC.rglob("*.json"):
|
|
rel = json_file.relative_to(OSS_JSON_SRC)
|
|
dst = OSS_DATA_DST / rel
|
|
if dst.exists() and dst.stat().st_mtime >= json_file.stat().st_mtime:
|
|
skipped += 1
|
|
continue
|
|
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(json_file, dst)
|
|
copied += 1
|
|
|
|
total = copied + skipped
|
|
print(f" OSS data sync: {copied} new, {skipped} existing, {total} total .json files")
|
|
|
|
|
|
def main():
|
|
print(f"TH1 Dashboard Data Exporter")
|
|
print(f"Unity Dir: {UNITY_DIR}")
|
|
print(f"Assets Dir: {ASSETS_DIR}")
|
|
print(f"Output Dir: {OUTPUT_DIR}")
|
|
print()
|
|
|
|
if not ASSETS_DIR.exists():
|
|
print(f"ERROR: Assets directory not found: {ASSETS_DIR}")
|
|
sys.exit(1)
|
|
|
|
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
ASSETS_OUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
units = export_units()
|
|
skills = export_skills()
|
|
techs = export_techs()
|
|
players = export_players()
|
|
heroes = export_heroes()
|
|
civs = export_civs()
|
|
actions = export_actions()
|
|
grids = export_grids()
|
|
culture_cards = export_culture_cards()
|
|
library = export_library()
|
|
stories = export_stories()
|
|
moments = export_moments()
|
|
chat_bubbles = export_chat_bubbles()
|
|
summary = generate_summary(units, skills, techs, players, heroes)
|
|
versions = export_versions()
|
|
plan = export_plan()
|
|
export_art_assets()
|
|
art_catalog = export_art_catalog()
|
|
|
|
# Copy OSS match data for balance analysis
|
|
sync_oss_data()
|
|
|
|
datasets = {
|
|
"units.json": units,
|
|
"skills.json": skills,
|
|
"techs.json": techs,
|
|
"players.json": players,
|
|
"heroes.json": heroes,
|
|
"civs.json": civs,
|
|
"actions.json": actions,
|
|
"grids.json": grids,
|
|
"culture_cards.json": culture_cards,
|
|
"library.json": library,
|
|
"stories.json": stories,
|
|
"moments.json": moments,
|
|
"chat_bubbles.json": chat_bubbles,
|
|
"summary.json": summary,
|
|
"versions.json": versions,
|
|
"plan.json": plan,
|
|
"art_catalog.json": art_catalog,
|
|
}
|
|
|
|
for filename, d in datasets.items():
|
|
filepath = OUTPUT_DIR / filename
|
|
with open(filepath, 'w', encoding='utf-8') as f:
|
|
json.dump(d, f, ensure_ascii=False, indent=2)
|
|
print(f" Written: {filepath}")
|
|
|
|
print(f"\nDone! Open index.html in your browser to view the dashboard.")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|