commit 60af184be58ef9b2375f2fbef7ae9c2cb9bba5cf Author: vickydotbat Date: Sat Jul 4 16:55:43 2026 +0200 wgt01: tileset build guide + 40-tile generator kit AGENTS.md: NWN:EE tileset construction reference (constraints, .mdl/.wok walkmesh rules, .set format, Blender->NWN pipeline, prototype->NWN mapping). wgt01/: Westgate Urban Foundation MVP kit. - generator/tiles.py: single source of truth (40-tile table, materials, 12x12 test map, pure paint/height/walkmesh/.set derivations). - generator/build.py: Blender 4.0.2 scene generator (VIS_/WOK_/BLOCK_ per tile, edge metadata, library grid + 12x12 district previews). - generator/make_wok.py: ASCII walkmesh emitter (verts+faces+surfacemat+AABB tree) from the same geometry. - generator/make_set.py: first-pass twu01.set (CRLF) from the tile grammar. - flake.nix: build/wok/set apps + dev shell (pinned Blender 4.0.2, NWN tools). Co-Authored-By: Claude Opus 4.8 (1M context) diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..ef72c59 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,77 @@ +# ========================= +# Neverwinter Nights - LFS Assets +# ========================= + +# ------------------------- +# Audio / Video +# ------------------------- +*.wav filter=lfs diff=lfs merge=lfs -text +*.bmu filter=lfs diff=lfs merge=lfs -text +*.mp3 filter=lfs diff=lfs merge=lfs -text +*.mp4 filter=lfs diff=lfs merge=lfs -text +*.bik filter=lfs diff=lfs merge=lfs -text + +# ------------------------- +# Images / Textures +# ------------------------- +*.tga filter=lfs diff=lfs merge=lfs -text +*.dds filter=lfs diff=lfs merge=lfs -text +*.plt filter=lfs diff=lfs merge=lfs -text +*.png filter=lfs diff=lfs merge=lfs -text +*.tif filter=lfs diff=lfs merge=lfs -text +*.psd filter=lfs diff=lfs merge=lfs -text + +# ------------------------- +# 3D Models / Geometry +# ------------------------- +*.mdl filter=lfs diff=lfs merge=lfs -text +*.mdx filter=lfs diff=lfs merge=lfs -text +*.fbx filter=lfs diff=lfs merge=lfs -text +*.blend filter=lfs diff=lfs merge=lfs -text + +# ------------------------- +# Walkmeshes / Collision +# ------------------------- +*.wok filter=lfs diff=lfs merge=lfs -text +*.pwk filter=lfs diff=lfs merge=lfs -text +*.dwk filter=lfs diff=lfs merge=lfs -text + +# ------------------------- +# NWN Tileset / Layout +# ------------------------- +*.lyt filter=lfs diff=lfs merge=lfs -text +*.vis filter=lfs diff=lfs merge=lfs -text + +# ------------------------- +# NWN GFF Files (CORE) +# ------------------------- +*.are filter=lfs diff=lfs merge=lfs -text +*.git filter=lfs diff=lfs merge=lfs -text +*.ifo filter=lfs diff=lfs merge=lfs -text +*.dlg filter=lfs diff=lfs merge=lfs -text +*.fac filter=lfs diff=lfs merge=lfs -text +*.jrl filter=lfs diff=lfs merge=lfs -text + +# ------------------------- +# NWN Objects (GFF-based) +# ------------------------- +*.utc filter=lfs diff=lfs merge=lfs -text +*.utp filter=lfs diff=lfs merge=lfs -text +*.uti filter=lfs diff=lfs merge=lfs -text +*.utd filter=lfs diff=lfs merge=lfs -text +*.uts filter=lfs diff=lfs merge=lfs -text +*.utm filter=lfs diff=lfs merge=lfs -text +*.utw filter=lfs diff=lfs merge=lfs -text +*.ute filter=lfs diff=lfs merge=lfs -text +*.utt filter=lfs diff=lfs merge=lfs -text + +# ------------------------- +# Character Files +# ------------------------- +*.bic filter=lfs diff=lfs merge=lfs -text + +# ------------------------- +# NWN Misc +# ------------------------- +*.gff filter=lfs diff=lfs merge=lfs -text +*.gic filter=lfs diff=lfs merge=lfs -text diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..a378cc0 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,374 @@ +# AGENTS.md — Building NWN:EE Tilesets + +Operational guide for building **Neverwinter Nights: Enhanced Edition** tilesets in +this repo. Read this before touching any tileset directory. It bridges our +Blender-prototype conventions to the hard requirements of the NWN engine and +toolset, so geometry built here converts cleanly to a shippable `.hak`. + +> This is a reference, not a tutorial to read top-to-bottom. Jump to the section +> you need. When a fact here disagrees with the linked nwn.wiki pages +> ([Sources](#sources)), the wiki wins — tell the user and fix this file. + +--- + +## 0. Repo layout + +Each tileset lives in its own directory. One tileset = one 5-char prefix = one +`.set` + its models/walkmeshes/palette. + +``` +sow-module-tilesets/ +├── AGENTS.md ← you are here (applies to all tilesets) +├── wgt01/ ← Westgate Urban Foundation MVP (streets, canals, ramps) +│ ├── KICKOFF.md ← design brief / tile list for this tileset (the spec) +│ ├── concept-art-1.png +│ ├── flake.nix ← Blender 4.0.2 + generator entry points +│ └── generator/ ← bpy scripts that build the .blend source +└── / ← same shape +``` + +- **wgt01** is the *urban foundation* set: ground grammar only (streets, + sidewalks, plazas, ramps, terraces, canals, bridges). Buildings/props are + placeables layered on top later — **not** part of any tileset here. +- Other tilesets (interiors, dungeons, wilderness) may follow. They share these + rules; only their `KICKOFF.md` brief and prefix differ. +- A tileset's `KICKOFF.md` is authoritative for *what tiles to build*. This file + is authoritative for *how NWN needs them shaped and packaged*. + +--- + +## 1. Hard constraints (memorize these) + +| Constraint | Value | Consequence if violated | +|---|---|---| +| Tile footprint | **10 m × 10 m** on XY, centered at origin (X,Y ∈ [−5, +5]) | Misaligned adjacency, gaps | +| Ground level | **Z = 0**; walkable floor cannot go below 0 | — | +| Axes | **Z up, X = West→East, Y = South→North** | — | +| Resref (any model/wok/tga name) | **≤ 16 characters** | Silent truncation / breakage | +| Tileset prefix | **5 chars**, e.g. `TWU01` (Tileset Westgate Urban 01) | — | +| Walkmesh polycount | **~100 faces per tile** ideal | GMax/exporter crash, broken AABB | +| Faces per walkmesh vertex | **≤ 8** | Export failure | +| Lights per tile | **≤ 2 mainlight + ≤ 2 sourcelight** | — | +| Walkable faces | **must face upward, single level, no overlap** | Downward-facing walkable *crashes the exporter* and makes players jerk/hop | +| Walkmesh edges | exactly at **±5 m** from center | Creatures can't cross tile boundary | +| `.set` file | **INI text, DOS/CRLF line endings** | Toolset crashes on area creation | +| Area size | **≤ 32 × 32 tiles** | — | +| Module | **≤ 100 tilesets** loaded | — | + +Prototype-phase relief (from `KICKOFF.md`): geometry may be simple, materials +flat, exact NWN export is **not** required for the Blender MVP. But build with +these numbers in mind so conversion is mechanical, not a rework. + +--- + +## 2. NWN tile fundamentals + +### 2.1 Corners, edges, terrains, crossers + +A tile connects to neighbors by matching **corners** and **edges**: + +- **4 corners** — `TopLeft, TopRight, BottomLeft, BottomRight`. Each carries a + **terrain type** (string, no whitespace) *and* a **height** (integer number of + transition steps above 0). Two tiles are adjacency-compatible only if the + shared corners' terrain **and** height match. +- **4 edges** — `Top, Right, Bottom, Left`. Each carries an optional **crosser** + (linear feature that splits the edge: road, stream, wall, canal). Blank = none. + +**Terrain** = the surface a corner sits on (`cobble`, `plaza`, `canal`, `void`). +**Crosser** = a linear feature crossing an edge (`street`, `sidewalk`, `canal_edge`). +Both are referenced **by name** in the `.set` and `.itp` — names are +case-sensitive and cannot contain spaces. + +Mixing terrains is expensive: every pair of terrains that can meet needs +transition tiles. +- 2 terrains → ~6–7 tiles minimum (1 pure A, 1 pure B, 4–5 transitions). +- 3 terrains → +9 tiles per added terrain. +- 4 terrains → full matrix, substantially more. +Keep the terrain/crosser vocabulary **small and deliberate** per tileset. + +### 2.2 Heights + +- `Transition` (in `[GENERAL]`) = meters between height step 0 and step 1. + Decimals allowed. BioWare original content used **5 m**; custom sets use **2–3 m**. +- **wgt01 uses a raised tier at Z = 1.5 m**, so its `Transition = 1.5` and the + raised tiles sit at corner height `1`. +- Walkable ground can only be *raised* in whole steps, never lowered below Z=0. + Visual water/void may dip (e.g. canal at Z = −0.3) **only under a non-walkable + walkmesh** — never a playable under/over space (matches `KICKOFF.md`). + +--- + +## 3. The tile model (`.mdl`) + +ASCII MDL. Binary MDLs must be decompiled first. Structure: + +### 3.1 AuroraBase (root dummy) +Mandatory root node. **Node name must equal the filename** (e.g. `twu01_a01_01`). +Set its classification/**Type = `Tile`** for any tile that has a `.wok`. Holds: +tile name, animation frames, supermodel link, and all child nodes. + +### 3.2 Renderable geometry (trimesh) +The visible tile. Children of AuroraBase. Keep polys low, edges snap-clean at +±5 m, no geometry past bounds except explicitly-marked harmless trim. + +### 3.3 Walkmesh (AABB node) — the `.wok` +Invisible collision + pathfinding mesh. **Parented to AuroraBase only, exactly +one AABB node per tile.** Governs walkable/non-walkable triangles, surface +material per triangle, water splashes, dynamic grass, camera collision, blood. + +**The 8 walkmesh rules** (violating these crashes exporters or breaks pathing): +1. ~100 faces max. +2. ≤ 8 faces per vertex (non-walkable often exempt, still keep minimal). +3. Single-level walkable — walkable-face vertices must not overlap other + walkable faces. Non-walkable faces unrestricted. +4. Walkable faces point **up**. Downward-facing walkable = exporter crash + + player jerking. Downward non-walkable (ceilings) is fine. +5. One AABB node only. +6. No straight vertical edges (breaks no-overlap). Offset height changes ≥ 1 cm + on XY per axis. +7. Origin at tile center `(0,0,0)`, matching AuroraBase. No scale/rotation + transforms — including on raised tiles. +8. Walkmesh edges at exactly ±5 m for cross-tile traversal. Sub-cm gaps usually + still cross; larger gaps block. + +**Surface material types** (per triangle; defined globally in `surfacemat.2da`, +sounds in `footstepsounds.2da`): +`Dirt, Grass, Stone, Wood, Water, Carpet, Metal, Puddles, Leaves, Sand, Snow`. +Non-walkable proxies use a non-walkable surfacemat (e.g. the "barebones" row that +blocks walking + camera but allows spell targeting). **Surfacemat #3 (Grass)** +triggers engine dynamic grass (configured in the `.set` `[GRASS]` section). + +### 3.4 Lights (dummy nodes, not real lamps) +Named `ml1`, `ml2` (mainlights — floating ambient, area- +controllable) and `sl1`, `sl2` (sourcelights — tied to +torches/fires, respond to day/night). Max 2 of each. Colors restricted to +`tilecolor.2da`. + +### 3.5 Animations (on AuroraBase) +Standard 50 frames @ 30 FPS: +`animloop1/2/3` (independently toggleable mesh subsets), `Day`, `Night`, +`Day2Night`, `Night2Day` (1-hour transitions), `tiledefault` (optional fallback). +Bind individual mesh parts to an `animloopN` so one tile can serve multiple states. + +### 3.6 A-node (transparency layer, optional) +Dummy `a` parented to AuroraBase; its mesh children render in the +dynamic phase (after static meshes) for correct transparency ordering (glass, +foliage, water). Note: VFX particles are still occluded by A-node transparent +objects unless their texture's alpha = 0. + +--- + +## 4. The `.set` file (INI, CRLF line endings) + +Named `.set` (e.g. `twu01.set`). **Strict section order:** +`[GENERAL] → [GRASS] → Terrain Types → Crosser Types → Primary Rules → +Secondary Rules → Tiles → Groups`. Tiles are referenced **by numeric index** +here (the `.itp` references the same tiles **by name** — keep them in sync or +placement silently breaks). + +### `[GENERAL]` +`Name` (internal id, referenced by doortypes/loadscreens), `Type=SET`, +`Version`, `Interior` (0=exterior w/ weather+day/night, 1=interior), +`HasHeightTransition` (1 if raise/lower used), `EnvMap` (water env map), +`Transition` (meters/step, decimals ok), `SelectorHeight` (wireframe visual), +`DisplayName` (tlk ref) or `UnlocalizedName`, `Border` (edge terrain), +`Default` (terrain for new areas), `Floor` (eraser terrain). + +### `[GRASS]` +`Grass` (0/1), `GrassTextureName`, `Density`, `Height` (m), +`Ambient{Red,Green,Blue}`, `Diffuse{Red,Green,Blue}`. + +### Terrain / Crosser types +`[TERRAIN TYPES] Count=N`, then `[TERRAIN0..N-1]` each with `Name` (case-sensitive, +no spaces) and optional `StrRef`. `[CROSSER TYPES]` + `[CROSSER#]` identical shape. + +### Primary rules (toolset auto-picking) +`[PRIMARY RULES] Count=N`, then `[PRIMARY RULE#]` with +`Placed, PlacedHeight, Adjacent, AdjacentHeight, Changed, ChangedHeight`. +When a builder paints adjacent matching terrain, the toolset swaps in the +`Changed` tile automatically. `[SECONDARY RULES]` is unused in vanilla (Count=0). + +### `[TILE#]` (per-tile entry — the core) +``` +[TILE0] +Model=twu01_a01_01 ; .mdl basename, ≤16 chars, == AuroraBase node name +WalkMesh=msb01 ; legacy walkmesh ref (BioWare default "msb01") +ImageMap2D=twu01_a01_01 ; minimap .tga/.dds, ≥16×16 px +TopLeft=cobble ; corner terrain +TopLeftHeight=0 ; corner height step +TopRight=cobble +TopRightHeight=0 +BottomLeft=cobble +BottomLeftHeight=0 +BottomRight=cobble +BottomRightHeight=0 +Top=street ; edge crosser (blank if none) +Right= +Bottom=street +Left= +MainLight1=0 ; tilecolor.2da index +MainLight2=0 +SourceLight1=0 +SourceLight2=0 +AnimLoop1=0 +AnimLoop2=0 +AnimLoop3=0 +Orientation=0 ; rotation in ±90° steps; positive = counter-clockwise +PathNode=A ; pathfinding descriptor (A-Z,a-z, case-sensitive) +VisibilityNode= ; LOS; defaults to PathNode if blank (keep simpler) +Doors=0 +Sounds=0 +``` +- **PathNode is required.** Without it, creatures and click-to-move break. + Choose the simplest valid pattern that lets a creature cross the tile. NWN:EE + added no new pathnode types — use the fixed set. +- **VisibilityNode** should usually be simpler than the pathnode (straight + edge-to-edge). Omit to inherit the pathnode. +- **Padding:** reserve future slots with `Model=padding` and corners `(null)`. + +### `[TILE#_DOOR#]` (only if that tile's `Doors>0`) +`Type` (doortypes.2da index; 0 = force generic from genericdoors.2da), +`X/Y/Z` (meters), `Orientation` (facing). + +### Groups / Features +`[GROUPS] Count=N`, `[GROUP#]` with `Name`, optional `StrRef`, `Rows`, `Columns`, +and `Tile0..` indices **ordered bottom-left → right → up → top-right**. A +1-tile group is a **feature**. Rules: +- First tile must have a **unique** filename (ITP detects the group by it) and + **cannot** be `-1` (random) — that crashes. +- Other positions may use `-1` for a randomized part. +- Whole group rotates as a unit; no per-tile orientation. +- Any non-group tile sharing the first tile's filename is blocked from autopicking. + +--- + +## 5. Supporting files (packaged into the `.hak`) + +| File | Purpose | Notes | +|---|---|---| +| `.set` | tileset definition | CRLF, references tiles by index | +| `palstd.itp` | toolset palette tree | **GFF binary**; references tiles **by name** — must match `.set` | +| `*.mdl` | tile models (ASCII) | one per tile/variant | +| `*.wok` | tile walkmeshes | baked from the AABB node | +| `*.tga` / `*.dds` | minimap icons (`ImageMap2D`) | ≥16×16 px, name ≤16 chars | +| `edge.2da` | edge/border tiles (optional) | boundary tiles, grayed + non-walkable, prefix often `z` | +| `doortypes.2da` | tileset-specific doors | **mandatory if custom doors** — must be merged with all other sets' versions and shipped to clients, or doors render blank | +| `genericdoors.2da` | tileset-agnostic doors | index 0 = player picks | +| `surfacemat.2da` | global walkmesh surface behavior | only edit if adding materials | +| `footstepsounds.2da` | per-surface audio | | +| `tilecolor.2da` | allowed light colors | mainlight/sourcelight picker source | +| `loadscreens.2da` | area load screens (optional) | `TileSet` column binds images | +| `areag.ini` | generic area presets (optional) | ambient sound, music, scripts, PvP, interior flag | + +**Name-sync gotcha:** `.set` = tiles by **index**, `.itp` = tiles by **name**. +Any mismatch breaks placement silently. Regenerate both from one source of truth. + +--- + +## 6. Blender → NWN pipeline + +This repo standardizes on **Blender 4.0.2** (`blender-402-bin`, exposed by the +NixOS overlay) specifically because the NWN toolchain (NeverBlender, cleanmodels) +tracks Blender 3.x–4.x, **not** nixpkgs' Blender 5.x. Do not "upgrade" it. + +### 6.1 Tools (all on `PATH` in the dev shell) +- **NeverBlender** — Blender addon that exports NWN `.mdl` + `.wok` and defines + node types (trimesh, **AABB walkmesh**, dummy, light). Install a NeverBlender + release compatible with **Blender 4.0.x** into this Blender + (`Edit ▸ Preferences ▸ Add-ons ▸ Install`). It is **not** bundled in the stock + binary — an agent cannot assume it is present; check and tell the user if a + real MDL export is requested. +- **cleanmodels** — cleans/optimizes ASCII MDL, fixes common walkmesh problems. +- **neverwinter-nim** — CLI (`nwn_erf`, `nwn_gff`, …): pack the `.hak`/ERF, and + read/write GFF (the `.itp` is GFF). +- **blender-mcp-nwn** (see `/nix/nixos/blender-mcp-nwn`) — MCP server exposing + `run_blender_script`, `read_blend_metadata`, `export_for_nwn` so Claude can + drive Blender 4.0.2 headless against these `.blend` sources. + +### 6.2 Build → export → pack +1. **Generate `.blend` source** — `nix run .#build -- out.blend` (or drive via the + MCP server). Produces the tile library scene. +2. **Model in NWN conventions** — one AuroraBase per tile (name == resref, Type + `Tile`), trimesh children, one AABB walkmesh, ≤2+2 lights, animations on base. +3. **Export** — NeverBlender → ASCII `.mdl` + `.wok` per tile. +4. **Clean** — run through `cleanmodels` (walkmesh sanity, tri limits). +5. **Author `.set`** — terrains, crossers, tiles (corners/edges/heights/pathnodes), + groups. **CRLF endings.** +6. **Author `.itp`** — palette tree (GFF), names matching the `.set`. +7. **Minimaps** — render/emit `ImageMap2D` `.tga`/`.dds` per tile. +8. **Pack** — `nwn_erf` bundles `.mdl` + `.wok` + `.set` + `.itp` + minimaps + + any 2das into `.hak`. Ship `doortypes.2da` to clients if custom doors. + +--- + +## 7. Mapping *our* prototype conventions → NWN + +`KICKOFF.md` prototypes deliberately mirror NWN so conversion is mechanical: + +| Prototype convention | NWN target | +|---|---| +| Tile centered at origin, X/Y ∈ [−5,+5], Z=0 ground, metric | Exactly NWN tile space | +| Raised tier `Z = 1.5` | Height step 1 with `Transition = 1.5` | +| Canal water `Z = −0.3`, void | Visual only, **under a non-walkable walkmesh** | +| Collection `CODE_name` (e.g. `ST_S_straight_street`) | Design ID → export resref `_A01_01` (grid position). Keep a code→resref map. | +| `VIS_` objects | Renderable trimesh children of AuroraBase | +| `WOK_` objects | The AABB walkmesh; assign walkable surfacemat (Stone/Cobble→Stone) | +| `BLOCK_` objects | Non-walkable triangles in the walkmesh (blocker surfacemat) | +| `MARK__*` / `TILE_` empties | Helpers/edge metadata — dropped or become dummies on export | +| `MAT_debug_walkmesh` (green) | Walkable surface material | +| `MAT_debug_blocker` (red) | Non-walkable surface material | +| Edge metadata `N/E/S/W ∈ {FLAT,RAISED,CANAL,RAMP,MIXED}` | `.set` corner terrain+height (FLAT=h0, RAISED=h1) and edge crossers (CANAL=non-walkable edge, RAMP=transition tile). Document each MIXED edge. | +| `TILE_EDGE_METADATA` text block | Source for authoring `.set` corners/edges | + +**wgt01 suggested prefix:** `TWU01` (Tileset Westgate Urban 01). Terrains e.g. +`cobble, plaza, foundation, canal, void, raised`; crossers e.g. +`street, sidewalk, curb, gutter, canal_edge, ramp`. + +--- + +## 8. New-tileset checklist + +1. Create `/` with a `KICKOFF.md` brief (tiles, edge grammar, height plan). +2. Pick a unique **5-char prefix**; define the **terrain + crosser vocabulary** + (keep it minimal — every terrain pairing multiplies tile count, §2.1). +3. Choose `Transition` (m) and whether `Interior=0/1`. +4. Map the **pathnode** type for each tile shape *before* modeling. +5. Copy the `flake.nix` + `generator/` pattern from `wgt01`; adjust prefix/tiles. +6. Build `.blend`, verify with `read_blend_metadata` (collection/object counts). +7. Export (NeverBlender) → clean (cleanmodels) → author `.set`/`.itp` → minimaps. +8. Pack `.hak` with `nwn_erf`; test in the toolset (create a small area, place + every tile, walk it). + +--- + +## 9. Gotchas (the stuff that silently breaks) + +- **`.set` must be CRLF.** Unix line endings crash the toolset on area creation. +- **`.set` index vs `.itp` name mismatch** → tiles won't place, no error. +- **Resref > 16 chars** truncates silently — plan short prefixes/positions. +- **AuroraBase name ≠ filename** → tile won't load correctly. +- **Downward-facing walkable face** → exporter crash + player jerking. Keep + walkable faces up, single-level, non-overlapping. +- **Vertical walkmesh edges / >100 walkmesh faces** → broken AABB. +- **Custom doors not in `doortypes.2da`** (and not merged/shipped) → blank doors. +- **`Transition=0`** disables adjacent placement of groups/features. +- **Group first tile `-1` or non-unique filename** → crash / no autopick. +- **NeverBlender not installed** in this Blender → `export_for_nwn` gives you + OBJ/FBX + an `mdl.json` config, **not** a real `.mdl`. Install the addon for + true MDL/WOK export; say so rather than claiming MDL output. +- **Don't upgrade Blender past 4.x** here — breaks NeverBlender/cleanmodels. + +--- + +## Sources + +- [Tileset Construction Tutorial — nwn.wiki](https://nwn.wiki/display/NWN1/Tileset+Construction+Tutorial) +- [SET (file format) — nwn.wiki](https://nwn.wiki/display/NWN1/SET) +- [Tilesets — nwn.wiki](https://nwn.wiki/display/NWN1/Tilesets) +- [NeverBlender — nwn.wiki](https://nwn.wiki/display/NWN1/NeverBlender) +- [Walkmesh — NWNWiki](https://nwn.fandom.com/wiki/Walkmesh) +- [Tile — NWNWiki](https://nwn.fandom.com/wiki/Tile) +- [Common Errors and Their Causes — nwn.wiki](https://nwn.wiki/display/NWN1/Common%20Errors%20and%20Their%20Causes) + +_Verify specifics against the wiki before relying on them; correct this file when +you find drift._ diff --git a/wgt01/KICKOFF.md b/wgt01/KICKOFF.md new file mode 100644 index 0000000..5860d99 --- /dev/null +++ b/wgt01/KICKOFF.md @@ -0,0 +1,400 @@ +You are assisting inside Blender to rapidly prototype a Neverwinter Nights: Enhanced Edition urban foundation tileset for the project “Shadows Over Westgate.” + +The goal is NOT to build a full city tileset with buildings. The goal is to generate a reusable tile library of streets, sidewalks, plazas, alleys, ramps, bridges, canals/voids, drains, raised terraces, and foundation pads. Buildings, facades, lamps, signs, roofs, walls, doors, clutter, and district decoration will be modular placeables later. + +Westgate’s intended atmosphere is noir dark fantasy: rain-slick streets, mercantile decay, old stone, smoke, fog, gothic silhouettes, and grimy desperation. The city also has an early industrial-magical layer: steam, alchemy, bound elementals, lightning engines, magical lamps, printing presses, and machinery beneath the gutters. For this pass, represent that only through ground grammar: drains, grates, canal cuts, raised streets, bridge foundations, dark cobbles, and industrial service channels. Do not make buildings. + +PROJECT GOAL + +Create a Blender scene containing a prototype tile library named: + + Westgate_Urban_Foundation_MVP + +Each tile must be a 10m x 10m square module suitable for later NWN tileset conversion. + +Use metric scale. + +Coordinate convention: + +- Each individual tile is centered at origin when built as a source asset. +- Tile bounds are X = -5 to +5, Y = -5 to +5. +- Ground level is Z = 0. +- Raised tier level is Z = 1.5. +- Canal/void water surface, if present, is Z = -0.3. +- Do not create true multi-level playable over/under spaces. Bridges may cross non-walkable canal/void areas only. +- Keep walkable slopes gentle and readable. +- Keep edges clean and snap-compatible. + +Create each tile as its own Collection. Name collections exactly with their code and readable name, for example: + + CP1_cobble_plain + ST_S_straight_street + RA_S_straight_ramp + +Inside each tile collection, create these sub-objects where appropriate: + + VIS_ visual geometry + WOK_ simple walkmesh proxy geometry + BLOCK_ non-walkable blocker proxy geometry, if needed + MARK__* edge/debug markers, if useful + +The WOK proxy should be simple and functional: mostly flat planes or simple ramp planes. Avoid complex triangulation unless necessary. + +Use simple prototype materials: + +- MAT_wet_cobble_dark +- MAT_cobble_patch +- MAT_plaza_slab +- MAT_sidewalk_stone +- MAT_curb_dark_stone +- MAT_canal_black_water +- MAT_void_black +- MAT_ramp_stone +- MAT_drain_iron +- MAT_foundation_pad +- MAT_debug_walkmesh +- MAT_debug_blocker + +Do not spend time on final textures. Use flat colors or simple procedural noise only. This is a geometry and tile-grammar prototype. + +REQUIRED TILE LIBRARY + +Generate these tiles first. + +FLAT WALKABLE GROUND + +1. CP1_cobble_plain + +- Full 10x10 walkable dark cobble. +- Subtle bevel or edge trim allowed. +- WOK full square at Z=0. + +2. CP2_cobble_plain_wet_variant + +- Same as CP1 but with darker wet material patches. +- Geometry can be the same with different material regions. + +3. CP3_cobble_plain_broken_variant + +- Same as CP1 but with a few shallow cracked/patch shapes. +- Keep WOK flat. + +4. AL1_narrow_alley_paving + +- Full tile walkable, but visual paving suggests a narrower central alley. +- Add darker side strips/foundation margins where modular building placeables might sit. +- Do not place buildings. + +5. PL1_plaza_slab + +- Full tile walkable large stone slab plaza. +- Less busy than cobble. + +6. DR1_drain_detail + +- Full tile walkable cobble with a central or offset iron drain/manhole/grate. +- Drain is visual only unless it is a tiny non-walkable inset. + +7. MU1_muddy_repair_patch + +- Full tile walkable cobble with broken/muddy repair patch. +- Keep WOK flat. + +8. LOT_foundation_pad + +- A quiet, flat urban foundation pad for placing modular buildings or large placeables. +- Mostly plain stone, minimal decoration. +- Full WOK square. + +STREET SHAPES + +9. ST_S_straight_street + +- Straight street path running north-south through the tile. +- Sidewalk/curb margins on east and west sides. +- Entire tile can remain walkable for flexibility, but VIS should clearly read as street + curb + sidewalk. +- WOK full square unless blockers are necessary. + +10. ST_C_street_corner + +- L-shaped street corner. +- Sidewalk/curb margins. +- Use visual contrast, not blockers, unless necessary. + +11. ST_T_street_t_junction + +- T-junction street. + +12. ST_X_street_crossroads + +- Four-way street crossing. + +13. ST_E_street_end + +- Dead-end street with a visual blocked/terminated paving edge. +- Do not add a wall or building; just create the ground condition. + +14. ST_W_wide_boulevard + +- Wider street segment, usable for civic or merchant districts. +- Include broad sidewalks. + +15. ST_G_gate_mouth + +- Street tile that visually suggests a gate approach or major threshold. +- No gate model. Just heavier paving, curb geometry, and alignment pads. + +16. ST_D_street_with_central_drain + +- Straight street with central gutter/drain channel. +- Channel should be shallow and walkable or visually recessed without pathing problems. + +SIDEWALK / CURB / GUTTER + +17. SW_S_sidewalk_straight + +- Raised sidewalk/promromenade strip along one or both sides. +- Use low curb height, around Z=0.12 to 0.18. +- Keep WOK simple. + +18. SW_C_sidewalk_corner + +- Corner curb/sidewalk condition. + +19. SW_T_sidewalk_t_split + +- Sidewalk T connector. + +20. SW_X_sidewalk_crossing + +- Sidewalk/plaza connector or crossing tile. + +21. SW_E_sidewalk_end + +- Sidewalk ending/transition tile. + +22. GD_S_gutter_straight + +- Straight shallow gutter/drain channel. + +23. GD_C_gutter_corner + +- Gutter corner. + +24. GD_X_gutter_crossing_grate + +- Drain crossing with visible grate. + +RAISED TIERS / RAMPS / STAIRS + +25. UP1_raised_terrace + +- Full tile raised walkable surface at Z=1.5. +- WOK full square at Z=1.5. + +26. UP2_raised_plaza_variant + +- Raised plaza/slab variant at Z=1.5. + +27. TE_S_retaining_edge_straight + +- Half or edge tile where one side is raised terrace at Z=1.5 and the lower side is at Z=0. +- Include vertical retaining wall face. +- The top and bottom walkable areas should be clear. +- Do not create inaccessible slivers. + +28. TE_C_retaining_edge_outer_corner + +- Raised/lower retaining corner. + +29. TE_I_retaining_edge_inner_corner + +- Raised/lower retaining inside corner. + +30. RA_S_straight_ramp + +- Straight ramp from Z=0 to Z=1.5 across the tile. +- Ramp direction north-south. +- Add simple side retaining edges. +- WOK should be a clean ramp plane. + +31. RA_C_corner_or_dogleg_ramp + +- A corner/dogleg ramp from lower to upper. +- Keep it simple and pathable. + +32. STA_broad_stair + +- Broad stair from Z=0 to Z=1.5. +- Since NWN pathing can be sensitive, create a simplified ramp-like WOK proxy over the visible stairs. +- Visual stairs may be stepped; WOK should be smooth or very simple. + +CANAL / VOID / BRIDGES + +33. CN1_canal_black_water + +- Non-walkable canal/black water/void tile. +- Visual water at Z=-0.3. +- WOK absent or marked non-walkable. +- Add stone canal edge only if it does not imply mismatched adjacency. + +34. CN2_narrow_drainage_cut + +- Narrow non-walkable drainage cut through the tile. +- Include stone/cobble edges. + +35. CE_S_canal_edge_straight + +- Straight canal embankment edge. +- One side walkable at Z=0, one side canal/void non-walkable. +- Add clean retaining wall face. + +36. CE_C_canal_edge_corner + +- Canal edge corner. + +37. CE_I_canal_edge_inside_corner + +- Canal inside corner. + +38. BR_1_narrow_bridge + +- Narrow bridge crossing canal/void. +- Walkable bridge deck at Z=0 or slightly above Z=0. +- Non-walkable canal beneath. +- No playable underpass. +- Add simple side parapet placeholders if useful, but keep them low/blocker-safe. + +39. BR_2_broad_bridge + +- Broad bridge suitable for a main street. +- Same constraints as BR_1. + +40. SEW_sewer_mouth_culvert + +- Canal/drain edge with sewer mouth or culvert visual. +- Mostly decorative, but useful for noir city composition. + +BUILDER GROUP / PREVIEW ASSEMBLIES + +After generating source tile collections, create a separate Collection: + + PREVIEW_tile_library_grid + +In this collection, instantiate or duplicate each tile in a neat grid with labels so I can inspect the full library. + +Also create another Collection: + + PREVIEW_12x12_test_district + +Build this exact 12x12 prototype map using the generated tiles. Place each tile on a 10m grid. Each tile should be positioned by row/column, 10m apart. + +Map: + +Row 01: +CN1 CN1 CE_S CE_S BR_2 CE_S CE_S CN1 CN1 CN1 CN1 CN1 + +Row 02: +CN1 CE_C SW_S ST_S ST_S ST_S SW_S CE_C CN1 CN1 CN1 CN1 + +Row 03: +CE_S SW_S ST_C ST_S ST_T ST_S ST_C SW_S CE_S CE_S BR_1 CE_S + +Row 04: +SW_S AL1 AL1 CP1 PL1 PL1 CP1 AL1 AL1 SW_S ST_S SW_S + +Row 05: +SW_S AL1 LOT CP2 PL1 PL1 CP2 LOT AL1 SW_S ST_S SW_S + +Row 06: +ST_S ST_S ST_T ST_S ST_X ST_S ST_T ST_S ST_S ST_C ST_S SW_S + +Row 07: +SW_S AL1 LOT CP1 ST_S ST_S CP1 LOT AL1 SW_S CP2 SW_S + +Row 08: +SW_S AL1 AL1 CP1 PL1 PL1 CP1 AL1 AL1 SW_S CP2 SW_S + +Row 09: +CE_S SW_S ST_C ST_S ST_T ST_S ST_C SW_S CE_S TE_S TE_C SW_S + +Row 10: +CN1 CE_C SW_S CP1 RA_S UP1 UP1 TE_S TE_S UP1 RA_S SW_S + +Row 11: +CN1 CN1 CE_S CE_S BR_2 CE_S CE_S CN1 CE_S SW_S ST_S SW_S + +Row 12: +CN1 CN1 CN1 CN1 CN1 CN1 CN1 CN1 CN1 SW_S ST_E SW_S + +Use tile names matching the generated collection names. If exact variants are not available yet, use the closest equivalent and note the substitution. + +DESIGN CONSTRAINTS + +- Do not model buildings. +- Do not model full walls except retaining/canal/bridge support walls needed by terrain. +- Do not model roofs. +- Do not model decorative clutter except minimal drains/grates required to communicate tile purpose. +- Avoid tiny details. +- Prioritize edge alignment, walkability, and builder usefulness. +- Keep polygon counts low. +- Keep all tile edges snap-clean. +- Avoid overhangs at tile boundaries. +- Avoid geometry extending beyond -5/+5 bounds unless it is an explicitly marked harmless visual trim. +- Use bevels sparingly. +- Prefer clean simple geometry over visual polish. +- Use material slots consistently so final textures can replace prototype materials later. + +EDGE COMPATIBILITY + +Think of each tile edge as one of these edge types: + + FLAT walkable ground at Z=0 + RAISED walkable ground at Z=1.5 + CANAL non-walkable canal/void + RAMP ramp transition edge + MIXED special edge, document it + +For each tile, create a custom property or add a Text object note listing its north/east/south/west edge types. + +Example: + + ST_S_straight_street + N: FLAT + E: FLAT + S: FLAT + W: FLAT + + RA_S_straight_ramp + N: RAISED + E: FLAT + S: FLAT + W: FLAT + + CE_S_canal_edge_straight + N: FLAT + E: CANAL + S: FLAT + W: FLAT + +Add these notes in a scene text block called: + + TILE_EDGE_METADATA + +OUTPUT EXPECTATIONS + +At the end: + +1. Save the Blender file as: + westgate_urban_foundation_mvp.blend + +2. Report: + - number of tile collections created; + - any tiles skipped; + - any substitutions made; + - any geometry/pathing concerns; + - next recommended pass. + +3. Do not spend time making final art. +4. Do not get stuck on perfect NWN export. +5. The deliverable is a rapid, inspectable Blender prototype of the tile library and a test district. diff --git a/wgt01/concept-art-1.png b/wgt01/concept-art-1.png new file mode 100644 index 0000000..b01c607 --- /dev/null +++ b/wgt01/concept-art-1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3085c7e4896466426289c4ff47e25d3fbf36f6029bb135ee340f3327f3fd9e19 +size 2728593 diff --git a/wgt01/flake.nix b/wgt01/flake.nix new file mode 100644 index 0000000..e73f6d4 --- /dev/null +++ b/wgt01/flake.nix @@ -0,0 +1,83 @@ +{ + description = "Westgate Urban Foundation MVP — Blender 4.0.2 tileset generator kit"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + # Reuse the machine's pinned Blender 4.0.2 + NWN toolchain via the repo overlay. + theorem.url = "git+file:///nix/nixos"; + }; + + outputs = + { + self, + nixpkgs, + theorem, + }: + let + system = "x86_64-linux"; + pkgs = import nixpkgs { + inherit system; + overlays = [ theorem.overlays.default ]; + }; + blender = pkgs.blender-402-bin; # /bin/blender-4.0.2 (NWN needs 4.0.x, not 5.x) + py = pkgs.python3.withPackages (ps: [ ps.mcp ]); + + gen = ./generator; # whole dir so tiles.py sits beside each script for import + + build = pkgs.writeShellApplication { + name = "wgt-build"; + runtimeInputs = [ blender ]; + text = '' + out="''${1:-westgate_urban_foundation_mvp.blend}" + exec blender-4.0.2 --background --python ${gen}/build.py -- "$out" + ''; + }; + + mkset = pkgs.writeShellApplication { + name = "wgt-set"; + runtimeInputs = [ py ]; + text = ''exec python ${gen}/make_set.py "$@"''; + }; + + mkwok = pkgs.writeShellApplication { + name = "wgt-wok"; + runtimeInputs = [ py ]; + text = ''exec python ${gen}/make_wok.py "$@"''; + }; + in + { + packages.${system}.default = build; + + apps.${system} = { + default = { + type = "app"; + program = "${build}/bin/wgt-build"; + }; + build = { + type = "app"; + program = "${build}/bin/wgt-build"; + }; + set = { + type = "app"; + program = "${mkset}/bin/wgt-set"; + }; + wok = { + type = "app"; + program = "${mkwok}/bin/wgt-wok"; + }; + }; + + devShells.${system}.default = pkgs.mkShell { + packages = [ + blender + py + pkgs.neverwinter-nim + pkgs.cleanmodels + ]; + shellHook = '' + export BLENDER_BIN=${blender}/bin/blender-4.0.2 + echo "wgt01 dev shell — build: blender-4.0.2 --background --python generator/build.py -- out.blend" >&2 + ''; + }; + }; +} diff --git a/wgt01/generator/.gitignore b/wgt01/generator/.gitignore new file mode 100644 index 0000000..7a60b85 --- /dev/null +++ b/wgt01/generator/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc diff --git a/wgt01/generator/build.py b/wgt01/generator/build.py new file mode 100644 index 0000000..9f0ffa8 --- /dev/null +++ b/wgt01/generator/build.py @@ -0,0 +1,166 @@ +"""Build the Westgate_Urban_Foundation_MVP Blender scene (run in Blender 4.0.2). + + blender-4.0.2 --background --python build.py -- out.blend + +Realises tiles.py: one Collection per tile (VIS_/WOK_/BLOCK_ sub-objects + edge +custom props), a TILE_EDGE_METADATA text block, and the two preview assemblies. +Geometry is intentionally simple (KICKOFF.md: a geometry/grammar prototype). +""" + +import json +import os +import sys + +import bpy + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import tiles # noqa: E402 + + +def get_mat(name): + m = bpy.data.materials.get(name) + if m: + return m + m = bpy.data.materials.new(name) + r, g, b = tiles.COLORS.get(name, (0.5, 0.5, 0.5)) + m.diffuse_color = (r, g, b, 1.0) # viewport (solid) colour + m.use_nodes = True + bsdf = m.node_tree.nodes.get("Principled BSDF") + if bsdf: + bsdf.inputs["Base Color"].default_value = (r, g, b, 1.0) + return m + + +def build_vis(spec): + """Subdivided 10x10 plane (100 quads): per-face material via paint(), per-vertex Z.""" + n, step = 10, 1.0 + paint = tiles.make_paint(spec) + verts, vidx = [], {} + + def vi(i, j): + if (i, j) not in vidx: + x, y = -5 + i * step, -5 + j * step + vidx[(i, j)] = len(verts) + verts.append((x, y, tiles.height(spec, x, y))) + return vidx[(i, j)] + + faces, face_mat, mats, midx = [], [], [], {} + for i in range(n): + for j in range(n): + faces.append((vi(i, j), vi(i + 1, j), vi(i + 1, j + 1), vi(i, j + 1))) + cx, cy = -5 + (i + 0.5) * step, -5 + (j + 0.5) * step + mn = paint(cx, cy) + if mn not in midx: + midx[mn] = len(mats) + mats.append(mn) + face_mat.append(midx[mn]) + + me = bpy.data.meshes.new("VIS_" + spec["code"]) + me.from_pydata(verts, [], faces) + for mn in mats: + me.materials.append(get_mat(mn)) + for poly, mi in zip(me.polygons, face_mat): + poly.material_index = mi + me.update() + ob = bpy.data.objects.new("VIS_" + spec["code"], me) + n_, e_, s_, w_ = spec["edges"] + ob["edge_N"], ob["edge_E"], ob["edge_S"], ob["edge_W"] = n_, e_, s_, w_ + return ob + + +def build_quads(name, quads, matname): + if not quads: + return None + verts, faces = [], [] + for q in quads: + b = len(verts) + verts += q + faces.append((b, b + 1, b + 2, b + 3)) + me = bpy.data.meshes.new(name) + me.from_pydata(verts, [], faces) + me.materials.append(get_mat(matname)) + me.update() + return bpy.data.objects.new(name, me) + + +def build_tile(spec): + col = bpy.data.collections.new(f"{spec['code']}_{spec['name']}") + bpy.context.scene.collection.children.link(col) + col.objects.link(build_vis(spec)) + quads = tiles.wok_quads(spec) + wok = build_quads("WOK_" + spec["code"], [q for q, w in quads if w], "MAT_debug_walkmesh") + blk = build_quads("BLOCK_" + spec["code"], [q for q, w in quads if not w], "MAT_debug_blocker") + if wok: + col.objects.link(wok) + if blk: + col.objects.link(blk) + return col + + +def instance(col, loc, parent, name): + ob = bpy.data.objects.new(name, None) + ob.instance_type = "COLLECTION" + ob.instance_collection = col + ob.location = loc + parent.objects.link(ob) + + +def label(text, loc, parent): + cu = bpy.data.curves.new(text, "FONT") + cu.body = text + cu.size = 1.4 + ob = bpy.data.objects.new("LBL_" + text, cu) + ob.location = loc + parent.objects.link(ob) + + +def main(): + argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] + out = os.path.abspath(argv[0]) if argv else os.path.abspath("westgate_urban_foundation_mvp.blend") + + bpy.ops.wm.read_factory_settings(use_empty=True) + bpy.context.scene.unit_settings.system = "METRIC" + + tilecol = {s["code"]: build_tile(s) for s in tiles.TILES} + + # edge metadata text block (KICKOFF.md) + txt = bpy.data.texts.new("TILE_EDGE_METADATA") + for s in tiles.TILES: + n_, e_, s_, w_ = s["edges"] + txt.write(f"{s['code']}_{s['name']}\nN: {n_}\nE: {e_}\nS: {s_}\nW: {w_}\n\n") + + # library grid preview (8 wide, 12 m pitch, labelled) + grid = bpy.data.collections.new("PREVIEW_tile_library_grid") + bpy.context.scene.collection.children.link(grid) + cols = 8 + for i, s in enumerate(tiles.TILES): + gx, gy = (i % cols) * 12, -(i // cols) * 12 + instance(tilecol[s["code"]], (gx, gy, 0), grid, "GRID_" + s["code"]) + label(s["code"], (gx - 4.5, gy + 5.5, 0), grid) + + # 12x12 test district (10 m pitch), row south-ward, col east-ward + dist = bpy.data.collections.new("PREVIEW_12x12_test_district") + bpy.context.scene.collection.children.link(dist) + skipped = [] + for r, row in enumerate(tiles.MAP): + for c, code in enumerate(row): + tc = tilecol.get(code) + if tc is None: + skipped.append([r, c, code]) + continue + instance(tc, (c * 10, -r * 10, 0), dist, f"D_{r:02d}_{c:02d}_{code}") + + bpy.ops.wm.save_as_mainfile(filepath=out) + + report = { + "blend": out, + "tile_collections": len(tilecol), + "district_cells": sum(len(r) for r in tiles.MAP), + "district_skipped": skipped, + "materials": len(tiles.COLORS), + } + print("<<>>" + json.dumps(report, indent=2) + "<<>>") + + +if __name__ == "__main__": + main() diff --git a/wgt01/generator/make_set.py b/wgt01/generator/make_set.py new file mode 100644 index 0000000..d80e244 --- /dev/null +++ b/wgt01/generator/make_set.py @@ -0,0 +1,107 @@ +"""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. + +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_.tga) that are not rendered yet. + * Groups/features Count=0; add multi-tile groups later. +""" + +import sys + +import tiles + +PREFIX = tiles.PREFIX + + +def tile_block(i, spec): + cor = tiles.nwn_corners(spec["edges"]) + n, e, s, w = spec["edges"] + tl, tr, bl, br = cor["TopLeft"], cor["TopRight"], cor["BottomLeft"], cor["BottomRight"] + ref = tiles.resref(spec) + walkable = spec["kind"] != "canal" + L = [f"[TILE{i}]"] + L += [ + f"Model={ref}", + "WalkMesh=", # legacy field; per-model .wok is matched by name + f"ImageMap2D={ref}", + 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={tiles.nwn_crosser(n)}", + f"Right={tiles.nwn_crosser(e)}", + f"Bottom={tiles.nwn_crosser(s)}", + f"Left={tiles.nwn_crosser(w)}", + "MainLight1=0", "MainLight2=0", "SourceLight1=0", "SourceLight2=0", + "AnimLoop1=0", "AnimLoop2=0", "AnimLoop3=0", + "Orientation=0", + f"PathNode={'A' if walkable else 'N'}", # placeholder — verify in toolset + "VisibilityNode=", + "Doors=0", + "Sounds=0", + ] + return L + + +def build(): + L = [] + L += [ + "[GENERAL]", + f"Name={PREFIX}", + "Type=SET", + "Interior=0", + "HasHeightTransition=1", + "Transition=1.5", # metres per height step = Z=1.5 raised tier + "Border=cobble", + "Default=cobble", + "Floor=cobble", + "DisplayName=-1", + "UnlocalizedName=Westgate Urban Foundation", + "Version=1", + "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", + "", "[SECONDARY RULES]", "Count=0", + "", "[TILES]", f"Count={len(tiles.TILES)}"] + for i, spec in enumerate(tiles.TILES): + L.append("") + L += tile_block(i, spec) + L += ["", "[GROUPS]", "Count=0", ""] + return L + + +def main(): + out = sys.argv[1] if len(sys.argv) > 1 else f"{PREFIX}.set" + with open(out, "w", newline="") as fh: # newline='' + explicit \r\n = CRLF + fh.write("\r\n".join(build())) + print(f"wrote {out} ({len(tiles.TILES)} tiles, " + f"{len(tiles.TERRAINS)} terrains, {len(tiles.CROSSERS)} crossers, CRLF)") + + +if __name__ == "__main__": + main() diff --git a/wgt01/generator/make_wok.py b/wgt01/generator/make_wok.py new file mode 100644 index 0000000..108aca3 --- /dev/null +++ b/wgt01/generator/make_wok.py @@ -0,0 +1,106 @@ +"""Generate per-tile ASCII walkmeshes (`twu01_.wok`) from tiles.py. + + python make_wok.py [outdir] # default: ./wok + +Pure Python, no Blender. Emits the Aurora ASCII walkmesh node (verts + faces with +per-face surface material + an AABB bounding-volume tree). This is the usable +intermediate the toolset/cleanmodels/nwnmdlcomp compile to a binary `.wok` +(AGENTS.md §6); NeverBlender would otherwise produce this. Geometry comes from +tiles.wok_quads so it matches the Blender WOK_/BLOCK_ proxies exactly. + +Surface material ids (surfacemat.2da, AGENTS.md §3.3): walkable faces use WALK, +non-walkable proxy faces use BLOCK. Verify these two ids against your +surfacemat.2da before shipping. +""" + +import os +import sys + +import tiles + +WALK = 1 # a walkable stone-ish row in surfacemat.2da (verify) +BLOCK = 7 # a non-walkable row (verify) + + +def geometry(spec): + """Return (verts, faces): dedup verts, faces = (a, b, c, surfacemat).""" + verts, vmap, faces = [], {}, [] + + def vid(v): + key = tuple(round(c, 4) for c in v) + if key not in vmap: + vmap[key] = len(verts) + verts.append(key) + return vmap[key] + + for quad, walkable in tiles.wok_quads(spec): + s = WALK if walkable else BLOCK + a, b, c, d = (vid(p) for p in quad) + faces.append((a, b, c, s)) # triangulate quad -> 2 tris + faces.append((a, c, d, s)) + return verts, faces + + +def _box(verts, face): + pts = [verts[face[0]], verts[face[1]], verts[face[2]]] + return ([min(p[i] for p in pts) for i in range(3)], + [max(p[i] for p in pts) for i in range(3)]) + + +def aabb_tree(verts, faces): + """Binary BVH, pre-order (node, left subtree, right subtree). Leaf carries its + face index; internal carries -1. Reader reconstructs by the same recursion.""" + nodes = [] + + def rec(items): # items = [(face_index, (min, max)), ...] + mn = [min(b[0][i] for _, b in items) for i in range(3)] + mx = [max(b[1][i] for _, b in items) for i in range(3)] + if len(items) == 1: + nodes.append((mn, mx, items[0][0])) + return + ax = max(range(3), key=lambda i: mx[i] - mn[i]) # split longest axis + items.sort(key=lambda ib: ib[1][0][ax] + ib[1][1][ax]) # by box centre + mid = len(items) // 2 + nodes.append((mn, mx, -1)) # internal placeholder + rec(items[:mid]) + rec(items[mid:]) + + rec([(i, _box(verts, f)) for i, f in enumerate(faces)]) + assert sum(1 for _, _, fi in nodes if fi >= 0) == len(faces) # every face is a leaf + assert len(nodes) == 2 * len(faces) - 1 # full binary tree + return nodes + + +def emit(spec): + verts, faces = geometry(spec) + nodes = aabb_tree(verts, faces) + name = tiles.resref(spec) + L = [f"# {name} walkmesh (ASCII, first-pass) - compile via nwnmdlcomp/cleanmodels", + f"node aabb {name}_wok", + f" parent {name}", + " position 0.0 0.0 0.0", + " orientation 0.0 0.0 0.0 0.0", + f" verts {len(verts)}"] + L += [f" {v[0]:.5f} {v[1]:.5f} {v[2]:.5f}" for v in verts] + L.append(f" faces {len(faces)}") + # face line: v1 v2 v3 smoothgroup tv1 tv2 tv3 surfacemat + L += [f" {a} {b} {c} 1 0 0 0 {s}" for a, b, c, s in faces] + L.append(" aabb") + L += [f" {mn[0]:.5f} {mn[1]:.5f} {mn[2]:.5f} {mx[0]:.5f} {mx[1]:.5f} {mx[2]:.5f} {fi}" + for mn, mx, fi in nodes] + L.append("endnode") + return name, "\n".join(L) + "\n" + + +def main(): + outdir = sys.argv[1] if len(sys.argv) > 1 else "wok" + os.makedirs(outdir, exist_ok=True) + for spec in tiles.TILES: + name, text = emit(spec) + with open(os.path.join(outdir, name + ".wok"), "w") as fh: + fh.write(text) + print(f"wrote {len(tiles.TILES)} .wok files to {outdir}/") + + +if __name__ == "__main__": + main() diff --git a/wgt01/generator/tiles.py b/wgt01/generator/tiles.py new file mode 100644 index 0000000..eb87118 --- /dev/null +++ b/wgt01/generator/tiles.py @@ -0,0 +1,318 @@ +"""Single source of truth for the wgt01 (Westgate Urban Foundation) tileset. + +Pure Python, NO bpy — imported by build.py (Blender), make_wok.py and make_set.py +alike. Defines the 40-tile table, materials, the 12x12 test map, and the pure +functions that derive visuals (paint), heights, walkmesh quads and the NWN .set +mapping from a tile spec. Blender realises these; the .wok/.set tools consume the +same data so nothing drifts. + +Coordinate convention (matches KICKOFF.md): tile centred at origin, X/Y in +[-5,+5], Z=0 ground, Z=1.5 raised tier, Z=-0.3 canal water. Z up. +""" + +PREFIX = "twu01" # Tileset Westgate Urban 01 (5-char resref prefix, AGENTS.md §1) +HALF = 5.0 +Z_RAISED = 1.5 +Z_WATER = -0.3 + +# --- prototype materials (flat viewport colours; KICKOFF.md wants geometry, not art) +COLORS = { + "MAT_wet_cobble_dark": (0.05, 0.05, 0.06), + "MAT_cobble_patch": (0.12, 0.10, 0.09), + "MAT_plaza_slab": (0.30, 0.29, 0.27), + "MAT_sidewalk_stone": (0.22, 0.22, 0.23), + "MAT_curb_dark_stone": (0.10, 0.10, 0.11), + "MAT_canal_black_water": (0.02, 0.03, 0.05), + "MAT_void_black": (0.00, 0.00, 0.00), + "MAT_ramp_stone": (0.18, 0.16, 0.14), + "MAT_drain_iron": (0.08, 0.08, 0.09), + "MAT_foundation_pad": (0.25, 0.24, 0.22), + "MAT_debug_walkmesh": (0.00, 0.80, 0.20), + "MAT_debug_blocker": (0.80, 0.10, 0.10), +} +CURB = "MAT_curb_dark_stone" + +F, R, C, RP, MX = "FLAT", "RAISED", "CANAL", "RAMP", "MIXED" + + +def T(code, name, kind, edges, style, **kw): + d = dict(code=code, name=name, kind=kind, edges=edges, style=style) + d.update(kw) + return d + + +# 40 tiles, KICKOFF.md order. edges = (N, E, S, W). +TILES = [ + # --- flat walkable ground --- + T("CP1", "cobble_plain", "flat", (F, F, F, F), "solid", mat="MAT_wet_cobble_dark"), + T("CP2", "cobble_plain_wet_variant", "flat", (F, F, F, F), "patches", + mat="MAT_wet_cobble_dark", patch="MAT_cobble_patch"), + T("CP3", "cobble_plain_broken_variant", "flat", (F, F, F, F), "patches", + mat="MAT_wet_cobble_dark", patch="MAT_cobble_patch"), + T("AL1", "narrow_alley_paving", "flat", (F, F, F, F), "alley"), + T("PL1", "plaza_slab", "flat", (F, F, F, F), "solid", mat="MAT_plaza_slab"), + T("DR1", "drain_detail", "flat", (F, F, F, F), "drain"), + T("MU1", "muddy_repair_patch", "flat", (F, F, F, F), "mud"), + T("LOT", "foundation_pad", "flat", (F, F, F, F), "solid", mat="MAT_foundation_pad"), + # --- streets (fully walkable cobble with painted street grammar) --- + T("ST_S", "straight_street", "flat", (F, F, F, F), "street", shape="straight"), + T("ST_C", "street_corner", "flat", (F, F, F, F), "street", shape="corner"), + T("ST_T", "street_t_junction", "flat", (F, F, F, F), "street", shape="t"), + T("ST_X", "street_crossroads", "flat", (F, F, F, F), "street", shape="x"), + T("ST_E", "street_end", "flat", (F, F, F, F), "street", shape="end"), + T("ST_W", "wide_boulevard", "flat", (F, F, F, F), "street", shape="wide"), + T("ST_G", "gate_mouth", "flat", (F, F, F, F), "street", shape="gate"), + T("ST_D", "street_with_central_drain", "flat", (F, F, F, F), "street", shape="drain"), + # --- sidewalk / curb / gutter --- + T("SW_S", "sidewalk_straight", "flat", (F, F, F, F), "sidewalk"), + T("SW_C", "sidewalk_corner", "flat", (F, F, F, F), "sidewalk"), + T("SW_T", "sidewalk_t_split", "flat", (F, F, F, F), "sidewalk"), + T("SW_X", "sidewalk_crossing", "flat", (F, F, F, F), "sidewalk"), + T("SW_E", "sidewalk_end", "flat", (F, F, F, F), "sidewalk"), + T("GD_S", "gutter_straight", "flat", (F, F, F, F), "gutter", shape="straight"), + T("GD_C", "gutter_corner", "flat", (F, F, F, F), "gutter", shape="corner"), + T("GD_X", "gutter_crossing_grate", "flat", (F, F, F, F), "gutter", shape="x", grate=True), + # --- raised tiers / ramps / stairs --- + T("UP1", "raised_terrace", "raised", (R, R, R, R), "raised"), + T("UP2", "raised_plaza_variant", "raised", (R, R, R, R), "raised"), + T("TE_S", "retaining_edge_straight", "retain", (R, MX, F, MX), "retain"), + T("TE_C", "retaining_edge_outer_corner", "retain", (R, R, MX, MX), "retain"), + T("TE_I", "retaining_edge_inner_corner", "retain", (R, MX, MX, R), "retain"), + T("RA_S", "straight_ramp", "ramp", (R, RP, F, RP), "ramp"), + T("RA_C", "corner_or_dogleg_ramp", "ramp", (R, RP, F, RP), "ramp"), # simplified to straight ramp + T("STA", "broad_stair", "stair", (R, MX, F, MX), "stair"), + # --- canal / void / bridges --- + T("CN1", "canal_black_water", "canal", (C, C, C, C), "canal"), + T("CN2", "narrow_drainage_cut", "cut", (C, F, C, F), "cut"), + T("CE_S", "canal_edge_straight", "canal_edge", (F, C, F, F), "canal_edge", which="E"), + T("CE_C", "canal_edge_corner", "canal_edge", (F, C, C, F), "canal_edge", which="ES_outer"), + T("CE_I", "canal_edge_inside_corner", "canal_edge", (F, C, C, F), "canal_edge", which="ES_inner"), + T("BR_1", "narrow_bridge", "bridge", (F, C, F, C), "bridge", hw=1.5), + T("BR_2", "broad_bridge", "bridge", (F, C, F, C), "bridge", hw=3.0), + T("SEW", "sewer_mouth_culvert", "canal_edge", (F, F, C, F), "sewer", which="S"), +] + +# 12x12 test district (KICKOFF.md). Placed by row (south-ward) / col (east-ward). +MAP = [ + ["CN1", "CN1", "CE_S", "CE_S", "BR_2", "CE_S", "CE_S", "CN1", "CN1", "CN1", "CN1", "CN1"], + ["CN1", "CE_C", "SW_S", "ST_S", "ST_S", "ST_S", "SW_S", "CE_C", "CN1", "CN1", "CN1", "CN1"], + ["CE_S", "SW_S", "ST_C", "ST_S", "ST_T", "ST_S", "ST_C", "SW_S", "CE_S", "CE_S", "BR_1", "CE_S"], + ["SW_S", "AL1", "AL1", "CP1", "PL1", "PL1", "CP1", "AL1", "AL1", "SW_S", "ST_S", "SW_S"], + ["SW_S", "AL1", "LOT", "CP2", "PL1", "PL1", "CP2", "LOT", "AL1", "SW_S", "ST_S", "SW_S"], + ["ST_S", "ST_S", "ST_T", "ST_S", "ST_X", "ST_S", "ST_T", "ST_S", "ST_S", "ST_C", "ST_S", "SW_S"], + ["SW_S", "AL1", "LOT", "CP1", "ST_S", "ST_S", "CP1", "LOT", "AL1", "SW_S", "CP2", "SW_S"], + ["SW_S", "AL1", "AL1", "CP1", "PL1", "PL1", "CP1", "AL1", "AL1", "SW_S", "CP2", "SW_S"], + ["CE_S", "SW_S", "ST_C", "ST_S", "ST_T", "ST_S", "ST_C", "SW_S", "CE_S", "TE_S", "TE_C", "SW_S"], + ["CN1", "CE_C", "SW_S", "CP1", "RA_S", "UP1", "UP1", "TE_S", "TE_S", "UP1", "RA_S", "SW_S"], + ["CN1", "CN1", "CE_S", "CE_S", "BR_2", "CE_S", "CE_S", "CN1", "CE_S", "SW_S", "ST_S", "SW_S"], + ["CN1", "CN1", "CN1", "CN1", "CN1", "CN1", "CN1", "CN1", "CN1", "SW_S", "ST_E", "SW_S"], +] + + +# --- geometry predicates ----------------------------------------------------- + +def _disk(x, y, cx, cy, r): + return (x - cx) ** 2 + (y - cy) ** 2 <= r * r + + +def _road(shape, x, y, hw): + ns = abs(x) <= hw + ew = abs(y) <= hw + if shape == "straight": + return ns + if shape in ("wide", "gate"): + return abs(x) <= hw * 1.7 + if shape == "corner": + return (ns and y >= -hw) or (ew and x <= hw) # N arm + E arm + if shape == "t": + return ew or (ns and y <= hw) # E-W bar + stub to S + if shape == "x": + return ns or ew + if shape == "end": + return ns and y <= 0.0 # enters from S, stops mid-tile + if shape == "drain": + return ns + return False + + +def _land_fn(which): + """Where a canal_edge tile is walkable land (True) vs canal (False).""" + return { + "E": lambda x, y: x <= 0.0, # canal on east + "S": lambda x, y: y >= 0.0, # canal on south + "ES_outer": lambda x, y: (x <= 0.0 and y >= 0.0), # land = NW quadrant only + "ES_inner": lambda x, y: not (x > 0.0 and y < 0.0), # land = all but SE quadrant + }[which] + + +# --- visuals: paint(x, y) -> material name ----------------------------------- + +def make_paint(spec): + st = spec["style"] + if st == "solid": + m = spec["mat"] + return lambda x, y: m + if st == "patches": + base, patch = spec["mat"], spec["patch"] + pts = [(-2, 2), (1.5, -1.5), (3, 2.5), (-2.5, -2)] + return lambda x, y: patch if any(_disk(x, y, cx, cy, 1.3) for cx, cy in pts) else base + if st == "alley": + return lambda x, y: "MAT_wet_cobble_dark" if abs(x) <= 2.2 else "MAT_foundation_pad" + if st == "drain": + return lambda x, y: "MAT_drain_iron" if (abs(x) <= 1 and abs(y) <= 1) else "MAT_wet_cobble_dark" + if st == "mud": + return lambda x, y: "MAT_cobble_patch" if _disk(x, y, 0.5, -0.5, 2.4) else "MAT_wet_cobble_dark" + if st == "street": + shape, hw = spec["shape"], spec.get("hw", 1.6) + + def f(x, y): + if _road(shape, x, y, hw): + return "MAT_drain_iron" if (shape == "drain" and abs(x) <= 0.5) else "MAT_wet_cobble_dark" + if _road(shape, x, y, hw + 1.0): + return CURB + return "MAT_sidewalk_stone" + return f + if st == "sidewalk": + return lambda x, y: CURB if (abs(x) >= 4.2 or abs(y) >= 4.2) else "MAT_sidewalk_stone" + if st == "gutter": + shape, grate = spec["shape"], spec.get("grate", False) + + def f(x, y): + if grate and abs(x) <= 0.9 and abs(y) <= 0.9: + return "MAT_drain_iron" + if _road(shape, x, y, 0.6): + return "MAT_drain_iron" + if _road(shape, x, y, 1.1): + return CURB + return "MAT_wet_cobble_dark" + return f + if st == "raised": + return lambda x, y: "MAT_plaza_slab" + if st == "retain": + return lambda x, y: "MAT_plaza_slab" if y >= 0 else "MAT_wet_cobble_dark" + if st in ("ramp", "stair"): + return lambda x, y: "MAT_ramp_stone" + if st == "canal": + return lambda x, y: "MAT_wet_cobble_dark" if (abs(x) >= 4.4 or abs(y) >= 4.4) else "MAT_canal_black_water" + if st == "cut": + return lambda x, y: "MAT_canal_black_water" if abs(x) <= 0.9 else "MAT_wet_cobble_dark" + if st == "canal_edge": + land = _land_fn(spec["which"]) + return lambda x, y: "MAT_wet_cobble_dark" if land(x, y) else "MAT_canal_black_water" + if st == "bridge": + hw = spec.get("hw", 1.5) + return lambda x, y: ("MAT_ramp_stone" if abs(x) <= hw + else (CURB if abs(x) <= hw + 0.3 else "MAT_canal_black_water")) + if st == "sewer": + return lambda x, y: (("MAT_drain_iron" if _disk(x, y, 0, -3.2, 1.6) else "MAT_wet_cobble_dark") + if y >= 0 else "MAT_canal_black_water") + return lambda x, y: "MAT_wet_cobble_dark" + + +def height(spec, x, y): + """Z of the visual top surface at (x, y). Drives the subdivided VIS plane.""" + k = spec["kind"] + if k == "raised": + return Z_RAISED + if k in ("ramp", "stair"): + return (y + HALF) / (2 * HALF) * Z_RAISED # S=0 -> N=1.5 + if k == "retain": + return Z_RAISED if y >= 0 else 0.0 + if k == "canal": + return 0.0 if (abs(x) >= 4.4 or abs(y) >= 4.4) else Z_WATER + if k == "cut": + return Z_WATER if abs(x) <= 0.9 else 0.0 + if k == "canal_edge": + return 0.0 if _land_fn(spec["which"])(x, y) else Z_WATER + if k == "bridge": + hw = spec.get("hw", 1.5) + return 0.2 if abs(x) <= hw + 0.3 else Z_WATER + if k == "sewer": + return 0.0 if y >= 0 else Z_WATER + if spec.get("style") == "sidewalk": + return 0.15 if (abs(x) >= 4.2 or abs(y) >= 4.2) else 0.0 # low curb (KICKOFF 0.12-0.18) + return 0.0 + + +# --- walkmesh proxy: list of (verts4, walkable) ------------------------------ + +def _rect(x0, x1, y0, y1, z): + return [(x0, y0, z), (x1, y0, z), (x1, y1, z), (x0, y1, z)] + + +def _ramp(x0, x1, y0, y1): + z0 = (y0 + HALF) / (2 * HALF) * Z_RAISED + z1 = (y1 + HALF) / (2 * HALF) * Z_RAISED + return [(x0, y0, z0), (x1, y0, z0), (x1, y1, z1), (x0, y1, z1)] + + +def wok_quads(spec): + k = spec["kind"] + if k == "flat": + return [(_rect(-5, 5, -5, 5, 0.0), True)] + if k == "raised": + return [(_rect(-5, 5, -5, 5, Z_RAISED), True)] + if k in ("ramp", "stair"): + return [(_ramp(-5, 5, -5, 5), True)] + if k == "retain": + return [(_rect(-5, 5, 0, 5, Z_RAISED), True), (_rect(-5, 5, -5, 0, 0.0), True)] + if k == "canal": + return [(_rect(-5, 5, -5, 5, 0.0), False)] + if k == "cut": + return [(_rect(-5, -0.9, -5, 5, 0.0), True), + (_rect(0.9, 5, -5, 5, 0.0), True), + (_rect(-0.9, 0.9, -5, 5, 0.0), False)] + if k == "canal_edge": + w = spec["which"] + if w == "E": + return [(_rect(-5, 0, -5, 5, 0.0), True), (_rect(0, 5, -5, 5, 0.0), False)] + if w == "S": + return [(_rect(-5, 5, 0, 5, 0.0), True), (_rect(-5, 5, -5, 0, 0.0), False)] + if w == "ES_outer": + return [(_rect(-5, 0, 0, 5, 0.0), True), + (_rect(0, 5, -5, 5, 0.0), False), (_rect(-5, 0, -5, 0, 0.0), False)] + if w == "ES_inner": + return [(_rect(-5, 0, -5, 5, 0.0), True), (_rect(0, 5, 0, 5, 0.0), True), + (_rect(0, 5, -5, 0, 0.0), False)] + if k == "bridge": + hw = spec.get("hw", 1.5) + return [(_rect(-hw, hw, -5, 5, 0.2), True), + (_rect(-5, -hw, -5, 5, 0.0), False), (_rect(hw, 5, -5, 5, 0.0), False)] + return [(_rect(-5, 5, -5, 5, 0.0), True)] + + +# --- NWN .set mapping (AGENTS.md §4, §7) ------------------------------------- +# Minimal, deliberate vocabulary (AGENTS.md §2.1: every terrain pairing multiplies +# tile count). Ground tiles all share 'cobble' so they interconnect freely +# (KICKOFF.md: "entire tile can remain walkable for flexibility"). +TERRAINS = ["cobble", "raised", "canal"] +CROSSERS = ["ramp", "canal_edge"] + + +def resref(spec): + return f"{PREFIX}_{spec['code'].lower()}" # <=16 chars, valid identifier + + +def _terr(e): + return {"RAISED": "raised", "CANAL": "canal"}.get(e, "cobble") + + +def _corner(ea, eb): + ts = {_terr(ea), _terr(eb)} + terr = "raised" if "raised" in ts else ("canal" if "canal" in ts else "cobble") + h = 1 if (ea == "RAISED" or eb == "RAISED") else 0 + return terr, h + + +def nwn_corners(edges): + """Corner (terrain, height) for TopLeft/TopRight/BottomLeft/BottomRight. + Top=N Bottom=S Left=W Right=E (AGENTS.md §4).""" + n, e, s, w = edges + return { + "TopLeft": _corner(n, w), "TopRight": _corner(n, e), + "BottomLeft": _corner(s, w), "BottomRight": _corner(s, e), + } + + +def nwn_crosser(edge): + return {"CANAL": "canal_edge", "RAMP": "ramp"}.get(edge, "")