1+ from __future__ import annotations
2+ import argparse
3+ from dataclasses import dataclass , field
4+ from pathlib import Path
5+ from typing import Dict , List , Tuple , Optional
6+
7+ import sys
8+
9+ SCRIPT_DIR = Path (__file__ ).resolve ().parent
10+ sys .path .insert (0 , str (SCRIPT_DIR ))
11+
12+ from p3d_libmap .map_parser import MapParser
13+
14+
15+ # ---------------------------------------------------------------------------
16+ # Data Structures
17+ # ---------------------------------------------------------------------------
18+
19+ @dataclass
20+ class ZoneBrush :
21+ mins : Tuple [float , float , float ]
22+ maxs : Tuple [float , float , float ]
23+
24+
25+ @dataclass
26+ class Zone :
27+ name : str
28+ zone_id : int
29+ target : str
30+ fog : str = ""
31+ adjacent_zones : List [int ] = field (default_factory = list )
32+ brushes : List [ZoneBrush ] = field (default_factory = list )
33+
34+
35+ # ---------------------------------------------------------------------------
36+ # Map utilities
37+ # ---------------------------------------------------------------------------
38+
39+ def load_map_without_geo (path : Path ):
40+ """Load a .map file but do not parse brush geometry."""
41+ parser = MapParser ()
42+ parser .parser_load (str (path ))
43+ return parser .map_data
44+
45+
46+ def get_bounds_for_brush (brush , origin = (0.0 , 0.0 , 0.0 )):
47+ """Compute min/max bounds for a brush."""
48+ ox , oy , oz = origin
49+
50+ xs , ys , zs = [], [], []
51+
52+ for face in brush .faces :
53+ pts = (face .plane_points .v0 , face .plane_points .v1 , face .plane_points .v2 )
54+ for p in pts :
55+ xs .append (p .x + ox )
56+ ys .append (p .y + oy )
57+ zs .append (p .z + oz )
58+
59+ if not xs :
60+ return None , None
61+
62+ return (min (xs ), min (ys ), min (zs )), (max (xs ), max (ys ), max (zs ))
63+
64+
65+ # ---------------------------------------------------------------------------
66+ # Zone file output
67+ # ---------------------------------------------------------------------------
68+
69+ def write_zones_to_file (zones : List [Zone ], output_path : Path ):
70+ """Write all zones to an .nsz file."""
71+ with output_path .open ("w" ) as f :
72+ f .write ("zone_file_version: 1.0.0\n " )
73+ f .write (f"number_of_zones: { len (zones )} \n " )
74+
75+ for zone in zones :
76+ f .write (f"{ zone .name } \n " )
77+ f .write (f"{ zone .zone_id } \n " )
78+ f .write (f"{ zone .target } \n " )
79+ f .write (f"{ zone .fog } \n " )
80+
81+ f .write (f"{ len (zone .adjacent_zones )} \n " )
82+ for adj in zone .adjacent_zones :
83+ f .write (f"{ adj } \n " )
84+
85+ f .write (f"{ len (zone .brushes )} \n " )
86+ for b in zone .brushes :
87+ f .write (f"{ b .mins [0 ]} { b .mins [1 ]} { b .mins [2 ]} \n " )
88+ f .write (f"{ b .maxs [0 ]} { b .maxs [1 ]} { b .maxs [2 ]} \n " )
89+
90+
91+ # ---------------------------------------------------------------------------
92+ # Main Processing Logic
93+ # ---------------------------------------------------------------------------
94+
95+ def process_map (map_data ):
96+ zone_name_to_id : Dict [str , int ] = {}
97+ next_zone_id = 1
98+ zones : List [Zone ] = []
99+
100+ def get_id_for_zone (name : str ) -> int :
101+ nonlocal next_zone_id
102+ if name not in zone_name_to_id :
103+ zone_name_to_id [name ] = next_zone_id
104+ next_zone_id += 1
105+ return zone_name_to_id [name ]
106+
107+ print (f"Total entities: { len (map_data .entities )} \n " )
108+
109+ for ent in map_data .entities :
110+ if ent .properties .get ("classname" ) != "spawn_zone" :
111+ continue
112+
113+ zone_name = ent .properties .get ("zone_name" )
114+ zone_target = ent .properties .get ("zone_target" )
115+ zone_fog = ent .properties .get ("zone_fog" , "" )
116+
117+ print (f"+ Found a spawn_zone entity:" )
118+ print (f" - Name: { zone_name } " )
119+ print (f" - Target: { zone_target } " )
120+ print (f" - Fog: { zone_fog } " )
121+
122+ # Resolve adjacent zones
123+ adjacents = ent .properties .get ("adjacent_zones" , "" )
124+ adjacent_zones = [
125+ get_id_for_zone (z .strip ())
126+ for z in adjacents .split ("," )
127+ if z .strip ()
128+ ]
129+
130+ print (f" - Adjacent: { adjacent_zones } " )
131+
132+ # Zone brushes
133+ brushes = []
134+ for i , brush in enumerate (ent .brushes ):
135+ mins , maxs = get_bounds_for_brush (brush )
136+ brushes .append (ZoneBrush (mins = mins , maxs = maxs ))
137+ print (f" * Brush { i } : mins={ mins } , maxs={ maxs } " )
138+
139+ zone_id = get_id_for_zone (zone_name )
140+
141+ zones .append (
142+ Zone (
143+ name = zone_name ,
144+ zone_id = zone_id ,
145+ target = zone_target ,
146+ fog = zone_fog ,
147+ adjacent_zones = adjacent_zones ,
148+ brushes = brushes ,
149+ )
150+ )
151+
152+ print ()
153+
154+ return zones
155+
156+
157+ # ---------------------------------------------------------------------------
158+ # Entry Point
159+ # ---------------------------------------------------------------------------
160+
161+ def main ():
162+ parser = argparse .ArgumentParser (description = "Creates NSZ (NZ:P Spawn Zones) file for use in maps." )
163+ parser .add_argument (
164+ "map_file" ,
165+ type = Path ,
166+ nargs = "?" ,
167+ help = "Path to the .map file to create NSZ from" ,
168+ )
169+ parser .add_argument (
170+ "-o" , "--output" ,
171+ type = Path ,
172+ help = "Output path for NSZ"
173+ )
174+
175+ args = parser .parse_args ()
176+
177+ print (f"Loading map: { args .map_file } " )
178+ map_data = load_map_without_geo (args .map_file )
179+
180+ zones = process_map (map_data )
181+
182+ print (f"Writing { len (zones )} zones to: { args .output } " )
183+ write_zones_to_file (zones , args .output )
184+
185+
186+ if __name__ == "__main__" :
187+ main ()
0 commit comments