Files
llamamod/tools/levelbuild.py
T

995 lines
45 KiB
Python

#!/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.
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
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
# 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),
}
# 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])
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 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
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('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) 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('<b', r, 3)[0] < 0:
r = bytes([r[0] | 4]) + r[1:]
m.coll[0].append(r)
if not m.faces and not m.coll[0]:
continue
# the tile's box must enclose the collision polygons (unused helper vertices)
cpts = [to_exp((p[0] - ox, p[1] - oy, p[2] - oz)) for pts, _ in coll_by_tile.get(key, []) for p in pts]
if cpts:
lo = [min(c[k] for c in cpts) for k in range(3)]
hi = [max(c[k] for c in cpts) for k in range(3)]
if m.verts:
lo = [min(lo[k], min(v[k] for v in m.verts)) for k in range(3)]
hi = [max(hi[k], max(v[k] for v in m.verts)) for k in range(3)]
vlo = [min(v[k] for v in m.verts) for k in range(3)]
vhi = [max(v[k] for v in m.verts) for k in range(3)]
need = any(lo[k] < vlo[k] - 1e-4 or hi[k] > 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('<I', d, o)[0]; o += 4
for _ in range(npal):
n = struct.unpack_from('<I', d, o + 4)[0]; o += 8 + 3 * n
npg = struct.unpack_from('<I', d, o)[0]; o += 4
for _ in range(npg):
fl, w, h = struct.unpack_from('<3I', d, o); o += 12
if fl == 0:
o += w * h
elif fl & 0x80:
o += 4 + struct.unpack_from('<I', d, o)[0]
else:
o += w * h * 2
nt = struct.unpack_from('<I', d, o)[0]
assert nt == len(self.texs)
add = b''
for name, idx in sorted(self.flats_used.items(), key=lambda kv: kv[1]):
r, g, b = FLAT[name]
add += struct.pack('<HbbBBBB', 0x105, -1, -1, r, g, b, 0)
e = o + 4 + 8 * nt
return d[:o] + struct.pack('<I', nt + len(self.flats_used)) + d[o + 4:e] + add + d[e:]
def _map(self, models, tiles):
m = dict(self.map)
m['tiles'] = [(t['x'], t['y'], t['z'], 0, t['model'], 0) for t in tiles]
m['tiles2'] = [tuple(v & 0xffffffff for v in (0, 0, 0, int(round(t['x'] * FX)), int(round(t['y'] * FX)),
int(round(-t['z'] * FX)))) for t in tiles]
m['tilemodel'] = [t['model'] for t in tiles]
m['alts'] = []
m['vcol'] = b''.join(struct.pack('<%dI' % len(t['cols']), *t['cols']) for t in tiles)
for t in tiles:
assert len(t['cols']) == len(models[t['model']].verts)
tab = list(m['tab32'])
tab[0] = 0xffffffff
m['tab32'] = tuple(tab)
m['grid'] = (0,) * (self.W * self.H)
# strats + waypoints
pool = list(self.pool0)
nodes = []
recs = []
orig_nodes = self.map['nodes']
for d in self.strats:
proto = list(self.protos[d['name']][d['variant']])
s = list(proto)
if d['rot'] is not None:
s[1] = deg(d['rot'])
s[3], s[4], s[5] = int(round(d['x'] * FX)), int(round(d['y'] * FX)), int(round(d['z'] * FX))
prm = d['params']
if prm is None:
if d['name'] in self.foreign_params:
prm = self.foreign_params[d['name']]
else:
prm = self.pool0[proto[8]:proto[8] + proto[7]] if proto[7] else []
if len(prm) != proto[7]:
raise ValueError('%s needs %d parameters, not %d' % (d['name'], proto[7], len(prm)))
if prm:
s[8] = len(pool)
pool.extend(int(v) & 0xffffffff for v in prm)
else:
s[8] = -1
# waypoints
wp = d['wp']
chain = []
if proto[13] >= 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<t<tmax."""
if len(p) == 0:
return np.zeros(0, bool)
with np.errstate(divide='ignore', invalid='ignore'):
inv = 1.0 / np.where(np.abs(d) < 1e-9, 1e-9, d)
lo = B[None, :, 0:3]; hi = B[None, :, 3:6]
t1 = (lo - p[:, None, :]) * inv[:, None, :]
t2 = (hi - p[:, None, :]) * inv[:, None, :]
tn = np.minimum(t1, t2).max(2)
tf = np.maximum(t1, t2).min(2)
hit = (tf >= 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:
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()}
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('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
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()