146 lines
6.1 KiB
Python
146 lines
6.1 KiB
Python
"""Generate the first-pass NWN tileset definition `twu01.set` from tiles.py.
|
|
|
|
python make_set.py [out.set] # default: twu01.set
|
|
|
|
Pure Python, no Blender. Emits a valid, DOS/CRLF `.set` (AGENTS.md §4/§9: Unix
|
|
line endings crash the toolset). Corners/heights/crossers derive from each tile's
|
|
edge grammar; this is a DRAFT to load and hand-tune in the toolset, not a final
|
|
set. See CAVEATS below.
|
|
|
|
Every NON-paint tile gets a 1x1 [GROUP#] (a "feature") so it is click-placeable
|
|
from the palette (AGENTS.md §2.3) — the .itp Features folder points its leaves at
|
|
these groups by first-tile resref, and Name must match tiles.group_name().
|
|
|
|
PAINT tiles (tiles.is_paint: cobble fill CP1/2/3, canal fill CN1, canal edges
|
|
CE_*) are deliberately NOT grouped: a grouped tile is excluded from the toolset's
|
|
terrain-paint pool, so leaving them ungrouped IS what makes cobble/canal
|
|
paintable and the eraser work. Grouping every tile empties the fill pool and the
|
|
New Area wizard crashes (verified vs stock tcn01, 2026-07-04). Transition tiles
|
|
expand into 4 orientations (tiles.placements) so the corner matrix is complete;
|
|
make_set.main() hard-fails if tiles.canal_paint_matrix() is missing any pattern.
|
|
|
|
CAVEATS (first pass — tune in the toolset):
|
|
* PathNode is a placeholder ('A' walkable / 'N' non-walkable). Real pathnode
|
|
letters govern creature routing and must be verified against the toolset.
|
|
* ImageMap2D references minimaps (twu01_<code>.tga) that are not rendered yet.
|
|
"""
|
|
|
|
import re
|
|
import sys
|
|
|
|
import tiles
|
|
|
|
PREFIX = tiles.PREFIX
|
|
|
|
|
|
def tile_block(i, spec, orient, corners, edges):
|
|
tl, tr, bl, br = corners
|
|
n, e, s, w = edges
|
|
ref = tiles.resref(spec)
|
|
walkable = spec["kind"] != "canal"
|
|
# Paint tiles connect by CORNER match only (like stock wut01 terrains) — blank
|
|
# their edge crossers so the brush isn't over-constrained; features keep theirs.
|
|
cr = (lambda edge: "") if tiles.is_paint(spec) else tiles.nwn_crosser
|
|
# Field order/values mirror the stock BioWare sets (the toolset's .set reader
|
|
# is picky); WalkMesh=msb01 is the universal legacy label, not a file.
|
|
L = [f"[TILE{i}]"]
|
|
L += [
|
|
f"Model={ref}",
|
|
"WalkMesh=msb01",
|
|
f"TopLeft={tl[0]}", f"TopLeftHeight={tl[1]}",
|
|
f"TopRight={tr[0]}", f"TopRightHeight={tr[1]}",
|
|
f"BottomLeft={bl[0]}", f"BottomLeftHeight={bl[1]}",
|
|
f"BottomRight={br[0]}", f"BottomRightHeight={br[1]}",
|
|
f"Top={cr(n)}",
|
|
f"Right={cr(e)}",
|
|
f"Bottom={cr(s)}",
|
|
f"Left={cr(w)}",
|
|
"MainLight1=1", "MainLight2=1", "SourceLight1=1", "SourceLight2=1",
|
|
"AnimLoop1=1", "AnimLoop2=1", "AnimLoop3=1",
|
|
"Doors=0",
|
|
"Sounds=0",
|
|
f"PathNode={'A' if walkable else 'N'}", # placeholder — verify in toolset
|
|
f"Orientation={orient}", # 0/90/180/270 rotations of one model
|
|
"VisibilityNode=A",
|
|
"VisibilityOrientation=0",
|
|
f"ImageMap2D={ref}", # minimap resref (no .tga yet; blank in toolset)
|
|
]
|
|
return L
|
|
|
|
|
|
def build():
|
|
L = []
|
|
L += [
|
|
"[GENERAL]",
|
|
f"Name={PREFIX}",
|
|
"Type=SET",
|
|
"Interior=0",
|
|
"HasHeightTransition=0", # MVP paints flat cobble only; matches stock
|
|
"Transition=1.5", # metres per height step = Z=1.5 raised tier (unused while HHT=0)
|
|
"Border=cobble",
|
|
"Default=cobble",
|
|
"Floor=cobble",
|
|
"DisplayName=-1",
|
|
"UnlocalizedName=Westgate Urban Foundation",
|
|
"Version=V1.0",
|
|
"EnvMap=",
|
|
"SelectorHeight=3",
|
|
"",
|
|
"[GRASS]",
|
|
"Grass=0",
|
|
"GrassTextureName=",
|
|
"Density=0.0",
|
|
"Height=0.0",
|
|
"AmbientRed=0.0", "AmbientGreen=0.0", "AmbientBlue=0.0",
|
|
"DiffuseRed=0.0", "DiffuseGreen=0.0", "DiffuseBlue=0.0",
|
|
"",
|
|
"[TERRAIN TYPES]",
|
|
f"Count={len(tiles.TERRAINS)}",
|
|
]
|
|
for idx, t in enumerate(tiles.TERRAINS):
|
|
L += ["", f"[TERRAIN{idx}]", f"Name={t}", "StrRef=-1"]
|
|
L += ["", "[CROSSER TYPES]", f"Count={len(tiles.CROSSERS)}"]
|
|
for idx, cr in enumerate(tiles.CROSSERS):
|
|
L += ["", f"[CROSSER{idx}]", f"Name={cr}", "StrRef=-1"]
|
|
L += ["", "[PRIMARY RULES]", "Count=0", # optional; wut01 paints 6 terrains w/ 0
|
|
"", "[SECONDARY RULES]", "Count=0"]
|
|
# Expand each spec into its placement(s): transition tiles emit 4 rotations, so
|
|
# one model backs several [TILE#] entries. Track each spec's FIRST tile index so
|
|
# its [GROUP#] (if any) points at the right tile.
|
|
placed, first_idx = [], {}
|
|
for spec in tiles.TILES:
|
|
for pl in tiles.placements(spec):
|
|
first_idx.setdefault(spec["code"], len(placed))
|
|
placed.append((spec, pl))
|
|
L += ["", "[TILES]", f"Count={len(placed)}"]
|
|
for i, (spec, (orient, corners, edges)) in enumerate(placed):
|
|
L.append("")
|
|
L += tile_block(i, spec, orient, corners, edges)
|
|
# One 1x1 group ("feature") per NON-paint tile so the palette can click-place it.
|
|
# Paint tiles (fills + terrain transitions) stay ungrouped -> the brush pool.
|
|
feats = [s for s in tiles.TILES if not tiles.is_paint(s)]
|
|
L += ["", "[GROUPS]", f"Count={len(feats)}"]
|
|
for gi, spec in enumerate(feats):
|
|
L += ["", f"[GROUP{gi}]", f"Name={tiles.group_name(spec)}",
|
|
"Rows=1", "Columns=1", f"Tile0={first_idx[spec['code']]}"]
|
|
L += [""]
|
|
return L
|
|
|
|
|
|
def main():
|
|
_, _cov, missing = tiles.canal_paint_matrix()
|
|
if missing: # incomplete matrix = paint crash; never stage
|
|
raise SystemExit(f"INCOMPLETE canal paint matrix — {len(missing)} pattern(s) "
|
|
f"have no tile: {sorted(missing)}")
|
|
out = sys.argv[1] if len(sys.argv) > 1 else f"{PREFIX}.set"
|
|
lines = build()
|
|
with open(out, "w", newline="") as fh: # newline='' + explicit \r\n = CRLF
|
|
fh.write("\r\n".join(lines))
|
|
ntiles = sum(1 for l in lines if re.fullmatch(r"\[TILE\d+\]", l))
|
|
print(f"wrote {out} ({ntiles} tile placements from {len(tiles.TILES)} models, "
|
|
f"{len(tiles.TERRAINS)} terrains, {len(tiles.CROSSERS)} crossers, CRLF)")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|