#!/usr/bin/env python3 """Build a test level: in an empty area of the MAP grid, lay a floor plate made of copies of a flat floor tile, place one strat (object) per type on it in a grid, put the player (strat "Llama") at the start and append a small parkour (floating floor plates, going up). Geometry/textures/strat code (TRAK/STPC/TEXT ...) stay those of the source level; only the MAP chunk is rewritten. make_testlevel.py source.wad target.wad [--keep-original] [--spacing 3] [--name "TestLevel"] --keep-original : keep the original strats (otherwise only player + grid + cheats) Strat types are recognised by name (strats.py); parameter/type fields are copied from the first original instance, the waypoint index is kept (may point at original waypoints). """ import argparse import os import struct import sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import mapchunk as M # noqa: E402 import strats as S # noqa: E402 import wadtool # noqa: E402 FX = 4096 # not in the grid: camera/cutscene/trigger zones, the player itself SKIP_PREFIX = ('CutScene', 'Camera', 'MainMenu', 'Options', 'Title', 'TheNewGroove', 'Force', 'EndGameTrigger', 'RiverTriggerBox', 'FallOffNoHurt') KEEP_ORIGINAL = ('CheatTeleport', 'CheatCharge') PLAYER = 'Llama' def trak_models(trak): n = struct.unpack_from('= 0.49 and \ min(v[2] for v in vs) <= -0.49 and max(v[2] for v in vs) >= 0.49: flat.add(i) c = Counter(mi for t, mi in zip(m['tiles'], m['tilemodel']) if mi in flat and not t[5]) # walkable floors sit at model y = +0.25 (strats stand on tile y + 0.25) best = [k for k, _ in c.most_common() if abs(models[k][0][1] - 0.25) < 1e-3] mdl = force if force is not None else (best or [c.most_common(1)[0][0]])[0] idx = next(i for i, (t, mi) in enumerate(zip(m['tiles'], m['tilemodel'])) if mi == mdl and not t[5]) return idx, mdl, models[mdl][0][1] def empty_rect(m, w, h): occ = {(int(t[0]), int(-t[2])) for t in m['tiles']} for z0 in range(m['H'] - h, -1, -1): for x0 in range(0, m['W'] - w + 1): if all((x, z) not in occ for x in range(x0 - 1, x0 + w + 1) for z in range(z0 - 1, z0 + h + 1)): return x0, z0 raise SystemExit(f'no free {w}x{h} area in the {m["W"]}x{m["H"]} grid') def main(): ap = argparse.ArgumentParser() ap.add_argument('src') ap.add_argument('dst') ap.add_argument('--spacing', type=int, default=3) ap.add_argument('--keep-original', action='store_true') ap.add_argument('--name') ap.add_argument('--floor-model', type=int, help='TRAK model index for floor plates') ap.add_argument('--y', type=float, default=None, help='height of the floor plate (tile y)') a = ap.parse_args() ch, wf, m, lo, types = S.level_types(a.src) models = trak_models(ch['TRAK']) ftile, fmodel, fsurf = find_floor_tile(m, models, a.floor_model) names = sorted(n for n in types if n != PLAYER and not n.startswith(SKIP_PREFIX) and n not in KEEP_ORIGINAL and n != 'LevelDoor') cols = 6 rows = (len(names) + cols - 1) // cols sp = a.spacing gw, gh = cols * sp + 2, rows * sp + 4 # grid + start strip pk = 14 # parkour strip x0, z0 = empty_rect(m, gw + pk, gh) base = m['tiles'][ftile] y = a.y if a.y is not None else base[1] print(f'floor tile #{ftile} model {fmodel} (surface y+{fsurf}), area x{x0}..{x0 + gw + pk} ' f'z{z0}..{z0 + gh}, height {y}') def add_tile(cx, cz, ty): m['tiles'].append((cx + 0.5, ty, -(cz + 0.5), base[3], fmodel, 0)) m['tiles2'].append((0, base[3], 0, int((cx + 0.5) * FX), int(ty * FX), int((cz + 0.5) * FX))) m['tilemodel'].append(fmodel) m['vcol_split'].append(m['vcol_split'][ftile]) for cx in range(x0, x0 + gw): for cz in range(z0, z0 + gh): add_tile(cx, cz, y) # parkour: stepping plates with gaps, each +0.5 higher, then a 3x3 goal platform px, pz, py = x0 + gw + 1, z0 + 1, y steps = [] for i in range(6): add_tile(px, pz, py) steps.append((px, pz, py)) px += 2 py += 0.5 if px > x0 + gw + pk - 4: px, pz = x0 + gw + 1, pz + 3 for dx in range(3): for dz in range(3): add_tile(px + dx, pz + dz, py) goal = (px + 1, pz + 1, py) def srec(proto, cx, cz, ty, ry=None): s = list(proto) s[1] = proto[1] if ry is None else ry s[3], s[4], s[5] = int((cx + 0.5) * FX), int((ty + fsurf) * FX), int((cz + 0.5) * FX) n, pi = proto[7], proto[8] if n: s[8] = len(m['list1']) m['list1'].extend(m['_list1_orig'][pi:pi + n]) else: s[8] = -1 s[15] = 0 return tuple(s) m['_list1_orig'] = list(m['list1']) old = m['strats'] new = [] if a.keep_original: new = list(old) else: m['list1'] = [] for s in old: n = S.strat_name(ch['STPC'], s[6], lo) if n in KEEP_ORIGINAL: pi = s[8] s = list(s) if s[7]: s[8] = len(m['list1']) m['list1'].extend(m['_list1_orig'][pi:pi + s[7]]) new.append(tuple(s)) # player at the start (edge of the grid, facing into it) if PLAYER in types: new = [s for s in new if S.strat_name(ch['STPC'], s[6], lo) != PLAYER] new.insert(0, srec(types[PLAYER][0], x0 + 1, z0 + 1, y, ry=types[PLAYER][0][1])) placed = [] for k, nm in enumerate(names): cx = x0 + 2 + (k % cols) * sp cz = z0 + 3 + (k // cols) * sp new.append(srec(types[nm][0], cx, cz, y)) placed.append((nm, cx, cz)) # coins on the parkour, exit (LevelDoor) on the goal platform if 'Coin' in types: for (cx, cz, ty) in steps: new.append(srec(types['Coin'][0], cx, cz, ty + 0.5)) if 'LevelDoor' in types: new.append(srec(types['LevelDoor'][0], goal[0], goal[1], goal[2])) m['strats'] = new m['strat_x'] = max(m['strat_x'], len(new) + 64) m['vcol'] = b''.join(m['vcol_split']) for i, t in enumerate(m['tiles']): # consistency assert 0 <= int(t[0] - 0.5) < m['W'] and 0 <= int(-t[2] - 0.5) < m['H'], (i, t) data = M.build(m, wf) vc = M.trak_vertcounts(ch['TRAK']) chk = M.parse(data, wf, vc) assert len(chk['strats']) == len(new) and not chk['rest'] wch = wadtool.read(a.src) wadtool.get(wch, 'MAP ')[1] = data if a.name: c = wadtool.get(wch, 'NAME') c[1] = c[1][:4] + a.name.encode('latin1') + b'\0' wadtool.write(a.dst, wch) print(f'{len(m["tiles"])} Tiles, {len(new)} Strats ({len(placed)} types in the grid) -> {a.dst}') for nm, cx, cz in placed: print(f' {nm:30s} cell ({cx},{cz})') if __name__ == '__main__': main()