#!/usr/bin/env python3 """levelbuild - eigene Level fuer "Ein Koenigreich fuer ein Lama" bauen (Croc-2-Engine, PC). Ein Level wird als Python-Skript beschrieben, das eine Funktion ``build(L)`` enthaelt. ``L`` ist ein :class:`Level` mit Grundkoerpern (Boxen, Plattformen, Rampen, Treppen, ...), Texturen per Name, automatischer Vertexfarben-Beleuchtung (Sonne, Himmel, Schatten, Umgebungsverdeckung) und Objekt-Platzierung (Strats per Typname, Parameter vom Original). levelbuild.py LEVEL.py OUT.wad [--base t1l1m001.wad] [--install] [--info] levelbuild.py --types [--base ...] platzierbare Strat-Typen des Basislevels levelbuild.py --textures OUT.png Kontaktabzug der Texturen (mit Namen/Index) Koordinaten (wie im Spiel / bei Strats): x nach Osten, y nach oben, z = Rasterzeile (positiv). 1 Einheit = 1 Rasterzelle. Das Raster des Basislevels ist 96x96 (x,z in 0..96). Winkel in Grad (0..360) um die y-Achse. Doku: ../notes/levelbau.md """ import argparse import hashlib import importlib.util import math import os import random import struct import sys import numpy as np HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, HERE) import geomlib as G # noqa: E402 import mapchunk as M # noqa: E402 import stpcwalk # noqa: E402 import strats as S # noqa: E402 WADS = os.path.expanduser('~/.wine-lama/iso-dk/Setup/Eng/Cd/wads') BASE = os.path.join(WADS, 't1l1m001.wad') MODWAD = os.path.expanduser('~/.wine-lama/mods/testlevel/wads/T1L9M001.wad') FX = 4096 # Texturnamen fuer das Dorf (t1l1m001). Kontaktabzug: notes/img/texturen_dorf.png TEX = { 'gras': 1, 'gras2': 170, 'gras_hell': 190, 'gras_streifen': 212, 'gras_dunkel': 157, 'gras_fleck': 99, 'blumen': 87, 'blumen2': 81, 'blumen3': 82, 'sand': 92, 'sand2': 14, 'sand3': 11, 'erde': 3, 'erde2': 4, 'erde3': 5, 'graskante': 38, 'graskante2': 2, 'graskante_sand': 12, 'stein': 149, 'stein2': 166, 'stein_blau': 154, 'stein_kante': 145, 'stein_gruen': 169, 'stein_gruen2': 148, 'stein_moos': 199, 'platte': 214, 'platte2': 225, 'platte_kante': 223, 'holz_dunkel': 203, 'bambus': 141, 'bambus2': 201, 'holz_maser': 127, 'holz_maser2': 128, 'kiste': 233, 'rinde': 222, 'holz_braun': 126, 'holz_braun2': 129, 'holzbalken': 142, 'holz_ringe': 119, 'holzkante': 118, 'inka': 131, 'muster_lila': 202, 'muster_lila2': 208, 'muster_lila3': 200, 'pilz_rot': 136, 'pilz_rot2': 137, 'treppe_stein': 63, } # zusaetzliche Flachfarben (werden an die Texturtabelle angehaengt): Name -> (R, G, B) FLAT = { 'meer': (40, 120, 150), 'meer_tief': (25, 85, 120), 'weiss': (235, 235, 225), 'rot': (190, 50, 40), 'gelb': (230, 190, 60), 'blau': (60, 90, 190), 'schwarz': (20, 20, 20), 'gruen': (70, 140, 50), 'grau': (120, 120, 120), } # Strat-Typen, die im Dorf-Code stehen, aber im Dorf nicht platziert sind: # Typfelder (nvars, Slots, Flags, Zusatz) + Parameter aus einem anderen Level uebernehmen FOREIGN = {'RubyIdol': 't1l1m002.wad'} def deg(a): return int(round((a % 360) / 360 * 4096)) & 0xfff # ---------------------------------------------------------------- Geometrie-Sammelbehaelter class Quad: __slots__ = ('p', 'tex', 'coll', 'mat', 'tint', 'shade', 'double', 'render') def __init__(self, p, tex, coll=True, mat=0, tint=(1, 1, 1), shade=True, double=False, render=True): self.p = p # 3 oder 4 Punkte (Spielkoordinaten) self.tex, self.coll, self.mat, self.tint, self.shade, self.double = tex, coll, mat, tint, shade, double self.render = render # False: nur Kollision (unsichtbar) def _sub(a, b): return (a[0] - b[0], a[1] - b[1], a[2] - b[2]) def _cross(a, b): return (a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]) def _norm(v): ln = math.sqrt(sum(c * c for c in v)) or 1.0 return tuple(c / ln for c in v) def to_exp(p): """Spielraum (z positiv) -> Exportraum/Vertexraum (z negativ).""" return (p[0], p[1], -p[2]) class Level: """Baukasten. Alle Methoden nehmen Spielkoordinaten (x, z = Rasterzelle, y oben).""" def __init__(self, base=BASE, name='Testwelt', seed=1): self.base = base self.name = name self.rng = random.Random(seed) self.quads = [] self.solids = [] # AABBs (x0,y0,z0,x1,y1,z1) fuer Schatten/Umgebungsverdeckung self.strats = [] # dict(name, x,y,z, rot, params, wp, variant) self.kill = None # (y, x0, z0, x1, z1) self.sun = dict(azimuth=215, elevation=50, color=(1.0, 0.93, 0.80), strength=0.62, sky=(0.50, 0.55, 0.66), ground=(0.30, 0.28, 0.24), ao=0.55, ao_radius=1.3, shadow=0.35) self.flats_used = {} self._load() # ------------------------------------------------------------ Basislevel def _load(self): self.ch = G.wad_read(self.base) self.wf = G.wfpc_flags(self.ch) self.trak0 = G.parse_trak(G.wad_get(self.ch, 'TRAK')) self.pages, self.texs = G.parse_text(G.wad_get(self.ch, 'TEXT')) vc = [len(m.verts) for m in self.trak0] self.map = M.parse(G.wad_get(self.ch, 'MAP '), self.wf, vc) self.W, self.H = self.map['W'], self.map['H'] st = G.wad_get(self.ch, 'STPC') self.stpc = st self.code_lo = stpcwalk.walk(st, self.wf)['code'] self.protos = {} # name -> [instanz-tupel je Code-Variante] for s in self.map['strats']: nm = (S.strat_name(st, s[6], self.code_lo) or '?@%x' % s[6]).replace('(spawn)', '') lst = self.protos.setdefault(nm, []) if all(p[6] != s[6] for p in lst): lst.append(s) self.pool0 = list(self.map['list1']) self.foreign_params = {} named = dict((n, o) for o, n in S.all_named(st, self.code_lo)) for nm, wad in FOREIGN.items(): if nm in self.protos or nm not in named: continue try: ch2, wf2, m2, lo2, types2 = S.level_types(os.path.join(WADS, wad)) except Exception: continue if nm in types2: s = list(types2[nm][0]) prm = m2['list1'][s[8]:s[8] + s[7]] if s[7] else [] s[6] = named[nm] s[13] = -1 self.protos[nm] = [tuple(s)] self.foreign_params[nm] = prm def types(self): return sorted(self.protos) def tex(self, t): """Texturname, Index (int) oder Flachfarbe ('meer' / (r,g,b)).""" if isinstance(t, int): return t if isinstance(t, tuple): key = 'rgb%02x%02x%02x' % t FLAT.setdefault(key, t) t = key if t in TEX: return TEX[t] if t in FLAT: if t not in self.flats_used: self.flats_used[t] = len(self.texs) + len(self.flats_used) return self.flats_used[t] raise KeyError('unbekannte Textur %r (Namen: %s)' % (t, ', '.join(sorted(TEX) + sorted(FLAT)))) # ------------------------------------------------------------ Grundbausteine def quad(self, p, tex, coll=True, mat=0, tint=(1, 1, 1), rot=0, shade=True, double=False, render=True): """Ein Viereck p = [unten-links, unten-rechts, oben-rechts, oben-links] von vorne gesehen (Textur aufrecht). rot = 0..3 dreht die Textur in 90-Grad-Schritten.""" p = [tuple(map(float, q)) for q in p] rot %= 4 p = p[rot:] + p[:rot] self.quads.append(Quad(p, self.tex(tex), coll, mat, tint, shade, double, render)) def collider(self, p, mat=0, normal=(0, 1, 0)): """Unsichtbare Kollisionsflaeche (3/4 Punkte), Vorderseite Richtung 'normal'.""" self.quad(_orient([tuple(map(float, q)) for q in p], normal), 'gras', coll=True, mat=mat, render=False) def _hface(self, x0, z0, x1, z1, y, tex, up=True, step=1.0, rot='random', tint=(1, 1, 1), coll=True, mat=0): """Waagerechte Flaeche, in Kacheln (step) unterteilt, an ganzen Zahlen ausgerichtet.""" xs = _cuts(x0, x1, step) zs = _cuts(z0, z1, step) for xa, xb in zip(xs, xs[1:]): for za, zb in zip(zs, zs[1:]): r = self.rng.choice((0, 2)) if rot == 'random' else rot # 2 Varianten: weniger Modelle if up: # von oben gesehen: unten-links = (xa, zb)? -> Normale +y p = [(xa, y, za), (xb, y, za), (xb, y, zb), (xa, y, zb)] else: p = [(xa, y, zb), (xb, y, zb), (xb, y, za), (xa, y, za)] self.quad(_orient(p, (0, 1 if up else -1, 0)), tex, coll=coll, mat=mat, tint=tint, rot=r) def _vface(self, a, b, y0, y1, tex, top_tex=None, step=1.0, tint=(1, 1, 1), coll=True, mat=0, normal=None): """Senkrechte Wand von a=(x,z) nach b=(x,z), aussen = rechts von a->b (von oben, z nach unten).""" L = math.hypot(b[0] - a[0], b[1] - a[1]) if L < 1e-6 or y1 - y0 < 1e-6: return n = max(1, int(round(L / step))) ys = _cuts(y0, y1, step, align=False) for i in range(n): t0, t1 = i / n, (i + 1) / n pa = (a[0] + (b[0] - a[0]) * t0, a[1] + (b[1] - a[1]) * t0) pb = (a[0] + (b[0] - a[0]) * t1, a[1] + (b[1] - a[1]) * t1) for k, (ya, yb) in enumerate(zip(ys, ys[1:])): tx = top_tex if (top_tex is not None and k == len(ys) - 2) else tex p = [(pa[0], ya, pa[1]), (pb[0], ya, pb[1]), (pb[0], yb, pb[1]), (pa[0], yb, pa[1])] nn = normal or _wall_normal(a, b) self.quad(_orient(p, nn), tx, coll=coll, mat=mat, tint=tint) def floor(self, x0, z0, x1, z1, y, tex='gras', rot='random', tint=(1, 1, 1), step=1.0, coll=True): """Flache Bodenflaeche (nur Oberseite).""" self._hface(x0, z0, x1, z1, y, tex, True, step, rot, tint, coll) def box(self, x0, z0, x1, z1, y0, y1, top='gras', side='erde', side_top=None, bottom=None, tint=(1, 1, 1), rot='random', sides='nesw', solid=True, coll=True, mat=0, step=1.0, side_coll=None): """Quader. side_top: Textur fuer die oberste Reihe der Seitenwaende (z.B. 'graskante'). bottom: Unterseite zeichnen (fuer schwebende Plattformen). sides: welche Seiten (n=z0, s=z1, w=x0, e=x1).""" x0, x1 = min(x0, x1), max(x0, x1) z0, z1 = min(z0, z1), max(z0, z1) y0, y1 = min(y0, y1), max(y0, y1) if top: self._hface(x0, z0, x1, z1, y1, top, True, step, rot, tint, coll, mat) if bottom: self._hface(x0, z0, x1, z1, y0, bottom, False, step, 0, tint, coll, mat) sc = coll if side_coll is None else side_coll if side: # Waende so, dass die Aussenseite rechts liegt if 'n' in sides: self._vface((x1, z0), (x0, z0), y0, y1, side, side_top, step, tint, sc, mat, (0, 0, -1)) if 's' in sides: self._vface((x0, z1), (x1, z1), y0, y1, side, side_top, step, tint, sc, mat, (0, 0, 1)) if 'w' in sides: self._vface((x0, z0), (x0, z1), y0, y1, side, side_top, step, tint, sc, mat, (-1, 0, 0)) if 'e' in sides: self._vface((x1, z1), (x1, z0), y0, y1, side, side_top, step, tint, sc, mat, (1, 0, 0)) if solid: self.solids.append((x0, y0, z0, x1, y1, z1)) def platform(self, x, z, w, d, y, top='gras', side='erde', thick=0.5, side_top='graskante', bottom='erde', **kw): """Schwebende Plattform: Oberkante y, Ecke (x,z), Groesse w x d.""" self.box(x, z, x + w, z + d, y - thick, y, top, side, side_top=side_top, bottom=bottom, **kw) def pillar(self, x, z, y0, y1, size=1.0, side='stein', top='stein', **kw): """Saeule mit Mittelpunkt (x,z).""" h = size / 2 self.box(x - h, z - h, x + h, z + h, y0, y1, top, side, **kw) def ramp(self, x0, z0, x1, z1, ya, yb, direction='+x', top='sand', side='erde', base=None, tint=(1, 1, 1), step=1.0, coll=True): """Rampe ueber dem Rechteck; steigt in 'direction' (+x,-x,+z,-z) von ya auf yb. Unterkante 'base' (Standard: ya - 0.5); Seiten und Stirnflaeche werden geschlossen.""" x0, x1 = min(x0, x1), max(x0, x1) z0, z1 = min(z0, z1), max(z0, z1) base = (min(ya, yb) - 0.5) if base is None else base ax = direction[1] sgn = 1 if direction[0] == '+' else -1 def h(x, z): t = (x - x0) / (x1 - x0) if ax == 'x' else (z - z0) / (z1 - z0) if sgn < 0: t = 1 - t return ya + (yb - ya) * t xs, zs = _cuts(x0, x1, step), _cuts(z0, z1, step) for xa, xb in zip(xs, xs[1:]): for za, zb in zip(zs, zs[1:]): p = [(xa, h(xa, za), za), (xb, h(xb, za), za), (xb, h(xb, zb), zb), (xa, h(xa, zb), zb)] self.quad(_orient(p, (0, 1, 0)), top, coll=coll, tint=tint) # Seiten: Trapeze in Streifen edges = [((x1, z0), (x0, z0), (0, 0, -1)), ((x0, z1), (x1, z1), (0, 0, 1)), ((x0, z0), (x0, z1), (-1, 0, 0)), ((x1, z1), (x1, z0), (1, 0, 0))] for a, b, nn in edges: Ln = math.hypot(b[0] - a[0], b[1] - a[1]) n = max(1, int(round(Ln / step))) for i in range(n): pa = (a[0] + (b[0] - a[0]) * i / n, a[1] + (b[1] - a[1]) * i / n) pb = (a[0] + (b[0] - a[0]) * (i + 1) / n, a[1] + (b[1] - a[1]) * (i + 1) / n) ha, hb = h(*pa), h(*pb) if max(ha, hb) - base < 1e-6: continue p = [(pa[0], base, pa[1]), (pb[0], base, pb[1]), (pb[0], hb, pb[1]), (pa[0], ha, pa[1])] self.quad(_orient(p, nn), side, coll=coll, tint=tint) lo = min(ya, yb) self.solids.append((x0, base, z0, x1, lo, z1)) def stairs(self, x0, z0, x1, z1, y0, y1, n, direction='+x', top='stein', side='stein2', base=None, walkable=True, tint=(1, 1, 1)): """Treppe mit n Stufen auf dem Rechteck, steigt in 'direction' (+x,-x,+z,-z) von y0 auf y1. walkable=True: Kollision als unsichtbare Schraege (Lama kann hochlaufen; die Engine hat keine Stufen-Logik, schon 0.12 hohe Kanten blockieren), sonst echte Stufen (springen).""" x0, x1 = min(x0, x1), max(x0, x1) z0, z1 = min(z0, z1), max(z0, z1) base = (y0 - 0.5) if base is None else base ax = direction[1] neg = direction[0] == '-' # in lokale Koordinaten u (Steigrichtung 0..1) / v (quer 0..1) abbilden def P(u, v, y): if neg: u = 1 - u return ((x0 + (x1 - x0) * u, y, z0 + (z1 - z0) * v) if ax == 'x' else (x0 + (x1 - x0) * v, y, z0 + (z1 - z0) * u)) def nrm(du, dv): if neg: du = -du return (du, 0, dv) if ax == 'x' else (dv, 0, du) rc = not walkable for i in range(n): u0, u1 = i / n, (i + 1) / n yt = y0 + (y1 - y0) * (i + 1) / n yl = base if i == 0 else y0 + (y1 - y0) * i / n last = i == n - 1 # Trittflaeche q = [P(u0, 0, yt), P(u1, 0, yt), P(u1, 1, yt), P(u0, 1, yt)] self.quad(_orient(q, (0, 1, 0)), top, coll=(rc or last), tint=tint) # Setzstufe (Stirnseite), Fuss bis base q = [P(u0, 0, base), P(u0, 1, base), P(u0, 1, yt), P(u0, 0, yt)] self.quad(_orient(q, nrm(-1, 0)), side, coll=rc, tint=tint) # Seitenwaende dieser Stufe (bis base) for v, dv in ((0, -1), (1, 1)): q = [P(u0, v, base), P(u1, v, base), P(u1, v, yt), P(u0, v, yt)] self.quad(_orient(q, nrm(0, dv)), side, coll=True, tint=tint) self.solids.append(_aabb([P(u0, 0, base), P(u1, 1, yt)])) # Rueckwand q = [P(1, 0, base), P(1, 1, base), P(1, 1, y1), P(1, 0, y1)] self.quad(_orient(q, nrm(1, 0)), side, coll=True, tint=tint) if walkable: ue = (n - 1) / n if n > 1 else 1.0 q = [P(0, 0, y0), P(ue, 0, y1), P(ue, 1, y1), P(0, 1, y0)] self.collider(q) def fence(self, pts, y, height=0.6, thick=0.12, post='holz_dunkel', rail='holz_maser', posts_every=1.0): """Zaun (Pfosten + Querlatte) entlang Punktliste [(x,z),...] auf Hoehe y. Mit Kollision.""" for (ax, az), (bx, bz) in zip(pts, pts[1:]): L = math.hypot(bx - ax, bz - az) n = max(1, int(round(L / posts_every))) for i in range(n + 1): px, pz = ax + (bx - ax) * i / n, az + (bz - az) * i / n self.box(px - thick, pz - thick, px + thick, pz + thick, y, y + height, top=post, side=post, rot=0) # Latte als duenner Quader t = thick * 0.6 yb = y + height * 0.55 if abs(bx - ax) >= abs(bz - az): self.box(min(ax, bx), az - t, max(ax, bx), az + t, yb, yb + 0.15, top=rail, side=rail, rot=0, solid=False) else: self.box(ax - t, min(az, bz), ax + t, max(az, bz), yb, yb + 0.15, top=rail, side=rail, rot=0, solid=False) def tree(self, x, z, y, height=2.5, crown=1.4, trunk='rinde', leaves='gras_dunkel', trunk_size=None): """Einfacher Klotz-Baum (Stamm + zwei Kronen-Wuerfel).""" self.pillar(x, z, y, y + height, size=trunk_size or max(0.15, crown * 0.25), side=trunk, top=trunk) c = crown / 2 self.box(x - c, z - c, x + c, z + c, y + height - 0.2, y + height + crown * 0.7, top=leaves, side=leaves, bottom=leaves, coll=True) c2 = c * 0.6 self.box(x - c2, z - c2, x + c2, z + c2, y + height + crown * 0.7, y + height + crown * 1.1, top=leaves, side=leaves, coll=True) def sea(self, x0, z0, x1, z1, y=-2.0, tex='meer', kill=None, step=4.0): """Wasser-/Grundflaeche ohne Kollision (grob unterteilt, step) + toedliche Ebene knapp darueber. kill=(x0,z0,x1,z1): Bereich der Todesebene (Standard: ganze Flaeche, False: keine). Die Todesebene wird nur in Zellen ohne durchgehenden Boden angelegt.""" self._hface(x0, z0, x1, z1, y, tex, True, step, 0, (1, 1, 1), False) if kill is not False: self.kill = (y + 0.05,) + tuple(kill or (x0, z0, x1, z1)) def obj(self, path, offset=(0, 0, 0), scale=1.0, tex='stein', coll=True): """OBJ-Datei (Spielkoordinaten, y oben). 'usemtl tex_N' bzw. Texturname als Material.""" V, cur = [], tex for line in open(path, encoding='latin1'): p = line.split() if not p: continue if p[0] == 'v': V.append(tuple(float(c) * scale + o for c, o in zip(p[1:4], offset))) elif p[0] == 'usemtl': m = p[1] cur = int(m[4:]) if m.startswith('tex_') else (m if (m in TEX or m in FLAT) else tex) elif p[0] == 'f': ids = [int(t.split('/')[0]) for t in p[1:]] pts = [V[i - 1 if i > 0 else len(V) + i] for i in ids] if len(pts) == 4: self.quad(pts, cur, coll=coll) else: for k in range(1, len(pts) - 1): self.quad([pts[0], pts[k], pts[k + 1]], cur, coll=coll) # ------------------------------------------------------------ Hoehe def height_at(self, x, z, below=1e9): """Hoechste begehbare Flaeche bei (x,z) unterhalb 'below' (oder None).""" best = None for q in self.quads: if not q.coll: continue p = q.p xs = [a[0] for a in p]; zs = [a[2] for a in p] if not (min(xs) - 1e-6 <= x <= max(xs) + 1e-6 and min(zs) - 1e-6 <= z <= max(zs) + 1e-6): continue n = _norm(_cross(_sub(p[1], p[0]), _sub(p[2], p[0]))) if abs(n[1]) < 0.4: continue for tri in ([p[0], p[1], p[2]], [p[0], p[2], p[3]]) if len(p) == 4 else ([p],): if _in_tri_xz(x, z, tri): a = tri[0] y = a[1] - (n[0] * (x - a[0]) + n[2] * (z - a[2])) / n[1] if y <= below + 1e-6 and (best is None or y > best): best = y return best # ------------------------------------------------------------ Objekte def strat(self, name, x, z, y=None, rot=None, params=None, wp='relative', variant=0, hidden=False): """Objekt platzieren. y=None: auf die hoechste Flaeche bei (x,z) stellen. rot: Grad (None = wie im Original). params: Liste u32 (None = Original). wp: 'relative' (Wegpunktkette des Originals mitverschieben, y auf Boden), 'here' (alle Wegpunkte auf das Objekt), 'none' (-1) oder Liste [(x,z[,y[,rot]]),...].""" if name not in self.protos: raise KeyError('Strat-Typ %r nicht im Basislevel (vorhanden: %s)' % (name, ', '.join(self.types()))) if y is None: y = self.height_at(x, z) if y is None: y = 0.0 d = dict(name=name, x=x, y=y, z=z, rot=rot, params=params, wp=wp, variant=variant, hidden=hidden) self.strats.append(d) return d def player(self, x, z, rot=0, y=None, cam_dist=8.0): """Spielerstart. Der erste Wegpunkt der Lama-Kette ist (im Original) die Startposition der Kamera -> hinter das Lama legen (Abstand cam_dist), die uebrigen auf das Lama.""" d = self.strat('Llama', x, z, y, rot, wp='here') a = math.radians(rot or 0) cx, cz = x - cam_dist * math.sin(a), z - cam_dist * math.cos(a) n = 1 k = self.protos['Llama'][0][13] while k >= 0 and self.map['nodes'][k][0] and n < 200: k = self.map['nodes'][k][0]; n += 1 d['wp'] = [(cx, cz, d['y'] - 0.4, rot or 0)] + [(x, z, d['y'], rot or 0)] * (n - 1) return d def checkpoint(self, x, z, rot=None, y=None): return self.strat('CheckPointPost', x, z, y, rot) def exit(self, x, z, rot=None, y=None, params=None): return self.strat('LevelDoor', x, z, y, rot, params) def coins(self, pts, y_off=0.0, name='Coin'): for p in pts: yy = self.height_at(p[0], p[1]) self.strat(name, p[0], p[1], (yy or 0) + y_off) # ------------------------------------------------------------ Bauen def build(self, out, verbose=True): geo = self._geometry() models, tiles = geo text = self._text() trak = G.build_trak(models) mp = self._map(models, tiles) ch = [[t, c] for t, c in self.ch] G.wad_set(ch, 'TRAK', trak) G.wad_set(ch, 'MAP', mp) G.wad_set(ch, 'TEXT', text) nm = G.wad_get(ch, 'NAME') if nm is not None and self.name: G.wad_set(ch, 'NAME', nm[:4] + self.name.encode('latin1') + b'\0') G.wad_write(out, ch) # Gegenprobe: MAP parst wieder vollstaendig chk = M.parse(mp, self.wf, [len(m.verts) for m in models]) assert not chk['rest'] and len(chk['strats']) == len(self._srecs) if verbose: nf = sum(len(m.faces) for m in models) nc = sum(len(m.coll[0]) for m in models) print('%s: %d Modelle, %d Kacheln, %d Dreiecke, %d Kollisionspolygone, %d Strats, %d Wegpunkte, ' 'TRAK %d KB, WAD %d KB' % (out, len(models), len(tiles), nf, nc, len(self._srecs), len(self._nodes), len(trak) // 1024, os.path.getsize(out) // 1024)) return out # -- Beleuchtung def _light(self, P, N, tint): """P,N: (n,3) Spielkoordinaten; liefert (n,3) Farbe 1.0 = Texturfarbe.""" s = self.sun az, el = math.radians(s['azimuth']), math.radians(s['elevation']) L = np.array([math.cos(el) * math.sin(az), math.sin(el), math.cos(el) * math.cos(az)]) sky = np.array(s['sky']); gnd = np.array(s['ground']); sc = np.array(s['color']) N0 = N ndl = np.clip(N @ L, 0, None) hemi = (N[:, 1:2] * 0.5 + 0.5) amb = gnd + (sky - gnd) * hemi B = np.array(self.solids, float) if self.solids else np.zeros((0, 6)) # Strahlen: Sonne + Halbkugel dirs = _hemi_dirs() shadow = np.ones(len(P)) ao = np.ones(len(P)) CH = 256 order = np.lexsort((P[:, 2] // 4, P[:, 0] // 4)) # raeumlich sortieren -> kleine Chunks P, N = P[order], N[order] res_shadow, res_ao = np.ones(len(P)), np.ones(len(P)) ndl = ndl[order] topY = B[:, 4].max() if len(B) else 0 for i in range(0, len(P), CH): p = P[i:i + CH] + N[i:i + CH] * 0.03 n = N[i:i + CH] if len(B): lo, hi = p.min(0), p.max(0) r = s['ao_radius'] near = B[((B[:, 0] <= hi[0] + r) & (B[:, 3] >= lo[0] - r) & (B[:, 1] <= hi[1] + r) & (B[:, 4] >= lo[1] - r) & (B[:, 2] <= hi[2] + r) & (B[:, 5] >= lo[2] - r))] tm = max(0.0, (topY - lo[1]) / max(L[1], 0.05)) e = L * tm slo, shi = np.minimum(lo, lo + e), np.maximum(hi, hi + e) far = B[((B[:, 0] <= shi[0]) & (B[:, 3] >= slo[0]) & (B[:, 1] <= shi[1]) & (B[:, 4] >= slo[1]) & (B[:, 2] <= shi[2]) & (B[:, 5] >= slo[2]))] lit = ndl[i:i + CH] > 0 hit = _ray_boxes(p, np.broadcast_to(L, p.shape), far, 60.0) if len(far) else np.zeros(len(p), bool) res_shadow[i:i + CH] = np.where(lit & hit, 0.0, 1.0) # AO: Richtungen in der Halbkugel um n w = np.clip(n @ dirs.T, 0, None) # (c, R) occ = np.zeros(len(p)) for r in range(len(dirs)): m = w[:, r] > 0.05 if not m.any(): continue if not len(near): continue h = _ray_boxes(p[m], np.broadcast_to(dirs[r], p[m].shape), near, s['ao_radius']) occ[np.where(m)[0]] += h * w[m, r] res_ao[i:i + CH] = 1 - s['ao'] * occ / np.maximum(w.sum(1), 1e-6) shadow = np.ones(len(P)); ao = np.ones(len(P)) shadow[order] = res_shadow; ao[order] = res_ao ndl = np.clip(N0 @ L, 0, None) sh = 1 - (1 - shadow) * (1 - s['shadow']) col = amb * ao[:, None] + sc * (s['strength'] * ndl * sh)[:, None] * (0.6 + 0.4 * ao[:, None]) return col * np.asarray(tint) # -- Kacheln/Modelle def _geometry(self): BAND, B0 = 10.0, -3.0 # Hoehenbaender je Kachel: [-3,7), [7,17), ... MARGIN = 0.3 tris_by_tile = {} coll_by_tile = {} # Dreiecke (Exportraum) + Vertexfarben vorbereiten allv, alln, allt = [], [], [] faces = [] for q in self.quads: pts = q.p n = tuple(-c for c in _norm(_cross(_sub(pts[1], pts[0]), _sub(pts[2], pts[0])))) # aussen if len(pts) == 4: tri = [((0, 1, 2), 0x820), ((2, 3, 0), 0x830)] else: tri = [((0, 1, 2), 0x820)] for ids, fl in (tri if q.render else []): P = [pts[k] for k in ids] if _area(P) < 1e-8: continue cen = [sum(c[k] for c in P) / 3 for k in range(3)] key = (math.floor(cen[0]), math.floor(cen[2]), math.floor((cen[1] - B0) / BAND)) vi = [] for p in P: vi.append(len(allv)); allv.append(p); alln.append(n); allt.append(q.tint) faces.append((key, vi, q.tex, fl | (0x400 if q.double else 0), q.shade)) if q.coll: xs = [a[0] for a in pts]; zs = [a[2] for a in pts] cy = sum(a[1] for a in pts) / len(pts) band = math.floor((cy - B0) / BAND) # Boeden: nur ueberdeckte Zellen; Waende/Schraegen: auch Nachbarzellen (Spielerradius) mg = 0.0 if abs(n[1]) > 0.95 else MARGIN for cx in range(math.floor(min(xs) - mg), math.floor(max(xs) + mg - 1e-6) + 1): for cz in range(math.floor(min(zs) - mg), math.floor(max(zs) + mg - 1e-6) + 1): coll_by_tile.setdefault((cx, cz, band), []).append((pts, q.mat)) P = np.array(allv, float); N = np.array(alln, float); T = np.array(allt, float) if len(P): key = np.round(np.hstack([P, N]), 4) uk, inv = np.unique(key, axis=0, return_inverse=True) C = self._light(uk[:, :3], uk[:, 3:], np.ones((len(uk), 3)))[inv.reshape(-1)] * T else: C = np.zeros((0, 3)) for key, vi, tex, fl, shade in faces: tris_by_tile.setdefault(key, []).append((vi, tex, fl, shade)) models, tiles, cache = [], [], {} kill_model = None texinfo = lambda t: (self.texs[t]['page'], self.texs[t]['flags']) if t < len(self.texs) else (-1, 0x105) for key in sorted(set(tris_by_tile) | set(coll_by_tile)): cx, cz, band = key if not (0 <= cx < self.W and 0 <= cz < self.H): raise SystemExit('Geometrie ausserhalb des Rasters %dx%d: Zelle %d,%d' % (self.W, self.H, cx, cz)) ox, oy, oz = cx + 0.5, B0 + band * BAND + BAND / 2, cz + 0.5 m = G.Model(); m.dirty = True vmap, cols, nacc = {}, [], [] flist = sorted(tris_by_tile.get(key, []), key=lambda f: (texinfo(f[1]), f[1])) fdata = [] for vi, tex, fl, shade in flist: ids = [] for k in vi: lp = tuple(round(c, 5) for c in to_exp((P[k][0] - ox, P[k][1] - oy, P[k][2] - oz))) col = C[k] if shade else np.ones(3) cu = _col_u32(col) kk = (lp, tuple(float(x) + 0.0 for x in np.round(N[k], 3))) if kk not in vmap: vmap[kk] = len(m.verts) m.verts.append(lp + tuple(to_exp(N[k]))) cols.append(cu) ids.append(vmap[kk]) if len(set(ids)) < 3: continue fdata.append((ids, tex, fl)) prev = None for ids, tex, fl in fdata: plane = G.face_plane(*[m.verts[i][:3] for i in ids]) st = texinfo(tex) if st != prev or len(m.faces) % 256 == 0: fl |= 0x8 prev = st m.faces.append(dict(flags=fl, v=tuple(ids), tex=tex, pad=0, plane=plane)) for pts, mat in coll_by_tile.get(key, []): loc = [to_exp((p[0] - ox, p[1] - oy, p[2] - oz)) for p in pts] if max(abs(c) for p in loc for c in p) > 7.9: continue recs = [] r = G.coll_from_polygon(loc, mat) if r is not None: recs = [r] for r in recs: if struct.unpack_from(' vhi[k] + 1e-4 for k in range(3)) else: need = True if need: for c in (lo, hi): m.verts.append((round(c[0], 5), round(c[1], 5), round(c[2], 5), 0.0, 1.0, 0.0)) cols.append(0x01808080) if len(m.verts) > 4000: raise SystemExit('Kachel %s zu gross (%d Vertices)' % (key, len(m.verts))) if not m.verts: # reine Kollisionskachel braucht keine Vertices pass h = _model_key(m) if h not in cache: cache[h] = len(models) models.append(m) tiles.append(dict(x=ox, y=oy, z=-oz, rot=0, model=cache[h], cols=cols)) # Todesebene (Original-Modell 0 des Dorfes: unsichtbar, Material 0x1e, y_lokal = -2.75) if self.kill: ky, x0, z0, x1, z1 = self.kill km = self.trak0[0] kill_model = len(models) models.append(km) ty = ky + 2.75 # nur Zellen ohne begehbaren Boden (spart Kacheln; unter Boden faellt man nicht) covered = self._covered_cells() for cx in range(max(0, math.floor(x0)), min(self.W, math.ceil(x1))): for cz in range(max(0, math.floor(z0)), min(self.H, math.ceil(z1))): if (cx, cz) in covered: continue tiles.append(dict(x=cx + 0.5, y=ty, z=-(cz + 0.5), rot=0, model=kill_model, cols=[0x01404040] * len(km.verts))) return models, tiles def _covered_cells(self): """Zellen, die vollstaendig von nach oben zeigender Kollision (Boden) bedeckt sind.""" area = {} for q in self.quads: if not q.coll or len(q.p) != 4: continue p = q.p n = _norm(_cross(_sub(p[1], p[0]), _sub(p[2], p[0]))) if abs(n[1]) < 0.7: continue xs = [a[0] for a in p]; zs = [a[2] for a in p] for cx in range(math.floor(min(xs)), math.ceil(max(xs))): for cz in range(math.floor(min(zs)), math.ceil(max(zs))): ov = max(0, min(max(xs), cx + 1) - max(min(xs), cx)) * max(0, min(max(zs), cz + 1) - max(min(zs), cz)) area[(cx, cz)] = area.get((cx, cz), 0) + ov return {k for k, v in area.items() if v > 0.999} def _text(self): d = G.wad_get(self.ch, 'TEXT') if not self.flats_used: return d o = 0 npal = struct.unpack_from('= 0: k = proto[13] while True: chain.append(orig_nodes[k]) nx = orig_nodes[k][0] if not nx or len(chain) > 200: break k = nx new = [] if isinstance(wp, (list, tuple)): for i, p in enumerate(wp): px, pz = p[0], p[1] py = p[2] if len(p) > 2 and p[2] is not None else (self.height_at(px, pz) or d['y']) pr = deg(p[3]) if len(p) > 3 else s[1] # Zusatzfelder (Wartezeit/Modus o.ae.) vom Original-Wegpunkt uebernehmen a_, b_ = (chain[i][8], chain[i][9]) if i < len(chain) else (0, 0) new.append([0, 0, 0, pr, 0, int(px * FX), int(py * FX), int(pz * FX), a_, b_]) elif chain and wp != 'none': ox, oy, oz = proto[3], proto[4], proto[5] for nd in chain: nd = list(nd) if wp == 'here': nd[5], nd[6], nd[7] = s[3], s[4], s[5] else: nx_ = _s32(nd[5]) - ox + s[3] nz_ = _s32(nd[7]) - oz + s[5] ny_ = _s32(nd[6]) - oy + s[4] g = self.height_at(nx_ / FX, nz_ / FX, below=ny_ / FX + 1.0) if g is not None and abs(g - ny_ / FX) < 2.0: ny_ = int(g * FX) nd[5], nd[6], nd[7] = nx_, ny_, nz_ new.append(nd) if new: base = len(nodes) for i, nd in enumerate(new): nd = list(nd) nd[0] = base + i + 1 if i < len(new) - 1 else 0 nd[5:8] = [v & 0xffffffff for v in nd[5:8]] nodes.append(tuple(nd)) s[13] = base else: s[13] = -1 s[15] = 2 if d['hidden'] else 0 s[3], s[4], s[5] = (v for v in (s[3], s[4], s[5])) recs.append(tuple(s)) m['list1'] = pool m['nodes'] = nodes if nodes else [tuple([0] * 10)] m['strats'] = recs m['strat_x'] = max(self.map['strat_x'], len(recs) + 80) self._srecs, self._nodes = recs, nodes return M.build(m, self.wf) # ---------------------------------------------------------------- Hilfsfunktionen def _s32(v): return v - (1 << 32) if v >= 1 << 31 else v def _cuts(a, b, step, align=True): a, b = min(a, b), max(a, b) if not align: n = max(1, int(round((b - a) / step))) return [a + (b - a) * i / n for i in range(n + 1)] out = [a] k = math.floor(a / step + 1e-9) + 1 while k * step < b - 1e-6: if k * step - out[-1] > 0.05: out.append(k * step) k += 1 if b - out[-1] < 0.05 and len(out) > 1: out[-1] = b else: out.append(b) return out def _wall_normal(a, b): dx, dz = b[0] - a[0], b[1] - a[1] n = _norm((-dz, 0, dx)) return n def _orient(p, normal): """Reihenfolge so, dass die Flaeche nach 'normal' (Spielraum) zeigt. Die Engine rechnet im Exportraum (z gespiegelt): dort muss (p1-p0)x(p2-p0) nach aussen zeigen, im Spielraum also nach innen (Spiegelung dreht den Umlaufsinn).""" n = _cross(_sub(p[1], p[0]), _sub(p[2], p[0])) if sum(a * b for a, b in zip(n, normal)) > 0: # spiegeln, Ecke 0 bleibt unten-links -> [1,0,3,2] p = [p[1], p[0], p[3], p[2]] if len(p) == 4 else [p[1], p[0], p[2]] return p def _aabb(pts): xs = [p[0] for p in pts]; ys = [p[1] for p in pts]; zs = [p[2] for p in pts] return (min(xs), min(ys), min(zs), max(xs), max(ys), max(zs)) def _area(P): c = _cross(_sub(P[1], P[0]), _sub(P[2], P[0])) return math.sqrt(sum(x * x for x in c)) / 2 def _in_tri_xz(x, z, t): (ax, _, az), (bx, _, bz), (cx, _, cz) = t d1 = (x - bx) * (az - bz) - (ax - bx) * (z - bz) d2 = (x - cx) * (bz - cz) - (bx - cx) * (z - cz) d3 = (x - ax) * (cz - az) - (cx - ax) * (z - az) neg = d1 < -1e-9 or d2 < -1e-9 or d3 < -1e-9 pos = d1 > 1e-9 or d2 > 1e-9 or d3 > 1e-9 return not (neg and pos) def _col_u32(c): r, g, b = (max(0, min(255, int(round(v * 128)))) for v in c) return (1 << 24) | (b << 16) | (g << 8) | r def _model_key(m): h = hashlib.sha1() for v in m.verts: h.update(struct.pack('<6f', *v)) for f in m.faces: h.update(struct.pack('<5H', f['flags'], *f['v'], f['tex'])) for r in m.coll[0]: h.update(r) return h.hexdigest() _HEMI = None def _hemi_dirs(): global _HEMI if _HEMI is None: d = [] # Fibonacci-Kugel, 24 Richtungen n = 24 for i in range(n): y = 1 - 2 * (i + 0.5) / n r = math.sqrt(1 - y * y) a = i * math.pi * (3 - math.sqrt(5)) d.append((r * math.cos(a), y, r * math.sin(a))) _HEMI = np.array(d) return _HEMI def _ray_boxes(p, d, B, tmax): """p,d: (n,3); B: (b,6). Liefert bool (n,): trifft irgendeine Box mit 0= np.maximum(tn, 1e-4)) & (tn < tmax) return hit.any(1) # ---------------------------------------------------------------- CLI def load_script(path): spec = importlib.util.spec_from_file_location('levelscript', path) mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) return mod def main(): ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument('level', nargs='?') ap.add_argument('out', nargs='?') ap.add_argument('--base', default=BASE) ap.add_argument('--install', action='store_true', help='als TestLevel (T1L9M001.wad) installieren') ap.add_argument('--types', action='store_true') ap.add_argument('--textures', metavar='PNG') ap.add_argument('--set', action='append', default=[], help='Variable fuer das Levelskript, z.B. --set teil=a') a = ap.parse_args() if a.types: L = Level(a.base) for n in L.types(): p = L.protos[n][0] print('%-28s Varianten %d Parameter %d Wegpunkte %s' % (n, len(L.protos[n]), p[7], 'ja' if p[13] >= 0 else '-')) return if a.textures: import subprocess names = {v: k for k, v in TEX.items()} subprocess.run([sys.executable, os.path.join(HERE, 'texsheet.py'), a.base, a.textures], check=True) for i in sorted(names): print('%4d %s' % (i, names[i])) return if not (a.level and a.out): ap.error('LEVEL.py und OUT.wad angeben') mod = load_script(a.level) opts = dict(kv.split('=', 1) for kv in a.set) L = Level(a.base, **getattr(mod, 'LEVEL_ARGS', {})) L.opts = opts mod.build(L) L.build(a.out) if hasattr(mod, 'report'): mod.report(L) if a.install: import shutil if not os.path.exists(MODWAD + '.orig'): shutil.copy2(MODWAD, MODWAD + '.orig') shutil.copyfile(a.out, MODWAD) print('installiert ->', MODWAD) kapitel_links() def kapitel_links(n=49): """Kapitel 2..n von Welt 9 als Symlinks auf Kapitel 1 (WAD + Ladebild T1%d%d.COR) in der Mod. Grund: jeder Ausgang (LevelDoor) schaltet im hoechsten freien Kapitel das naechste frei; fehlt dessen WAD, stuerzt das Spiel bei der Auswahl ab ("Couldnt load wad").""" wd = os.path.dirname(MODWAD) mod = os.path.dirname(wd) neu = 0 for k in range(2, n + 1): for d, name, ziel in ((wd, 'T1L9M%03d.wad' % k, 'T1L9M001.wad'), (mod, 'T19%d.COR' % k, 'T191.COR')): p = os.path.join(d, name) if not os.path.lexists(p) and os.path.exists(os.path.join(d, ziel)): os.symlink(ziel, p) neu += 1 if neu: import subprocess print('%d Kapitel-Links angelegt -> lama-mod an' % neu) subprocess.run([os.path.expanduser('~/.local/bin/lama-mod'), 'an'], check=True, stdout=subprocess.DEVNULL) if __name__ == '__main__': main()