alligator arena minigame: walled pit in a testlevel chapter, waves of alligators with several lives (coins pop out on every hit, blink, back off), bites cost energy, exit opens after the last wave; options for lives, waves, energy, coins per hit and chapter

This commit is contained in:
MineTec committed 2026-11-07 14:53:00 +01:00
1 parent 38c679b319
commit ec3c619120
13 files changed
+1385

No files matched your search

+29
View File
@@ -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
<!-- generated:mods -->
### 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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

+107
View File
@@ -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<params, vars, slots, typeFlags, extra>`: 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.
@@ -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). */
@@ -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.
*
* <p>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<ArenaImport.Unit> units(boolean probe, boolean debug) {
List<ArenaImport.Unit> 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<String, byte[]> wads, Map<String, String> 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}));
}
}
@@ -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<String, Integer> codes, StpcMerge.Stats stats) {}
/** Units appended to a source STPC: new data and entry offsets (source STPC offsets). */
record Appended(byte[] stpc, Map<String, Integer> entries, Map<String, Integer> 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<Unit> units) {
byte[] d = stpc;
Map<String, Integer> entries = new LinkedHashMap<>();
Map<String, Integer> index = new LinkedHashMap<>();
Map<String, String> 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<Unit> units) {
Level src = Level.parse(sourceWad);
Appended ap = append(src.wad().require("STPC"), src.wfpc(), units);
TreeSet<Integer> 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<Integer> 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<SoundMerge.Sample> 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<StpcMerge.Request> 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<MapChunk.Strat> 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<String, Integer> 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);
}
}
@@ -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<ModOption> 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<String, String> 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);
}
}
@@ -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.
*
* <p>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<double[]> 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
}
}
""";
}
}
@@ -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<String, List<MapChunk.Strat>> 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<String, List<MapChunk.Strat>> 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<Integer> 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<String, String> 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<String, List<MapChunk.Strat>> t = types(lv);
assertEquals(1, t.get("ArenaProbe").size());
assertTrue(!t.containsKey("ArenaMaster"), new ArrayList<>(t.keySet()).toString());
}
}
@@ -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=<this class>}, 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<ModOption> options() {
List<ModOption> 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<String, String> 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);
}
}