diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ea6bd9..d948862 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,9 @@ There are two downloads: **full** (launcher + level editor) and **launcher** (mo Boot to level) no longer damages your progress. - The level tally shows the real coin and secret totals of new levels, the Test World and the arena instead of the Village's "55". + - **New: Llama Coaster** – a roller-coaster ride on a TestLevel chapter (default 48), with the + cart from the Catacombs: drops, climbs, boost arrows, a jump and a spin pad. Two tracks, 1–3 + laps, three speeds. - **Save games** - Backups can no longer overwrite each other. - Import… opens in the backups folder. diff --git a/docs/README.md b/docs/README.md index e20653a..d24c17e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -51,7 +51,7 @@ tutorials (first level, parkour, editing an original level, Blender import) * [The script language](strats/script-language.md): writing new behaviour * [Customising strats](strats/custom-types.md): constants, imports, clones, custom types, mods * [Example: the Alligator Arena](strats/arena.md): a complete minigame explained step by step -* [Example: the roller coaster](strats/roller-coaster.md): coming soon +* [Example: the roller coaster](strats/roller-coaster.md): a new ride for the game's own coaster cart – track data and waypoints, no new strat code **[Tools](tools.md)** – LlamaMod's launcher, editor and command line, the Python kit, the in-game test harnesses diff --git a/docs/img/strats/coaster_ride.jpg b/docs/img/strats/coaster_ride.jpg new file mode 100644 index 0000000..1bd8968 Binary files /dev/null and b/docs/img/strats/coaster_ride.jpg differ diff --git a/docs/img/strats/coaster_tally.jpg b/docs/img/strats/coaster_tally.jpg new file mode 100644 index 0000000..6fb997d Binary files /dev/null and b/docs/img/strats/coaster_tally.jpg differ diff --git a/docs/img/strats/coaster_tracks.jpg b/docs/img/strats/coaster_tracks.jpg new file mode 100644 index 0000000..e0e8f1f Binary files /dev/null and b/docs/img/strats/coaster_tracks.jpg differ diff --git a/docs/manual/18-mods.md b/docs/manual/18-mods.md index 41bfd18..4154eb8 100644 --- a/docs/manual/18-mods.md +++ b/docs/manual/18-mods.md @@ -74,6 +74,21 @@ another mod on the same chapter stops the apply with a message – pick another ![Alligator Arena: two alligators, coins popping out](img/mod_arena.jpg) +## The Llama Coaster ride + +The **Llama Coaster** mod (Levels) puts a roller-coaster ride on a TestLevel chapter (32–49, +default 48). You ride the cart from the Catacombs coaster levels: it leaves the station by itself, +races down drops and up climbs, speeds up on the boost arrows, jumps over a gap and turns round on +the spin pad. Press **Left** and **Right** to move the cart across the track and collect the +coins. After the last lap the cart stops at the station, fireworks go off and the tally appears; +**Enter** rides again, and *Beenden und zur Auswahlkarte wechseln* in the Esc menu goes back to the +map. The options pick the track (*Loop* or the longer *Grand circuit*), the laps (1–3), the speed +and whether there are coins. Unlock TestLevel on the **Save games** tab and pick the chapter on the +world-9 map. A level project or another mod on the same chapter stops the apply with a message – +pick another chapter then. + +![Llama Coaster: boost arrows on the Grand circuit](img/mod_coaster.jpg) + ## Command line LlamaMod can be scripted: @@ -109,6 +124,20 @@ A minigame on a TestLevel chapter: alligators come in waves into a walled pit. E | Coins per hit | 0 – 5 | 1 | Coins that pop out per hit (5 more at the end). | | TestLevel chapter | 32 – 49 | 49 | Chapter of world 9 the arena replaces (32–49; chapter 1 is the Test World's). | +#### Llama Coaster + +A roller coaster ride on a TestLevel chapter, with the cart from the Catacombs coaster levels: drops, climbs, boost arrows, a spin pad that turns the cart backwards and a jump over a gap. Steer left and right to stay on the track and collect coins; the ride ends at the station. Unlock TestLevel on the Save games tab. + +*Id* `coaster` + +| Option | Values | Default | Description | +|---|---|---|---| +| Track | Loop, Grand circuit | Loop | Short loop (about 100 units) or the grand circuit (about 150 units, two jumps, two spins). | +| Laps | 1 – 3 | 2 | Laps before the ride stops at the station. | +| Speed | Slow, Normal (as in the game), Fast | Normal (as in the game) | Cruise speed and boost strength of the cart. | +| Coins | on / off | on | Coins along the track. | +| TestLevel chapter | 32 – 49 | 48 | Chapter of world 9 the coaster replaces (32–49; chapter 1 is the Test World's, 49 the arena's). | + #### Test World Turns the hidden TestLevel (world 9, chapter 1) into a playground island: a gallery of every character and object model of all worlds (standing still, grouped by world) and a parkour course with checkpoints. To reach it, click "Unlock TestLevel" on the Save games tab. diff --git a/docs/manual/img/mod_coaster.jpg b/docs/manual/img/mod_coaster.jpg new file mode 100644 index 0000000..421d98f Binary files /dev/null and b/docs/manual/img/mod_coaster.jpg differ diff --git a/docs/strats/README.md b/docs/strats/README.md index d4a804a..4dc8324 100644 --- a/docs/strats/README.md +++ b/docs/strats/README.md @@ -12,7 +12,7 @@ can read every one of them, change their numbers, write new ones and move them b | [The script language](script-language.md) | writing new behaviour in LlamaMod's structured language | | [Customising strats](custom-types.md) | changing constants, cloning types, custom types in the editor and in mods | | [Example: the Alligator Arena](arena.md) | a complete minigame written in the script language, explained step by step | -| [Example: the roller coaster](roller-coaster.md) | coming soon | +| [Example: the roller coaster](roller-coaster.md) | a new ride for the original coaster cart: how the ride works, track and waypoint data, speed patch | Lab notes and tables: [notes/strat-opcodes.md](../../notes/strat-opcodes.md) (all 434 opcodes with semantics, confidence and Argonaut names), [notes/strat-language.md](../../notes/strat-language.md) diff --git a/docs/strats/roller-coaster.md b/docs/strats/roller-coaster.md index 5d647db..d218262 100644 --- a/docs/strats/roller-coaster.md +++ b/docs/strats/roller-coaster.md @@ -1,20 +1,192 @@ # Example: the roller coaster -> **Coming soon.** A roller-coaster mod is in development. This page will walk through it the way -> [the arena page](arena.md) does, once it is finished. Until then, its lab notes will appear as -> `notes/mod-coaster.md` and its code under `src/main/java/com/eliasmueller/llamamod/mods/coaster/`. +The *Llama Coaster* mod builds a new roller-coaster ride: a circuit with drops, climbs, boost +arrows, a spin pad, a jump over a gap, a station where the ride starts and ends, and fireworks at +the finish. Unlike the [arena](arena.md) it writes no new strat code: it **reuses the game's own +coaster cart** (`RollerCoaster`) and only gives it new track data – new geometry and a new +waypoint chain. This page explains how the original ride works and how the mod feeds it. - +![The ride: first drop, boost, jump, spin pad, climb, station](../img/strats/coaster_ride.jpg) -## What is already known about the original rides +| File | Role | +|---|---| +| [`CoasterMod.java`](../../src/main/java/com/eliasmueller/llamamod/mods/coaster/CoasterMod.java) | the mod: options, chapter check, writes the WAD and the loading picture | +| [`CoasterTrack.java`](../../src/main/java/com/eliasmueller/llamamod/mods/coaster/CoasterTrack.java) | the two layouts; turns legs and pieces into segments, corners and the waypoint chain | +| [`CoasterLevel.java`](../../src/main/java/com/eliasmueller/llamamod/mods/coaster/CoasterLevel.java) | the level script: track bed, rails, pillars, station, ground, objects | +| [`CoasterSpeed.java`](../../src/main/java/com/eliasmueller/llamamod/mods/coaster/CoasterSpeed.java) | patches the cart's speed constants for the Speed option | +| [`CoasterSim.java`](../../src/main/java/com/eliasmueller/llamamod/mods/coaster/CoasterSim.java) | frame-by-frame model of the cart, to check a layout without the game | +| [`CoasterTest.java`](../../src/test/java/com/eliasmueller/llamamod/mods/coaster/CoasterTest.java) | unit tests | +| [notes/mod-coaster.md](../../notes/mod-coaster.md) | lab notes: engine facts, what was verified in the game | -* Catacombs chapters 1, 3 and 5 (`t1l7m001`, `t1l7m003`, `t1l7m005`) are roller-coaster rides. In - these levels the player is not the `Llama` strat but a **`RollerCoaster`** strat - ([Objects](../reference/objects.md#special-objects)). -* The rides have the longest views of the game: fog end up to 23 cells - ([Engine facts](../reference/engine-limits.md#drawing)). -* The ride music is track 25, `ROLLER` ([Music](../reference/audio.md#music)). -* Objects of these levels can be imported into other levels, but they only work with matching - geometry ([manual chapter 9](../manual/09-objects.md#limits-and-things-to-know)). +Options: **track** (Loop ~100 units, Grand circuit ~150 units), **laps** (1–3, default 2), +**speed** (slow / normal / fast), **coins** (on/off), TestLevel **chapter** (32–49, default 48). +See [manual chapter 18](../manual/18-mods.md#the-llama-coaster-ride) for players. + +## 1. How the original ride works + +Catacombs chapters 1, 3 and 5 (`t1l7m001`, `t1l7m003`, `t1l7m005`) are roller-coaster rides. The +ride music is track 25, `ROLLER` ([Music](../reference/audio.md#music)); the levels have the +longest views of the game (fog end up to 23 cells, [Engine facts](../reference/engine-limits.md#drawing)). + +* **The cart is the player.** These levels have no `Llama` strat. The `RollerCoaster` strat calls + `isPlayer()` and draws `model:0` – the cart *with* Kuzco the llama and Pacha in it – so the llama + is not attached at run time, it is part of the cart model and its animations (lean, cheer, look + back). See [Objects](../reference/objects.md#special-objects). +* **The camera** is the ordinary `Camera` strat, spawned by the cart like the Llama spawns it. It + follows the global `g29`; in a corner the cart sets `g29` to the heading *after* the turn, so the + camera swings round first. +* **Track following**: the cart drives straight along its heading and turns by exactly 90° at a + waypoint. Its height comes from the floor under it. So a ride is a chain of straight legs on the + grid with 90° corners; slopes, jumps and gaps are pure geometry. +* **Floor materials** of the track bed trigger the effects: 5 boost arrows, 11 jump, 13 spin pad + (the cart hops and turns round, riding backwards to the next corner), 16 the end, 30 the + invisible death plane below (fall = restart). +* **Start and end**: the first waypoints are *station* nodes – the cart crawls out with letterbox + borders. On material 16, when the current waypoint is the last one, the strat waits 30 frames and + calls `endLevel` with its own parameters 1–3 (tribe, world, chapter) → the tally screen. +* Helpers: `RollerDoor` (gates), `FireworksTrigger` (rockets at its waypoint spots when the cart + passes), `CoinRollerCoaster` (coins hovering over the track), `WompyRollerCoaster`. + +The river levels (`t1l3m001…4`) look similar but are not rail rides: their player +`KuscoAndPachaTogetherOnATree` floats on water, is pushed by current zones, steers freely between +the banks and leaves through a normal `LevelDoor`. That is why the mod uses the coaster. + +### The cart script step by step + +Excerpts of `RollerCoaster` as the editor's script view shows it (`v19` = speed, `v22` = boost +excess, `v18` = sideways offset, `v21` = 0 or 2048 when riding backwards): + +``` +call L_0200() // prepare the waypoints: +// self.wpX = int(self.wpX * 2 + 0.5) / 2 x/z rounded to halves +// setWaypointParam2((self.wpRotY + 512) & 3072) heading rounded to a quarter turn +spawn<4, 41, 2, 0x248000, 0>(0.9, 1.0, 0, 0, unit:Camera) +isPlayer() +self.y = self.floorHeight +showLevelStartInfo<1>() +loop { + call L_0074() // the end check (below) + call L_012c() // left/right input → v18 (at most half the speed) + moveRightQ(v18) + moveForwardQ(v19) + if (v22 > 2r) { v19 -= 2r; v22 -= 2r } // a boost wears off … + else if (v19 > 0.15) { v19 = max(v19 - 2r, 0.15) } // … down to cruise 0.15 (614 raw) + if (self.floorMaterial == 11) { … g22 = 163r } // jump: vertical speed + else if (self.floorMaterial == 5) { v19 += 20r; v22 += 20r } // boost arrows + else if (self.floorMaterial == 13) { … g22 = 61r; v20 = 128 } // spin: hop + half turn + if (self.y - 204r - v19 * 1228r < self.floorHeight && self.floorMaterial != -1) { + self.y = self.floorHeight // stay on the floor (a climb > ~0.1 per frame is lost) + } else { … fall … } + // corner: within 0.75 of the waypoint, steer round it and take the next one + v15 = (self.x - self.wpX) * v4 + (self.z - self.wpZ) * v5 + if (v15 < 0.75 | (self.wpParam && v15 < 2.0)) { … g29 = self.wpRotY … } + … +} + +L_0074: // the end +if (v13 > 1) { v13 -= 1; if (v13 <= 1) { endLevel(v1, v2, v3) } } +else if (self.floorMaterial == 16 && (self.wpEndFlags & 3r)) { v13 = 30 } +``` + +## 2. The plan + +* **Base level = Catacombs 1 (`t1l7m001`)**, not the village: it already contains the cart, its + camera, coins, gates, fireworks, textures, sounds and the ride music. Importing them into the + village ([like the arena](arena.md#3-compiling-and-importing-arenaimport)) would only copy the + same things over. The mod keeps the base level's code and replaces geometry and objects. +* The **track** follows the cart's rules: straight legs along the axes, 90° corners, heights + changing only on straights. +* The **cart's waypoint chain** is generated from the same layout, so geometry and path cannot + disagree. +* The level goes into a TestLevel chapter ([why 32–49](../reference/testlevel.md)). + +## 3. The track (`CoasterTrack`) + +A layout is a start corner and a list of legs; each leg is a heading and a list of pieces +(kind, length in units, height change): + +```java +new CoasterTrack("loop", new double[] {10, 10, 24}, List.of( + stationLeg(90, 4, 8, 4, 2), // run-in, END, brake, STATION, first drop + leg(0, p(TRACK, 2), p(TRACK, 8, 2), p(TRACK, 2)), // climb 2 + leg(270, p(TRACK, 2), p(SPIN, 1), p(TRACK, 1), p(TRACK, 4, -2)), + leg(0, p(BOOST, 2), p(JUMP, 1), p(GAP, 2, -1), p(TRACK, 9)), // jump over a gap + leg(270, p(TRACK, 3), p(TRACK, 5, -2), p(TRACK, 2)), + leg(180, p(BOOST, 1), p(TRACK, 2), p(TRACK, 25, 7)))); // lift hill back up +``` + +The constructor lays the pieces out and refuses a layout that does not close, turns by anything +but 90° or lacks exactly one STATION and one END on leg 0. Each kind maps to a floor material +(TRACK 0, BOOST 5, JUMP 11, SPIN 13, END 16). + +![The two layouts in the editor's top view: Loop and Grand circuit](../img/strats/coaster_tracks.jpg) + +**Waypoints** (`nodes(laps)`): node 0 one unit behind the cart and node 1 at the end of the +station, both with parameter 1.0 (the crawl); then one node per corner, 0.5 before its centre with +the heading after the turn, repeated for every lap; and a last node 9 units past the END pad. So +the END pad only ends the ride in the last lap – in earlier laps the cart rolls over it. + +## 4. The level (`CoasterLevel.build`) + +The level is a [`LevelScript`](../../src/main/java/com/eliasmueller/llamamod/levelbuild/LevelScript.java) +for the [`LevelBuilder`](../../src/main/java/com/eliasmueller/llamamod/levelbuild/LevelBuilder.java): + +* **Track bed**: per unit two 1 × 1 quads with the piece's texture and material (boost arrows, + jump stripes, spin target), an underside, and **rails** 0.35 high on both sides – their inner + faces are collision walls, so steering cannot leave the track. Gaps get end faces. +* **Corners**: flat 2 × 2 squares with rails on the two outer sides. +* **Pillars** every 5 units and under each corner, a **boarding platform** with posts left of the + station, a **ground plane** at height 0 with material 30 (the death plane). +* **Objects**: + +```java +l.strat("RollerCoaster", st[0], st[1], st[2], st[3], + new int[] {0, tribe * FX, world * FX, chapter * FX}, // endLevel target = this chapter + LevelBuilder.Waypoints.of(wps), 0, false); +l.strat("RollerDoor", …); // gate at the station exit +l.strat("FireworksTrigger", …, Waypoints.of(fw), …); // six rocket spots around the END pad +l.strat("CoinRollerCoaster", …); // every 1.5 units on long straights +``` + +## 5. Speed and checking a layout (`CoasterSpeed`, `CoasterSim`) + +The **Speed** option scales two constants of the cart: the cruise speed `pushShort 614` (six +places: the floor after a boost and the cap in corners) and the boost `pushShort 20` (two places). +`CoasterSpeed.find` locates them in the unit's listing (the instruction before and after must +match) and rewrites only those 16-bit operands. + +A faster cart flies farther, and a jump that lands in a corner's turn zone misses the turn. +`CoasterSim` replays the cart's speed and jump rules frame by frame on a layout and reports jumps +that do not clear their gap or land too late, and climbs steeper than the floor probe allows. +The tests run it for every track × speed × laps. + +## 6. The mod (`CoasterMod`) + +`apply` refuses a chapter whose WAD another mod (or a level project) already changed, builds the +level on `t1l7m001`, sets the tally total to the number of coins (`LevelTally.counted`) and draws +a loading picture in Java (the title sits in the band the tally text leaves free). + +![The tally after the ride](../img/strats/coaster_tally.jpg) + +## 7. How it was tested + +* **Unit tests** (`CoasterTest`): both layouts close, contain every feature and do not overlap; + bad layouts are refused; the waypoint chain follows the cart's rounding rules; every jump lands + for all speeds and laps; the speed patch changes exactly the 8 operands; the built level has one + RollerCoaster (and no Llama), the gate, the fireworks, the coins, the right exit target, the + floor materials 0/5/11/13/16/30 and the tally total; a chapter another mod wrote is refused. +* **In the game** (`tools/apptest.py`): Loop and Grand circuit at all three speeds, 1 and 2 laps – + full rides from the station to the tally with a screenshot every 1.5 s, no crash. After the tally + Enter restarts the ride; in the ride Esc → *Beenden und zur Auswahlkarte wechseln* goes back to + the world map. + +## What to take from it + +1. **Reuse a ride type with new data** when its rules fit: a new geometry plus a new waypoint chain + is a new ride, without writing strat code. +2. **Read the original strat first** – the 90° corners, the half-unit rounding and the 0.1 climb + limit all come from its script, and the layout code enforces them. +3. **Generate path and geometry from one description**, so they always match. +4. **Model the physics offline** (`CoasterSim`) to catch bad jumps before starting the game. +5. **Constants can be tuned in place**: find them in the listing by their neighbours and patch the + operand, instead of recompiling the strat. diff --git a/notes/README.md b/notes/README.md index a91fc4b..6b0af79 100644 --- a/notes/README.md +++ b/notes/README.md @@ -29,6 +29,7 @@ Images used by the notes are in [`img/`](img/). |---|---| | [llamamod-app.md](llamamod-app.md) | build, CLI, data folder, registry, mod framework, exe patches, save games, `tools/apptest.py` | | [mod-arena.md](mod-arena.md) | Alligator Arena | +| [mod-coaster.md](mod-coaster.md) | Llama Coaster (roller-coaster ride; how the original rides work) | | [mod-testworld.md](mod-testworld.md) | Test World and its gallery | | [mod-levels.md](mod-levels.md) | level projects as mods | | [mod-boottolevel.md](mod-boottolevel.md) | Boot to level | diff --git a/notes/img/mods/coaster_ride.jpg b/notes/img/mods/coaster_ride.jpg new file mode 100644 index 0000000..1bd8968 Binary files /dev/null and b/notes/img/mods/coaster_ride.jpg differ diff --git a/notes/img/mods/coaster_tally.jpg b/notes/img/mods/coaster_tally.jpg new file mode 100644 index 0000000..6fb997d Binary files /dev/null and b/notes/img/mods/coaster_tally.jpg differ diff --git a/notes/img/mods/coaster_tracks.jpg b/notes/img/mods/coaster_tracks.jpg new file mode 100644 index 0000000..e0e8f1f Binary files /dev/null and b/notes/img/mods/coaster_tracks.jpg differ diff --git a/notes/mod-coaster.md b/notes/mod-coaster.md new file mode 100644 index 0000000..4ea0d1b --- /dev/null +++ b/notes/mod-coaster.md @@ -0,0 +1,113 @@ +# Llama Coaster (`coaster`, LEVELS) + +Package `mods/coaster`: `CoasterMod` (options, chapter check, writes `wads/T1L9M0nn.wad` + +loading picture `T19nn.COR`), `CoasterLevel` (level script: track geometry + objects), +`CoasterTrack` (layouts → segments, corners, waypoint chain), `CoasterSpeed` (patches the +RollerCoaster strat's speed constants), `CoasterSim` (frame model of the strat's speed/jump logic, +used by the tests to check a layout). Tests: `CoasterTest`. Registered in `ModRegistry` (one line). +Docs: [docs/strats/roller-coaster.md](../docs/strats/roller-coaster.md), manual chapter 18. + +| Option | Default | Meaning | +|---|---|---| +| `track` | `loop` | `loop` (~103 units/lap) or `grand` (~150 units, two jumps, two spin pads) | +| `laps` | 2 (1–3) | laps before the ride ends at the station | +| `speed` | `normal` | `slow` ×0.8, `normal` ×1, `fast` ×1.25 (cruise 614 raw and boost 20 raw/frame) | +| `coins` | on | CoinRollerCoaster along the straights (loop 27, grand 44; placed once, not per lap) | +| `chapter` | 48 (32–49) | TestLevel chapter; a chapter another mod already wrote → apply error | + +## How the original rides work + +**Roller coaster = Catacombs 1, 3, 5 (`t1l7m001/3/5`, world 7 – not world 8; Yzma's lab is +world 8 and has no ride).** Strat units of t1l7m001: `RollerCoaster` (unit 0, 1775 words), +`CameraFixedPointBox`, `RollerDoor`, `CoinRollerCoaster`, `WompyRollerCoaster`, +`FireworksTrigger`, `Camera`, `SpawnableRing`, `Sparkles`, `SpawnableNeedleBall`. + +* **The cart is the player.** There is no `Llama` strat: `RollerCoaster` calls `isPlayer()` and + `lookAtMe()`, uses `model:0` (cart with Kuzco-llama and Pacha, scale 2) and its animations + (lean, cheer, look back, fall). The llama is part of the cart model, not attached at runtime. +* **Camera:** the strat spawns the shared `Camera` strat (`spawn<4,41,2,0x248000,0>(0.9, 1.0, …, + unit:Camera)`), the same chase camera as the Llama's; it follows `g29` = the cart's heading, + which the strat sets to the next waypoint's heading in corners (so the camera swings round + before the cart). `CameraFixedPointBox` (one per level) is a fixed-point camera zone. +* **Track following = waypoint chain + floor.** At start (`L_0200`) every waypoint x/z is rounded + to halves and its heading to a quarter turn (param2). The cart drives straight along its heading + at speed `v19` (min 0.15 = 614 raw), the floor height comes from the collision floor below it + (`noFloorPhysics`, `self.y = floorHeight`; a climb steeper than ~0.1 per frame loses the + floor). Within 0.75 in front of the current waypoint it turns by 90° on a small arc round the + waypoint (funnel: lateral offset clamped to 1.2·distance) and switches to the next one – so the + tracks are straight legs on the grid with 90° corners; only the geometry makes slopes. + Left/right input shifts the cart sideways (`v18`, ±half the speed); the rails are collision walls. +* **Floor materials** (collision material of the track bed): 5 boost (+20 raw speed and + "excess" per frame, decays back to cruise), 11 jump (vertical speed 163 raw, gravity 14 raw per + frame), 13 spin (small hop + half turn: the cart rides backwards until the next corner), 15 an + animation/sound pad, 16 the end (only when the current waypoint is the last one), 30 the + invisible death plane far below (fall → restart). +* **Start:** the first two waypoints have param 1.0 (station): the cart crawls (0.025 + nudges) + out of the station with letterbox borders and the start line, then picks up cruise speed. +* **End:** on material 16 with the last waypoint active the strat plays a sound and after 30 + frames calls `endLevel(param1..3)` – the cart's own params 1–3 are the target (tribe, world, + chapter) → tally screen. `RollerDoor` are gates the cart passes, `FireworksTrigger` fires rockets + at its waypoint spots when the cart passes, `WompyRollerCoaster` is a spinning Wompy pick-up. + +**River = Jungle river 1–4 (`t1l3m001…4`, world 3; world 2 is the jungle at night).** The player +strat is `KuscoAndPachaTogetherOnATree` (both on a log, also `isPlayer()` + the shared `Camera`). +It also rounds its waypoints to halves/quarter turns and turns at waypoints whose param is 1 or 2 +(left/right), but it floats on water (`floorMaterial 5` = water there: rumble, splash particles) +and is pushed by current/force zones (`RiverCurrentBox`, many "kraft" zones); the player steers +freely between the banks, `CameraLift`/`RiverWaterfallCamera` give the scripted shots, and the +level ends at a normal `LevelDoor`. It is a steerable boat, not a rail ride, so the coaster mod +uses the RollerCoaster strat. + +## Design + +* **Base level = t1l7m001** (not the village + import): its STPC already has every unit the ride + needs and their textures, sounds and the ride music; importing `RollerCoaster` + `Camera` into + the village would only duplicate them. The original geometry and objects are replaced by + `LevelBuilder` output (`LevelScript.buildWad`), so `format.stpcmerge` is only used read-only + (`StpcImage`/`StratCode`) by `CoasterSpeed`. +* **Track:** straight legs along the axes, 2 wide (two 1-wide lanes, centre line on integers), + joined by flat 2 × 2 corner squares; heights change only on straights. Pieces: TRACK, BOOST (mat + 5), JUMP (11), SPIN (13), END (16), STATION, GAP (no floor). Rails 0.35 high (inner face = + collision), underside, pillars every 5 units and under corners, a boarding platform with posts + left of the station, ground plane at y = 0 with material 30. Textures are t1l7m001 TEXT indices. +* **Leg 0** of every layout: run-in, END pad, brake run, STATION (5), first drop with a boost at + its top. The circuit must close (checked) and END must come before STATION on leg 0. +* **Waypoint chain** (`CoasterTrack.nodes`): node 0 one unit behind the cart and node 1 at the + station's end, both param 1.0 (crawl); then one node per corner, 0.5 before its centre, heading + after the turn, repeated per lap; last node 9 units past the END pad on leg 0, so the END pad is + live only in the last lap. RollerCoaster params = (0, 1, 9, chapter) ×4096 → the tally restarts + the ride, like the arena. +* **Speed** (`CoasterSpeed`): finds the six `pushShort 614` (cruise floor/cap) and two + `pushShort 20` (boost, after `pushVar v19/v22`) in the RollerCoaster unit with `StratListing` + and rewrites the 16-bit operands; nothing else changes. +* **Checking a layout offline** (`CoasterSim`, test `everyJumpLandsAndNoClimbLosesTheFloor`): + for every track × speed × laps every jump must clear its gap and land before the commit zone of + the next corner, no climb > 0.1 per frame, speed stays 0.02–0.45. +* Objects: `RollerCoaster` at the station start, a `RollerDoor` at the station exit, a + `FireworksTrigger` before the END pad with six rocket spots, CoinRollerCoaster every 1.5 units + on straights ≥ 4 long outside leg 0 (on a slight zig-zag). The tally total (LNFO) is the coin count. +* Loading/tally picture: drawn in Java (dark purple, a rail curve, title "Llama Coaster" in the + band y 265–335 that the game's tally text leaves free). + +## Verified in game (apptest, :26, 2026-11-07) + +* loop/normal 1 lap and 2 laps, loop/fast 1 lap, grand/fast 1 lap (chapter 40), grand/slow: + station crawl with letterbox and start line, drops, climbs, boost arrows, start hint "Kusco, bleib in der + Mitte der Kurve und wir werden uns automatisch umdrehen!", spin pad (cart turns backwards), jump over the gap and + landing, corners, coins counted (e.g. 14/27, 19/44), END pad → fireworks → tally with the + loading picture. No crash in any run. +* After the tally: **Enter** restarts the ride (same chapter); **Esc** asks "Möchtest du das + Spiel beenden?" (No → back to the tally). In the ride, Esc → *Beenden und zur Auswahlkarte + wechseln* → Yes → world-9 map on "TestLevel Kapitel 48". Same behaviour as the arena. + +![ride](img/mods/coaster_ride.jpg) +![tracks (editor top view: loop, grand)](img/mods/coaster_tracks.jpg) +![tally](img/mods/coaster_tally.jpg) + +## Limits + +* Only 90° corners on the grid and straight slopes (the strat cannot follow curves or banking); + no loops/inversions (the cart has no roll). +* The tally coin count is not kept on the world map (it showed 0 after the ride) – same as the + other TestLevel mods; not investigated. +* The original levels' sky/fog/lighting of t1l7m001 stays (dark catacombs, fog end 23 cells). diff --git a/src/main/java/com/eliasmueller/llamamod/mods/ModRegistry.java b/src/main/java/com/eliasmueller/llamamod/mods/ModRegistry.java index 9d89296..31d872c 100644 --- a/src/main/java/com/eliasmueller/llamamod/mods/ModRegistry.java +++ b/src/main/java/com/eliasmueller/llamamod/mods/ModRegistry.java @@ -24,6 +24,7 @@ public final class ModRegistry { "com.eliasmueller.llamamod.mods.practice.PracticeMod", "com.eliasmueller.llamamod.mods.freecam.FreeCamMod", "com.eliasmueller.llamamod.mods.arena.ArenaMod", + "com.eliasmueller.llamamod.mods.coaster.CoasterMod", }; /** {@link ModSource}s loaded by class name (dynamic mods, e.g. level projects). */ diff --git a/src/main/java/com/eliasmueller/llamamod/mods/coaster/CoasterLevel.java b/src/main/java/com/eliasmueller/llamamod/mods/coaster/CoasterLevel.java new file mode 100644 index 0000000..6f2e5b2 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/mods/coaster/CoasterLevel.java @@ -0,0 +1,446 @@ +package com.eliasmueller.llamamod.mods.coaster; + +import static com.eliasmueller.llamamod.levelbuild.Opts.o; + +import com.eliasmueller.llamamod.format.Lin3; +import com.eliasmueller.llamamod.levelbuild.LevelBuilder; +import com.eliasmueller.llamamod.levelbuild.LevelScript; +import com.eliasmueller.llamamod.mods.coaster.CoasterTrack.Corner; +import com.eliasmueller.llamamod.mods.coaster.CoasterTrack.Kind; +import com.eliasmueller.llamamod.mods.coaster.CoasterTrack.Node; +import com.eliasmueller.llamamod.mods.coaster.CoasterTrack.Segment; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +/** + * The coaster level, built on the original roller coaster level t1l7m001 (Catacombs 1): its strat + * code (RollerCoaster = the llama in its cart and the player, its Camera, CoinRollerCoaster, + * RollerDoor, FireworksTrigger), textures, sounds and environment stay; geometry and objects are + * new. The track bed is the floor the cart drives on, with rails as collision walls; the cart + * follows its waypoint chain ({@link CoasterTrack#nodes}). + */ +public final class CoasterLevel implements LevelScript { + public static final String BASE = "wads/t1l7m001.wad"; + + /** Base texture indices (t1l7m001 TEXT). */ + static final String TEX_DECK = "#5"; + + static final String TEX_CORNER = "#20"; + static final String TEX_STATION = "#72"; + static final String TEX_BOOST = "#76"; + static final String TEX_JUMP = "#79"; + static final String TEX_SPIN = "#33"; + static final String TEX_END = "#129"; + static final String TEX_RAIL = "#191"; + static final String TEX_RAIL_TOP = "#4"; + static final String TEX_SIDE = "#21"; + static final String TEX_PILLAR = "#22"; + static final String TEX_GROUND = "#0"; + + static final double RAIL_HEIGHT = 0.35; + static final double RAIL_THICK = 0.15; + static final double DECK_THICK = 0.3; + /** Collision material of the ground far below (the original's death plane material). */ + static final int GROUND_MAT = 30; + + private static final double[] WHITE = {1, 1, 1}; + + /** Track, laps, speed factor, coins, exit target (tribe, world, chapter). */ + public record Settings(CoasterTrack track, int laps, double speed, boolean coins, int[] target) { + public Settings { + if (laps < 1 || laps > 5) { + throw new IllegalArgumentException("laps must be 1..5"); + } + if (speed < 0.5 || speed > 2) { + throw new IllegalArgumentException("speed factor must be 0.5..2"); + } + } + } + + private final Settings settings; + private int coinCount; + + public CoasterLevel(Settings settings) { + this.settings = settings; + } + + @Override + public String levelName() { + return "Llama Coaster"; + } + + @Override + public long seed() { + return 17; + } + + /** CoinRollerCoaster objects placed by the last {@link #build}. */ + int coinCount() { + return coinCount; + } + + @Override + public byte[] buildWad(byte[] baseWad, Function wads, Map opts) { + return LevelScript.super.buildWad(CoasterSpeed.patch(baseWad, settings.speed()), wads, opts); + } + + @Override + public void build(LevelBuilder l) { + CoasterTrack t = settings.track(); + l.sun.azimuth = 210; + l.sun.elevation = 60; + l.sun.strength = 0.55; + l.sun.ao = 0.4; + + List segs = t.segments; + for (int i = 0; i < segs.size(); i++) { + Segment s = segs.get(i); + if (s.kind() == Kind.GAP) { + continue; + } + deck(l, s); + Segment prev = i > 0 ? segs.get(i - 1) : null; + Segment next = i + 1 < segs.size() ? segs.get(i + 1) : null; + if (prev != null && prev.kind() == Kind.GAP && prev.leg() == s.leg()) { + endFace(l, s, false); + } + if (next != null && next.kind() == Kind.GAP && next.leg() == s.leg()) { + endFace(l, s, true); + } + } + for (Corner c : t.corners) { + corner(l, c); + } + supports(l, t); + station(l, t); + ground(l, t); + objects(l, t); + } + + // ------------------------------------------------------------ geometry + + private static double[] right(int heading) { + double[] h = CoasterTrack.dir(heading); + return new double[] {h[1], -h[0]}; + } + + private static double[] at(Segment s, double d, double lat, double dy) { + double[] h = CoasterTrack.dir(s.heading()); + double[] r = right(s.heading()); + return new double[] {s.x0() + h[0] * d + r[0] * lat, s.y(d) + dy, s.z0() + h[1] * d + r[1] * lat}; + } + + /** Adds the quad with its front towards {@code want} (corner order flipped if needed). */ + private static void face( + LevelBuilder l, double[][] p, double[] want, String tex, boolean coll, int mat, boolean render) { + double[] nq = Lin3.cross(Lin3.sub(p[1], p[0]), Lin3.sub(p[2], p[0])); + if (-(nq[0] * want[0] + nq[1] * want[1] + nq[2] * want[2]) < 0) { + p = new double[][] {p[1], p[0], p[3], p[2]}; + } + l.quad(p, tex, coll, mat, WHITE, 0, true, false, render); + } + + private static String tex(Kind k) { + return switch (k) { + case BOOST -> TEX_BOOST; + case JUMP -> TEX_JUMP; + case SPIN -> TEX_SPIN; + case END -> TEX_END; + case STATION -> TEX_STATION; + default -> TEX_DECK; + }; + } + + /** Track bed (two 1-wide lanes per unit), rails on both sides, underside. */ + private void deck(LevelBuilder l, Segment s) { + double len = s.length(); + double[] up = {0, 1, 0}; + double[] down = {0, -1, 0}; + int mat = Math.max(0, s.kind().mat); + for (int k = 0; k < len; k++) { + double d0 = k, d1 = Math.min(len, k + 1); + for (double[] lane : new double[][] {{-1, 0}, {0, 1}}) { + face( + l, + new double[][] { + at(s, d0, lane[0], 0), at(s, d0, lane[1], 0), at(s, d1, lane[1], 0), at(s, d1, lane[0], 0) + }, + up, + tex(s.kind()), + true, + mat, + true); + } + double w = 1 + RAIL_THICK; + for (double[] lane : new double[][] {{-w, 0}, {0, w}}) { + face( + l, + new double[][] { + at(s, d0, lane[0], -DECK_THICK), + at(s, d0, lane[1], -DECK_THICK), + at(s, d1, lane[1], -DECK_THICK), + at(s, d1, lane[0], -DECK_THICK) + }, + down, + TEX_SIDE, + false, + 0, + true); + } + for (int side = -1; side <= 1; side += 2) { + rail(l, at(s, d0, side, 0), at(s, d1, side, 0), right(s.heading()), side); + } + } + } + + /** + * Rail along the bed edge a → b: inner face (collision, keeps the cart on the bed), top, outer + * face down to the underside. side: −1 left, +1 right of the travel direction r. + */ + private static void rail(LevelBuilder l, double[] a, double[] b, double[] r, int side) { + double[] out = {r[0] * side, 0, r[1] * side}; + double[] in = {-out[0], 0, -out[2]}; + double[] a2 = {a[0] + out[0] * RAIL_THICK, a[1], a[2] + out[2] * RAIL_THICK}; + double[] b2 = {b[0] + out[0] * RAIL_THICK, b[1], b[2] + out[2] * RAIL_THICK}; + face(l, new double[][] {a, b, lift(b, RAIL_HEIGHT), lift(a, RAIL_HEIGHT)}, in, TEX_RAIL, true, 0, true); + face( + l, + new double[][] {lift(a, RAIL_HEIGHT), lift(b, RAIL_HEIGHT), lift(b2, RAIL_HEIGHT), lift(a2, RAIL_HEIGHT) + }, + new double[] {0, 1, 0}, + TEX_RAIL_TOP, + false, + 0, + true); + face( + l, + new double[][] { + lift(a2, -DECK_THICK), lift(b2, -DECK_THICK), lift(b2, RAIL_HEIGHT), lift(a2, RAIL_HEIGHT) + }, + out, + TEX_SIDE, + true, + 0, + true); + } + + private static double[] lift(double[] p, double dy) { + return new double[] {p[0], p[1] + dy, p[2]}; + } + + /** Vertical face closing the bed at a gap: at the segment's end (forward) or start. */ + private static void endFace(LevelBuilder l, Segment s, boolean atEnd) { + double d = atEnd ? s.length() : 0; + double[] h = CoasterTrack.dir(s.heading()); + double[] want = atEnd ? new double[] {h[0], 0, h[1]} : new double[] {-h[0], 0, -h[1]}; + double w = 1 + RAIL_THICK; + for (double[] lane : new double[][] {{-w, 0}, {0, w}}) { + face( + l, + new double[][] { + at(s, d, lane[0], -DECK_THICK), + at(s, d, lane[1], -DECK_THICK), + at(s, d, lane[1], 0), + at(s, d, lane[0], 0) + }, + want, + TEX_SIDE, + true, + 0, + true); + } + } + + /** Flat 2 × 2 corner; rails on the two outer sides (ahead of the incoming leg, behind the outgoing). */ + private static void corner(LevelBuilder l, Corner c) { + double y = c.y(); + for (int i = 0; i < 2; i++) { + for (int k = 0; k < 2; k++) { + double x0 = c.x() - 1 + i, z0 = c.z() - 1 + k; + face( + l, + new double[][] {{x0, y, z0}, {x0 + 1, y, z0}, {x0 + 1, y, z0 + 1}, {x0, y, z0 + 1}}, + new double[] {0, 1, 0}, + TEX_CORNER, + true, + 0, + true); + face( + l, + new double[][] { + {x0, y - DECK_THICK, z0}, + {x0 + 1, y - DECK_THICK, z0}, + {x0 + 1, y - DECK_THICK, z0 + 1}, + {x0, y - DECK_THICK, z0 + 1} + }, + new double[] {0, -1, 0}, + TEX_SIDE, + false, + 0, + true); + } + } + double[] h0 = CoasterTrack.dir(c.in()); + double[] h1 = CoasterTrack.dir(c.out()); + // outer walls: across the square at +h0 and at -h1, each 2 long plus the rail thickness at the outer corner + for (double[][] w : new double[][][] {{h0, h1}, {{-h1[0], -h1[1]}, {-h0[0], -h0[1]}}}) { + double[] n = w[0]; + double[] along = w[1]; + double ex = 1 + RAIL_THICK; + for (double[] part : new double[][] {{-1, 0}, {0, 1}}) { + double t0 = part[0], t1 = part[1]; + // extend the outer end (towards -along) by the rail thickness + if (t0 == -1) { + t0 = -ex; + } + double[] a = {c.x() + n[0] + along[0] * t0, y, c.z() + n[1] + along[1] * t0}; + double[] b = {c.x() + n[0] + along[0] * t1, y, c.z() + n[1] + along[1] * t1}; + // rail() wants the travel direction's right vector and a side so that "out" = n + rail(l, a, b, new double[] {n[0], n[1]}, 1); + } + } + } + + /** Pillars under the corners and every few units under the straights (not at gaps). */ + private static void supports(LevelBuilder l, CoasterTrack t) { + for (Corner c : t.corners) { + l.pillar( + c.x(), + c.z(), + 0, + c.y() - DECK_THICK, + 0.5, + o().side(TEX_PILLAR).top(TEX_RAIL_TOP)); + } + for (Segment s : t.segments) { + if (s.kind() == Kind.GAP) { + continue; + } + for (double d = 3; d < s.length() - 1; d += 5) { + double[] p = at(s, d, 0, 0); + l.pillar( + p[0], + p[2], + 0, + s.y(d) - DECK_THICK - 0.1, + 0.4, + o().side(TEX_PILLAR).top(TEX_RAIL_TOP)); + } + } + } + + /** Boarding platform on the left of the station. */ + private static void station(LevelBuilder l, CoasterTrack t) { + Segment s = t.station(); + double[] a = at(s, -2, -1 - RAIL_THICK, 0); + double[] b = at(s, s.length() + 1, -3.2, 0); + double y = s.y0(); + l.box( + Math.min(a[0], b[0]), + Math.min(a[2], b[2]), + Math.max(a[0], b[0]), + Math.max(a[2], b[2]), + y - 1.2, + y + 0.1, + o().top(TEX_STATION).side(TEX_SIDE).bottom(TEX_SIDE).rot(0)); + for (double d : new double[] {-1.5, s.length() + 0.5}) { + double[] p = at(s, d, -2.6, 0); + l.pillar(p[0], p[2], y + 0.1, y + 2.4, 0.35, o().side(TEX_PILLAR).top(TEX_RAIL_TOP)); + l.pillar(p[0], p[2], 0, y - 1.2, 0.6, o().side(TEX_PILLAR).top(TEX_RAIL_TOP)); + } + } + + /** Ground plane far below (material 30 like the original's invisible bottom plane). */ + private static void ground(LevelBuilder l, CoasterTrack t) { + double[] b = t.bounds(); + int m = 6; + for (int x = (int) Math.floor(b[0]) - m; x < Math.ceil(b[2]) + m; x++) { + for (int z = (int) Math.floor(b[1]) - m; z < Math.ceil(b[3]) + m; z++) { + if (x < 0 || z < 0) { + continue; + } + face( + l, + new double[][] {{x, 0, z}, {x + 1, 0, z}, {x + 1, 0, z + 1}, {x, 0, z + 1}}, + new double[] {0, 1, 0}, + TEX_GROUND, + true, + GROUND_MAT, + true); + } + } + } + + // ------------------------------------------------------------ objects + + private static final int FX = 4096; + + private void objects(LevelBuilder l, CoasterTrack t) { + double[] st = t.cartStart(); + int[] tg = settings.target(); + List wps = new ArrayList<>(); + for (Node n : t.nodes(settings.laps())) { + wps.add(new double[] {n.x(), n.z(), n.y(), n.heading(), n.station() ? FX : 0}); + } + l.strat( + "RollerCoaster", + st[0], + st[1], + st[2], + st[3], + new int[] {0, tg[0] * FX, tg[1] * FX, tg[2] * FX}, + LevelBuilder.Waypoints.of(wps), + 0, + false); + + // gate at the station exit + Segment s = t.station(); + l.strat( + "RollerDoor", + s.x1(), + s.z1(), + s.y1(), + (double) s.heading(), + null, + LevelBuilder.Waypoints.NONE, + 0, + false); + + // fireworks over the end pad, started when the cart passes the run-in + Segment e = t.end(); + double[] trig = at(e, -0.5, 0, 0); + List fw = new ArrayList<>(); + double[][] spots = {{1, -2.5, 2}, {3, 2.5, 2.5}, {5, -2.5, 3}, {2, 2.5, 1.5}, {4, -2.5, 2.5}, {6, 2.5, 3}}; + for (double[] p : spots) { + double[] q = at(e, p[0], p[1], p[2]); + fw.add(new double[] {q[0], q[2], q[1]}); + } + l.strat("FireworksTrigger", trig[0], trig[2], trig[1], 0.0, null, LevelBuilder.Waypoints.of(fw), 0, false); + + coinCount = 0; + if (settings.coins()) { + for (Segment g : t.segments) { + if (g.leg() == 0 || g.kind() != Kind.TRACK || g.length() < 4) { + continue; + } + for (double d = 1.5; d < g.length() - 1; d += 1.5) { + double lat = Math.sin(d * 1.1) * 0.55; + double[] p = at(g, d, lat, 0); + l.strat( + "CoinRollerCoaster", + p[0], + p[2], + p[1] + 0.1, + null, + null, + LevelBuilder.Waypoints.NONE, + 0, + false); + coinCount++; + } + } + } + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/mods/coaster/CoasterMod.java b/src/main/java/com/eliasmueller/llamamod/mods/coaster/CoasterMod.java new file mode 100644 index 0000000..7435c40 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/mods/coaster/CoasterMod.java @@ -0,0 +1,183 @@ +package com.eliasmueller.llamamod.mods.coaster; + +import com.eliasmueller.llamamod.format.CorEncoder; +import com.eliasmueller.llamamod.mods.Mod; +import com.eliasmueller.llamamod.mods.ModContext; +import com.eliasmueller.llamamod.mods.ModOption; +import com.eliasmueller.llamamod.mods.levels.LevelTally; +import com.eliasmueller.llamamod.save.TestLevelChapters; +import java.awt.BasicStroke; +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.geom.Path2D; +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; + +/** A roller coaster ride on a TestLevel (world 9) chapter, using the game's own coaster cart. */ +public final class CoasterMod implements Mod { + static final int DEFAULT_CHAPTER = 48; + + @Override + public String id() { + return "coaster"; + } + + @Override + public String name() { + return "Llama Coaster"; + } + + @Override + public String category() { + return LEVELS; + } + + @Override + public String description() { + return "A roller coaster ride on a TestLevel chapter, with the cart from the Catacombs coaster levels:" + + " drops, climbs, boost arrows, a spin pad that turns the cart backwards and a jump over a gap." + + " Steer left and right to stay on the track and collect coins; the ride ends at the station." + + " Unlock TestLevel on the Save games tab."; + } + + @Override + public List options() { + return List.of( + ModOption.choice( + "track", + "Track", + "Short loop (about 100 units) or the grand circuit (about 150 units, two jumps, two spins).", + "loop", + ModOption.of("loop", "Loop"), + ModOption.of("grand", "Grand circuit")), + ModOption.integer("laps", "Laps", "Laps before the ride stops at the station.", 2, 1, 3), + ModOption.choice( + "speed", + "Speed", + "Cruise speed and boost strength of the cart.", + "normal", + ModOption.of("slow", "Slow"), + ModOption.of("normal", "Normal (as in the game)"), + ModOption.of("fast", "Fast")), + ModOption.bool("coins", "Coins", "Coins along the track.", true), + chapterOption()); + } + + static ModOption chapterOption() { + ModOption.Choice[] cs = new ModOption.Choice[TestLevelChapters.LAST - TestLevelChapters.FIRST + 1]; + for (int c = TestLevelChapters.FIRST; c <= TestLevelChapters.LAST; c++) { + cs[c - TestLevelChapters.FIRST] = ModOption.of(Integer.toString(c), Integer.toString(c)); + } + return ModOption.choice( + "chapter", + "TestLevel chapter", + "Chapter of world 9 the coaster replaces (32–49; chapter 1 is the Test World's, 49 the arena's).", + Integer.toString(DEFAULT_CHAPTER), + cs); + } + + static double speedFactor(String speed) { + return switch (speed) { + case "slow" -> 0.8; + case "fast" -> 1.25; + default -> 1.0; + }; + } + + /** After Test World (50), level projects (60) and the arena (65). */ + @Override + public int order() { + return 66; + } + + static String wadPath(int chapter) { + return String.format(Locale.ROOT, "wads/T1L9M%03d.wad", chapter); + } + + static CoasterLevel.Settings settings(ModContext ctx) { + return new CoasterLevel.Settings( + CoasterTrack.named(ctx.option("track")), + ctx.optionInt("laps"), + speedFactor(ctx.option("speed")), + ctx.optionBool("coins"), + new int[] {1, 9, ctx.optionInt("chapter")}); + } + + @Override + public void apply(ModContext ctx) 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("Llama Coaster: " + target + " is already changed by another mod (a level on" + + " TestLevel chapter " + chapter + "). Choose another chapter in the coaster's options."); + } + } + if (!ctx.exists(CoasterLevel.BASE)) { + throw new IOException("Llama Coaster: " + CoasterLevel.BASE + " (the Catacombs coaster level) is missing"); + } + ctx.progress(0.0, "building roller coaster"); + CoasterLevel level = new CoasterLevel(settings(ctx)); + byte[] wad = level.buildWad( + ctx.read(CoasterLevel.BASE), + rel -> { + try { + return ctx.exists(rel) ? ctx.read(rel) : null; + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }, + Map.of()); + ctx.write(target, LevelTally.counted(wad, level.coinCount())); + ctx.progress(0.9, "loading picture"); + ctx.write("T19" + chapter + ".COR", loadingPicture(chapter)); + ctx.progress(1.0, "coaster done"); + } + + /** + * Loading and tally picture. The game prints the level title, the coin and secret counts and the + * key hints over most of the screen; the band y ≈ 265–335 (of 480) stays free for the title. + */ + 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(0x231238), 0, 480, new Color(0x07030d))); + g.fillRect(0, 0, 640, 480); + Path2D rail = new Path2D.Double(); + rail.moveTo(20, 250); + rail.curveTo(140, 20, 250, 40, 320, 230); + rail.curveTo(390, 400, 500, 60, 620, 180); + g.setStroke(new BasicStroke(3f)); + g.setColor(new Color(0x3a2a5c)); + for (int x = 40; x < 620; x += 40) { + g.drawLine(x, 380, x, 230 + (int) (70 * Math.sin(x / 60.0))); + } + g.setStroke(new BasicStroke(8f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND)); + g.setColor(new Color(0x1b5f86)); + g.draw(rail); + String title = "Llama Coaster"; + g.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 46)); + FontMetrics fm = g.getFontMetrics(); + g.setColor(new Color(0, 0, 0, 170)); + g.drawString(title, (640 - fm.stringWidth(title)) / 2 + 2, 324); + g.setColor(new Color(0xffd23f)); + g.drawString(title, (640 - fm.stringWidth(title)) / 2, 322); + g.dispose(); + return CorEncoder.encode(img); + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/mods/coaster/CoasterSim.java b/src/main/java/com/eliasmueller/llamamod/mods/coaster/CoasterSim.java new file mode 100644 index 0000000..060d011 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/mods/coaster/CoasterSim.java @@ -0,0 +1,228 @@ +package com.eliasmueller.llamamod.mods.coaster; + +import com.eliasmueller.llamamod.mods.coaster.CoasterTrack.Corner; +import com.eliasmueller.llamamod.mods.coaster.CoasterTrack.Kind; +import com.eliasmueller.llamamod.mods.coaster.CoasterTrack.Segment; +import java.util.ArrayList; +import java.util.List; + +/** + * Frame-by-frame model of the original RollerCoaster strat's speed and jump logic (t1l7m001 unit 0) + * on a {@link CoasterTrack}, to check a layout before building it: the speed v (units per frame) + * and the boost excess v22; outside corners v22 > 2r decays both by 2r, else v > cap falls to the + * cap; in a corner arc v < cap rises by 6r per frame; on BOOST v and v22 grow by the boost; JUMP + * starts a flight with vertical speed 163r (y += 2·g, g −= 14r) that lands when the cart is back on + * a floor. The station crawl leaves the cart at 0.025. Limits: a climb of more than 0.1 per frame + * loses the floor (the strat probes 0.1 above the cart); a flight must land before the commit + * zone of the next corner (0.75 before its waypoint), else the cart misses the turn. + */ +final class CoasterSim { + static final double R = 1 / 4096.0; + static final double CRAWL_SPEED = 0.025; + + /** One flight: take-off and landing distance along the leg. */ + record Flight(int leg, double takeOff, double landing, double needed, double limit, double speed) { + boolean ok() { + return landing >= needed && landing <= limit; + } + } + + record Result( + int frames, + double minSpeed, + double maxSpeed, + double maxClimb, + List flights, + List problems) {} + + private final CoasterTrack track; + private final double cap; + private final double boost; + + private double v; + private double v22; + private int frames; + private double minV = Double.MAX_VALUE; + private double maxV; + private double maxClimb; + private final List flights = new ArrayList<>(); + private final List problems = new ArrayList<>(); + + CoasterSim(CoasterTrack track, double cap, double boost) { + this.track = track; + this.cap = cap; + this.boost = boost; + } + + static Result run(CoasterTrack track, double cap, double boost, int laps) { + return new CoasterSim(track, cap, boost).ride(laps); + } + + private Result ride(int laps) { + List segs = track.segments; + int station = segs.indexOf(track.station()); + int end = segs.indexOf(track.end()); + frames += (int) Math.ceil(track.station().length() / 0.03); + v = CRAWL_SPEED; + int i = station + 1; + int lap = 0; + double offset = 0; + while (true) { + Segment s = segs.get(i); + if (lap == laps && i == end) { + break; + } + if (s.kind() != Kind.GAP) { + double[] landing = straight(s, i, offset); + offset = 0; + if (landing != null) { + if (landing[0] < 0) { + break; + } + i = (int) landing[0]; + offset = landing[1]; + continue; + } + } + int n = (i + 1) % segs.size(); + if (segs.get(n).leg() != s.leg()) { + corner(track.corners.get(s.leg())); + } + if (n == 0) { + lap++; + } + i = n; + if (lap > laps + 1) { + problems.add("no end"); + break; + } + } + return new Result(frames, minV, maxV, maxClimb, flights, problems); + } + + private void tick() { + frames++; + minV = Math.min(minV, v); + maxV = Math.max(maxV, v); + } + + /** Rides segment s from offset; after a jump: {segment index, offset} of the landing ({-1} = fell). */ + private double[] straight(Segment s, int index, double offset) { + double len = s.length(); + for (double d = offset; d < len; ) { + d += v; + if (v22 > 2 * R) { + v -= 2 * R; + v22 -= 2 * R; + } else if (v > cap) { + v = Math.max(v - 2 * R, cap); + } + tick(); + if (d >= len) { + break; + } + double climb = (s.y1() - s.y0()) / len * v; + maxClimb = Math.max(maxClimb, climb); + if (climb > 0.1) { + problems.add(String.format("leg %d: climb %.3f per frame loses the floor", s.leg(), climb)); + } + if (s.kind() == Kind.BOOST) { + v += boost; + v22 += boost; + } else if (s.kind() == Kind.JUMP) { + return fly(s, index, d); + } + } + return null; + } + + /** Distance along leg {@code s.leg()} of the start of segment s. */ + private double legPos(Segment s) { + double[] h = CoasterTrack.dir(s.heading()); + Segment first = null; + for (Segment g : track.segments) { + if (g.leg() == s.leg()) { + first = g; + break; + } + } + return (s.x0() - first.x0()) * h[0] + (s.z0() - first.z0()) * h[1]; + } + + private double[] fly(Segment pad, int index, double d) { + List segs = track.segments; + double start = legPos(pad) + d; + double y = pad.y(d); + double g = 163 * R; + double x = start; + double needed = Double.NaN; + Segment landed = null; + while (true) { + y += 2 * g; + x += v; + g -= 14 * R; + tick(); + if (g < -820 * R) { + problems.add(String.format("leg %d: jump fell (no floor)", pad.leg())); + break; + } + Segment under = null; + for (int k = index; k < segs.size() && segs.get(k).leg() == pad.leg(); k++) { + Segment c = segs.get(k); + double p0 = legPos(c); + if (c.kind() == Kind.GAP) { + needed = p0 + c.length(); + continue; + } + if (x >= p0 && x < p0 + c.length()) { + under = c; + } + } + Corner corner = track.corners.get(pad.leg()); + double legEnd = segs.stream() + .filter(c -> c.leg() == pad.leg()) + .mapToDouble(c -> legPos(c) + c.length()) + .max() + .orElseThrow(); + double floor; + if (under != null) { + floor = under.y(x - legPos(under)); + } else if (x >= legEnd && x < legEnd + 2) { + floor = corner.y(); + } else { + continue; + } + if (y <= floor && y + 0.05 - 4 * g >= floor && g > -0.1) { + landed = under; + break; + } + } + double limit = segs.stream() + .filter(c -> c.leg() == pad.leg()) + .mapToDouble(c -> legPos(c) + c.length()) + .max() + .orElseThrow() + + 0.5 + - 0.75; + Flight f = new Flight(pad.leg(), start, x, needed, limit, v); + flights.add(f); + if (!f.ok()) { + problems.add(String.format( + "leg %d: jump at %.2f lands at %.2f (needs %.2f..%.2f)", pad.leg(), start, x, needed, limit)); + } + if (landed == null) { + return new double[] {-1}; + } + return new double[] {segs.indexOf(landed), x - legPos(landed)}; + } + + private void corner(Corner c) { + for (double a = 0; a < Math.PI / 4; ) { + if (v < cap) { + v = Math.min(v + 6 * R, cap); + } + a += v; + tick(); + } + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/mods/coaster/CoasterSpeed.java b/src/main/java/com/eliasmueller/llamamod/mods/coaster/CoasterSpeed.java new file mode 100644 index 0000000..655b4a9 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/mods/coaster/CoasterSpeed.java @@ -0,0 +1,88 @@ +package com.eliasmueller.llamamod.mods.coaster; + +import com.eliasmueller.llamamod.format.Level; +import com.eliasmueller.llamamod.format.Wad; +import com.eliasmueller.llamamod.format.stpcmerge.StpcImage; +import com.eliasmueller.llamamod.format.stpcmerge.StratCode; +import com.eliasmueller.llamamod.format.strat.StratListing; +import java.util.ArrayList; +import java.util.List; +import java.util.TreeSet; + +/** + * Speed constants of the RollerCoaster strat (t1l7m001 unit 0), patched in place: the cruise speed + * 0.15 (614 raw; six {@code pushShort}: the decay floor after a boost and the corner speed-up cap) + * and the boost per frame on material 5 (20 raw, added to v19 and v22). + */ +final class CoasterSpeed { + static final int CRUISE = 614; + static final int BOOST = 20; + + private CoasterSpeed() {} + + /** STPC offsets of the operand words: {cruise…, boost…}. */ + record Sites(List cruise, List boost) {} + + static Sites find(byte[] wad) { + Level lv = Level.parse(wad); + StpcImage img = new StpcImage(lv.wad().require("STPC"), lv.wfpc()); + TreeSet roots = new TreeSet<>(); + lv.map().strats.forEach(s -> roots.add(s.code())); + StratCode code = StratCode.analyse(img, roots); + for (int u = 0; u < img.units.size(); u++) { + StratListing li = StratListing.of(img, code, u, roots, StratListing.Names.NONE); + if (!li.strats.containsValue("RollerCoaster")) { + continue; + } + List ins = li.lines.stream() + .filter(l -> l.kind() == StratListing.Kind.INSTR) + .toList(); + List cruise = new ArrayList<>(); + List boost = new ArrayList<>(); + for (int i = 1; i + 1 < ins.size(); i++) { + StratListing.Line l = ins.get(i); + if (!l.constShort() || !l.text().startsWith("pushShort")) { + continue; + } + String prev = ins.get(i - 1).text(); + String next = ins.get(i + 1).text(); + if (l.value() == CRUISE && (prev.equals("pushVar v19") || next.equals("max") || next.equals("min"))) { + cruise.add(l.constAt()); + } else if (l.value() == BOOST + && (prev.equals("pushVar v19") || prev.equals("pushVar v22")) + && next.equals("add")) { + boost.add(l.constAt()); + } + } + return new Sites(cruise, boost); + } + throw new IllegalArgumentException("no RollerCoaster strat in this level"); + } + + /** The WAD with cruise and boost scaled by {@code factor} (1.0 = unchanged). */ + static byte[] patch(byte[] wad, double factor) { + if (factor == 1.0) { + return wad; + } + Sites s = find(wad); + if (s.cruise().size() != 6 || s.boost().size() != 2) { + throw new IllegalStateException("unexpected RollerCoaster code: " + s); + } + Wad w = Wad.parse(wad); + byte[] st = w.require("STPC").clone(); + for (int at : s.cruise()) { + setHigh(st, at, (int) Math.round(CRUISE * factor)); + } + for (int at : s.boost()) { + setHigh(st, at, (int) Math.round(BOOST * factor)); + } + w.set("STPC", st); + return w.toBytes(); + } + + /** pushShort keeps its operand in the high 16 bits of the opcode word. */ + private static void setHigh(byte[] d, int at, int v) { + d[at + 2] = (byte) v; + d[at + 3] = (byte) (v >> 8); + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/mods/coaster/CoasterTrack.java b/src/main/java/com/eliasmueller/llamamod/mods/coaster/CoasterTrack.java new file mode 100644 index 0000000..a487f3f --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/mods/coaster/CoasterTrack.java @@ -0,0 +1,295 @@ +package com.eliasmueller.llamamod.mods.coaster; + +import java.util.ArrayList; +import java.util.List; + +/** + * A closed roller coaster circuit for the original {@code RollerCoaster} strat: straight legs + * along the grid axes joined by flat 2 × 2 corners (the strat only turns by 90° on a radius-0.5 arc + * at a waypoint). The track bed is two units wide, its centre line on integer coordinates; heights + * change on straights only. Leg {@code i} starts behind corner {@code i - 1} (corner −1 = {@link + * #start}) and ends at corner {@code i}; the last corner is the start corner again. + */ +public final class CoasterTrack { + /** Floor kinds; {@code mat} is the collision material the strat reacts to. */ + public enum Kind { + TRACK(0), + /** Speed boost while on it (+20 raw per frame). */ + BOOST(5), + /** Jump (vertical speed 163 raw). */ + JUMP(11), + /** Small hop and a half turn: the llama rides backwards until the next corner. */ + SPIN(13), + /** Ends the level when the current waypoint is the last one. */ + END(16), + /** Start: the cart crawls along it out of the station. */ + STATION(0), + /** No floor. */ + GAP(-1); + + public final int mat; + + Kind(int mat) { + this.mat = mat; + } + } + + /** A piece of a leg: whole units long, changing the height by {@code rise}. */ + public record Piece(Kind kind, int length, double rise) {} + + /** A straight between two corners; heading in degrees (0 = +z, 90 = +x). */ + public record Leg(int heading, List pieces) { + int length() { + return pieces.stream().mapToInt(Piece::length).sum(); + } + + double rise() { + return pieces.stream().mapToDouble(Piece::rise).sum(); + } + } + + /** A placed piece: centre line from (x0, z0) to (x1, z1), heights y0 → y1. */ + public record Segment( + Kind kind, int leg, int heading, double x0, double z0, double y0, double x1, double z1, double y1) { + public double length() { + return Math.hypot(x1 - x0, z1 - z0); + } + + /** Height at distance d from the start. */ + public double y(double d) { + double l = length(); + return l == 0 ? y0 : y0 + (y1 - y0) * d / l; + } + } + + /** A corner square centred on (x, z): turns from heading {@code in} to {@code out}. */ + public record Corner(double x, double z, double y, int in, int out) {} + + /** A waypoint of the strat's chain: param 1.0 = station (crawl to the next node). */ + public record Node(double x, double z, double y, int heading, boolean station) {} + + public final String name; + /** Start corner centre {x, z, y} (the corner in front of leg 0). */ + public final double[] start; + + public final List legs; + public final List segments = new ArrayList<>(); + public final List corners = new ArrayList<>(); + + public CoasterTrack(String name, double[] start, List legs) { + this.name = name; + this.start = start.clone(); + this.legs = List.copyOf(legs); + layout(); + } + + static double[] dir(int heading) { + double a = Math.toRadians(heading); + return new double[] {Math.round(Math.sin(a)), Math.round(Math.cos(a))}; + } + + private void layout() { + if (legs.size() < 4) { + throw new IllegalArgumentException(name + ": a circuit needs at least 4 legs"); + } + double x = start[0], z = start[1]; + double y = start[2]; + int stations = 0, ends = 0; + for (int i = 0; i < legs.size(); i++) { + Leg l = legs.get(i); + if (l.heading() % 90 != 0) { + throw new IllegalArgumentException(name + ": leg " + i + " heading must be a multiple of 90°"); + } + int next = legs.get((i + 1) % legs.size()).heading(); + if (Math.abs(((next - l.heading()) % 360 + 360) % 360 - 180) != 90) { + throw new IllegalArgumentException(name + ": corner " + i + " must turn by 90°"); + } + double[] d = dir(l.heading()); + x += d[0]; + z += d[1]; + for (Piece p : l.pieces()) { + if (p.length() <= 0) { + throw new IllegalArgumentException(name + ": empty piece in leg " + i); + } + if (p.kind() == Kind.STATION) { + stations += i == 0 ? 1 : 100; + } + if (p.kind() == Kind.END) { + ends += i == 0 ? 1 : 100; + } + double nx = x + d[0] * p.length(), nz = z + d[1] * p.length(), ny = y + p.rise(); + segments.add(new Segment(p.kind(), i, l.heading(), x, z, y, nx, nz, ny)); + x = nx; + z = nz; + y = ny; + } + x += d[0]; + z += d[1]; + corners.add(new Corner(x, z, y, l.heading(), next)); + } + if (Math.abs(x - start[0]) > 1e-9 || Math.abs(z - start[1]) > 1e-9 || Math.abs(y - start[2]) > 1e-9) { + throw new IllegalArgumentException( + String.format("%s: the circuit does not close (ends at %.2f, %.2f, %.2f)", name, x, z, y)); + } + if (stations != 1 || ends != 1) { + throw new IllegalArgumentException(name + ": leg 0 needs exactly one STATION and one END piece"); + } + int iEnd = indexOf(Kind.END), iSt = indexOf(Kind.STATION); + if (iEnd > iSt) { + throw new IllegalArgumentException(name + ": the END piece must come before the STATION"); + } + } + + private int indexOf(Kind k) { + for (int i = 0; i < segments.size(); i++) { + if (segments.get(i).kind() == k) { + return i; + } + } + return -1; + } + + public Segment station() { + return segments.get(indexOf(Kind.STATION)); + } + + public Segment end() { + return segments.get(indexOf(Kind.END)); + } + + /** Centre-line length of one lap (corners counted as quarter arcs). */ + public double lapLength() { + double s = 0; + for (Segment g : segments) { + s += g.length(); + } + return s + corners.size() * (2 - 1 + Math.PI / 4); + } + + /** The cart's start: station start, heading of leg 0. */ + public double[] cartStart() { + Segment s = station(); + return new double[] {s.x0(), s.z0(), s.y0(), s.heading()}; + } + + /** Distance of the last node behind the start of the END piece (the strat ends the level 30 frames after the pad). */ + static final double END_NODE_AFTER_PAD = 9; + + /** + * The waypoint chain for {@code laps} laps: station node 1 behind the cart and its crawl end + * (both param 1.0), one node per corner 0.5 before the corner centre (heading after the turn), + * and in the last lap a final node behind the END pad. + */ + public List nodes(int laps) { + List out = new ArrayList<>(); + Segment st = station(); + double[] d = dir(st.heading()); + out.add(new Node(st.x0() - d[0], st.z0() - d[1], st.y0() + 1.5, st.heading(), true)); + out.add(new Node(st.x1(), st.z1(), st.y1(), st.heading(), true)); + for (int lap = 0; lap < laps; lap++) { + for (Corner c : corners) { + double[] h = dir(c.in()); + out.add(new Node(c.x() - 0.5 * h[0], c.z() - 0.5 * h[1], c.y(), c.out(), false)); + } + } + Segment e = end(); + double[] h = dir(e.heading()); + out.add(new Node( + e.x0() + END_NODE_AFTER_PAD * h[0], e.z0() + END_NODE_AFTER_PAD * h[1], e.y0(), e.heading(), false)); + return out; + } + + /** Bounding box {x0, z0, x1, z1} of the track bed. */ + public double[] bounds() { + double x0 = Double.MAX_VALUE, z0 = Double.MAX_VALUE, x1 = -Double.MAX_VALUE, z1 = -Double.MAX_VALUE; + for (Corner c : corners) { + x0 = Math.min(x0, c.x() - 1); + z0 = Math.min(z0, c.z() - 1); + x1 = Math.max(x1, c.x() + 1); + z1 = Math.max(z1, c.z() + 1); + } + return new double[] {x0, z0, x1, z1}; + } + + public double minY() { + double m = Double.MAX_VALUE; + for (Segment s : segments) { + m = Math.min(m, Math.min(s.y0(), s.y1())); + } + return m; + } + + // ------------------------------------------------------------ layouts + + private static Piece p(Kind k, int len) { + return new Piece(k, len, 0); + } + + private static Piece p(Kind k, int len, double rise) { + return new Piece(k, len, rise); + } + + private static Leg leg(int heading, Piece... pieces) { + return new Leg(heading, List.of(pieces)); + } + + /** Leg 0 of every layout: run-in, END pad, brake run, station, then the first drop (boost at its top). */ + private static Leg stationLeg(int heading, int boostLen, int dropLen, double drop, int tail) { + double b = drop * boostLen / dropLen; + return leg( + heading, + p(Kind.TRACK, 1), + p(Kind.END, 1), + p(Kind.TRACK, 2), + p(Kind.STATION, 5), + p(Kind.TRACK, 1), + p(Kind.BOOST, boostLen, -b), + p(Kind.TRACK, dropLen - boostLen, b - drop), + p(Kind.TRACK, tail)); + } + + /** Compact circuit (~100 units): first drop, climb, spin pad, jump over a gap, lift back up. */ + public static CoasterTrack loop() { + return new CoasterTrack( + "loop", + new double[] {10, 10, 24}, + List.of( + stationLeg(90, 4, 8, 4, 2), + leg(0, p(Kind.TRACK, 2), p(Kind.TRACK, 8, 2), p(Kind.TRACK, 2)), + leg(270, p(Kind.TRACK, 2), p(Kind.SPIN, 1), p(Kind.TRACK, 1), p(Kind.TRACK, 4, -2)), + leg(0, p(Kind.BOOST, 2), p(Kind.JUMP, 1), p(Kind.GAP, 2, -1), p(Kind.TRACK, 9)), + leg(270, p(Kind.TRACK, 3), p(Kind.TRACK, 5, -2), p(Kind.TRACK, 2)), + leg(180, p(Kind.BOOST, 1), p(Kind.TRACK, 2), p(Kind.TRACK, 25, 7)))); + } + + /** Longer circuit (~150 units): a bigger first drop, two jumps, two spin pads, a long lift hill. */ + public static CoasterTrack grand() { + return new CoasterTrack( + "grand", + new double[] {8, 8, 28}, + List.of( + stationLeg(90, 4, 12, 6, 2), + leg(0, p(Kind.TRACK, 3), p(Kind.TRACK, 6, 2), p(Kind.TRACK, 1)), + leg(90, p(Kind.TRACK, 1), p(Kind.SPIN, 1), p(Kind.TRACK, 2), p(Kind.TRACK, 6, -3)), + leg(0, p(Kind.BOOST, 2), p(Kind.JUMP, 1), p(Kind.GAP, 2, -1), p(Kind.TRACK, 9)), + leg(270, p(Kind.TRACK, 2), p(Kind.TRACK, 6, 2), p(Kind.TRACK, 2), p(Kind.TRACK, 6, -2)), + leg(0, p(Kind.TRACK, 2), p(Kind.SPIN, 1), p(Kind.TRACK, 5)), + leg( + 270, + p(Kind.BOOST, 2), + p(Kind.JUMP, 1), + p(Kind.GAP, 2, -1), + p(Kind.TRACK, 4), + p(Kind.TRACK, 4, -1), + p(Kind.TRACK, 5)), + leg(180, p(Kind.TRACK, 1), p(Kind.BOOST, 1), p(Kind.TRACK, 34, 10)))); + } + + public static CoasterTrack named(String name) { + return switch (name) { + case "loop" -> loop(); + case "grand" -> grand(); + default -> throw new IllegalArgumentException("unknown track " + name); + }; + } +} diff --git a/src/test/java/com/eliasmueller/llamamod/mods/coaster/CoasterTest.java b/src/test/java/com/eliasmueller/llamamod/mods/coaster/CoasterTest.java new file mode 100644 index 0000000..2079fa1 --- /dev/null +++ b/src/test/java/com/eliasmueller/llamamod/mods/coaster/CoasterTest.java @@ -0,0 +1,242 @@ +package com.eliasmueller.llamamod.mods.coaster; + +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.Bytes; +import com.eliasmueller.llamamod.format.Collision; +import com.eliasmueller.llamamod.format.Level; +import com.eliasmueller.llamamod.format.MapChunk; +import com.eliasmueller.llamamod.format.Model; +import com.eliasmueller.llamamod.format.Strats; +import com.eliasmueller.llamamod.format.Trak; +import com.eliasmueller.llamamod.format.Wad; +import com.eliasmueller.llamamod.mods.GameData; +import com.eliasmueller.llamamod.mods.ModOption; +import com.eliasmueller.llamamod.mods.ModRegistry; +import com.eliasmueller.llamamod.mods.TestModContext; +import com.eliasmueller.llamamod.mods.coaster.CoasterTrack.Kind; +import com.eliasmueller.llamamod.mods.coaster.CoasterTrack.Node; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Test; + +class CoasterTest { + private static final List TRACKS = List.of("loop", "grand"); + + @Test + void layoutsCloseAndHaveTheFeatures() { + for (String n : TRACKS) { + CoasterTrack t = CoasterTrack.named(n); + Set kinds = new HashSet<>(); + t.segments.forEach(s -> kinds.add(s.kind())); + assertTrue( + kinds.containsAll(List.of(Kind.BOOST, Kind.JUMP, Kind.GAP, Kind.SPIN, Kind.END, Kind.STATION)), n); + assertTrue(t.segments.stream().anyMatch(s -> s.y1() > s.y0()), n + ": a climb"); + assertTrue(t.segments.stream().anyMatch(s -> s.y1() < s.y0() && s.kind() != Kind.GAP), n + ": a drop"); + // the waypoint funnel and the corner arcs need corners on the half-unit grid + for (CoasterTrack.Corner c : t.corners) { + assertEquals(Math.rint(c.x()), c.x(), n); + assertEquals(Math.rint(c.z()), c.z(), n); + } + // tracks must not overlap in plan (no crossings) + Set cells = new HashSet<>(); + int overlaps = 0; + for (CoasterTrack.Segment s : t.segments) { + double[] h = CoasterTrack.dir(s.heading()); + for (double d = 0.5; d < s.length(); d += 1) { + for (double lat : new double[] {-0.5, 0.5}) { + double x = s.x0() + h[0] * d + h[1] * lat, z = s.z0() + h[1] * d - h[0] * lat; + if (!cells.add((long) Math.floor(x) * 1000 + (long) Math.floor(z))) { + overlaps++; + } + } + } + } + assertEquals(0, overlaps, n); + } + assertTrue(CoasterTrack.loop().lapLength() > 100); + assertTrue(CoasterTrack.grand().lapLength() > CoasterTrack.loop().lapLength() + 40); + } + + @Test + void badLayoutsAreRefused() { + assertThrows( + IllegalArgumentException.class, + () -> new CoasterTrack( + "open", + new double[] {10, 10, 5}, + List.of( + new CoasterTrack.Leg(90, List.of(new CoasterTrack.Piece(Kind.TRACK, 4, 0))), + new CoasterTrack.Leg(0, List.of(new CoasterTrack.Piece(Kind.TRACK, 4, 0))), + new CoasterTrack.Leg(270, List.of(new CoasterTrack.Piece(Kind.TRACK, 4, 0))), + new CoasterTrack.Leg(180, List.of(new CoasterTrack.Piece(Kind.TRACK, 3, 0)))))); + } + + @Test + void nodesFollowTheStratsRules() { + CoasterTrack t = CoasterTrack.loop(); + for (int laps = 1; laps <= 3; laps++) { + List ns = t.nodes(laps); + assertEquals(2 + laps * t.corners.size() + 1, ns.size()); + double[] st = t.cartStart(); + // node 0: station flag, one unit behind the cart (the cart starts past it and crawls) + assertTrue(ns.get(0).station() && ns.get(1).station()); + assertEquals(1.0, Math.hypot(ns.get(0).x() - st[0], ns.get(0).z() - st[1]), 1e-9); + for (Node n : ns) { + // RollerCoaster rounds node x/z to halves and headings to quarter turns + assertEquals(Math.rint(n.x() * 2) / 2, n.x(), 1e-9); + assertEquals(Math.rint(n.z() * 2) / 2, n.z(), 1e-9); + assertEquals(0, n.heading() % 90); + } + assertTrue(ns.subList(2, ns.size()).stream().noneMatch(Node::station)); + // the last node lies behind the END pad on leg 0: the strat ends the level there + Node last = ns.getLast(); + CoasterTrack.Segment e = t.end(); + assertEquals(e.heading(), last.heading()); + assertEquals(e.z0(), last.z(), 1e-9); + assertTrue(last.x() > e.x1() + 5); + } + } + + @Test + void everyJumpLandsAndNoClimbLosesTheFloor() { + for (String n : TRACKS) { + for (String sp : List.of("slow", "normal", "fast")) { + double f = CoasterMod.speedFactor(sp); + for (int laps = 1; laps <= 3; laps++) { + CoasterSim.Result r = CoasterSim.run( + CoasterTrack.named(n), CoasterSpeed.CRUISE * f / 4096, CoasterSpeed.BOOST * f / 4096, laps); + String what = n + "/" + sp + "/" + laps + ": " + r; + assertTrue(r.problems().isEmpty(), what); + assertEquals( + laps + * CoasterTrack.named(n).segments.stream() + .filter(s -> s.kind() == Kind.JUMP) + .count(), + r.flights().size(), + what); + assertTrue(r.minSpeed() > 0.02 && r.maxSpeed() < 0.45, what); + } + } + } + } + + @Test + void registered() { + assertNotNull(new ModRegistry().get("coaster")); + } + + @Test + void chapterOption() { + ModOption o = new CoasterMod() + .options().stream() + .filter(x -> x.key().equals("chapter")) + .findFirst() + .orElseThrow(); + assertEquals("48", o.defaultValue()); + assertEquals("32", o.normalize("32")); + for (String bad : List.of("1", "8", "31", "50")) { + assertThrows(IllegalArgumentException.class, () -> o.normalize(bad), bad); + } + } + + @Test + void speedPatch() throws IOException { + Path cd = GameData.cd(); + byte[] base = Files.readAllBytes(cd.resolve(CoasterLevel.BASE)); + CoasterSpeed.Sites s = CoasterSpeed.find(base); + assertEquals(6, s.cruise().size()); + assertEquals(2, s.boost().size()); + assertTrue(CoasterSpeed.patch(base, 1.0) == base); + byte[] fast = CoasterSpeed.patch(base, 1.25); + byte[] st = Wad.parse(fast).require("STPC"); + for (int at : s.cruise()) { + assertEquals(768, Bytes.i32(st, at) >>> 16); + } + for (int at : s.boost()) { + assertEquals(25, Bytes.i32(st, at) >>> 16); + } + // nothing else changed + byte[] st0 = Wad.parse(base).require("STPC"); + int diff = 0; + for (int i = 0; i < st.length; i++) { + if (st[i] != st0[i]) { + diff++; + } + } + assertTrue(diff <= 2 * 8, "bytes changed: " + diff); + } + + @Test + void builtLevel() throws IOException { + Path cd = GameData.cd(); + CoasterMod mod = new CoasterMod(); + TestModContext ctx = new TestModContext(cd, mod, Map.of("chapter", "40", "laps", "3"), null); + mod.apply(ctx); + byte[] wad = ctx.written.get("wads/T1L9M040.wad"); + assertNotNull(wad, "written: " + ctx.written.keySet()); + assertNotNull(ctx.written.get("T1940.COR")); + Level lv = Level.parse(wad); + Map> t = Strats.levelTypes(lv.wad().require("STPC"), lv.wfpc(), lv.map()); + assertEquals(1, t.get("RollerCoaster").size()); + assertTrue(!t.containsKey("Llama"), t.keySet().toString()); + assertEquals(1, t.get("RollerDoor").size()); + assertEquals(1, t.get("FireworksTrigger").size()); + int coins = t.get("CoinRollerCoaster").size(); + assertTrue(coins > 10, "coins " + coins); + assertEquals(coins, Bytes.u32(lv.wad().require("LNFO"), 0)); + + MapChunk m = lv.map(); + MapChunk.Strat rc = t.get("RollerCoaster").get(0); + // exit target (tribe, world, chapter) = this chapter + assertEquals( + List.of(0, 4096, 9 * 4096, 40 * 4096), + List.of( + m.params[rc.paramIndex()], + m.params[rc.paramIndex() + 1], + m.params[rc.paramIndex() + 2], + m.params[rc.paramIndex() + 3])); + List chain = new ArrayList<>(); + for (int k = rc.waypoint(); ; k = m.nodes.get(k).next()) { + chain.add(m.nodes.get(k)); + if (m.nodes.get(k).next() == 0) { + break; + } + } + CoasterTrack loop = CoasterTrack.loop(); + assertEquals(loop.nodes(3).size(), chain.size()); + assertEquals(4096, chain.get(0).unk2()); + assertEquals(4096, chain.get(1).unk2()); + assertEquals(0, chain.get(2).unk2()); + + // floor materials the strat reacts to are on the track bed + Set mats = new HashSet<>(); + List trak = Trak.parse(lv.wad().require("TRAK")); + for (MapChunk.Tile tile : m.tiles) { + for (byte[] r : trak.get(tile.model()).coll.get(0)) { + mats.add(Collision.decode(r).mat()); + } + } + assertTrue(mats.containsAll(List.of(0, 5, 11, 13, 16, 30)), mats.toString()); + assertEquals("Llama Coaster", lv.wad().levelName()); + } + + @Test + void refusesAChapterAnotherModChanged() throws IOException { + Path cd = GameData.cd(); + CoasterMod mod = new CoasterMod(); + TestModContext ctx = new TestModContext(cd, mod, Map.of(), null); + ctx.write("wads/T1L9M048.wad", new byte[] {1, 2, 3}); + IOException e = assertThrows(IOException.class, () -> mod.apply(ctx)); + assertTrue(e.getMessage().contains("chapter 48"), e.getMessage()); + } +}