TH1/Tools/Dashboard/export_data.py

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()