Include the current addon, terrain integrations, tools, regression tests and recorded development evidence.
102 lines
7.2 KiB
Python
102 lines
7.2 KiB
Python
#!/usr/bin/env python3
|
||
"""Export a terrain scenario using Escape's universal TerrainConnector prefab.
|
||
Copy the exported Missions/ and Worlds/ into the terrain author's addon and add
|
||
Escape as a dependency. --standalone creates a separate addon instead. Neither
|
||
mode modifies the terrain source or generates navigation.
|
||
"""
|
||
import argparse, hashlib, json, math, re
|
||
from pathlib import Path
|
||
ROOT=Path(__file__).resolve().parents[1]
|
||
CORE='2528466DE220D91C'; BASE='58D0FB3206B6F859'
|
||
CONNECTOR='{8FB1559AE3F29722}Prefabs/ERR/TerrainConnector.et'
|
||
PROJECTS=('Soldiers','BTRlike','LowRes')
|
||
RESOURCE=re.compile(r'\{([A-F0-9]{16})\}([A-Za-z0-9_./-]+)')
|
||
def guid(label):return hashlib.sha256(('ERR_TERRAIN_V1:'+label).encode()).hexdigest()[:16].upper()
|
||
def resource(value,suffix):
|
||
m=RESOURCE.fullmatch(value) if isinstance(value,str) else None
|
||
if not m or any(part in ('', '.', '..') for part in m[2].split('/')) or not m[2].endswith(suffix):raise ValueError('Invalid resource: '+str(value))
|
||
return value
|
||
|
||
def validate(profile):
|
||
if not isinstance(profile,dict):raise ValueError('Terrain profile must be an object')
|
||
if set(profile)-{'id','title','addon','world','navigation','ai_world','inherit_ai_world','airfields','notes'}:raise ValueError('Unknown terrain profile fields')
|
||
if not re.fullmatch(r'[a-z][a-z0-9_]{0,39}',profile.get('id','')):raise ValueError('Invalid terrain id')
|
||
if not re.fullmatch(r'[A-Za-z0-9 _-]{1,48}',profile.get('title','')):raise ValueError('Invalid terrain title')
|
||
if not re.fullmatch('[A-F0-9]{16}',profile.get('addon','')):raise ValueError('Invalid terrain addon GUID')
|
||
resource(profile.get('world'),'.ent')
|
||
nav=profile.get('navigation')
|
||
if not isinstance(nav,dict) or set(nav)-set(PROJECTS):raise ValueError('Unknown navigation project')
|
||
for files in nav.values():
|
||
if not isinstance(files,list) or not len(files)==1:raise ValueError('Navigation needs one .nmn file or native .conf per project')
|
||
for f in files:resource(f,('.nmn','.conf'))
|
||
if 'ai_world' in profile:
|
||
resource(profile['ai_world'],'.et')
|
||
if nav:raise ValueError('Use a terrain AI world prefab OR navigation resources, not both')
|
||
if type(profile.get('inherit_ai_world',False)) is not bool:raise ValueError('inherit_ai_world must be a boolean')
|
||
if profile.get('inherit_ai_world') and (nav or profile.get('ai_world')):raise ValueError('Inherited terrain AI world must not be duplicated by adapter navigation')
|
||
anchors=profile.get('airfields',[])
|
||
if not isinstance(anchors,list) or len(anchors)>16:raise ValueError('At most 16 airfield centres are supported')
|
||
for anchor in anchors:
|
||
if not isinstance(anchor,list) or len(anchor)!=3 or any(type(v) not in (int,float) or not math.isfinite(v) or abs(v)>1000000 for v in anchor):raise ValueError('Airfield centres must be finite [x,y,z] coordinates')
|
||
return profile
|
||
|
||
def meta(path,identity,kind='ResourceClass'):
|
||
return f'MetaFileClass {{\n Name "{{{identity}}}{path}"\n Configurations {{\n {kind} PC {{}}\n {kind} HEADLESS : PC {{}}\n }}\n}}\n'
|
||
|
||
def files(profile, standalone=False):
|
||
validate(profile);name=profile['id'];title=profile['title'];identity=guid(name+':addon')
|
||
world=f'Worlds/ERR_{name}.ent';mission=f'Missions/ERR_{name}.conf'
|
||
nav=profile['navigation'];ready=profile.get('inherit_ai_world',False) or bool(profile.get('ai_world')) or all(p in nav for p in PROJECTS)
|
||
deps=list(dict.fromkeys([BASE,CORE,profile['addon']]))
|
||
result={}
|
||
if standalone:
|
||
result['addon.gproj']='GameProject {\n ID "ERR_Terrain_'+name+'"\n GUID "'+identity+'"\n TITLE "Escape - '+title+'"\n Dependencies {\n'+''.join(' "'+d+'"\n' for d in deps)+' }\n}\n'
|
||
result[world]='SubScene {\n Parent "'+profile['world']+'"\n}\n'
|
||
result[world+'.meta']=meta(world,guid(name+':world'),'ENTResourceClass')
|
||
result[mission]=f'SCR_MissionHeader {{\n World "{{{guid(name+":world")}}}{world}"\n SystemsConfig "{{C50579A2EC48E8EF}}Configs/Systems/MissionSystems.conf"\n m_sName "Co10 Escape Reintroduced - {title}"\n m_sAuthor "Escape Reintroduced"\n m_sDescription "Escape captivity, gather intelligence, find the radio and flee by helicopter."\n m_sGameMode "Escape"\n m_iPlayerCount 12\n m_eSaveTypes 0\n m_bShowInScenarioMenu {int(ready)}\n}}\n'
|
||
result[mission+'.meta']=meta(mission,guid(name+':mission'))
|
||
# All maps reference the same prefab. Only navigation and airport hints vary.
|
||
layer='ERR_GameMode Reintroduced : "'+CONNECTOR+'" {}\n'
|
||
ai_world=profile.get('ai_world','{E0A05C76552E7F58}Prefabs/AI/SCR_AIWorld.et')
|
||
ai='SCR_AIWorld AIWorld : "'+ai_world+'" {\n'
|
||
if nav:
|
||
ai+=' components {\n'
|
||
components=['5584F30E67F617AD','5584F30EEFEE1223','5C8C9B750D124A63']
|
||
settings=['NavmeshWorld','ChimeraNavmeshWorld','NavmeshWorld']
|
||
settings_ids=['50FC63BEBE3973C5','50FC63BEBE3973C5','5C8C9B750B60C6E2']
|
||
config_ids=['60CA9BE5536BF701','60CA9BE5536BF701','5C90BD0EC793647D']
|
||
for i,project in enumerate(PROJECTS):
|
||
if project not in nav:continue
|
||
navfile=nav[project][0]
|
||
config=(f': "{navfile}" {{}}' if navfile.endswith('.conf') else f'{{\n NavmeshFile "{navfile}"\n }}')
|
||
ai+=f' NavmeshWorldComponent "{{{components[i]}}}" {{\n NavmeshSettings {settings[i]} "{{{settings_ids[i]}}}" {{\n NavmeshFilesConfig BaseNavmeshFilesConfig "{{{config_ids[i]}}}" {config}\n }}\n }}\n'
|
||
ai+=' }\n'
|
||
ai+='}\n'
|
||
if profile.get('inherit_ai_world'):ai=''
|
||
layer+=ai
|
||
for index,anchor in enumerate(profile.get('airfields',[])):
|
||
coords=' '.join(format(v,'.9g') for v in anchor)
|
||
layer+=f'GenericEntity ERR_Airfield_{index} {{\n coords {coords}\n}}\n'
|
||
result[f'Worlds/ERR_{name}_Layers/default.layer']=layer
|
||
result['terrain-adapter.json']=json.dumps({'schema':2,'profile':profile,'mode':'standalone' if standalone else 'embedded','addon':identity if standalone else profile['addon'],'required_dependencies':deps if standalone else [BASE,CORE],'connector':CONNECTOR,'scenarioId':'{'+guid(name+':mission')+'}'+mission,'world':world,'navigation_configured':ready,'qualification':'unverified'},indent=2)+'\n'
|
||
return result
|
||
|
||
def generate(profile,destination,standalone=False):
|
||
rendered=files(profile,standalone=standalone);destination=Path(destination)
|
||
if destination.exists():raise ValueError('Output already exists; use a new destination to preserve authored/navigation data')
|
||
destination.mkdir(parents=True)
|
||
for name,content in rendered.items():
|
||
path=destination/name;path.parent.mkdir(parents=True,exist_ok=True);path.write_text(content)
|
||
return json.loads(rendered['terrain-adapter.json'])
|
||
|
||
def main():
|
||
p=argparse.ArgumentParser(description=__doc__);p.add_argument('profile',type=Path);p.add_argument('--output',type=Path,required=True)
|
||
p.add_argument('--standalone',action='store_true',help='Create a separate addon project instead of files to include in the terrain addon')
|
||
a=p.parse_args()
|
||
try:report=generate(json.loads(a.profile.read_text()),a.output,standalone=a.standalone)
|
||
except (OSError,ValueError) as e:p.error(str(e))
|
||
print(json.dumps(report,indent=2))
|
||
if not a.standalone:print('Copy Missions/ and Worlds/ into the terrain addon, add Escape '+CORE+' to its project dependencies, then publish that terrain. Do not also load a standalone adapter with the same scenario resources.')
|
||
if not report['navigation_configured']:print('Navigation references missing: supply the published terrain addon’s AI-world prefab or its Soldiers/BTRlike/LowRes resources before running Escape.')
|
||
if __name__=='__main__':main()
|