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:
1 parent
38c679b319
commit
ec3c619120
13 files changed
+1385
No files matched your search
@@ -59,6 +59,21 @@ not want with **Remove…**.
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
|
||||

|
||||
|
||||
## 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 |
@@ -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.
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
## 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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user