This commit is contained in:
2026-07-05 01:46:27 +02:00
parent 0a939e3a85
commit 08f544b358
23 changed files with 970 additions and 217 deletions
+145
View File
@@ -0,0 +1,145 @@
"""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()