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
This commit is contained in:
1 parent
6d449257a7
commit
f5fd411592
21 files changed
+1818
-17
No files matched your search
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user