diff --git a/docs/manual/18-mods.md b/docs/manual/18-mods.md index 088bed4..198c9ef 100644 --- a/docs/manual/18-mods.md +++ b/docs/manual/18-mods.md @@ -59,6 +59,21 @@ not want with **Remove…**. ![Removing a level](img/levels_remove.jpg) +## The Alligator Arena minigame + +The **Alligator Arena** mod (Levels) puts a small minigame on a TestLevel chapter (default 8): +a walled sand pit where alligators come in waves (wave 1: one, then two at a time). Face an +alligator and **kick** (Action) or **jump and press Action in the air** (the llama dives onto +it). Every hit costs it one life: it blinks, backs off and coins pop out – they count on the +coin counter. A bite costs you energy; with no energy left you get the game's usual +*Continue* screen, which restarts the arena. After the last wave five bonus coins and sparkles +appear on the purple pad at the north wall: step on it to finish the level. Unlock TestLevel on +the **Save games** tab and pick the chapter on the world-9 map. The options set the alligators' +lives, the number of waves, your energy (1–5) and the coins per hit. A level project or +another mod on the same chapter stops the apply with a message – pick another chapter then. + +![Alligator Arena: two alligators, coins popping out](img/mod_arena.jpg) + ## Command line LlamaMod can be scripted: @@ -80,6 +95,20 @@ The following list is generated from the program, so it always matches your Llam ### Levels mods +#### Alligator Arena + +A minigame on a TestLevel chapter: alligators come in waves into a walled pit. Each kick or jump attack (jump, then Action) costs one of their lives and coins pop out; the bite costs your energy. Beat every wave and the exit pad opens. Unlock TestLevel on the Save games tab. + +*Id* `arena` + +| Option | Values | Default | Description | +|---|---|---|---| +| Alligator lives | 1 – 10 | 5 | Hits each alligator takes. | +| Waves | 1 – 5 | 3 | Wave n brings n alligators (at most 2 at once). | +| Llama energy | 1 – 5 | 4 | Energy cap of the llama in the arena (5 = normal). | +| Coins per hit | 0 – 5 | 1 | Coins that pop out per hit (5 more at the end). | +| TestLevel chapter | 2 – 8 | 8 | Chapter of world 9 the arena replaces. | + #### Test World Turns the hidden TestLevel (world 9, chapter 1) into a playground island: a gallery of every character and object model of all worlds (standing still, grouped by world) and a parkour course with checkpoints. To reach it, click "Unlock TestLevel" on the Save games tab. diff --git a/docs/manual/img/mod_arena.jpg b/docs/manual/img/mod_arena.jpg new file mode 100644 index 0000000..98afa64 Binary files /dev/null and b/docs/manual/img/mod_arena.jpg differ diff --git a/notes/img/mods/arena_defeat.jpg b/notes/img/mods/arena_defeat.jpg new file mode 100644 index 0000000..a132d55 Binary files /dev/null and b/notes/img/mods/arena_defeat.jpg differ diff --git a/notes/img/mods/arena_fight.jpg b/notes/img/mods/arena_fight.jpg new file mode 100644 index 0000000..98afa64 Binary files /dev/null and b/notes/img/mods/arena_fight.jpg differ diff --git a/notes/img/mods/arena_win.jpg b/notes/img/mods/arena_win.jpg new file mode 100644 index 0000000..2088031 Binary files /dev/null and b/notes/img/mods/arena_win.jpg differ diff --git a/notes/mod-arena.md b/notes/mod-arena.md new file mode 100644 index 0000000..b074e49 --- /dev/null +++ b/notes/mod-arena.md @@ -0,0 +1,107 @@ +# Alligator Arena (`arena`, LEVELS) + +Package `mods/arena`: `ArenaMod` (writes `wads/T1L9M0nn.wad` + loading picture `T19n.COR`), +`Arena` (level script, village base like the test world), `ArenaScripts` (the strat scripts, +script language 2), `ArenaImport` (compile against a source level + `StpcMerge` import). +Registered in `ModRegistry` (one line). Test-only: `ArenaTestMod` (src/test, script options). + +| Option | Default | Meaning | +|---|---|---| +| `lives` | 5 (1–10) | hits per alligator | +| `waves` | 3 (1–5) | wave n has min(n, 2) alligators (3 waves = 5 alligators) | +| `energy` | 4 (1–5) | llama energy cap (5 = game normal) | +| `coins` | 1 (0–5) | EnemyCoins per hit (+5 on defeat, +5 bonus at the exit) | +| `chapter` | 8 (2–8) | TestLevel chapter; a chapter already written by another mod → apply error | + +Chapter ≤ 8 on purpose: `levelRecord` (coin counter) lives at `0x584F1C[world·6 + chapter]`; for +world 9 chapter 9 that is 0x585018 = furthest progress (unverified risk for level projects on +chapters ≥ 9, see "Issues"). + +Script options (`Arena`, tests/`ArenaTestMod`): `probe=true` (event probe 1.6 ahead of the llama), +`master=false` (no alligators), `start=x,z,rot`, `debug=true` (HUD coin counter shows +kills·100 + wave·10 + alive). + +## Layout + +Island 10..30 in sea (death plane). Pit x/z 16..24 (sand), walls 1.1 high, corner towers. Llama +(20.5, 18) facing +z, start camera node 1.6 behind (inside the pit). CheckPointPost (16.8, 16.8). +Exit pad 1×1 cell at (20.5, 23.5), material 16, gate on the north wall; **ArenaMaster** stands on +it. Alligator spawn spots (20.5, 22.2), (18, 21.8), (23, 21.8). Two Coins at the side walls. + +## How it works + +`ArenaImport.append` compiles the units with `StratCompiler` and appends them to the STPC of +**t1l4m002** (Jungle by day 2, has the Alligator), so `model:47`, `anim:98–104`, `sound:58–62` and +`unit:56+0xf0` (EnemyCoin) / `unit:36+0x28` (Sparkles) are that level's. A unit may refer to an +earlier one as `unit:@Name` (→ `unit:N+off`; the compiler has no `@label` references). +`merge` imports the units into the village base like the editor's cross import (models, +animations, textures, 3 sound samples, EnemyCoin/Sparkles shared), adds one prototype MAP record +per type (nVars/slots/typeFlags/extra), then `LevelBuilder` builds the level. + +* **ArenaMaster** (12 vars, no model): `g104/g105 = −1`, 2 s, then per wave: move to a spawn + spot, **yield**, `spawn<0, 16, 5, 0xC400, 3>(ArenaGator)`, `alive += 1`; waits until + `alive == 0` (gators do `parent.v3 −= 1`), 1.5 s pause. Win: `g101..g103` = door target + (1, 9, chapter), `g104/g105` = its cell (the llama's own material-16 check then ends the level + too), 5 EnemyCoins, Sparkles every 8 frames, `endLevel` when the llama stands on the pad (as + LevelDoor). Each frame `player.health2` is capped at `energy` (also after a respawn). +* **ArenaGator** (spawned, 16 vars): the Alligator's model, collision (0x4000, 3 bones), + particles and animations. `hitDamage = 1793` (1 | 256 | 512 | 1024). `on touched`: if + `damageTaken & 1792` (kick 256, dive 512, charge 1024) → hit flag, else → "bit" flag. Main + loop (switch state): 0 watch (turn to the player, after a timer snap if within 5 units and on + the same floor ±0.4), 1 snap anim, 2 chase (speed up to 0.03 + 0.006·wave), 4/5 lunge and + slow down (original numbers), 3 back off. Hit: lives −1, sound, green puff, `coins` EnemyCoins, + blink 45 frames (alpha 0.25 + red ambient every 4 frames, no further hits), back off. Bite: + back off and rest 90 frames. Clamped to the pit, y = floor. Last life: the original death + (shrink, puffs, 5 EnemyCoins), `parent.v3 −= 1`, remove. The `aim` sub (ported L_03d0) writes + g17..g20 so the llama's dive homes in on it. Fade-in on spawn (setAmbient 0→63). + +## Engine facts found + +* **Compiled `on touched` handlers fire** (event type 4): probe in game – the llama walks into + it, it removes itself and three EnemyCoins pop out and are collected (coin counter 3). +* Contact: each side's `hitDamage` is copied into the other's `damageTaken`, both get event 4. + Llama: kick = 256 (Action on the ground, anim 28, hind-leg collision bones), dive = 512 (Action + while jumping: homes in on the target in g17..g19 that enemies' scripts publish, best distance + g20), charge = 1024. The llama counts a contact as its hit if `damageTaken & hitDamage ≠ 0`, + else `damageTaken & 127 == 1` → `loseHeart` + knock-back (the original Alligator has 513 = only + the dive hurts it, a kick gets you bitten). +* `spawn`: same fields as a MAP record (FUN_0054bfc0); + `extra` = words for collision bones. A MAP-placed type that uses `collisionOn` needs collision + type flags (0x28000 like Coin) – with 0 the game crashed (page fault in the heap allocator + FUN_0041ae20). +* `spawn` copies the strat's matrix/translation block, not `x/y/z`: after `self.x = …` the child + appears at the old place unless the spawner yields first. +* Energy 0: the game's "Weiter / Beenden" screen; Weiter restarts the level (all strats fresh). +* The level tally after the exit shows "Münzen gesammelt 0 / 55" (not our coin counter). + +## Verified in game (apptest :24, Wine 6, load 3–9, 2026-11-07) + +* Probe (`probe=true;master=false`): handler fires, coins collected (counter 3). +* Default options (energy 5 and 4 runs): alligator fades in, watches, snaps, chases, bites (HUD + energy drops), backs off. Kicks (Z) hit: puff, blink, coins pop out, counter rises + (1 → 13 over two waves); defeat with puff and coins; wave 2 brings two alligators + (debug counter 122 = 1 kill, wave 2, 2 alive). Blind kicking dies in wave 2 → Weiter restarts + the arena with energy capped again. +* Win (`lives=1, waves=1, start=20.5,21,0`): one kick defeats it, bonus coins + sparkles on the + pad, walking onto it → tally screen with the arena loading picture. +* Not verified separately: the dive (jump + Action) as the hit (sequences mixed kicks and dives), + the charge, waves 4–5, coins option values other than 1. + +![fight](img/mods/arena_fight.jpg) +![defeat](img/mods/arena_defeat.jpg) +![win: bonus coins on the exit pad, tally](img/mods/arena_win.jpg) + +## Tests + +`ArenaTest`: scripts compile for all option corners (EnemyCoin spawns = coins + 5, gator spawns = +alligators, energy cap code only below 5); wave sizes; settings validation; registered; built +level (CD): Llama, master on the exit cell with its vars, checkpoint, 2 coins, no LevelDoor, merged +code decodes, master spawns the gator unit, gator spawns EnemyCoin, `lives` in the code; another +mod's chapter → IOException; probe option. + +## Issues / limits + +* Loading picture and tally use the TestLevel texts ("TestLevel Kapitel 8"). +* The exit target is the arena itself (1, 9, chapter): it restarts after the tally. +* Level projects on TestLevel chapters ≥ 9 may write the coin counter over furthest progress + (0x585018) – not verified. diff --git a/src/main/java/com/eliasmueller/llamamod/mods/ModRegistry.java b/src/main/java/com/eliasmueller/llamamod/mods/ModRegistry.java index daaee12..9d89296 100644 --- a/src/main/java/com/eliasmueller/llamamod/mods/ModRegistry.java +++ b/src/main/java/com/eliasmueller/llamamod/mods/ModRegistry.java @@ -23,6 +23,7 @@ public final class ModRegistry { "com.eliasmueller.llamamod.mods.cheats.CheatsMod", "com.eliasmueller.llamamod.mods.practice.PracticeMod", "com.eliasmueller.llamamod.mods.freecam.FreeCamMod", + "com.eliasmueller.llamamod.mods.arena.ArenaMod", }; /** {@link ModSource}s loaded by class name (dynamic mods, e.g. level projects). */ diff --git a/src/main/java/com/eliasmueller/llamamod/mods/arena/Arena.java b/src/main/java/com/eliasmueller/llamamod/mods/arena/Arena.java new file mode 100644 index 0000000..cfd47cc --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/mods/arena/Arena.java @@ -0,0 +1,201 @@ +package com.eliasmueller.llamamod.mods.arena; + +import static com.eliasmueller.llamamod.levelbuild.Opts.NONE; +import static com.eliasmueller.llamamod.levelbuild.Opts.o; + +import com.eliasmueller.llamamod.levelbuild.LevelBuilder; +import com.eliasmueller.llamamod.levelbuild.LevelScript; +import com.eliasmueller.llamamod.levelbuild.PyRandom; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +/** + * Alligator arena (based on the village base level like the test world): a walled 8 × 8 pit on a + * small island, the llama at the south end, the exit pad at the north end. The {@link + * ArenaScripts#MASTER} strat on the exit pad spawns the alligator waves and opens the exit after + * the last one. + * + *

Script options (tests): {@code probe=true} places an {@link ArenaScripts#PROBE} 1.6 units in + * front of the llama; {@code start=x,z,rot} moves the llama; {@code master=false} leaves out + * the master (no alligators). + */ +public final class Arena implements LevelScript { + /** Source level of the alligator (models, animations, sounds, EnemyCoin). */ + static final String SOURCE = "wads/t1l4m002.wad"; + + static final ArenaScripts.Layout LAYOUT = new ArenaScripts.Layout( + 16, 16, 24, 24, 0, 20.5, 23.5, List.of(new double[] {20.5, 22.2}, new double[] {18.0, 21.8}, new double[] { + 23.0, 21.8 + })); + + /** Player start (x, z, yaw): south end, facing north (+z). */ + static final double[] START = {20.5, 18.0, 0}; + + /** Collision material of the exit floor. */ + private static final int DOOR_MAT = 16; + + private static final double WALL = 1.1; + + private final ArenaScripts.Settings settings; + private ArenaImport.Merged merged; + + public Arena(ArenaScripts.Settings settings) { + this.settings = settings; + } + + @Override + public String levelName() { + return "Alligator Arena"; + } + + @Override + public long seed() { + return 11; + } + + ArenaImport.Merged merged() { + return merged; + } + + /** The script types in unit order (the master spawns the gator, so the gator comes first). */ + List units(boolean probe, boolean debug) { + List u = new ArrayList<>(); + u.add(new ArenaImport.Unit( + ArenaScripts.GATOR, + ArenaScripts.gator(settings, LAYOUT), + ArenaScripts.GATOR_VARS, + 5, + 0xC000, + ArenaScripts.GATOR_BONES)); + u.add(new ArenaImport.Unit( + ArenaScripts.MASTER, ArenaScripts.master(settings, LAYOUT, debug), ArenaScripts.MASTER_VARS, 0, 0, 0)); + if (probe) { + u.add(new ArenaImport.Unit(ArenaScripts.PROBE, ArenaScripts.probe(), 3, 1, 0x28000, 0)); + } + return u; + } + + /** Imports the arena types from {@link #SOURCE} into the base level, then builds as usual. */ + @Override + public byte[] buildWad(byte[] baseWad, Function wads, Map opts) { + byte[] src = wads.apply(SOURCE); + if (src == null) { + throw new IllegalStateException(SOURCE + " missing (needed for the alligator)"); + } + boolean probe = opts != null && "true".equals(opts.get("probe")); + merged = ArenaImport.merge( + baseWad, src, "t1l4m002", units(probe, opts != null && "true".equals(opts.get("debug")))); + return LevelScript.super.buildWad(merged.wad(), wads, opts); + } + + @Override + public void build(LevelBuilder l) { + ArenaScripts.Layout a = LAYOUT; + PyRandom rnd = new PyRandom(5); + l.sun.azimuth = 200; + l.sun.elevation = 55; + l.sun.strength = 0.7; + + // island in the sea (death plane), arena in the middle + double ix0 = 10, iz0 = 10, ix1 = 30, iz1 = 30; + l.sea(0, 0, 40, 40, -1.5, new double[] {ix0 - 3, iz0 - 3, ix1 + 3, iz1 + 3}); + l.box( + ix0, + iz0, + ix1, + iz1, + -2.2, + 0, + o().top(NONE).side("earth2").sideTop("grass_edge").solid(false)); + l.floor(ix0, iz0, ix1, a.z0(), 0, "grass"); + l.floor(ix0, a.z1(), ix1, iz1, 0, "grass"); + l.floor(ix0, a.z0(), a.x0(), a.z1(), 0, "grass"); + l.floor(a.x1(), a.z0(), ix1, a.z1(), 0, "grass"); + + // pit floor: sand with a stone ring + l.floor(a.x0(), a.z0(), a.x1(), a.z1(), a.floor(), "sand"); + l.floor(a.x0() + 1.5, a.z0() + 1.5, a.x1() - 1.5, a.z1() - 1.5, a.floor() + 0.01, "sand2", o().coll(false)); + // walls (inner faces visible), corner towers + double t = 0.4; + l.box( + a.x0() - t, + a.z0() - t, + a.x1() + t, + a.z0(), + 0, + WALL, + o().top("stone").side("stone_moss")); + l.box( + a.x0() - t, + a.z1(), + a.x1() + t, + a.z1() + t, + 0, + WALL, + o().top("stone").side("stone_moss")); + l.box(a.x0() - t, a.z0(), a.x0(), a.z1(), 0, WALL, o().top("stone").side("stone_moss")); + l.box(a.x1(), a.z0(), a.x1() + t, a.z1(), 0, WALL, o().top("stone").side("stone_moss")); + for (double[] p : new double[][] { + {a.x0() - t / 2, a.z0() - t / 2}, {a.x1() + t / 2, a.z0() - t / 2}, + {a.x0() - t / 2, a.z1() + t / 2}, {a.x1() + t / 2, a.z1() + t / 2} + }) { + l.pillar(p[0], p[1], 0, WALL + 0.7, 0.7, o().side("inca").top("stone")); + } + // exit pad (1 × 1 cell, exit floor material) under a gate on the north wall + double ex = a.exitX(), ez = a.exitZ(); + l.box( + ex - 0.5, + ez - 0.5, + ex + 0.5, + ez + 0.5, + a.floor(), + a.floor() + 0.06, + o().top("pattern_purple").side("inca").rot(0).sideColl(false).mat(DOOR_MAT)); + for (double x : new double[] {ex - 0.7, ex + 0.7}) { + l.pillar(x, a.z1() + t / 2, WALL, 2.4, 0.3, o().side("inca").top("stone")); + } + l.box( + ex - 0.85, + a.z1(), + ex + 0.85, + a.z1() + t, + 2.4, + 2.65, + o().top("stone").side("stone_edge").rot(0)); + + // decoration outside + for (double[] p : new double[][] {{12, 12.5}, {28, 12}, {12.5, 27.5}, {27.5, 28}, {13, 20}, {27, 19.5}}) { + l.tree(p[0], p[1], 0, rnd.uniform(1.3, 1.9), 1.1); + } + + // objects + double[] st = START; + String s = l.opt("start", ""); + if (!s.isEmpty()) { + String[] v = s.split(","); + st = new double[] {Double.parseDouble(v[0]), Double.parseDouble(v[1]), Double.parseDouble(v[2])}; + } + l.player(st[0], st[1], st[2], null, 1.6); + l.checkpoint(a.x0() + 0.8, a.z0() + 0.8); + if (!"false".equals(l.opt("master", "true"))) { + l.strat(ArenaScripts.MASTER, ex, ez, a.floor(), 0.0, null, LevelBuilder.Waypoints.NONE, 0, false); + } + if ("true".equals(l.opt("probe", "false"))) { + double r = Math.toRadians(st[2]); + l.strat( + ArenaScripts.PROBE, + st[0] + 1.6 * Math.sin(r), + st[1] + 1.6 * Math.cos(r), + a.floor(), + 0.0, + null, + LevelBuilder.Waypoints.NONE, + 0, + false); + } + // a few coins along the walls to start the counter + l.coins(List.of(new double[] {a.x0() + 0.6, 20}, new double[] {a.x1() - 0.6, 20})); + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/mods/arena/ArenaImport.java b/src/main/java/com/eliasmueller/llamamod/mods/arena/ArenaImport.java new file mode 100644 index 0000000..ec233e7 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/mods/arena/ArenaImport.java @@ -0,0 +1,183 @@ +package com.eliasmueller.llamamod.mods.arena; + +import com.eliasmueller.llamamod.format.Bytes; +import com.eliasmueller.llamamod.format.Level; +import com.eliasmueller.llamamod.format.MapChunk; +import com.eliasmueller.llamamod.format.TextChunk; +import com.eliasmueller.llamamod.format.Wad; +import com.eliasmueller.llamamod.format.stpcmerge.DialogMerge; +import com.eliasmueller.llamamod.format.stpcmerge.SoundMerge; +import com.eliasmueller.llamamod.format.stpcmerge.StpcImage; +import com.eliasmueller.llamamod.format.stpcmerge.StpcMerge; +import com.eliasmueller.llamamod.format.stpcmerge.TextMerge; +import com.eliasmueller.llamamod.format.strat.StratAssembler; +import com.eliasmueller.llamamod.format.strat.StratCompiler; +import com.eliasmueller.llamamod.format.strat.StratListing; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeSet; + +/** + * Compiles script types against a source level (whose models, animations, sounds and strats they + * use by index) and imports them into a base level with {@link StpcMerge}, like the editor's + * cross-world import: the closure (models, animation sets, textures, sounds, spawned strats such as + * EnemyCoin) comes along. One unplaced prototype MAP record per type lets the level builder place it. + */ +final class ArenaImport { + /** A script type: compiled as its own unit; {@code nVars}/{@code slots}/{@code typeFlags} go into the prototype. */ + record Unit(String name, String source, int nVars, int slots, int typeFlags, int extra) {} + + /** Merged base level and the entry code of every type in it. */ + record Merged(byte[] wad, Map codes, StpcMerge.Stats stats) {} + + /** Units appended to a source STPC: new data and entry offsets (source STPC offsets). */ + record Appended(byte[] stpc, Map entries, Map unitIndex) {} + + private ArenaImport() {} + + /** + * Appends the units in order. A source may name an earlier unit as {@code unit:@Name}; it is + * replaced by {@code unit:N+off} (index and entry offset of that unit). + */ + static Appended append(byte[] stpc, int wfpc, List units) { + byte[] d = stpc; + Map entries = new LinkedHashMap<>(); + Map index = new LinkedHashMap<>(); + Map refs = new LinkedHashMap<>(); + for (Unit u : units) { + StpcImage cur = new StpcImage(d, wfpc); + int base = d.length + 4; + String src = u.source(); + for (var e : refs.entrySet()) { + src = src.replace("unit:@" + e.getKey(), e.getValue()); + } + if (src.contains("unit:@")) { + throw new IllegalArgumentException(u.name() + ": unknown unit reference in " + src); + } + StratAssembler.Result r = + StratAssembler.assemble(StratCompiler.compile(src), StratListing.layout(cur, base)); + Integer at = r.labels().get(u.name()); + if (at == null) { + throw new IllegalArgumentException("no strat " + u.name() + " in its source"); + } + byte[] words = r.bytes(); + byte[] nd = Arrays.copyOf(d, d.length + 4 + words.length); + putI32(nd, d.length, words.length / 4); + System.arraycopy(words, 0, nd, d.length + 4, words.length); + putI32(nd, cur.codeStart, Bytes.i32(nd, cur.codeStart) + 1); + int idx = cur.units.size(); + entries.put(u.name(), base + at); + index.put(u.name(), idx); + refs.put(u.name(), "unit:" + idx + "+0x" + Integer.toHexString(at)); + d = nd; + } + return new Appended(d, entries, index); + } + + /** Imports the units (compiled against {@code sourceWad}) into {@code baseWad}. */ + static Merged merge(byte[] baseWad, byte[] sourceWad, String sourceName, List units) { + Level src = Level.parse(sourceWad); + Appended ap = append(src.wad().require("STPC"), src.wfpc(), units); + TreeSet roots = new TreeSet<>(); + src.map().strats.forEach(s -> roots.add(s.code())); + roots.addAll(ap.entries().values()); + StpcMerge.Analysis sa = new StpcMerge.Analysis(new StpcImage(ap.stpc(), src.wfpc()), roots); + if (!sa.code.errors.isEmpty()) { + throw new IllegalStateException("arena units do not decode: " + sa.code.errors.get(0)); + } + Wad sw = src.wad(); + StpcMerge.Source source = new StpcMerge.Source( + sourceName, + sa, + TextMerge.Textures.of(sw.require("TEXT")), + SoundMerge.parse(sw.get("SMPC")), + sw.get("LGPC") == null ? null : DialogMerge.Texts.parse(sw.get("LGPC")), + DialogMerge.Speech.parse(sw.get("SRPC"))); + + Level lv = Level.parse(baseWad); + Wad wad = lv.wad(); + TreeSet troots = new TreeSet<>(); + lv.map().strats.forEach(s -> troots.add(s.code())); + StpcMerge.Analysis tgt = new StpcMerge.Analysis(new StpcImage(wad.require("STPC"), lv.wfpc()), troots); + byte[] text = wad.require("TEXT"); + TextMerge.Textures tex = TextMerge.Textures.of(text); + int origTail = 8 + 8 * tex.entries.size(); + for (TextMerge.Anim a : tex.anims) { + origTail += 3 + a.data().length; + } + List snd = SoundMerge.parse(wad.get("SMPC")); + DialogMerge.Texts texts = wad.get("LGPC") == null ? null : DialogMerge.Texts.parse(wad.get("LGPC")); + DialogMerge.Speech speech = DialogMerge.Speech.parse(wad.get("SRPC")); + List reqs = new ArrayList<>(); + for (Unit u : units) { + reqs.add(new StpcMerge.Request(source, ap.entries().get(u.name()))); + } + StpcMerge.Result res = StpcMerge.merge(tgt, reqs, new StpcMerge.Target(tex, snd, texts, speech)); + if (res.stats().addedTexts() != 0) { + throw new IllegalStateException("arena types must not need dialogue texts"); + } + MapChunk map = lv.map(); + List recs = new ArrayList<>(); + for (MapChunk.Strat s : map.strats) { + int[] a = s.toArray(); + a[6] = res.target().map(s.code()); + recs.add(MapChunk.Strat.of(a)); + } + Map codes = new LinkedHashMap<>(); + for (int i = 0; i < units.size(); i++) { + Unit u = units.get(i); + int code = res.entries().get(reqs.get(i)); + codes.put(u.name(), code); + // rx ry rz x y z code nParams paramIndex nVars slots typeFlags extra waypoint zero flags last + recs.add(MapChunk.Strat.of(new int[] { + 0, 0, 0, 0, 0, 0, code, 0, -1, u.nVars(), u.slots(), u.typeFlags(), u.extra(), -1, 0, 0, 0 + })); + } + map.strats.clear(); + map.strats.addAll(recs); + Wad out = new Wad(wad.chunks()); + out.set("STPC", res.stpc()); + out.set("MAP", map.build()); + out.set("TEXT", text(text, tex, origTail)); + if (wad.get("SMPC") != null) { + out.set("SMPC", SoundMerge.build(snd)); + } + return new Merged(out.toBytes(), codes, res.stats()); + } + + /** TEXT chunk with the (extended) pages, entries and animations of tex; the rest is kept. */ + private static byte[] text(byte[] orig, TextMerge.Textures tex, int origTailHead) { + TextChunk tc = TextChunk.parse(orig); + byte[] rest = Arrays.copyOfRange(tc.tail, origTailHead, tc.tail.length); + ByteBuffer b = ByteBuffer.allocate(8 + 8 * tex.entries.size() + tex.anims.size() * 64 + rest.length + 1024) + .order(ByteOrder.LITTLE_ENDIAN); + b.putInt(tex.entries.size()); + for (TextChunk.TextureEntry e : tex.entries) { + b.putShort((short) e.flags()) + .put((byte) e.page()) + .put((byte) e.page2()) + .put((byte) e.u0()) + .put((byte) e.u1()) + .put((byte) e.v0()) + .put((byte) e.v1()); + } + b.putInt(tex.anims.size()); + for (TextMerge.Anim a : tex.anims) { + b.putShort((short) a.texture()).put((byte) a.type()).put(a.data()); + } + b.put(rest); + tc.pages.clear(); + tc.pages.addAll(tex.pages); + tc.tail = Arrays.copyOf(b.array(), b.position()); + return tc.build(); + } + + private static void putI32(byte[] d, int o, int v) { + ByteBuffer.wrap(d, o, 4).order(ByteOrder.LITTLE_ENDIAN).putInt(v); + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/mods/arena/ArenaMod.java b/src/main/java/com/eliasmueller/llamamod/mods/arena/ArenaMod.java new file mode 100644 index 0000000..c11f3cb --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/mods/arena/ArenaMod.java @@ -0,0 +1,139 @@ +package com.eliasmueller.llamamod.mods.arena; + +import com.eliasmueller.llamamod.format.CorEncoder; +import com.eliasmueller.llamamod.levelbuild.LevelBuilder; +import com.eliasmueller.llamamod.mods.Mod; +import com.eliasmueller.llamamod.mods.ModContext; +import com.eliasmueller.llamamod.mods.ModOption; +import java.awt.Color; +import java.awt.Font; +import java.awt.FontMetrics; +import java.awt.GradientPaint; +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** Alligator Arena minigame on a TestLevel (world 9) chapter. */ +public final class ArenaMod implements Mod { + @Override + public String id() { + return "arena"; + } + + @Override + public String name() { + return "Alligator Arena"; + } + + @Override + public String category() { + return LEVELS; + } + + @Override + public String description() { + return "A minigame on a TestLevel chapter: alligators come in waves into a walled pit. Each kick or" + + " jump attack (jump, then Action) costs one of their lives and coins pop out; the bite costs" + + " your energy. Beat every wave and the exit pad opens. Unlock TestLevel on the Save games tab."; + } + + @Override + public List options() { + return List.of( + ModOption.integer("lives", "Alligator lives", "Hits each alligator takes.", 5, 1, 10), + ModOption.integer("waves", "Waves", "Wave n brings n alligators (at most 2 at once).", 3, 1, 5), + ModOption.integer( + "energy", "Llama energy", "Energy cap of the llama in the arena (5 = normal).", 4, 1, 5), + ModOption.integer("coins", "Coins per hit", "Coins that pop out per hit (5 more at the end).", 1, 0, 5), + ModOption.integer("chapter", "TestLevel chapter", "Chapter of world 9 the arena replaces.", 8, 2, 8)); + } + + /** After Test World (50) and level projects (60), so a clash with them is reported here. */ + @Override + public int order() { + return 65; + } + + static String wadPath(int chapter) { + return String.format(Locale.ROOT, "wads/T1L9M%03d.wad", chapter); + } + + static ArenaScripts.Settings settings(ModContext ctx) { + int chapter = ctx.optionInt("chapter"); + return new ArenaScripts.Settings( + ctx.optionInt("lives"), + ctx.optionInt("waves"), + ctx.optionInt("energy"), + ctx.optionInt("coins"), + new int[] {1, 9, chapter}); + } + + @Override + public void apply(ModContext ctx) throws IOException { + apply(ctx, Map.of()); + } + + /** Applies with level script options ({@link Arena}: probe, start; for tests). */ + static void apply(ModContext ctx, Map scriptOpts) throws IOException { + int chapter = ctx.optionInt("chapter"); + String target = wadPath(chapter); + if (ctx.exists(target)) { + Path op = ctx.originalCd().resolve(target); + byte[] cur = ctx.read(target); + if (!Files.exists(op) || !Arrays.equals(cur, Files.readAllBytes(op))) { + throw new IOException("Alligator Arena: " + target + " is already changed by another mod (a level" + + " on TestLevel chapter " + chapter + "). Choose another chapter in the arena's options."); + } + } + ctx.progress(0.0, "building alligator arena"); + byte[] base = ctx.read(LevelBuilder.BASE_WAD); + byte[] wad = new Arena(settings(ctx)) + .buildWad( + base, + rel -> { + try { + return ctx.exists(rel) ? ctx.read(rel) : null; + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }, + scriptOpts); + ctx.write(target, wad); + ctx.progress(0.9, "loading picture"); + ctx.write("T19" + chapter + ".COR", loadingPicture(chapter)); + ctx.progress(1.0, "arena done"); + } + + static byte[] loadingPicture(int chapter) { + BufferedImage img = new BufferedImage(640, 480, BufferedImage.TYPE_INT_RGB); + Graphics2D g = img.createGraphics(); + g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); + g.setPaint(new GradientPaint(0, 0, new Color(0x1f4d2b), 0, 480, new Color(0x0a140c))); + g.fillRect(0, 0, 640, 480); + g.setColor(new Color(0xc9a24a)); + g.fillRect(80, 300, 480, 8); + String title = "Alligator Arena"; + g.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 58)); + FontMetrics fm = g.getFontMetrics(); + g.setColor(new Color(0, 0, 0, 160)); + g.drawString(title, (640 - fm.stringWidth(title)) / 2 + 2, 252); + g.setColor(new Color(0xf5c542)); + g.drawString(title, (640 - fm.stringWidth(title)) / 2, 250); + String sub = "TestLevel – chapter " + chapter; + g.setFont(new Font(Font.SANS_SERIF, Font.PLAIN, 26)); + fm = g.getFontMetrics(); + g.setColor(new Color(0xe8f0e0)); + g.drawString(sub, (640 - fm.stringWidth(sub)) / 2, 350); + g.dispose(); + return CorEncoder.encode(img); + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/mods/arena/ArenaScripts.java b/src/main/java/com/eliasmueller/llamamod/mods/arena/ArenaScripts.java new file mode 100644 index 0000000..3708b35 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/mods/arena/ArenaScripts.java @@ -0,0 +1,510 @@ +package com.eliasmueller.llamamod.mods.arena; + +import java.util.List; +import java.util.Locale; + +/** + * Strat scripts of the arena (script language 2), written against the source level t1l4m002 + * (Jungle by day 2), whose Alligator they reuse: model 47, animations 98–104, sounds 58–62 and the + * EnemyCoin strat (unit 56 + 0xf0) that the original drops when it dies. + * + *

Combat facts from the original scripts: the llama's attacks set its {@code hitDamage} + * (256 kick, 512 dive from a jump, 1024 charge); on contact each side's {@code hitDamage} is + * copied into the other's {@code damageTaken} and both get a "touched" event. The original + * Alligator has hitDamage 513 (= 512 | 1): the llama takes it as a successful hit if its own + * hitDamage shares a bit (only the dive), otherwise bit 1 makes it lose a heart and bounce back. + * ArenaGator uses 1793 (1 | 256 | 512 | 1024) so kick, dive and charge all count. The dive homes + * in on the target that its strat writes into g17..g20 (the "aim" sub, ported from the original). + */ +final class ArenaScripts { + static final String GATOR = "ArenaGator"; + static final String MASTER = "ArenaMaster"; + static final String PROBE = "ArenaProbe"; + + /** Variables of a spawned gator (spawn operand) and of the master (MAP prototype). */ + static final int GATOR_VARS = 16; + + static final int MASTER_VARS = 12; + + /** + * Collision bones of the gator = last spawn operand / MAP "extra" (SpawnedAlligator: 2 bones and 2, + * Alligator: 3 and 3). Too small corrupts the strat heap (page fault in FUN_0041ae20). + */ + static final int GATOR_BONES = 3; + + /** Strat hitDamage bits of the llama's attacks: kick, dive, charge. */ + static final int ATTACK_BITS = 256 | 512 | 1024; + + /** Most alligators alive at once (wave n has min(n, this)). */ + static final int MAX_AT_ONCE = 2; + + /** + * @param lives hits an alligator takes + * @param waves number of waves (wave n: min(n, 2) alligators) + * @param energy llama energy cap (5 = the game's normal) + * @param coinsPerHit EnemyCoins popping out per hit (5 more when it is defeated) + * @param door exit target (tribe, world, chapter), ×0x1000 = raw words + */ + record Settings(int lives, int waves, int energy, int coinsPerHit, int[] door) { + Settings { + if (lives < 1 || waves < 1 || energy < 1 || energy > 5 || coinsPerHit < 0 || door.length != 3) { + throw new IllegalArgumentException("bad arena settings"); + } + door = door.clone(); + } + + static Settings defaults(int chapter) { + return new Settings(5, 3, 4, 1, new int[] {1, 9, chapter}); + } + } + + /** Arena bounds and spots (game units). */ + record Layout( + double x0, + double z0, + double x1, + double z1, + double floor, + double exitX, + double exitZ, + List spawns) {} + + private ArenaScripts() {} + + static int aliveInWave(int wave) { + return Math.min(wave, MAX_AT_ONCE); + } + + static int totalAlligators(int waves) { + int n = 0; + for (int w = 1; w <= waves; w++) { + n += aliveInWave(w); + } + return n; + } + + static String num(double v) { + String s = String.format(Locale.ROOT, "%.3f", v); + return s.replaceAll("0+$", "").replaceAll("\\.$", ".0"); + } + + /** One EnemyCoin, offset from the gator (forward, right) and back. */ + private static String coin(String ind, double fwd, double right) { + StringBuilder b = new StringBuilder(); + if (fwd != 0) { + b.append(ind).append("moveForwardQ(").append(num(fwd)).append(")\n"); + } + if (right != 0) { + b.append(ind).append("moveRightQ(").append(num(right)).append(")\n"); + } + b.append(ind).append("spawn<1, 3, 1, 0x28000, 0>(0, unit:56+0xf0)\n"); + if (right != 0) { + b.append(ind).append("moveLeftQ(").append(num(right)).append(")\n"); + } + if (fwd != 0) { + b.append(ind).append("moveBackwardQ(").append(num(fwd)).append(")\n"); + } + return b.toString(); + } + + /** The alligator: N lives, chases and lunges, blinks and backs off when hit, drops coins. */ + static String gator(Settings s, Layout a) { + double m = 0.6; + StringBuilder hitCoins = new StringBuilder(); + double[][] offs = {{0.35, 0}, {0.2, 0.3}, {0.2, -0.3}, {0.45, 0.2}, {0.45, -0.2}}; + for (int i = 0; i < s.coinsPerHit(); i++) { + double[] o = offs[i % offs.length]; + hitCoins.append(coin(" ", o[0], o[1])); + } + return """ + strat ArenaGator { + alias lives = v0 + alias state = v1 + alias timer = v2 + alias speed = v3 + alias hit = v4 + alias aimH = v5 + alias aimD = v6 + alias cool = v7 + alias top = v8 + alias k = v9 + alias size = v10 + alias bit = v11 + alias rest = v12 + setModel(model:47) + scale(1.5) + self.hitDamage = %HITDAMAGE% + collisionOn<16384>() + collRadius(409r) + collisionBone(0, 409r) + collisionBone(6.0, 409r) + collisionBone(10.0, 409r) + on touched { + if (self.damageTaken & %ATTACK%) { + if (cool <= 0 && lives > 0) { + hit = 1 + } + } else { + bit = 1 + } + } + emitterInit(0, 23r, 1.0, 0, 0, 0, 0) + emitterSetColour(0, 0, 0, 0, packRgb(0, rnd(255.0), 0)) + emitterSetParams(0, 358r, 50.0, 0, 0) + emitterInit(1.0, 23r, 1.0, 0, 0, 0, 0) + emitterSetColour(1.0, 0, 0, 0, packRgb(0, rnd(255.0), 0)) + emitterSetParams(1.0, 0.075, 45.0, 0, 0) + emitterInit(2.0, 23r, 1.0, 0, 0, 0, 0) + emitterSetColour(2.0, 0, 0, 0, packRgb(0, rnd(255.0), 0)) + emitterSetParams(2.0, 256r, 40.0, 0, 0) + lives = %LIVES% + hit = 0 + bit = 0 + rest = 25 + cool = 0 + top = 0.03 + parent.v1 * 0.006 + self.y = %FLOOR% + k = 0 + do { + k = min(k + 2, 63) + setAmbient(k, k, k) + if (!self.animPlaying) { + animPlay(anim:99) + } + call aim + yield + } while (k < 63) + state = 0 + // the k-th alligator of a wave waits longer before its first attack + timer = 10 + parent.v3 * 40 + loop { + call aim + if (hit) { + hit = 0 + lives -= 1 + if (lives <= 0) { + break + } + soundPlay(sound:62) + savePos() + call puff + call puff + call puff + %HITCOINS% + cool = 45 + state = 3 + timer = 10 + rest = 25 + setAnimSpeed(1.0) + animPlay(anim:104) + } + if (bit) { + // the llama was bitten: back off and rest a while + bit = 0 + if (state != 3) { + setAnimSpeed(1.0) + animPlay(anim:104) + state = 3 + timer = 12 + rest = 90 + } + } + if (cool > 0) { + cool -= 1 + if (cool & 4) { + self.alpha = 0.25 + setAmbient(255, 120, 120) + } else { + self.alpha = 1.0 + setAmbient(63, 63, 63) + } + } + switch (state) { + case 0: + if (!self.animPlaying) { + animPlay(anim:99) + } + turnTowardAngleY(self.angleToPlayer, 2deg) + timer -= 1 + if (timer < 0 && self.distToPlayerSqXZ < 25 && abs(player.floorHeight - self.y) < 0.4) { + soundPlay(sound:61) + animPlay(anim:100) + state = 1 + } + break + case 1: + if (!self.animPlaying) { + speed = 99r + timer = 75 + state = 2 + } + break + case 2: + if (!self.animPlaying) { + animPlay(anim:98) + } + speed = min(speed + 6r, top) + setAnimSpeed(speed * 0x1638e) + turnTowardAngleY(self.angleToPlayer, 5.625deg) + moveForwardQ(speed) + timer -= 1 + if (self.distToPlayerSqXZ < 1.44) { + soundPlay(sound:62) + animPlay(anim:101) + setAnimSpeed(1.0) + speed = 243r + state = 4 + } else if (timer < 0 || abs(player.floorHeight - self.y) > 0.4) { + setAnimSpeed(1.0) + animPlay(anim:104) + timer = 30 + rnd(30) + state = 0 + } + break + case 3: + moveBackwardQ(0.07) + timer -= 1 + if (timer < 0) { + timer = rest + state = 0 + } + break + case 4: + if (!self.animPlaying) { + animPlay(anim:102) + } + speed = max(speed - 2r, 175r) + moveForwardQ(speed) + if (speed <= 175r) { + animPlay(anim:103) + state = 5 + } + break + default: + if (!self.animPlaying) { + animPlay(anim:98) + } + speed = max(speed - 4r, 99r) + setAnimSpeed(speed * 0x1638e) + moveForwardQ(speed) + if (speed <= 99r) { + setAnimSpeed(1.0) + animPlay(anim:104) + timer = 50 + rnd(30) + state = 0 + } + } + self.x = clamp(%X0%, self.x, %X1%) + self.z = clamp(%Z0%, self.z, %Z1%) + self.y = %FLOOR% + yield + } + collisionOff<16384>() + soundPlay(sound:60) + self.alpha = 1.0 + setAmbient(63, 63, 63) + savePos() + animStop() + size = 1.5 + k = 0 + do { + call shrink + k += 1 + if (k == 1) { + %DEATH1% + } else if (k == 3) { + %DEATH3% + } else if (k == 5) { + %DEATH5% + } else if (k == 7) { + %DEATH7% + } else if (k == 9) { + %DEATH9% + } + yield + } while (size > 0) + parent.v3 -= 1 + parent.v4 += 1 + remove + } + + // the llama's dive homes in on the nearest target below it (g17..g19, g20 = best distance) + sub aim { + if (g20 > 0) { + aimH = player.y - (self.y + 0.15) + if (aimH > 0 && aimH < 2) { + aimD = distSqToPosXZ(player.x + ((sin(player.rotY) * aimH) >> 1), player.z + ((cos(player.rotY) * aimH) >> 1)) + aimH = aimH * aimH + if (aimD < aimH) { + aimD = aimD + aimH + if (aimD < g20) { + g20 = aimD + g17 = self.x + g18 = self.y + 0.15 + g19 = self.z + } + } + } + } + } + + sub puff { + moveRightQ(randSigned(0.15)) + moveForwardQ(rnd(0.7) - 0.4) + emitParticlesVel(int(rnd(2.95)), 1.0, 0, 0, rnd(40r) + 20r, 0) + resetPos() + } + + sub shrink { + if (!self.animPlaying) { + animPlay(anim:98) + } + call puff + call puff + call puff + scaleX(3 - size) + scaleZ(3 - size) + scaleY(size) + self.alpha = size * 2730r + size = max(size - 409r, 0) + } + """ + .replace("%HITDAMAGE%", Integer.toString(1 | ATTACK_BITS)) + .replace("%ATTACK%", Integer.toString(ATTACK_BITS)) + .replace("%LIVES%", Integer.toString(s.lives())) + .replace("%FLOOR%", num(a.floor())) + .replace("%X0%", num(a.x0() + m)) + .replace("%X1%", num(a.x1() - m)) + .replace("%Z0%", num(a.z0() + m)) + .replace("%Z1%", num(a.z1() - m)) + .replace("%HITCOINS%\n", hitCoins.toString()) + .replace("%DEATH1%\n", coin(" ", 0.32, 0)) + .replace("%DEATH3%\n", coin(" ", 0.16, 0)) + .replace("%DEATH5%\n", coin(" ", 0, 0)) + .replace("%DEATH7%\n", coin(" ", -0.16, 0)) + .replace("%DEATH9%\n", coin(" ", -0.32, 0)); + } + + /** + * Invisible referee at the exit: spawns the waves, keeps the llama's energy at the cap, and + * after the last alligator opens the exit (LevelDoor logic: g101..g105 = target and cell, + * endLevel when the llama stands on the exit floor in this cell) with bonus coins and sparkles. + */ + static String master(Settings s, Layout a) { + return master(s, a, false); + } + + /** debug: the HUD coin counter shows kills·100 + wave·10 + alive. */ + static String master(Settings s, Layout a, boolean debug) { + StringBuilder waves = new StringBuilder(); + for (int w = 1; w <= s.waves(); w++) { + waves.append(" wave = ").append(w).append('\n'); + for (int i = 0; i < aliveInWave(w); i++) { + double[] p = a.spawns().get(i % a.spawns().size()); + waves.append(" self.x = ").append(num(p[0])).append('\n'); + waves.append(" self.z = ").append(num(p[1])).append('\n'); + // the spawn copies the strat's matrix/translation block, updated only when the frame ends + waves.append(" yield\n"); + waves.append(" spawn<0, ") + .append(GATOR_VARS) + .append(", 5, 50176, ") + .append(GATOR_BONES) + .append(">(unit:@") + .append(GATOR) + .append(")\n"); + waves.append(" alive += 1\n"); + } + waves.append(" self.x = ").append(num(a.exitX())).append('\n'); + waves.append(" self.z = ").append(num(a.exitZ())).append('\n'); + waves.append(" while (alive > 0) {\n call cap\n yield\n }\n"); + waves.append(" t = 45\n while (t > 0) {\n t -= 1\n call cap\n yield\n }\n"); + } + String cap = s.energy() >= 5 + ? "" + : " if (player.health2 > " + s.energy() + ") {\n player.health2 = " + s.energy() + + "\n }\n"; + int[] d = s.door(); + String door = (d[0] * 0x1000) + "r, " + (d[1] * 0x1000) + "r, " + (d[2] * 0x1000) + "r"; + return """ + strat ArenaMaster { + alias wave = v1 + alias alive = v3 + alias kills = v4 + alias t = v5 + g104 = -1 + g105 = -1 + wave = 0 + alive = 0 + kills = 0 + t = 60 + while (t > 0) { + t -= 1 + call cap + yield + } + %WAVES% + g101 = %D0% + g102 = %D1% + g103 = %D2% + g104 = int(self.x) + g105 = int(self.z) + %BONUS% + t = 0 + loop { + call cap + if (player.floorMaterial == 16 && int(self.x) == int(player.x) && int(self.z) == int(player.z)) { + endLevel(%DOOR%) + g1 += 1 + remove + } + t -= 1 + if (t < 0) { + spawn<3, 6, 0, 0, 0>(10.0, 204r, 450r, unit:36+0x28) + t = 8 + } + yield + } + } + + sub cap { + %CAP% + } + """ + .replace("%WAVES%\n", waves.toString()) + .replace("%BONUS%\n", coin(" ", 0, 0).repeat(5)) + .replace( + "%CAP%\n", + cap + + (debug + ? " self.levelRecord = kills * 100 + wave * 10 + alive\n" + + " self.hudTimer = 100\n" + : "")) + .replace("%D0%", (d[0] * 0x1000) + "r") + .replace("%D1%", (d[1] * 0x1000) + "r") + .replace("%D2%", (d[2] * 0x1000) + "r") + .replace("%DOOR%", door); + } + + /** Event-handler probe: a big coin that bursts into three EnemyCoins when the llama touches it. */ + static String probe() { + return """ + strat ArenaProbe { + setModel(model:12) + scale(3) + collisionOn<0x20000>() + collRadius(0.3) + self.y = self.y + 0.3 + on touched { + spawn<1, 3, 1, 0x28000, 0>(0, unit:56+0xf0) + spawn<1, 3, 1, 0x28000, 0>(0, unit:56+0xf0) + spawn<1, 3, 1, 0x28000, 0>(0, unit:56+0xf0) + remove + } + loop { + turnLeft(4deg) + yield + } + } + """; + } +} diff --git a/src/test/java/com/eliasmueller/llamamod/mods/arena/ArenaTest.java b/src/test/java/com/eliasmueller/llamamod/mods/arena/ArenaTest.java new file mode 100644 index 0000000..1a68ae9 --- /dev/null +++ b/src/test/java/com/eliasmueller/llamamod/mods/arena/ArenaTest.java @@ -0,0 +1,159 @@ +package com.eliasmueller.llamamod.mods.arena; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.eliasmueller.llamamod.format.Level; +import com.eliasmueller.llamamod.format.MapChunk; +import com.eliasmueller.llamamod.format.Strats; +import com.eliasmueller.llamamod.format.stpcmerge.StpcImage; +import com.eliasmueller.llamamod.format.stpcmerge.StratCode; +import com.eliasmueller.llamamod.format.strat.StratCompiler; +import com.eliasmueller.llamamod.format.strat.StratListing; +import com.eliasmueller.llamamod.mods.GameData; +import com.eliasmueller.llamamod.mods.ModRegistry; +import com.eliasmueller.llamamod.mods.TestModContext; +import java.io.IOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeSet; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.junit.jupiter.api.Test; + +class ArenaTest { + private static final ArenaScripts.Layout L = Arena.LAYOUT; + + /** Occurrences in the generated code (not in the embedded source comments). */ + private static int count(String asm, String what) { + String s = asm.lines() + .filter(l -> !l.startsWith(StratCompiler.SOURCE_PREFIX.trim())) + .reduce("", (a, b) -> a + b + "\n"); + int n = 0; + for (int i = s.indexOf(what); i >= 0; i = s.indexOf(what, i + 1)) { + n++; + } + return n; + } + + @Test + void scriptsCompileForAllOptionCorners() { + for (int lives : new int[] {1, 10}) { + for (int waves = 1; waves <= 5; waves++) { + for (int energy : new int[] {1, 5}) { + for (int coins : new int[] {0, 5}) { + ArenaScripts.Settings s = + new ArenaScripts.Settings(lives, waves, energy, coins, new int[] {1, 9, 8}); + String gator = StratCompiler.compile(ArenaScripts.gator(s, L)); + String master = + StratCompiler.compile(ArenaScripts.master(s, L).replaceAll("unit:@\\w+", "unit:0")); + assertTrue(gator.contains("ArenaGator:"), gator); + assertTrue(master.contains("ArenaMaster:"), master); + // per hit + 5 on defeat; master: one spawn per alligator, 5 bonus coins, sparkles + assertEquals(coins + 5, count(gator, "0x28000"), "EnemyCoin spawns in the gator"); + assertEquals(ArenaScripts.totalAlligators(waves), count(master, "spawn 0, 16, 5, 50176, 3")); + assertEquals(5, count(master, "spawn 1, 3, 1, 0x28000, 0")); + assertEquals(energy < 5, count(master, "popPlayerProp health2") > 0, "energy cap " + energy); + } + } + } + } + StratCompiler.compile(ArenaScripts.probe()); + } + + @Test + void waveSizes() { + assertEquals(1, ArenaScripts.totalAlligators(1)); + assertEquals(3, ArenaScripts.totalAlligators(2)); + assertEquals(5, ArenaScripts.totalAlligators(3)); + assertEquals(9, ArenaScripts.totalAlligators(5)); + assertThrows(IllegalArgumentException.class, () -> new ArenaScripts.Settings(0, 1, 3, 1, new int[] {1, 9, 8})); + assertThrows(IllegalArgumentException.class, () -> new ArenaScripts.Settings(5, 1, 6, 1, new int[] {1, 9, 8})); + } + + @Test + void registered() { + assertNotNull(new ModRegistry().get("arena")); + } + + private static Map> types(Level lv) { + return Strats.levelTypes(lv.wad().require("STPC"), lv.wfpc(), lv.map()); + } + + @Test + void builtLevel() throws IOException { + Path cd = GameData.cd(); + ArenaMod mod = new ArenaMod(); + TestModContext ctx = new TestModContext(cd, mod, Map.of("lives", "7", "chapter", "5"), null); + mod.apply(ctx); + byte[] wad = ctx.written.get("wads/T1L9M005.wad"); + assertNotNull(wad, "written: " + ctx.written.keySet()); + assertNotNull(ctx.written.get("T195.COR")); + + Level lv = Level.parse(wad); + Map> t = types(lv); + assertEquals(1, t.get("Llama").size()); + assertEquals(1, t.get("ArenaMaster").size()); + assertEquals(1, t.get("CheckPointPost").size()); + assertEquals(2, t.get("Coin").size()); + assertTrue( + !t.containsKey("LevelDoor") && !t.containsKey("ArenaGator"), + t.keySet().toString()); + MapChunk.Strat m = t.get("ArenaMaster").get(0); + assertEquals(ArenaScripts.MASTER_VARS, m.nVars()); + assertEquals((int) (L.exitX() * 4096), m.x()); + assertEquals((int) (L.exitZ() * 4096), m.z()); + + // merged code decodes; the master spawns the gator unit, the gator the EnemyCoin + byte[] st = lv.wad().require("STPC"); + StpcImage img = new StpcImage(st, lv.wfpc()); + TreeSet roots = new TreeSet<>(); + lv.map().strats.forEach(s -> roots.add(s.code())); + StratCode code = StratCode.analyse(img, roots); + assertTrue(code.errors.isEmpty(), code.errors.toString()); + Map listing = new HashMap<>(); + for (int u = 0; u < img.units.size(); u++) { + StratListing li = StratListing.of(img, code, u, roots, StratListing.Names.NONE); + for (String n : li.strats.values()) { + listing.put(n, li.text()); + } + } + String gator = listing.get("ArenaGator"); + String master = listing.get("ArenaMaster"); + assertNotNull(gator, "gator unit"); + assertTrue(gator.contains("push 7.0") || gator.contains("pushShort 7.0"), "lives option in the code"); + Matcher sp = Pattern.compile("address unit:(\\d+)\\+0x[0-9a-f]+\\s+; strat ArenaGator") + .matcher(master); + assertTrue(sp.find(), master); + assertTrue(Pattern.compile("address unit:\\d+\\+0x[0-9a-f]+\\s+; strat EnemyCoin") + .matcher(gator) + .find()); + } + + @Test + void refusesAChapterAnotherModChanged() throws IOException { + Path cd = GameData.cd(); + ArenaMod mod = new ArenaMod(); + TestModContext ctx = new TestModContext(cd, mod, Map.of(), null); + ctx.write("wads/T1L9M008.wad", new byte[] {1, 2, 3}); + IOException e = assertThrows(IOException.class, () -> mod.apply(ctx)); + assertTrue(e.getMessage().contains("chapter 8"), e.getMessage()); + } + + @Test + void probeOption() throws IOException { + Path cd = GameData.cd(); + ArenaMod mod = new ArenaMod(); + TestModContext ctx = new TestModContext(cd, mod, Map.of(), null); + ArenaMod.apply(ctx, Map.of("probe", "true", "master", "false")); + Level lv = Level.parse(ctx.written.get("wads/T1L9M008.wad")); + Map> t = types(lv); + assertEquals(1, t.get("ArenaProbe").size()); + assertTrue(!t.containsKey("ArenaMaster"), new ArrayList<>(t.keySet()).toString()); + } +} diff --git a/src/test/java/com/eliasmueller/llamamod/mods/arena/ArenaTestMod.java b/src/test/java/com/eliasmueller/llamamod/mods/arena/ArenaTestMod.java new file mode 100644 index 0000000..8ff7379 --- /dev/null +++ b/src/test/java/com/eliasmueller/llamamod/mods/arena/ArenaTestMod.java @@ -0,0 +1,56 @@ +package com.eliasmueller.llamamod.mods.arena; + +import com.eliasmueller.llamamod.mods.Mod; +import com.eliasmueller.llamamod.mods.ModContext; +import com.eliasmueller.llamamod.mods.ModOption; +import java.io.IOException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * The arena with level script options for in-game tests (not registered): {@code + * APPTEST_CP=build/classes/java/test APPTEST_JAVA_OPTS=-Dllamamod.extraMods=}, option + * {@code script} = {@code key=value;key=value} (probe=true, start=x,z,rot). + */ +public final class ArenaTestMod implements Mod { + @Override + public String id() { + return "arena-test"; + } + + @Override + public String name() { + return "Alligator Arena (test)"; + } + + @Override + public String description() { + return "Arena with script options for tests."; + } + + @Override + public List options() { + List o = new ArrayList<>(new ArenaMod().options()); + o.add(ModOption.string("script", "Script options", "key=value;…", "")); + return o; + } + + @Override + public int order() { + return 65; + } + + @Override + public void apply(ModContext ctx) throws IOException { + Map opts = new LinkedHashMap<>(); + for (String kv : ctx.option("script").split(";")) { + int i = kv.indexOf('='); + if (i > 0) { + opts.put(kv.substring(0, i).trim(), kv.substring(i + 1).trim()); + } + } + ArenaMod.apply(ctx, opts); + } +}