#!/usr/bin/env python3 """levelbuild - build custom levels for "Ein Koenigreich fuer ein Lama" (Croc 2 engine, PC). A level is described as a Python script containing a function ``build(L)``. ``L`` is a :class:`Level` with primitives (boxes, platforms, ramps, stairs, ...), textures by name, automatic vertex colour lighting (sun, sky, shadows, ambient occlusion) and object placement (strats by type name, parameters from the original). levelbuild.py LEVEL.py OUT.wad [--base t1l1m001.wad] [--install] [--info] levelbuild.py --types [--base ...] placeable strat types of the base level levelbuild.py --textures OUT.png texture contact sheet (with names/indices) Coordinates (as in the game / for strats): x east, y up, z = grid row (positive). 1 unit = 1 grid cell. The base level's grid is 96x96 (x,z in 0..96). Angles in degrees (0..360) about the y axis. Texture names (TEX/FLAT below) are German keys and part of the level script API; the English names of the Java builder work too (ALIASES). TEX indices are village indices, other bases (--base) map a name to the same picture or reject it. Docs: ../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 import lamapaths # noqa: E402 WADS = lamapaths.CD_WADS BASE = os.path.join(WADS, 't1l1m001.wad') MODWAD = lamapaths.TESTLEVEL_WAD FX = 4096 # texture names for the village (t1l1m001). Contact sheet: 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, } # additional flat colours (appended to the texture table): 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), } # English names of the Java builder (levelbuild/LevelBuilder.java) -> the German keys above ALIASES = { 'grass': 'gras', 'grass2': 'gras2', 'grass_light': 'gras_hell', 'grass_striped': 'gras_streifen', 'grass_dark': 'gras_dunkel', 'grass_patch': 'gras_fleck', 'flowers': 'blumen', 'flowers2': 'blumen2', 'flowers3': 'blumen3', 'earth': 'erde', 'earth2': 'erde2', 'earth3': 'erde3', 'grass_edge': 'graskante', 'grass_edge2': 'graskante2', 'grass_edge_sand': 'graskante_sand', 'stone': 'stein', 'stone2': 'stein2', 'stone_blue': 'stein_blau', 'stone_edge': 'stein_kante', 'stone_green': 'stein_gruen', 'stone_green2': 'stein_gruen2', 'stone_moss': 'stein_moos', 'slab': 'platte', 'slab2': 'platte2', 'slab_edge': 'platte_kante', 'wood_dark': 'holz_dunkel', 'bamboo': 'bambus', 'bamboo2': 'bambus2', 'wood_grain': 'holz_maser', 'wood_grain2': 'holz_maser2', 'crate': 'kiste', 'bark': 'rinde', 'wood_brown': 'holz_braun', 'wood_brown2': 'holz_braun2', 'beam': 'holzbalken', 'wood_rings': 'holz_ringe', 'wood_edge': 'holzkante', 'inca': 'inka', 'pattern_purple': 'muster_lila', 'pattern_purple2': 'muster_lila2', 'pattern_purple3': 'muster_lila3', 'mushroom_red': 'pilz_rot', 'mushroom_red2': 'pilz_rot2', 'stone_stairs': 'treppe_stein', 'sea': 'meer', 'sea_deep': 'meer_tief', 'white': 'weiss', 'red': 'rot', 'yellow': 'gelb', 'blue': 'blau', 'black': 'schwarz', 'green': 'gruen', 'grey': 'grau', } # strat types present in the village code but not placed in the village: # take type fields (nvars, slots, flags, extra) + parameters from another level FOREIGN = {'RubyIdol': 't1l1m002.wad'} def deg(a): return int(round((a % 360) / 360 * 4096)) & 0xfff # ---------------------------------------------------------------- geometry containers 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 or 4 points (game coordinates) self.tex, self.coll, self.mat, self.tint, self.shade, self.double = tex, coll, mat, tint, shade, double self.render = render # False: collision only (invisible) 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): """Game space (z positive) -> export/vertex space (z negative).""" return (p[0], p[1], -p[2]) def _tex_fingerprints(pages, texs): """Per texture entry: hash of page format + pixel rectangle, None for flat colours.""" cache, out = {}, [] for t in texs: pg = t['page'] if t['flags'] & 1 or pg < 0 or pg >= len(pages) or t['u1'] < t['u0'] or t['v1'] < t['v0']: out.append(None) continue if pg not in cache: cache[pg] = G.page_rgba(pages[pg]) a = cache[pg][t['v0']:t['v1'] + 1, t['u0']:t['u1'] + 1] out.append(hashlib.md5(bytes([pages[pg]['flags'] & 0x7f]) + str(a.shape).encode() + a.tobytes()).hexdigest()) return out class Level: """Construction kit. All methods take game coordinates (x, z = grid cell, y up).""" 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) for shadows/ambient occlusion 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() # ------------------------------------------------------------ base level 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 -> [instance tuple per code variant] 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 _base_names(self): """TEX names on this base: same picture (page format + pixels) at the village index or elsewhere (Java: levelbuild.BaseTextures).""" if getattr(self, '_names', None) is None: if os.path.abspath(self.base) == os.path.abspath(BASE): self._names = dict(TEX) else: vp, vt = G.parse_text(G.wad_get(G.wad_read(BASE), 'TEXT')) own = _tex_fingerprints(self.pages, self.texs) vfp = _tex_fingerprints(vp, vt) first = {} for i, f in enumerate(own): if f is not None and (f not in first or (self.texs[i]['flags'] == 0x104 and self.texs[first[f]]['flags'] != 0x104)): first[f] = i self._names = {} for n, i in TEX.items(): if i < len(own) and own[i] is not None and own[i] == vfp[i]: self._names[n] = i elif vfp[i] in first: self._names[n] = first[vfp[i]] return self._names def types(self): return sorted(self.protos) def tex(self, t): """Texture name, index (int) or flat colour ('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 t = ALIASES.get(t, t) if t in TEX: names = self._base_names() if t not in names: raise KeyError('texture %r is a village texture the base level does not have (use an index, a flat ' 'colour, or the Java builder, which copies it over)' % t) return names[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('unknown texture %r (names: %s)' % (t, ', '.join(sorted(TEX) + sorted(FLAT)))) # ------------------------------------------------------------ basic building blocks def quad(self, p, tex, coll=True, mat=0, tint=(1, 1, 1), rot=0, shade=True, double=False, render=True): """A quad p = [bottom-left, bottom-right, top-right, top-left] seen from the front (texture upright). rot = 0..3 rotates the texture in 90-degree steps.""" 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)): """Invisible collision surface (3/4 points), front side towards '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): """Horizontal surface, subdivided into tiles (step), aligned to integers.""" 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 variants: fewer models if up: # seen from above: bottom-left = (xa, zb)? -> normal +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): """Vertical wall from a=(x,z) to b=(x,z), outside = right of a->b (seen from above, z pointing down).""" 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): """Flat floor surface (top side only).""" 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): """Cuboid. side_top: texture for the top row of the side walls (e.g. 'graskante'). bottom: draw the underside (for floating platforms). sides: which sides (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: # walls oriented so that the outside is on the right 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): """Floating platform: top edge y, corner (x,z), size 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): """Pillar centred at (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): """Ramp over the rectangle; rises in 'direction' (+x,-x,+z,-z) from ya to yb. Bottom edge 'base' (default: ya - 0.5); sides and front face are closed.""" 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) # sides: trapezoids in strips 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)): """Stairs with n steps on the rectangle, rising in 'direction' (+x,-x,+z,-z) from y0 to y1. walkable=True: collision as an invisible slope (the llama can walk up; the engine has no step logic, even 0.12-high edges block), otherwise real steps (jump).""" 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] == '-' # map to local coordinates u (rise direction 0..1) / v (across 0..1) 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 # tread 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) # riser (front face), foot down to 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) # side walls of this step (down to 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)])) # back wall 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): """Fence (posts + rail) along point list [(x,z),...] at height y. With collision.""" 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) # rail as a thin cuboid 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): """Simple blocky tree (trunk + two crown cubes).""" 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): """Water/ground surface without collision (coarsely subdivided, step) + deadly plane just above it. kill=(x0,z0,x1,z1): area of the death plane (default: whole surface, False: none). The death plane is only created in cells without continuous floor.""" 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 file (game coordinates, y up). 'usemtl tex_N' or a texture name as 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 or m in ALIASES) 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) # ------------------------------------------------------------ height def height_at(self, x, z, below=1e9): """Highest walkable surface at (x,z) below 'below' (or 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 # ------------------------------------------------------------ objects def strat(self, name, x, z, y=None, rot=None, params=None, wp='relative', variant=0, hidden=False): """Place an object. y=None: put it on the highest surface at (x,z). rot: degrees (None = as in the original). params: list of u32 (None = original). wp: 'relative' (move the original's waypoint chain along, y snapped to the ground), 'here' (all waypoints on the object), 'none' (-1) or list [(x,z[,y[,rot[,unk2]]]),...].""" if name not in self.protos: raise KeyError('strat type %r not in base level (available: %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=2.0): """Player start with its start camera: one waypoint cam_dist behind the llama, 0.5 above it, unk2 = 0 (default camera distance/height). The game takes the first node of the llama chain within 2.5 cells as the camera start (keep cam_dist below that); the prototype's chain would put the camera into the llama's head on village 2 bases.""" 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) d['wp'] = [(cx, cz, d['y'] + 0.5, rot or 0, 0)] 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) # ------------------------------------------------------------ build 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) # cross-check: MAP parses back completely 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 models, %d tiles, %d triangles, %d collision polygons, %d strats, %d waypoints, ' '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 # -- lighting def _light(self, P, N, tint): """P,N: (n,3) game coordinates; returns (n,3) colour, 1.0 = texture colour.""" 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)) # rays: sun + hemisphere dirs = _hemi_dirs() shadow = np.ones(len(P)) ao = np.ones(len(P)) CH = 256 order = np.lexsort((P[:, 2] // 4, P[:, 0] // 4)) # sort spatially -> small 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: directions in the hemisphere around 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) # -- tiles/models def _geometry(self): BAND, B0 = 10.0, -3.0 # height bands per tile: [-3,7), [7,17), ... MARGIN = 0.3 tris_by_tile = {} coll_by_tile = {} # prepare triangles (export space) + vertex colours 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])))) # outwards 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) # floors: only covered cells; walls/slopes: neighbouring cells too (player radius) 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('geometry outside the %dx%d grid: cell %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('tile %s too large (%d vertices)' % (key, len(m.verts))) if not m.verts: # a collision-only tile needs no 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)) # death plane (the village's original model 0: invisible, material 0x1e, y_local = -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 # only cells without walkable floor (saves tiles; you can't fall below a floor) 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): """Cells fully covered by upward-facing collision (floor).""" 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] # copy extra fields (wait time/mode etc.) from the original waypoint a_, b_ = (chain[i][8], chain[i][9]) if i < len(chain) else (0, 0) if len(p) > 4: a_ = p[4] 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) # ---------------------------------------------------------------- helpers 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): """Order the points so the surface faces 'normal' (game space). The engine works in export space (z mirrored): there (p1-p0)x(p2-p0) must point outwards, i.e. inwards in game space (mirroring reverses the winding).""" n = _cross(_sub(p[1], p[0]), _sub(p[2], p[0])) if sum(a * b for a, b in zip(n, normal)) > 0: # mirror, corner 0 stays bottom-left -> [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 sphere, 24 directions 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). Returns bool (n,): hits any box with 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='install as TestLevel (T1L9M001.wad)') ap.add_argument('--types', action='store_true') ap.add_argument('--textures', metavar='PNG') ap.add_argument('--set', action='append', default=[], help='variable for the level script, e.g. --set teil=a') a = ap.parse_args() if a.types or a.textures or a.level: lamapaths.need(a.base, 'base level', 'pass --base or set LLAMAMOD_CD') if a.types: L = Level(a.base) for n in L.types(): p = L.protos[n][0] print('%-28s variants %d parameters %d waypoints %s' % (n, len(L.protos[n]), p[7], 'yes' if p[13] >= 0 else '-')) return if a.textures: import subprocess names = {v: k for k, v in TEX.items()} english = {g: e for e, g in ALIASES.items()} subprocess.run([sys.executable, os.path.join(HERE, 'texsheet.py'), a.base, a.textures], check=True) for i in sorted(names): print('%4d %-16s %s' % (i, names[i], english.get(names[i], ''))) return if not (a.level and a.out): ap.error('specify LEVEL.py and OUT.wad') 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 lamapaths.need(os.path.dirname(MODWAD), 'TestLevel mod folder', 'set LAMA_WINEPREFIX') if not os.path.exists(MODWAD + '.orig'): shutil.copy2(MODWAD, MODWAD + '.orig') shutil.copyfile(a.out, MODWAD) print('installed ->', MODWAD) kapitel_links() def kapitel_links(n=49): """Chapters 2..n of world 9 as symlinks to chapter 1 (WAD + loading image T1%d%d.COR) in the mod. Reason: every exit (LevelDoor) in the highest unlocked chapter unlocks the next one; if its WAD is missing, the game crashes on selecting it ("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 chapter links created -> lama-mod an' % neu) subprocess.run([os.path.expanduser('~/.local/bin/lama-mod'), 'an'], check=True, stdout=subprocess.DEVNULL) if __name__ == '__main__': main()