llama coaster: ride the catacombs coaster cart on a new track (loop or grand circuit with drops, climbs, boosts, spin pads and jumps), 1-3 laps, three speeds, coins; offline cart simulation checks every jump and climb; roller coaster chapter in the strats docs
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 79 KiB |
@@ -74,6 +74,21 @@ another mod on the same chapter stops the apply with a message – pick another
|
||||
|
||||

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

|
||||
|
||||
## 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.
|
||||
|
||||
|
After Width: | Height: | Size: 43 KiB |
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
<!-- TODO (docs): fill in when mods/coaster is merged: overview + screenshots (docs/img/strats/),
|
||||
files table, the track geometry, the cart/player strat scripts step by step, options, tests,
|
||||
lessons learned. Link notes/mod-coaster.md and the 18-mods.md section. -->
|
||||

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

|
||||
|
||||
**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).
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
@@ -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 |
|
||||
|
||||
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 79 KiB |
@@ -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.
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
## 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).
|
||||
@@ -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). */
|
||||
|
||||
@@ -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<String, byte[]> wads, Map<String, String> 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<Segment> 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<double[]> 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<double[]> 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++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<ModOption> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<Flight> flights,
|
||||
List<String> 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<Flight> flights = new ArrayList<>();
|
||||
private final List<String> 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<Segment> 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<Segment> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Integer> cruise, List<Integer> boost) {}
|
||||
|
||||
static Sites find(byte[] wad) {
|
||||
Level lv = Level.parse(wad);
|
||||
StpcImage img = new StpcImage(lv.wad().require("STPC"), lv.wfpc());
|
||||
TreeSet<Integer> 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<StratListing.Line> ins = li.lines.stream()
|
||||
.filter(l -> l.kind() == StratListing.Kind.INSTR)
|
||||
.toList();
|
||||
List<Integer> cruise = new ArrayList<>();
|
||||
List<Integer> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<Piece> 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<Leg> legs;
|
||||
public final List<Segment> segments = new ArrayList<>();
|
||||
public final List<Corner> corners = new ArrayList<>();
|
||||
|
||||
public CoasterTrack(String name, double[] start, List<Leg> 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<Node> nodes(int laps) {
|
||||
List<Node> 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);
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<String> TRACKS = List.of("loop", "grand");
|
||||
|
||||
@Test
|
||||
void layoutsCloseAndHaveTheFeatures() {
|
||||
for (String n : TRACKS) {
|
||||
CoasterTrack t = CoasterTrack.named(n);
|
||||
Set<Kind> 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<Long> 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<Node> 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<String, List<MapChunk.Strat>> 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<MapChunk.PathNode> 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<Integer> mats = new HashSet<>();
|
||||
List<Model> 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());
|
||||
}
|
||||
}
|
||||