objects: snap to floor follows the engine's cell rule (cell tiles, swap models, then neighbours), alligator, alligator spawner and cow guard spawner schemas with box checks, every way of adding a box walker gives it a box, guard info names the objects that set its event bit, new and moved player starts carry their start camera node (no camera in the llama's head), edit and delete type in the object palettes' context menu

This commit is contained in:
MineTec committed 2026-11-07 13:51:12 +01:00
1 parent 01208aa36f
commit 6a76cac69f
34 files changed
+1302 -203

No files matched your search

@@ -0,0 +1,46 @@
package com.eliasmueller.llamamod.editor.model;
import java.util.List;
/**
* Object types whose strat code reads their waypoints as walking areas: x/z boxes of node pairs
* (notes/editor-object-schemas.md "Guards", "Alligators"). A newly placed one gets a single box
* around it instead of the prototype's boxes (which belong to the prototype's position).
*/
public final class WaypointBoxes {
/** The guard template (CityGuard family): start box must have field unk2 = 0, p2 = event bit. */
public static final List<String> GUARDS = List.of(
"CityGuard",
"CityGuardWithRubyIdol",
"CowGuard",
"CowGuardNoCoin",
"GorillaGuard",
"LizardGuard",
"LizardGuardNoCoin",
"WarthogGuard");
/** Other box walkers: spawners hand their chain to the spawned guard / alligator. */
public static final List<String> OTHERS =
List.of("CowGuardSpawner", "CowGuardSpawned", "Alligator", "AlligatorSpawner", "SpawnedAlligator");
/** Parameter index of a guard's event bit. */
public static final int GUARD_EVENT_PARAM = 2;
/** Half size of the box a newly placed box walker gets (cells). */
public static final double NEW_BOX_HALF = 2.0;
private WaypointBoxes() {}
/** Type name without the "(spawn)" suffix of code-only types. */
public static String base(String type) {
return type != null && type.endsWith("(spawn)") ? type.substring(0, type.length() - 7) : type;
}
public static boolean isGuard(String type) {
return type != null && GUARDS.contains(base(type));
}
public static boolean walksBoxes(String type) {
return isGuard(type) || type != null && OTHERS.contains(base(type));
}
}
@@ -12,6 +12,7 @@ import com.eliasmueller.llamamod.editor.model.Scene;
import com.eliasmueller.llamamod.editor.model.Strat;
import com.eliasmueller.llamamod.editor.model.StratCatalog;
import com.eliasmueller.llamamod.editor.model.Tile;
import com.eliasmueller.llamamod.editor.model.WaypointBoxes;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
@@ -105,7 +106,10 @@ public final class Edits {
/**
* Places a new object of a catalog type: type fields and default parameters from the
* prototype, its waypoint chain copied relative to the new position ("relative" mode of
* levelbuild, without snapping to the ground).
* levelbuild, without snapping to the ground). Box walkers ({@link WaypointBoxes}: guards,
* alligators, their spawners) get one box of 2 nodes around the new position instead (the game
* removes a guard that does not start inside a box), and guards event bit 0 (active from the
* start).
*/
public static Command placeStrat(Scene s, String type, int variant, double x, double y, double z, Double yawDeg) {
StratCatalog.Type t = s.catalog().type(type);
@@ -119,7 +123,23 @@ public final class Edits {
int fy = fixed(y);
int fz = fixed(z);
List<PathNode> chain = new ArrayList<>();
for (int[] c : v.chain()) {
List<int[]> proto = v.chain();
boolean boxes = WaypointBoxes.walksBoxes(type);
if (boxes) {
int h = fixed(WaypointBoxes.NEW_BOX_HALF);
int[] a = proto.size() > 0 ? proto.get(0).clone() : new int[9];
int[] b = proto.size() > 1 ? proto.get(1).clone() : new int[9];
a[0] = -h;
a[1] = 0;
a[2] = -h;
b[0] = h;
b[1] = 0;
b[2] = h;
a[7] = 0;
b[7] = 0;
proto = List.of(a, b);
}
for (int[] c : proto) {
PathNode p = (PathNode) s.create(NodeKind.PATH_NODE);
p.setRaw("x", fx + c[0]);
p.setRaw("y", fy + c[1]);
@@ -148,7 +168,11 @@ public final class Edits {
st.setRaw("x", fx);
st.setRaw("y", fy);
st.setRaw("z", fz);
st.setRaw("params", v.params().clone());
int[] params = v.params().clone();
if (boxes && WaypointBoxes.isGuard(type) && params.length > WaypointBoxes.GUARD_EVENT_PARAM) {
params[WaypointBoxes.GUARD_EVENT_PARAM] = 0;
}
st.setRaw("params", params);
st.setRaw("waypoint", chain.isEmpty() ? null : chain.getFirst());
st.setTypeName(type);
cmds.add(new AddNode(st));
@@ -0,0 +1,151 @@
package com.eliasmueller.llamamod.editor.model.cmd;
import com.eliasmueller.llamamod.editor.model.Node;
import com.eliasmueller.llamamod.editor.model.NodeKind;
import com.eliasmueller.llamamod.editor.model.PathNode;
import com.eliasmueller.llamamod.editor.model.Scene;
import com.eliasmueller.llamamod.editor.model.Strat;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Player start (Llama) and its start camera. The Llama's waypoints are camera start positions
* (Camera strat, L_1330 at level start): the first node within {@value #RANGE} cells (x/z) of the
* llama becomes the camera position, looking at the llama; if the node's field unk2 ≠ 0 its x/z
* distance and height to the llama also become the camera distance/height (else the defaults 1.0
* / 0.5), held until the llama moves. Original levels have one node per entry point (doors), so a
* prototype chain copied onto the llama puts the camera into its head (village 2: node 1 has unk2
* ≠ 0) or makes it look along +z (village 1: zero distance, no direction).
*/
public final class PlayerStart {
/** Range of the camera start search (cells, x/z). */
public static final double RANGE = 2.5;
/** Camera node of a new player start: this far behind the llama, at default height. */
public static final double BACK = 2.0;
public static final double UP = 0.5;
/** CutSceneTrigger parameter 6: bits 1 = end camera behind the player, 2 = not skippable. */
static final int TRIGGER_FLAGS = 6;
private PlayerStart() {}
/** Index of the node the game takes as start camera, or −1 (no node within range). */
public static int startNode(List<PathNode> chain, double x, double z) {
for (int i = 0; i < chain.size(); i++) {
PathNode p = chain.get(i);
if (Math.hypot(p.gx() - x, p.gz() - z) < RANGE) {
return i;
}
}
return -1;
}
/** Places a Llama with a single camera node {@link #BACK} behind it (unk2 = 0: default camera). */
public static Command place(Scene s, double x, double y, double z, double yawDeg) {
Batch b = (Batch) Edits.placeStrat(s, "Llama", 0, x, y, z, yawDeg);
List<Command> cmds = new ArrayList<>();
PathNode first = null;
for (Command c : b.commands()) {
if (c instanceof AddNode a && a.node() instanceof PathNode p) {
if (first != null) {
continue;
}
first = p;
}
cmds.add(c);
}
if (first == null) {
// a Llama prototype without chain: give it one
first = (PathNode) s.create(NodeKind.PATH_NODE);
cmds.add(0, new AddNode(first));
for (Command c : cmds) {
if (c instanceof AddNode a && a.node() instanceof Strat st) {
st.setRaw("waypoint", first);
}
}
}
cameraNode(first, x, y, z, yawDeg);
first.setRaw("next", null);
return new Batch(b.label(), cmds);
}
private static void cameraNode(PathNode p, double x, double y, double z, double yawDeg) {
double a = Math.toRadians(yawDeg);
p.setRaw("x", Edits.fixed(x - BACK * Math.sin(a)));
p.setRaw("y", Edits.fixed(y + UP));
p.setRaw("z", Edits.fixed(z - BACK * Math.cos(a)));
p.setRaw("ry", Edits.angleUnits(yawDeg));
p.setRaw("unk2", 0);
}
/**
* Moves the existing player start. Camera nodes within range of the old start move (and turn)
* with it, the others (entry points of doors) stay; so does a CutSceneTrigger at the old start
* (the base level's intro fly-by), set to end behind the llama – otherwise the fly-by ends with
* the camera at the old start, where it stays until the llama moves. If then no node is in range of the new start,
* a camera node {@link #BACK} behind it is appended to the chain.
*/
public static Command move(Scene s, Strat st, double x, double y, double z, double yawDeg) {
Map<Node, Map<String, Object>> v = new LinkedHashMap<>();
v.put(
st,
Map.of("x", Edits.fixed(x), "y", Edits.fixed(y), "z", Edits.fixed(z), "ry", Edits.angleUnits(yawDeg)));
List<PathNode> chain = st.waypoint() != null && s.contains(st.waypoint()) ? s.chain(st.waypoint()) : List.of();
double turn = Math.toRadians(yawDeg - st.yawDegrees());
double c = Math.cos(turn), sn = Math.sin(turn);
int turnUnits = Edits.angleUnits(yawDeg - st.yawDegrees());
double ox = st.gx(), oy = st.gy(), oz = st.gz();
java.util.function.BiConsumer<Node, double[]> carry = (n, g) -> {
double rx = g[0] - ox, rz = g[2] - oz;
Map<String, Object> m = new LinkedHashMap<>();
m.put("x", Edits.fixed(x + rx * c + rz * sn));
m.put("y", Edits.fixed(g[1] + y - oy));
m.put("z", Edits.fixed(z - rx * sn + rz * c));
m.put("ry", (g[3] < 0 ? 0 : (int) g[3] + turnUnits) & 0xfff);
v.put(n, m);
};
boolean inRange = false;
for (PathNode p : chain) {
if (Math.hypot(p.gx() - ox, p.gz() - oz) < RANGE) {
carry.accept(p, new double[] {p.gx(), p.gy(), p.gz(), p.ry()});
inRange = true;
} else if (Math.hypot(p.gx() - x, p.gz() - z) < RANGE) {
inRange = true;
}
}
// the base level's intro fly-by (village 1/2: a CutSceneTrigger on the start, radius 0.7): it
// moves along, and flag bit 0 (p6) makes the game end the fly-by behind the llama instead of
// at its last node (placed for the old start)
for (Strat o : s.strats()) {
if (!o.type().equals("CutSceneTrigger") || Math.hypot(o.gx() - ox, o.gz() - oz) >= RANGE) {
continue;
}
carry.accept(o, new double[] {o.gx(), o.gy(), o.gz(), o.ry()});
int[] prm = o.params().clone();
if (prm.length > TRIGGER_FLAGS) {
prm[TRIGGER_FLAGS] = ((prm[TRIGGER_FLAGS] >> 12) | 1) << 12;
v.get(o).put("params", prm);
}
}
Command set = Edits.set("Move player start", v, null);
if (inRange) {
return set;
}
PathNode fresh = (PathNode) s.create(NodeKind.PATH_NODE);
cameraNode(fresh, x, y, z, yawDeg);
List<Command> cmds = new ArrayList<>(List.of(set));
if (chain.isEmpty()) {
cmds.add(new AddNode(fresh));
cmds.add(SetProp.of(st, "waypoint", fresh));
} else {
PathNode last = chain.getLast();
cmds.add(new AddNode(fresh, s.pathNodes().indexOf(last) + 1));
cmds.add(SetProp.of(last, "next", fresh));
}
return new Batch("Move player start", cmds);
}
}
@@ -0,0 +1,82 @@
package com.eliasmueller.llamamod.editor.tools.objects;
import com.eliasmueller.llamamod.editor.model.Scene;
import com.eliasmueller.llamamod.editor.model.Strat;
import com.eliasmueller.llamamod.editor.model.WaypointBoxes;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
/**
* Event (progress) bits of global 95: objects that set a bit with {@code g95 |= 1 << (v − 1)} where
* v is one of their parameters (found by a sweep over the strat code of all original levels,
* notes/editor-object-schemas.md "Event bits"). Guards with an event bit wait for it.
*/
final class EventBits {
/** Setter type → parameter indices holding the bit number (1..32, 0 = none). */
static final Map<String, int[]> SETTERS = new LinkedHashMap<>();
static {
SETTERS.put("TipoVillage", new int[] {6});
SETTERS.put("ChacaVillage", new int[] {6});
SETTERS.put("SmashStatueSmall", new int[] {0});
SETTERS.put("SmashStatueBig", new int[] {0});
SETTERS.put("SmashStatueDoor", new int[] {0});
SETTERS.put("RiverCrocGame", new int[] {1});
SETTERS.put("RiverTriggerBox", new int[] {0});
SETTERS.put("KidOnCanoe", new int[] {4});
SETTERS.put("YzmaRiver", new int[] {2, 6});
SETTERS.put("Condor", new int[] {2});
SETTERS.put("GoldIdol", new int[] {1});
SETTERS.put("CityWall", new int[] {4});
SETTERS.put("NakedPeacock", new int[] {0});
SETTERS.put("LabSlidingDoor", new int[] {5});
SETTERS.put("LabSlidingDoorJamie", new int[] {5});
SETTERS.put("LabBigCutscene", new int[] {1});
SETTERS.put("LabWall", new int[] {4});
SETTERS.put("EndGameTriggerBox", new int[] {0});
}
private EventBits() {}
/** Bits (1..32) the objects of the level can set. */
static Set<Integer> setBits(Scene s) {
Set<Integer> out = new TreeSet<>();
for (Strat st : s.strats()) {
int[] idx = SETTERS.get(WaypointBoxes.base(st.type()));
if (idx == null) {
continue;
}
for (int i : idx) {
if (i < st.params().length) {
int b = st.params()[i] >> 12;
if (b > 0 && b <= 32) {
out.add(b);
}
}
}
}
return out;
}
/**
* "TipoVillage (parameter 6), …": the setter types placeable in this level, or all known
* setters marked as not in this base.
*/
static String setterList(Scene s) {
StringBuilder b = new StringBuilder();
SETTERS.forEach((t, idx) -> {
if (s.catalog().has(t)) {
b.append(b.isEmpty() ? "" : ", ")
.append(t)
.append(" (parameter ")
.append(idx[0])
.append(')');
}
});
return b.isEmpty()
? "none of the setter types (" + String.join(", ", SETTERS.keySet()) + ") is in this base level"
: "set by " + b;
}
}
@@ -12,6 +12,12 @@ import java.util.Map;
/**
* Floor finder on the collision polygons of all tiles (original tiles and the tiles generated from
* brushes), in game space (x east, y up, z = grid row). Cached per scene revision.
*
* <p>Follows the engine's floor test (FUN_004036d0, used for the player and for objects): only the
* tiles listed in the grid cell of the point count ({@link Tile#cellX}/{@link Tile#cellZ}), so a
* polygon reaching into the cell from a neighbouring tile carries nothing there. If no such tile
* has a floor at the point, the swap models of the cell's tiles (a door object may switch the tile
* to them) are tried, and last the neighbouring tiles' polygons (where the game finds no floor).
*/
public final class Ground {
/** A floor polygon (normal up) in game space with its collision material. */
@@ -35,10 +41,23 @@ public final class Ground {
}
}
/** Result of a query: floor height and material. */
public record Hit(double y, int material) {}
/** Where a floor came from: the cell's own tiles (as in game), their swap models, a neighbour tile. */
public enum Source {
CELL,
SWAP,
NEIGHBOUR
}
/** Result of a query: floor height, material and source. */
public record Hit(double y, int material, Source source) {
public Hit(double y, int material) {
this(y, material, Source.CELL);
}
}
private final Map<Long, List<Floor>> cells = new HashMap<>();
private final Map<Long, List<Floor>> swaps = new HashMap<>();
private final Map<Long, List<Floor>> overlaps = new HashMap<>();
private Ground() {}
@@ -56,19 +75,11 @@ public final class Ground {
}
private void addTile(Tile t) {
for (TileModel.PlacedPolygon cp : t.worldCollisionPolygons()) {
double[][] pts = new double[cp.corners().length][];
for (int i = 0; i < pts.length; i++) {
double[] w = cp.corners()[i];
pts[i] = new double[] {w[0], w[1], -w[2]};
}
double[] pl = plane(pts);
if (pl == null || pl[1] < 0.5) {
continue;
}
Floor f = new Floor(pts, pl, cp.material());
long own = key(t.cellX(), t.cellZ());
for (Floor f : floors(t.worldCollisionPolygons())) {
cells.computeIfAbsent(own, q -> new ArrayList<>()).add(f);
double x0 = Double.MAX_VALUE, z0 = Double.MAX_VALUE, x1 = -Double.MAX_VALUE, z1 = -Double.MAX_VALUE;
for (double[] p : pts) {
for (double[] p : f.pts) {
x0 = Math.min(x0, p[0]);
x1 = Math.max(x1, p[0]);
z0 = Math.min(z0, p[2]);
@@ -76,10 +87,37 @@ public final class Ground {
}
for (int cx = (int) Math.floor(x0); cx <= (int) Math.floor(x1); cx++) {
for (int cz = (int) Math.floor(z0); cz <= (int) Math.floor(z1); cz++) {
cells.computeIfAbsent(key(cx, cz), q -> new ArrayList<>()).add(f);
if (key(cx, cz) != own) {
overlaps.computeIfAbsent(key(cx, cz), q -> new ArrayList<>())
.add(f);
}
}
}
}
if (t.hasSwap() && !t.noCollision()) {
for (TileModel m : new TileModel[] {t.swapModel(), t.swapAltModel()}) {
if (m != null) {
swaps.computeIfAbsent(own, q -> new ArrayList<>())
.addAll(floors(m.collisionPolygons(t.transform())));
}
}
}
}
private static List<Floor> floors(List<TileModel.PlacedPolygon> polys) {
List<Floor> out = new ArrayList<>();
for (TileModel.PlacedPolygon cp : polys) {
double[][] pts = new double[cp.corners().length][];
for (int i = 0; i < pts.length; i++) {
double[] w = cp.corners()[i];
pts[i] = new double[] {w[0], w[1], -w[2]};
}
double[] pl = plane(pts);
if (pl != null && pl[1] >= 0.5) {
out.add(new Floor(pts, pl, cp.material()));
}
}
return out;
}
private static double[] plane(double[][] p) {
@@ -108,10 +146,27 @@ public final class Ground {
/**
* Highest floor at (x, z) not more than {@code above} over {@code probeY} (game space); with
* {@code probeY} = NaN the highest floor at all. Null if there is none.
* {@code probeY} = NaN the highest floor at all. Null if there is none. The cell's own tiles
* first (what the game uses), then their swap models, then polygons of neighbouring tiles.
*/
public Hit below(double x, double z, double probeY, double above) {
List<Floor> l = cells.get(key((int) Math.floor(x), (int) Math.floor(z)));
long k = key((int) Math.floor(x), (int) Math.floor(z));
Hit h = best(cells.get(k), Source.CELL, x, z, probeY, above);
if (h == null) {
h = best(swaps.get(k), Source.SWAP, x, z, probeY, above);
}
if (h == null) {
h = best(overlaps.get(k), Source.NEIGHBOUR, x, z, probeY, above);
}
return h;
}
/** Highest polygon of a neighbouring tile reaching into the cell (what the game ignores there). */
Hit neighbourBelow(double x, double z, double probeY, double above) {
return best(overlaps.get(key((int) Math.floor(x), (int) Math.floor(z))), Source.NEIGHBOUR, x, z, probeY, above);
}
private static Hit best(List<Floor> l, Source src, double x, double z, double probeY, double above) {
if (l == null) {
return null;
}
@@ -125,21 +180,22 @@ public final class Ground {
continue;
}
if (best == null || y > best.y) {
best = new Hit(y, f.material);
best = new Hit(y, f.material, src);
}
}
return best;
}
/** True if a floor polygon with the material covers any part of the cell (cx, cz). */
/**
* True if a tile listed in the cell (cx, cz) has a floor polygon with the material, in its
* model or a swap model (the engine's LevelDoor test: any height).
*/
public boolean hasMaterial(int cx, int cz, int material) {
List<Floor> l = cells.get(key(cx, cz));
if (l == null) {
return false;
}
for (Floor f : l) {
if (f.material == material) {
return true;
for (Map<Long, List<Floor>> m : List.of(cells, swaps)) {
for (Floor f : m.getOrDefault(key(cx, cz), List.of())) {
if (f.material == material) {
return true;
}
}
}
return false;
@@ -3,46 +3,63 @@ package com.eliasmueller.llamamod.editor.tools.objects;
import com.eliasmueller.llamamod.editor.model.PathNode;
import com.eliasmueller.llamamod.editor.model.Scene;
import com.eliasmueller.llamamod.editor.model.Strat;
import com.eliasmueller.llamamod.editor.model.WaypointBoxes;
import java.util.List;
/**
* Guard family (CityGuard, CityGuardWithRubyIdol, CowGuard, GorillaGuard, …: one compiled template).
* Strat code at start: the waypoints are walked in pairs; an odd count ("Uneven number of WPs") or a
* start position outside every pair's x/z box whose first node has field unk2 = 0 ("Guard not in WP
* box") removes the guard before it is ever drawn. Parameter 2 &gt; 0 keeps it hidden until progress
* bit p2 − 1 of global 95 is set; 0 = active as soon as the player exists.
* Objects whose waypoints are walking areas (x/z boxes of node pairs, {@link WaypointBoxes}).
*
* <ul>
* <li>Guard family (CityGuard, CowGuard, …: one compiled template) and CowGuardSpawned: an odd
* count ("Uneven number of WPs") or a start position outside every pair's box whose first
* node has field unk2 = 0 ("Guard not in WP box") removes the guard before it is drawn.
* Guards: parameter 2 &gt; 0 keeps it hidden until progress bit p2 − 1 of global 95 is set.
* <li>CowGuardSpawner: spawns CowGuardSpawned at its own position with its chain – the same rule
* applies to the spawner's position, else every spawned guard is removed at once.
* <li>Alligator, SpawnedAlligator: odd count ("Odd number of WPs!") removes it; outside every box
* it is clamped into the first box ("Forcing alligator inside first WP box"). No unk2 rule.
* <li>AlligatorSpawner: the spawned alligator gets the spawner's chain; outside the first box it
* walks straight ahead (spawner yaw) until it is inside that box.
* </ul>
*/
final class Guards {
static final List<String> TYPES = List.of(
"CityGuard",
"CityGuardWithRubyIdol",
"CowGuard",
"CowGuardNoCoin",
"GorillaGuard",
"LizardGuard",
"LizardGuardNoCoin",
"WarthogGuard");
static final List<String> TYPES = WaypointBoxes.GUARDS;
static final int EVENT_PARAM = 2;
/** Half size of the box a newly placed guard gets (cells). */
static final double NEW_BOX_HALF = 2.0;
static final int EVENT_PARAM = WaypointBoxes.GUARD_EVENT_PARAM;
private Guards() {}
/** Guard family (has the event bit parameter). */
static boolean is(String type) {
return type != null && TYPES.contains(type.endsWith("(spawn)") ? type.substring(0, type.length() - 7) : type);
return WaypointBoxes.isGuard(type);
}
/** Any object whose waypoints are box pairs (guards, cow guard spawner, alligators). */
static boolean walksBoxes(String type) {
return WaypointBoxes.walksBoxes(type);
}
private static boolean guardRule(String type) {
String b = WaypointBoxes.base(type);
return is(type) || b.equals("CowGuardSpawner") || b.equals("CowGuardSpawned");
}
private static boolean inBox(PathNode a, PathNode b, double x, double z) {
return x >= Math.min(a.x(), b.x())
&& x <= Math.max(a.x(), b.x())
&& z >= Math.min(a.z(), b.z())
&& z <= Math.max(a.z(), b.z());
}
/** Index of the first node of the start box (pair), or −1 if the guard starts in none. */
static int startBox(List<PathNode> chain, Strat st) {
return startBox(chain, st, true);
}
private static int startBox(List<PathNode> chain, Strat st, boolean unk2Rule) {
for (int k = 0; k + 1 < chain.size(); k += 2) {
PathNode a = chain.get(k), b = chain.get(k + 1);
if (a.unk2() == 0
&& st.x() >= Math.min(a.x(), b.x())
&& st.x() <= Math.max(a.x(), b.x())
&& st.z() >= Math.min(a.z(), b.z())
&& st.z() <= Math.max(a.z(), b.z())) {
if ((!unk2Rule || a.unk2() == 0) && inBox(a, b, st.x(), st.z())) {
return k;
}
}
@@ -52,21 +69,70 @@ final class Guards {
/** Event bit (0 = none) of a guard. */
static int eventBit(Strat st) {
int[] p = st.params();
return p.length > EVENT_PARAM ? p[EVENT_PARAM] >> 12 : 0;
return is(st.type()) && p.length > EVENT_PARAM ? p[EVENT_PARAM] >> 12 : 0;
}
/** Why the game removes the guard at level start, or null if it stays. */
static List<PathNode> chain(Scene s, Strat st) {
return st.waypoint() != null && s.contains(st.waypoint()) ? s.chain(st.waypoint()) : List.of();
}
/** Why the game removes the object (or, for a spawner, what it spawns) at once, or null. */
static String removedBecause(Scene s, Strat st) {
List<PathNode> chain = st.waypoint() != null && s.contains(st.waypoint()) ? s.chain(st.waypoint()) : List.of();
List<PathNode> chain = chain(s, st);
if (chain.isEmpty()) {
return "it has no waypoint boxes";
}
if (chain.size() % 2 == 1) {
return "its waypoint count is odd (" + chain.size() + "; boxes are node pairs)";
}
if (startBox(chain, st) < 0) {
if (guardRule(st.type()) && startBox(chain, st, true) < 0) {
return "it does not start inside one of its waypoint boxes";
}
return null;
}
/**
* A softer start problem of alligators (null if none): an Alligator outside all boxes is moved
* into the first box; an AlligatorSpawner's alligators walk ahead and may never reach it.
*/
static String startWarning(Scene s, Strat st) {
String b = WaypointBoxes.base(st.type());
List<PathNode> chain = chain(s, st);
if (chain.size() < 2 || chain.size() % 2 == 1) {
return null;
}
if ((b.equals("Alligator") || b.equals("SpawnedAlligator")) && startBox(chain, st, false) < 0) {
return "it does not start inside a box: the game moves it into the first box";
}
if (b.equals("AlligatorSpawner")) {
PathNode p = chain.get(0), q = chain.get(1);
if (!inBox(p, q, st.x(), st.z()) && !rayHitsBox(st, p, q)) {
return "its alligators walk straight ahead until they are inside the first box, which is not"
+ " in front of it (turn it towards the box)";
}
}
return null;
}
/** Whether the ray from the object along its yaw crosses the x/z box of the two nodes. */
static boolean rayHitsBox(Strat st, PathNode a, PathNode b) {
double yaw = Math.toRadians(st.yawDegrees());
double dx = Math.sin(yaw), dz = Math.cos(yaw);
double x0 = Math.min(a.gx(), b.gx()), x1 = Math.max(a.gx(), b.gx());
double z0 = Math.min(a.gz(), b.gz()), z1 = Math.max(a.gz(), b.gz());
double t0 = 0, t1 = 1e9;
double[][] axes = {{st.gx(), dx, x0, x1}, {st.gz(), dz, z0, z1}};
for (double[] ax : axes) {
if (Math.abs(ax[1]) < 1e-9) {
if (ax[0] < ax[2] || ax[0] > ax[3]) {
return false;
}
} else {
double ta = (ax[2] - ax[0]) / ax[1], tb = (ax[3] - ax[0]) / ax[1];
t0 = Math.max(t0, Math.min(ta, tb));
t1 = Math.min(t1, Math.max(ta, tb));
}
}
return t0 <= t1;
}
}
@@ -6,6 +6,7 @@ import com.eliasmueller.llamamod.editor.model.Scene;
import com.eliasmueller.llamamod.editor.model.Strat;
import com.eliasmueller.llamamod.editor.model.StratCatalog;
import com.eliasmueller.llamamod.editor.model.Tile;
import com.eliasmueller.llamamod.editor.model.cmd.PlayerStart;
import com.eliasmueller.llamamod.editor.tools.objects.ParamSchema.ParamDef;
import com.eliasmueller.llamamod.editor.tools.objects.ParamSchema.TypeSchema;
import com.eliasmueller.llamamod.editor.ui.LevelCheck;
@@ -13,6 +14,8 @@ import com.eliasmueller.llamamod.editor.ui.Problem;
import com.eliasmueller.llamamod.save.Worlds;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.function.Supplier;
/** Problems panel checks for objects, parameters and waypoint chains. */
@@ -33,11 +36,17 @@ final class ObjectChecks implements LevelCheck {
broken));
}
List<Tile> doorTiles = null;
Set<Integer> setBits = null;
int players = 0;
for (Strat st : s.strats()) {
String type = st.type();
if ("player".equals(StratCatalog.category(type))) {
players++;
String cam = startCamera(s, st);
if (cam != null) {
out.add(Problem.warning(
label(st) + ": " + cam + " (Place tool → Player start sets a camera node)", st));
}
}
TypeSchema sc = Schemas.of(type);
TypeStats.Stat stat = TypeStats.of(type);
@@ -57,19 +66,38 @@ final class ObjectChecks implements LevelCheck {
}
}
}
String gone = Guards.is(type) ? Guards.removedBecause(s, st) : null;
if (gone != null) {
String gone = Guards.walksBoxes(type) ? Guards.removedBecause(s, st) : null;
String spawned = type.equals("CowGuardSpawner")
? "guards"
: type.equals("AlligatorSpawner") ? "alligators" : null;
if (gone != null && spawned != null) {
out.add(Problem.warning(
label(st) + ": the game removes its " + spawned + " as soon as they spawn because " + gone
+ " (Waypoint tool: give it a box of 2 nodes around its position)",
st));
} else if (gone != null) {
out.add(Problem.error(
label(st) + " never appears: the game removes it at level start because " + gone
+ " (Waypoint tool: give it a box of 2 nodes around its position)",
st));
} else if (Guards.is(type) && Guards.eventBit(st) > 0) {
} else if (Guards.startWarning(s, st) != null) {
out.add(Problem.warning(label(st) + ": " + Guards.startWarning(s, st), st));
}
if (gone == null && Guards.eventBit(st) > 0) {
int bit = Guards.eventBit(st);
if (setBits == null) {
setBits = EventBits.setBits(s);
}
out.add(Problem.info(
label(st) + " stays hidden until event bit " + Guards.eventBit(st)
+ " is set by another object; set Event bit to 0 to have it active from the start",
label(st) + " stays hidden until event bit " + bit + " is set by another object"
+ (setBits.contains(bit)
? ""
: ", but no object in this level sets bit " + bit + " ("
+ EventBits.setterList(s) + ")")
+ "; set Event bit to 0 to have it active from the start",
st));
}
if (!Guards.is(type)
if (!Guards.walksBoxes(type)
&& sc.waypoints().required()
&& chain.size() < sc.waypoints().min()) {
out.add(Problem.warning(
@@ -83,7 +111,7 @@ final class ObjectChecks implements LevelCheck {
+ chain.size(),
st));
}
if (sc.waypoints().pairs() && chain.size() % 2 == 1 && !Guards.is(type)) {
if (sc.waypoints().pairs() && chain.size() % 2 == 1 && !Guards.walksBoxes(type)) {
out.add(Problem.warning(
label(st) + ": box nodes come in pairs, the chain has " + chain.size(), st));
}
@@ -159,6 +187,28 @@ final class ObjectChecks implements LevelCheck {
return false;
}
/** Start camera problem of a player start (see {@link PlayerStart}), or null. */
static String startCamera(Scene s, Strat st) {
List<PathNode> chain = st.waypoint() != null && s.contains(st.waypoint()) ? s.chain(st.waypoint()) : List.of();
int k = PlayerStart.startNode(chain, st.gx(), st.gz());
if (k < 0) {
return null;
}
PathNode p = chain.get(k);
double d = Math.hypot(p.gx() - st.gx(), p.gz() - st.gz());
if (p.unk2() != 0 && d < 0.5) {
return String.format(
Locale.ROOT,
"the start camera is inside the llama's head – waypoint %d is the camera start (first node within"
+ " %.1f cells) and, with unk2 ≠ 0, its distance %.2f becomes the camera distance until the"
+ " llama moves",
k + 1,
PlayerStart.RANGE,
d);
}
return null;
}
static String label(Strat s) {
return s.type() + " (" + s.id() + ")";
}
@@ -8,6 +8,7 @@ import com.eliasmueller.llamamod.editor.ui.EditorContext;
import com.eliasmueller.llamamod.editor.ui.EditorWindow;
import com.eliasmueller.llamamod.editor.ui.Icons;
import com.eliasmueller.llamamod.editor.ui.NodeIcons;
import com.eliasmueller.llamamod.editor.ui.TypeMenus;
import com.eliasmueller.llamamod.editor.ui.view.Thumbnails;
import com.eliasmueller.llamamod.util.SwingListeners;
import java.awt.BorderLayout;
@@ -36,7 +37,9 @@ import javax.swing.JComboBox;
import javax.swing.JComponent;
import javax.swing.JLabel;
import javax.swing.JList;
import javax.swing.JMenuItem;
import javax.swing.JPanel;
import javax.swing.JPopupMenu;
import javax.swing.JScrollPane;
import javax.swing.JTextField;
import javax.swing.JToggleButton;
@@ -144,22 +147,40 @@ final class ObjectPalette extends JPanel {
list.addMouseListener(new MouseAdapter() {
@Override
public void mousePressed(MouseEvent e) {
if (e.isPopupTrigger() || e.getButton() == MouseEvent.BUTTON3) {
int i = list.locationToIndex(e.getPoint());
if (i >= 0) {
Session.of(ctx).toggleFavourite(model.get(i).type);
list.repaint();
if (favOnly.isSelected()) {
reload();
}
}
menu(e);
}
@Override
public void mouseReleased(MouseEvent e) {
menu(e);
}
private void menu(MouseEvent e) {
int i = list.locationToIndex(e.getPoint());
if (!e.isPopupTrigger() || i < 0) {
return;
}
String type = model.get(i).type;
boolean fav = Session.of(ctx).favourites().contains(type);
JPopupMenu m = new JPopupMenu();
JMenuItem f = new JMenuItem(fav ? "Remove from favourites" : "Add to favourites ★");
f.addActionListener(a -> {
Session.of(ctx).toggleFavourite(type);
list.repaint();
if (favOnly.isSelected()) {
reload();
}
});
m.add(f);
TypeMenus.fill(ctx, type, m);
m.show(list, e.getX(), e.getY());
}
});
JScrollPane sp = new JScrollPane(list);
sp.setBorder(BorderFactory.createMatteBorder(1, 0, 0, 0, UIManager.getColor("Component.borderColor")));
add(sp, BorderLayout.CENTER);
JLabel hint = new JLabel("Click: place with the Place tool (P) · drag into the viewport · right-click: ★");
JLabel hint =
new JLabel("Click: place with the Place tool (P) · drag into the viewport · right-click: ★, type menu");
hint.setFont(hint.getFont().deriveFont(10.5f));
hint.setForeground(UIManager.getColor("Label.disabledForeground"));
hint.setBorder(BorderFactory.createEmptyBorder(3, 8, 4, 6));
@@ -4,6 +4,7 @@ import com.eliasmueller.llamamod.editor.model.Node;
import com.eliasmueller.llamamod.editor.model.PathNode;
import com.eliasmueller.llamamod.editor.model.Scene;
import com.eliasmueller.llamamod.editor.model.Strat;
import com.eliasmueller.llamamod.editor.tools.strats.CustomTypes;
import com.eliasmueller.llamamod.editor.ui.ActionSpec;
import com.eliasmueller.llamamod.editor.ui.AssetTab;
import com.eliasmueller.llamamod.editor.ui.Dock;
@@ -17,6 +18,7 @@ import com.eliasmueller.llamamod.editor.ui.LayerSpec;
import com.eliasmueller.llamamod.editor.ui.NodeNames;
import com.eliasmueller.llamamod.editor.ui.PanelSpec;
import com.eliasmueller.llamamod.editor.ui.ToolEvent;
import com.eliasmueller.llamamod.editor.ui.TypeMenus;
import com.eliasmueller.llamamod.editor.ui.tools.EditOps;
import java.awt.FlowLayout;
import java.awt.Toolkit;
@@ -30,7 +32,9 @@ import java.util.function.Predicate;
import javax.swing.JButton;
import javax.swing.JComponent;
import javax.swing.JLabel;
import javax.swing.JMenuItem;
import javax.swing.JPanel;
import javax.swing.JPopupMenu;
import javax.swing.SwingUtilities;
/**
@@ -67,6 +71,7 @@ public final class ObjectsEditorPlugin implements EditorPlugin {
x.renderContributor(new ObjectOverlays.Cameras());
x.check(new ObjectChecks(() -> null));
x.assetTab(new AssetTab("prefabs", "Prefabs", "assets", 60, Prefabs::tab));
TypeMenus.register("objects.customType", ObjectsEditorPlugin::customTypeMenu);
x.action(ActionSpec.of(
"objects.copy",
@@ -297,4 +302,17 @@ public final class ObjectsEditorPlugin implements EditorPlugin {
return root;
}
}
/** Edit type… / Delete type… for custom strat types (dialogs of editor.tools.strats). */
static void customTypeMenu(EditorContext ctx, String type, JPopupMenu m) {
if (CustomTypes.index(ctx, type) < 0) {
return;
}
JMenuItem edit = new JMenuItem("Edit type…");
edit.addActionListener(e -> CustomTypes.edit(ctx, type));
JMenuItem del = new JMenuItem("Delete type…");
del.addActionListener(e -> CustomTypes.delete(ctx, type));
m.add(edit);
m.add(del);
}
}
@@ -134,7 +134,7 @@ final class ParamsSection implements InspectorSection {
if (st.type().equals("LevelDoor") || st.type().equals("VillageEndDoor")) {
exitRow(st);
}
if (Guards.is(st.type())) {
if (Guards.walksBoxes(st.type())) {
guardRow(st);
}
for (int i = 0; i < Math.max(n, sc != null ? sc.paramCount() : 0); i++) {
@@ -330,14 +330,20 @@ final class ParamsSection implements InspectorSection {
private void guardRow(Strat st) {
Scene s = ctx.scene();
String gone = Guards.removedBecause(s, st);
String warn = gone == null ? Guards.startWarning(s, st) : null;
int bit = Guards.eventBit(st);
String txt = gone != null
? "<b>Never appears:</b> the game removes it at level start because " + gone + "."
: bit > 0
? "Starts inside its box. <b>Hidden</b> until event bit " + bit
+ " is set by another object (0 = active from the start)."
: "Starts inside its box; active from the start.";
if (gone == null) {
: warn != null
? "Note: " + warn + "."
: bit > 0
? "Starts inside its box. <b>Hidden</b> until event bit " + bit
+ " is set by another object (0 = active from the start)"
+ (EventBits.setBits(s).contains(bit)
? "."
: "; no object in this level sets it.")
: "Starts inside its box; active from the start.";
if (gone == null && warn == null) {
full(note(txt));
return;
}
@@ -351,7 +357,7 @@ final class ParamsSection implements InspectorSection {
BorderLayout.CENTER);
JButton fix = new JButton("Box around it");
fix.setFocusable(false);
fix.setToolTipText("Replace its waypoints with one 4×4 box around the guard");
fix.setToolTipText("Replace its waypoints with one 4×4 box around it");
fix.addActionListener(e -> {
ctx.execute(Setups.guardBox(s, st));
javax.swing.SwingUtilities.invokeLater(this::refresh);
@@ -8,6 +8,7 @@ import com.eliasmueller.llamamod.editor.model.StratCatalog;
import com.eliasmueller.llamamod.editor.model.cmd.Batch;
import com.eliasmueller.llamamod.editor.model.cmd.Command;
import com.eliasmueller.llamamod.editor.model.cmd.Edits;
import com.eliasmueller.llamamod.editor.model.cmd.PlayerStart;
import com.eliasmueller.llamamod.editor.render.Camera;
import com.eliasmueller.llamamod.editor.render.Legend;
import com.eliasmueller.llamamod.editor.render.Overlay;
@@ -292,7 +293,13 @@ public final class PlaceTool implements Tool {
List<Node> nodes = new ArrayList<>();
nodes.add(dragging);
if (dragging.waypoint() != null && ctx.prefs().getBool("objects.moveWaypoints", true)) {
nodes.addAll(ctx.scene().chain(dragging.waypoint()));
for (PathNode n : ctx.scene().chain(dragging.waypoint())) {
// a player start's far nodes are camera starts of door entries: they stay
if (dragging != ctx.scene().playerStart()
|| Math.hypot(n.gx() - dragging.gx(), n.gz() - dragging.gz()) < PlayerStart.RANGE) {
nodes.add(n);
}
}
}
ctx.execute(Edits.translate(nodes, dx, dy, dz, mergeKey));
ctx.repaintTool();
@@ -315,6 +322,20 @@ public final class PlaceTool implements Tool {
"z", Edits.fixed(st.gz() + r * Math.cos(a)),
"ry", Edits.angleUnits(deg)));
}
if (st.scene() != null && st == st.scene().playerStart() && st.waypoint() != null) {
// the start camera node(s) turn around the llama
double t = Math.toRadians(deg - st.yawDegrees()), c = Math.cos(t), sn = Math.sin(t);
for (PathNode p : st.scene().chain(st.waypoint())) {
double rx = p.gx() - st.gx(), rz = p.gz() - st.gz();
if (Math.hypot(rx, rz) < PlayerStart.RANGE) {
v.put(
p,
Map.of(
"x", Edits.fixed(st.gx() + rx * c + rz * sn),
"z", Edits.fixed(st.gz() - rx * sn + rz * c)));
}
}
}
return Edits.set("Rotate " + st.type(), v, mergeKey);
}
@@ -166,7 +166,10 @@ public final class Schemas {
.add();
// ---------------------------------------------------------- player, checkpoints, exits
t("Llama", "Player start. The first node of its chain is the start position of the camera.")
t(
"Llama",
"Player start. Its waypoints are start camera positions: the first one within 2.5 cells"
+ " of the llama becomes the camera at level start.")
.p("Helper", Kind.BOOL, "on: spawns a helper strat at start (STPC 0x434e22 in the village)")
.p("Unused", Kind.RAW, "not read by the Llama code")
.choice(
@@ -178,7 +181,10 @@ public final class Schemas {
Role.CAMERA,
0,
0,
"first node = camera start (8 cells behind the llama); the others sit on the llama")
"camera starts (originals: one per entry door): at level start the first node within 2.5"
+ " cells (x/z) of the llama is the camera position, looking at the llama; with unk2 ≠ 0"
+ " also its distance and height (held until the llama moves – a node on the llama"
+ " puts the camera into its head)")
.add();
t(
"CheckPointPost",
@@ -363,6 +369,57 @@ public final class Schemas {
.add();
}
}
t(
"CowGuardSpawner",
"Spawns CowGuardSpawned guards at its position (they walk inside its waypoint boxes)"
+ " while the player is more than 1.5 cells away.")
.p("View range", Kind.RADIUS, 0, 50, "handed on: the spawned guard's view range")
.p("Chase range", Kind.RADIUS, 0, 50, "handed on: the spawned guard's chase range")
.p("Max guards", Kind.COUNT, 0, 20, "spawns again only while fewer of its guards are alive")
.p("Interval", Kind.SECONDS, 0, 120, "wait between spawns")
.p("p4", Kind.RAW, "not read")
.p("p5", Kind.FIXED, "handed on as the guard's parameter 3, which CowGuardSpawned does not read")
.wpPairs(
Role.BOX,
2,
"walking areas of the spawned guards (they get this chain): boxes of node pairs; the"
+ " spawner must stand inside a box whose first node has unk2 = 0, else every"
+ " spawned guard is removed at once")
.conf("medium")
.add();
t("CowGuardSpawned", "Guard made by a CowGuardSpawner (same code as the guards).")
.p("View range", Kind.RADIUS, 0, 50, "")
.p("Chase range", Kind.RADIUS, 0, 50, "")
.p("Event bit", Kind.EVENT_BIT, 0, 32, guardEvent)
.p("p3", Kind.FIXED, "not read")
.wpPairs(Role.BOX, 2, guardBoxes)
.conf("medium")
.add();
String gatorBoxes = "walking areas: boxes of node pairs (x/z). An odd count removes it; outside every"
+ " box the game moves it into the first box. It snaps at the player only on the same floor"
+ " height (±0.2) and inside its boxes";
t("Alligator", "Alligator lying in wait inside its waypoint boxes; snaps at a llama on its floor level.")
.wpPairs(Role.BOX, 2, gatorBoxes)
.conf("medium")
.add();
t("SpawnedAlligator", "Alligator made by an AlligatorSpawner (gets the spawner's waypoint boxes).")
.wpPairs(Role.BOX, 2, gatorBoxes)
.conf("medium")
.add();
t(
"AlligatorSpawner",
"Spawns alligators that walk straight ahead (its yaw) into the first waypoint box, then"
+ " behave like an Alligator.")
.p("Max alligators", Kind.COUNT, 0, 20, "spawns again only while fewer of its alligators are alive")
.p("Interval", Kind.SECONDS, 0, 120, "wait between spawns (and the player must be 1.5 cells away)")
.p("Floor collision", Kind.BOOL, "on: spawned alligators get strat flags 0x8400 (floor collision)")
.wpPairs(
Role.BOX,
2,
"boxes of node pairs handed to the spawned alligators; the spawner should face the first"
+ " box (alligators walk straight ahead until inside it)")
.conf("medium")
.add();
t("JumpingBeetleBlue", "Jumping beetle following its path.")
.p("Jump time", Kind.FIXED, "")
.p("p1", Kind.RAW, "unused")
@@ -67,15 +67,21 @@ public final class Session {
return ground;
}
/** Floor height under a game-space point (searching from a little above), or NaN. */
/**
* Floor height under a game-space point (searching from a little above), or NaN. A floor of the
* cell's own tiles (the one the game uses) wins over a neighbour tile's polygon at any height.
*/
public double floorY(double x, double z, double probeY) {
Ground g = ground();
if (g == null) {
return Double.NaN;
}
Ground.Hit h = g.below(x, z, probeY, 0.6);
if (h == null) {
h = g.below(x, z, Double.NaN, 0);
if (h == null || h.source() == Ground.Source.NEIGHBOUR) {
Ground.Hit any = g.below(x, z, Double.NaN, 0);
if (h == null || any != null && any.source() != Ground.Source.NEIGHBOUR) {
h = any;
}
}
return h == null ? Double.NaN : h.y();
}
@@ -5,13 +5,14 @@ import com.eliasmueller.llamamod.editor.model.Node;
import com.eliasmueller.llamamod.editor.model.PathNode;
import com.eliasmueller.llamamod.editor.model.Scene;
import com.eliasmueller.llamamod.editor.model.Strat;
import com.eliasmueller.llamamod.editor.model.WaypointBoxes;
import com.eliasmueller.llamamod.editor.model.cmd.AddNode;
import com.eliasmueller.llamamod.editor.model.cmd.Batch;
import com.eliasmueller.llamamod.editor.model.cmd.Command;
import com.eliasmueller.llamamod.editor.model.cmd.Edits;
import com.eliasmueller.llamamod.editor.model.cmd.PlayerStart;
import com.eliasmueller.llamamod.editor.model.cmd.SetProp;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -62,8 +63,9 @@ public final class Setups {
}
/**
* Places an object (yaw in degrees). Guards ({@link Guards}) get one box around them instead of the prototype's
* boxes and event bit 0, so they are active from the start.
* Places an object (yaw in degrees). Box walkers (guards, alligators: {@link Guards}) get one box
* around them instead of the prototype's boxes and guards event bit 0, so they are active from
* the start ({@link Edits#placeStrat}, the same for every way of adding an object).
*/
public static Made object(Scene s, String type, int variant, double x, double y, double z, double yaw) {
return object(s, type, variant, x, y, z, Double.valueOf(yaw));
@@ -72,48 +74,12 @@ public final class Setups {
/** As above; yaw null = the prototype's yaw. */
public static Made object(Scene s, String type, int variant, double x, double y, double z, Double yaw) {
Command c = Edits.placeStrat(s, type, variant, x, y, z, yaw);
Strat st = lastStrat(c);
if (Guards.is(type)) {
c = guardSetup(s, (Batch) c, st, x, y, z);
}
return new Made(c, st);
return new Made(c, lastStrat(c));
}
private static Command guardSetup(Scene s, Batch placed, Strat st, double x, double y, double z) {
double h = Guards.NEW_BOX_HALF;
List<PathNode> nodes = addedNodes(placed);
List<Command> cmds = new ArrayList<>();
if (nodes.size() >= 2) {
for (Command k : placed.commands()) {
if (!(k instanceof AddNode a && a.node() instanceof PathNode p && nodes.indexOf(p) >= 2)) {
cmds.add(k);
}
}
pos(nodes.get(0), x - h, y, z - h);
pos(nodes.get(1), x + h, y, z + h);
nodes.get(0).setRaw("unk2", 0);
nodes.get(1).setRaw("unk2", 0);
nodes.get(1).setRaw("next", null);
} else {
cmds.addAll(placed.commands());
cmds.addAll(ChainOps.newChain(
s,
st,
List.of(
ChainOps.newNode(s, x - h, y, z - h, 0, null),
ChainOps.newNode(s, x + h, y, z + h, 0, null))));
}
int[] p = st.params().clone();
if (p.length > Guards.EVENT_PARAM) {
p[Guards.EVENT_PARAM] = 0;
st.setRaw("params", p);
}
return new Batch(placed.label(), cmds);
}
/** Replaces a guard's waypoints with one box of 2 nodes around it (it then starts inside). */
/** Replaces a box walker's waypoints with one box of 2 nodes around it (it then starts inside). */
public static Command guardBox(Scene s, Strat st) {
double h = Guards.NEW_BOX_HALF;
double h = WaypointBoxes.NEW_BOX_HALF;
List<Command> cmds = new ArrayList<>();
if (st.waypoint() != null
&& s.contains(st.waypoint())
@@ -130,40 +96,19 @@ public final class Setups {
}
/**
* Player start: moves the existing player object if there is one (a level has exactly one),
* else places a Llama with its chain (first node = camera 8 cells behind, rest on the llama).
* Player start: moves the existing player object if there is one (a level has exactly one; its
* camera nodes near the old start move along), else places a Llama with one start camera node
* behind it ({@link PlayerStart}).
*/
public static Made player(Scene s, double x, double y, double z, double yaw) {
Strat st = s.playerStart();
if (st != null) {
Map<Node, Map<String, Object>> v = new LinkedHashMap<>();
double dx = x - st.gx(), dy = y - st.gy(), dz = z - st.gz();
v.put(
st,
Map.of("x", Edits.fixed(x), "y", Edits.fixed(y), "z", Edits.fixed(z), "ry", Edits.angleUnits(yaw)));
if (st.waypoint() != null) {
List<PathNode> ch = s.chain(st.waypoint());
double a = Math.toRadians(yaw);
for (int i = 0; i < ch.size(); i++) {
PathNode p = ch.get(i);
double nx = i == 0 ? x - 8 * Math.sin(a) : p.gx() + dx;
double nz = i == 0 ? z - 8 * Math.cos(a) : p.gz() + dz;
double ny = i == 0 ? y - 0.4 : p.gy() + dy;
v.put(p, Map.of("x", Edits.fixed(nx), "y", Edits.fixed(ny), "z", Edits.fixed(nz)));
}
}
return new Made(Edits.set("Move player start", v, null), st);
return new Made(PlayerStart.move(s, st, x, y, z, yaw), st);
}
if (!s.catalog().has("Llama")) {
throw new IllegalStateException("this base level has no Llama (player start) to copy");
}
Command c = Edits.placeStrat(s, "Llama", 0, x, y, z, yaw);
double a = Math.toRadians(yaw);
int k = 0;
for (PathNode p : addedNodes(c)) {
pos(p, k == 0 ? x - 8 * Math.sin(a) : x, k == 0 ? y - 0.4 : y, k == 0 ? z - 8 * Math.cos(a) : z);
k++;
}
Command c = PlayerStart.place(s, x, y, z, yaw);
return new Made(new Batch("Player start", List.of(c)), lastStrat(c));
}
@@ -0,0 +1,31 @@
package com.eliasmueller.llamamod.editor.tools.strats;
import com.eliasmueller.llamamod.editor.ui.EditorContext;
/** Public entry to the custom type dialogs for other packages (object palettes). */
public final class CustomTypes {
private CustomTypes() {}
/** Index of a custom type of the open project by name, or −1. */
public static int index(EditorContext ctx, String type) {
return ctx.document() == null
? -1
: StratEdits.customNames(ctx.document().project()).indexOf(type);
}
/** Opens the script dialog of a custom type ({@link TypeEdits#edit} on OK). */
public static void edit(EditorContext ctx, String type) {
int k = index(ctx, type);
if (k >= 0) {
TypeDialog.showEdit(ctx, k);
}
}
/** Asks and deletes a custom type with its objects ({@link TypeEdits#delete}). */
public static void delete(EditorContext ctx, String type) {
int k = index(ctx, type);
if (k >= 0) {
TypeDialog.delete(ctx, k);
}
}
}
@@ -2,15 +2,13 @@ package com.eliasmueller.llamamod.editor.ui;
import com.eliasmueller.llamamod.editor.model.Brush;
import com.eliasmueller.llamamod.editor.model.BrushKind;
import com.eliasmueller.llamamod.editor.model.PathNode;
import com.eliasmueller.llamamod.editor.model.Scene;
import com.eliasmueller.llamamod.editor.model.Strat;
import com.eliasmueller.llamamod.editor.model.StratCatalog;
import com.eliasmueller.llamamod.editor.model.TileModel;
import com.eliasmueller.llamamod.editor.model.cmd.AddNode;
import com.eliasmueller.llamamod.editor.model.cmd.Batch;
import com.eliasmueller.llamamod.editor.model.cmd.Command;
import com.eliasmueller.llamamod.editor.model.cmd.Edits;
import com.eliasmueller.llamamod.editor.model.cmd.PlayerStart;
import com.eliasmueller.llamamod.editor.render.Camera;
import com.eliasmueller.llamamod.editor.render.Gizmo;
import com.eliasmueller.llamamod.editor.ui.panels.AssetBrowserPanel;
@@ -340,25 +338,9 @@ final class BuiltinEditorPlugin implements EditorPlugin {
}
}
/**
* Player start like levelbuild: the Llama with its waypoint chain, first node = camera start 8
* cells behind, the others on the llama.
*/
/** Player start: the Llama with one start camera node behind it ({@link PlayerStart}). */
private static Command placePlayer(Scene s, double x, double y, double z, double yawDeg) {
Batch b = (Batch) Edits.placeStrat(s, "Llama", 0, x, y, z, yawDeg);
double a = Math.toRadians(yawDeg);
int k = 0;
for (Command c : b.commands()) {
if (c instanceof AddNode an && an.node() instanceof PathNode p) {
double nx = k == 0 ? x - 8 * Math.sin(a) : x, nz = k == 0 ? z - 8 * Math.cos(a) : z;
double ny = k == 0 ? y - 0.4 : y;
p.setRaw("x", Edits.fixed(nx));
p.setRaw("y", Edits.fixed(ny));
p.setRaw("z", Edits.fixed(nz));
k++;
}
}
return b;
return PlayerStart.place(s, x, y, z, yawDeg);
}
// ------------------------------------------------------------------ checks
@@ -0,0 +1,43 @@
package com.eliasmueller.llamamod.editor.ui;
import java.util.Map;
import java.util.concurrent.ConcurrentSkipListMap;
import javax.swing.JPopupMenu;
/**
* Context-menu entries for an object type (right-click in the Assets → Objects grid and the
* Objects palette). Plugins register a contributor under an id; each adds its items for the type.
*/
public final class TypeMenus {
/** Adds menu items for {@code type} (catalog type name) to the menu. */
@FunctionalInterface
public interface Contributor {
void contribute(EditorContext ctx, String type, JPopupMenu menu);
}
private static final Map<String, Contributor> CONTRIBUTORS = new ConcurrentSkipListMap<>();
private TypeMenus() {}
public static void register(String id, Contributor c) {
CONTRIBUTORS.put(id, c);
}
/** Lets every contributor add its entries (a separator before each non-empty group). */
public static void fill(EditorContext ctx, String type, JPopupMenu menu) {
for (Contributor c : CONTRIBUTORS.values()) {
int before = menu.getComponentCount();
if (before > 0) {
menu.addSeparator();
}
try {
c.contribute(ctx, type, menu);
} catch (RuntimeException e) {
e.printStackTrace();
}
if (before > 0 && menu.getComponentCount() == before + 1) {
menu.remove(before);
}
}
}
}
@@ -18,6 +18,7 @@ import com.eliasmueller.llamamod.editor.ui.EditorWindow;
import com.eliasmueller.llamamod.editor.ui.Icons;
import com.eliasmueller.llamamod.editor.ui.NodeIcons;
import com.eliasmueller.llamamod.editor.ui.NodeNames;
import com.eliasmueller.llamamod.editor.ui.TypeMenus;
import com.eliasmueller.llamamod.editor.ui.Widgets;
import com.eliasmueller.llamamod.editor.ui.tools.EditOps;
import com.eliasmueller.llamamod.editor.ui.view.SceneRenderer;
@@ -55,7 +56,9 @@ import javax.swing.JComboBox;
import javax.swing.JComponent;
import javax.swing.JLabel;
import javax.swing.JList;
import javax.swing.JMenuItem;
import javax.swing.JPanel;
import javax.swing.JPopupMenu;
import javax.swing.JScrollPane;
import javax.swing.JSplitPane;
import javax.swing.JTabbedPane;
@@ -318,8 +321,39 @@ public final class AssetBrowserPanel extends JPanel {
list.setToolTipText(hint);
AssetDrop.dragSource(list, it -> it.dragKind == null ? null : AssetDrop.payload(it.dragKind, it.dragData));
list.addMouseListener(new MouseAdapter() {
@Override
public void mousePressed(MouseEvent e) {
typeMenu(e);
}
@Override
public void mouseReleased(MouseEvent e) {
typeMenu(e);
}
private void typeMenu(MouseEvent e) {
int i = list.locationToIndex(e.getPoint());
if (!e.isPopupTrigger()
|| i < 0
|| !list.getCellBounds(i, i).contains(e.getPoint())
|| !(model.get(i).data instanceof StratCatalog.Type t)) {
return;
}
list.setSelectedIndex(i);
Item it = model.get(i);
JPopupMenu m = new JPopupMenu();
JMenuItem place = new JMenuItem("Place at view centre");
place.addActionListener(a -> placeAtCentre(it));
m.add(place);
TypeMenus.fill(ctx, t.name(), m);
m.show(list, e.getX(), e.getY());
}
@Override
public void mouseClicked(MouseEvent e) {
if (e.getButton() != MouseEvent.BUTTON1) {
return;
}
Item it = list.getSelectedValue();
if (it == null) {
return;
@@ -0,0 +1,71 @@
package com.eliasmueller.llamamod.editor.tools.objects;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.eliasmueller.llamamod.editor.io.Bases;
import com.eliasmueller.llamamod.editor.io.BrushCompiler;
import com.eliasmueller.llamamod.editor.io.Project;
import com.eliasmueller.llamamod.editor.model.Scene;
import com.eliasmueller.llamamod.editor.tools.playtest.FloorFinder;
import java.util.ArrayList;
import org.junit.jupiter.api.Test;
/** Snap-to-floor follows the engine's cell rule (needs -Dllamamod.cd). */
class GroundTest {
@Test
void cellFloorsMatchTheEngineRuleOfThePlayerFinder() throws Exception {
int same = 0, neighbourOnly = 0, ignoredAbove = 0;
for (String base : new String[] {"t1l1m001", "t1l1m002", "t1l5m001"}) {
Scene s = ObjectToolsTest.load(base);
Ground g = Ground.build(s, null);
for (int cx = 0; cx < s.settings().width(); cx++) {
for (int cz = 0; cz < s.settings().height(); cz++) {
for (double[] o : new double[][] {{0.5, 0.5}, {0.1, 0.9}, {0.85, 0.2}}) {
double x = cx + o[0], z = cz + o[1];
Ground.Hit h = g.below(x, z, Double.NaN, 0);
FloorFinder.Floor f = FloorFinder.below(s, x, 1000, z);
if (h == null) {
continue;
}
Ground.Hit nb = g.neighbourBelow(x, z, Double.NaN, 0);
if (h.source() == Ground.Source.CELL && nb != null && nb.y() > h.y() + 0.05) {
ignoredAbove++;
}
if (h.source() == Ground.Source.NEIGHBOUR) {
neighbourOnly++;
assertTrue(f == null || f.material() == 30, x + "," + z);
} else if (h.source() == Ground.Source.CELL && f != null && f.material() != 30) {
assertEquals(f.y(), h.y(), 0.02, x + "," + z);
same++;
}
}
}
}
}
assertTrue(same > 500, "cell floors compared: " + same);
// the villages have places where a higher polygon reaches into a cell its tile is not listed in
// (the geometric finder snapped onto it)
assertTrue(ignoredAbove + neighbourOnly > 0, "ignored points: " + ignoredAbove + " / " + neighbourOnly);
System.out.println("GroundTest: " + same + " same, " + ignoredAbove + " higher neighbour polygons ignored, "
+ neighbourOnly + " neighbour-only");
}
@Test
void brushFloorsAndSwapModels() throws Exception {
Project pr = Project.create(Bases.fromCd(ObjectToolsTest.cd()), "new", "t1l1m002");
Scene s = pr.scene();
Setups.exit(s, 12.2, 0, 26.2, 1, 1, 2).command().apply(s);
var out = BrushCompiler.compile(s, new ArrayList<>());
Ground g = Ground.build(s, out);
Ground.Hit h = g.below(12.5, 26.5, Double.NaN, 0);
assertNotNull(h);
assertEquals(Ground.Source.CELL, h.source());
assertEquals(16, h.material());
// village 1: the door tile's exit floor is only in its swap model (RubyIdolDoor opens it)
Scene v = ObjectToolsTest.load("t1l1m001");
Ground gv = Ground.build(v, null);
assertTrue(gv.hasMaterial(42, 49, 16));
}
}
@@ -11,6 +11,8 @@ import com.eliasmueller.llamamod.editor.model.PathNode;
import com.eliasmueller.llamamod.editor.model.Scene;
import com.eliasmueller.llamamod.editor.model.Strat;
import com.eliasmueller.llamamod.editor.model.cmd.Batch;
import com.eliasmueller.llamamod.editor.model.cmd.Command;
import com.eliasmueller.llamamod.editor.model.cmd.Edits;
import com.eliasmueller.llamamod.editor.model.cmd.History;
import com.eliasmueller.llamamod.editor.ui.Problem;
import java.nio.file.Files;
@@ -29,7 +31,10 @@ class GuardsTest {
@Test
void originalGuardsAndDoorsPassTheChecks() throws Exception {
int guards = 0, withEvent = 0;
Map<String, Integer> walkers = new TreeMap<>();
List<String> odd = new ArrayList<>();
Map<String, Integer> doorWarnings = new TreeMap<>();
List<String> unsetBits = new ArrayList<>();
try (var files = Files.list(ObjectToolsTest.cd().resolve("wads"))) {
for (Path p : files.filter(f -> f.getFileName().toString().matches("t1l\\dm00\\d\\.wad"))
.sorted()
@@ -41,12 +46,22 @@ class GuardsTest {
guards++;
assertNull(Guards.removedBecause(s, st), base + " " + st.id());
withEvent += Guards.eventBit(st) > 0 ? 1 : 0;
} else if (Guards.walksBoxes(st.type())) {
walkers.merge(st.type(), 1, Integer::sum);
String why = Guards.removedBecause(s, st);
if (why != null) {
odd.add(base + " " + st.id());
}
assertNull(Guards.startWarning(s, st), base + " " + st.id());
}
}
List<Problem> out = new ArrayList<>();
new ObjectChecks(() -> null).check(s, out);
for (Problem pr : out) {
assertTrue(!pr.message().contains("never appears"), base + ": " + pr.message());
if (pr.message().contains("no object in this level sets")) {
unsetBits.add(base + " " + pr.message().replaceAll(".*until event bit (\\d+).*", "$1"));
}
if (pr.message().contains("without exit floor")) {
doorWarnings.merge(base, 1, Integer::sum);
}
@@ -57,6 +72,64 @@ class GuardsTest {
assertEquals(4, withEvent); // lab: cow, gorilla and lizard guards wait for scenes
// the two spare doors of t1l3m003 have no exit floor (their partners next to them do)
assertEquals(Map.of("t1l3m003", 2), doorWarnings);
assertEquals(Map.of("Alligator", 1, "AlligatorSpawner", 2, "CowGuardSpawner", 8), walkers);
// one lab spawner stands 0.12 cells outside its boxes
assertEquals(List.of("t1l8m005 strat:82"), odd);
// lab guards: bits 8 (LabBigCutscene) and 30 (LabWall) have setters, 1 and 20 none in the level
assertEquals(List.of("t1l8m001 20", "t1l8m001 1"), unsetBits);
}
@Test
void everyWayOfAddingGivesBoxWalkersOneBox() throws Exception {
Scene s = Project.create(Bases.fromCd(ObjectToolsTest.cd()), "new", "t1l1m002")
.scene();
// the Add object path (asset drop, Objects panel drag, place at view centre)
Command c = Edits.placeStrat(s, "CityGuard", 0, 22, 0, 18, null);
c.apply(s);
Strat guard = Setups.lastStrat(c);
assertEquals(2, s.chain(guard.waypoint()).size());
assertNull(Guards.removedBecause(s, guard));
assertEquals(0, Guards.eventBit(guard));
assertTrue(ChainOps.contiguous(s));
assertTrue(problems(s).isEmpty(), problems(s).toString());
}
@Test
void alligatorsAndSpawners() throws Exception {
Bases b = Bases.fromCd(ObjectToolsTest.cd());
Scene s = ObjectToolsTest.load("t1l6m003");
assertTrue(s.catalog().has("AlligatorSpawner"));
History h = new History(s);
Setups.Made m = Setups.object(s, "AlligatorSpawner", 0, 30, 0, 30, 0);
h.execute(m.command());
Strat sp = (Strat) m.main();
List<PathNode> ch = s.chain(sp.waypoint());
assertEquals(2, ch.size());
assertNull(Guards.removedBecause(s, sp));
assertNull(Guards.startWarning(s, sp));
// box moved behind the spawner: its alligators would walk away from it
for (PathNode p : ch) {
p.setRaw("z", p.z() - 6 * 4096);
}
assertNotNull(Guards.startWarning(s, sp));
sp.setRaw("ry", 2048); // turned round: facing the box again
assertNull(Guards.startWarning(s, sp));
// odd node count: spawned alligators are removed at once
h.execute(new Batch("del", ChainOps.remove(s, ch.get(1))));
assertTrue(problems(s).stream()
.anyMatch(p ->
p.severity() == Problem.Severity.WARNING && p.message().contains("its alligators")));
Scene v = Project.create(b, "new", "t1l4m002").scene();
Setups.Made g = Setups.object(v, "Alligator", 0, 20, 0, 20, 0);
g.command().apply(v);
Strat al = (Strat) g.main();
assertEquals(2, v.chain(al.waypoint()).size());
assertTrue(problems(v).isEmpty(), problems(v).toString());
al.setRaw("x", al.x() + 5 * 4096);
assertTrue(problems(v).stream()
.anyMatch(p ->
p.severity() == Problem.Severity.WARNING && p.message().contains("first box")));
}
@Test
@@ -124,7 +197,7 @@ class GuardsTest {
private static List<Problem> problems(Scene s) {
List<Problem> out = new ArrayList<>();
new ObjectChecks(() -> null).check(s, out);
out.removeIf(p -> !(p.node() instanceof Strat st) || !Guards.is(st.type()));
out.removeIf(p -> !(p.node() instanceof Strat st) || !Guards.walksBoxes(st.type()));
return out;
}
}
@@ -0,0 +1,123 @@
package com.eliasmueller.llamamod.editor.tools.objects;
import com.eliasmueller.llamamod.editor.io.Bases;
import com.eliasmueller.llamamod.editor.io.Project;
import com.eliasmueller.llamamod.editor.io.ProjectIO;
import com.eliasmueller.llamamod.editor.model.Brush;
import com.eliasmueller.llamamod.editor.model.BrushKind;
import com.eliasmueller.llamamod.editor.model.PathNode;
import com.eliasmueller.llamamod.editor.model.Scene;
import com.eliasmueller.llamamod.editor.model.Strat;
import com.eliasmueller.llamamod.editor.model.cmd.AddNode;
import com.eliasmueller.llamamod.editor.model.cmd.Command;
import com.eliasmueller.llamamod.editor.model.cmd.Edits;
import com.eliasmueller.llamamod.editor.ui.Problem;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* Not a unit test: test levels for the start camera and box walkers. A 12×12 plaza, the player start
* in its middle facing +x; "old" = the previous player setup (prototype chain on the llama, first
* node 8 behind); objects added through {@link Edits#placeStrat} (the Add object path) 3 cells ahead.
* Args: CD folder, output .llamalevel, base, chapter, name, old|new, [object type].
*/
public final class ObjectsGameDemo {
private ObjectsGameDemo() {}
public static void main(String[] a) throws Exception {
Bases cd = Bases.fromCd(Path.of(a[0]));
boolean orig = a[5].startsWith("orig");
Project pr = orig
? new Project(
com.eliasmueller.llamamod.editor.io.Templates.original(cd, a[2]),
com.eliasmueller.llamamod.editor.io.WadImporter.importWad(cd.wad(a[2]), a[2], cd::fileOrNull))
: Project.create(cd, "new", a[2]);
Scene s = pr.scene();
if (a[5].equals("origprobe")) {
double nx = Double.parseDouble(a[6]), nz = Double.parseDouble(a[7]);
for (int yaw = 0; yaw < 360; yaw += 30) {
double r = Math.toRadians(yaw);
StringBuilder b2 = new StringBuilder("yaw " + yaw + ":");
for (double d : new double[] {0, 1, 2}) {
var f = com.eliasmueller.llamamod.editor.tools.playtest.FloorFinder.below(
s, nx - d * Math.sin(r), 20, nz - d * Math.cos(r));
b2.append(String.format(" %.2f", f == null ? Double.NaN : f.y()));
}
System.out.println(b2);
}
return;
}
if (orig) {
// move the start of the original level: old setup (node 0 eight behind, the rest by the delta) or new
double nx = Double.parseDouble(a[6]), nz = Double.parseDouble(a[7]), yaw = Double.parseDouble(a[8]);
var f = com.eliasmueller.llamamod.editor.tools.playtest.FloorFinder.near(s, nx, 20, nz);
Strat ll = s.playerStart();
if (a[5].equals("origold")) {
List<PathNode> ch = s.chain(ll.waypoint());
double dx = nx - ll.gx(), dy = f.y() - ll.gy(), dz = nz - ll.gz(), r = Math.toRadians(yaw);
for (int i = 0; i < ch.size(); i++) {
PathNode p = ch.get(i);
if (i == 0) {
Setups.pos(p, nx - 8 * Math.sin(r), f.y() - 0.4, nz - 8 * Math.cos(r));
} else {
Setups.pos(p, p.gx() + dx, p.gy() + dy, p.gz() + dz);
}
}
ll.setRaw("x", Edits.fixed(nx));
ll.setRaw("y", Edits.fixed(f.y()));
ll.setRaw("z", Edits.fixed(nz));
ll.setRaw("ry", Edits.angleUnits(yaw));
} else {
Setups.player(s, nx, f.y(), nz, yaw).command().apply(s);
}
List<PathNode> ch = s.chain(ll.waypoint());
System.out.println("start " + nx + " " + f.y() + " " + nz + " camera node "
+ com.eliasmueller.llamamod.editor.model.cmd.PlayerStart.startNode(ch, nx, nz));
ProjectIO.save(pr, Path.of(a[1]), cd);
System.out.println("saved " + a[1]);
return;
}
s.settings().prop("name").setRaw(s.settings(), a[4]);
s.settings().prop("targetChapter").setRaw(s.settings(), Integer.parseInt(a[3]));
Brush b = s.createBrush(BrushKind.BOX);
Map.<String, Object>of("x0", 10.0, "z0", 10.0, "x1", 22.0, "z1", 22.0, "y0", -1.0, "y1", 0.0)
.forEach(b::setRaw);
new AddNode(b).apply(s);
double px = 14.5, pz = 16.5;
if (a[5].equals("old")) {
Command c = Edits.placeStrat(s, "Llama", 0, px, 0, pz, 90.0);
c.apply(s);
List<PathNode> ch = s.chain(Setups.lastStrat(c).waypoint());
for (int i = 0; i < ch.size(); i++) {
Setups.pos(ch.get(i), i == 0 ? px - 8 : px, i == 0 ? -0.4 : 0, pz);
}
} else {
Setups.player(s, px, 0, pz, 90).command().apply(s);
}
if (a.length > 6) {
Command c = Edits.placeStrat(s, a[6], 0, px + 3.5, 0, pz, 270.0);
c.apply(s);
}
if (a.length > 7 && a[7].equals("ui")) {
// editor screenshot setup: a custom type and a guard waiting for an event nobody sets
var t = com.eliasmueller.llamamod.editor.tools.strats.StratEdits.cloneOf(s, "Coin", "MyCoin");
com.eliasmueller.llamamod.editor.tools.strats.StratEdits.addType(pr, t);
Command c = Edits.placeStrat(s, a[6], 0, px + 3.5, 0, pz + 3, 270.0);
c.apply(s);
Strat g = Setups.lastStrat(c);
int[] prm = g.params().clone();
prm[2] = 5 << 12;
g.setRaw("params", prm);
}
List<Problem> out = new ArrayList<>();
new ObjectChecks(() -> null).check(s, out);
out.forEach(p -> System.out.println(p.severity() + " " + p.message()));
for (Strat st : s.strats()) {
System.out.println(st.type() + " chain " + s.chain(st.waypoint()).size());
}
ProjectIO.save(pr, Path.of(a[1]), cd);
System.out.println("saved " + a[1]);
}
}
@@ -0,0 +1,111 @@
package com.eliasmueller.llamamod.editor.tools.objects;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.eliasmueller.llamamod.editor.io.Bases;
import com.eliasmueller.llamamod.editor.io.Project;
import com.eliasmueller.llamamod.editor.model.PathNode;
import com.eliasmueller.llamamod.editor.model.Scene;
import com.eliasmueller.llamamod.editor.model.Strat;
import com.eliasmueller.llamamod.editor.model.cmd.Command;
import com.eliasmueller.llamamod.editor.model.cmd.Edits;
import com.eliasmueller.llamamod.editor.model.cmd.History;
import com.eliasmueller.llamamod.editor.model.cmd.PlayerStart;
import java.util.List;
import org.junit.jupiter.api.Test;
/** The Llama's waypoints are start camera positions (first node within 2.5 cells). Needs -Dllamamod.cd. */
class PlayerStartTest {
@Test
void newPlayerStartGetsOneCameraNodeBehind() throws Exception {
Bases b = Bases.fromCd(ObjectToolsTest.cd());
for (String base : new String[] {"t1l1m001", "t1l1m002"}) {
Scene s = Project.create(b, "new", base).scene();
Setups.Made m = Setups.player(s, 20.5, 3, 20.5, 90);
m.command().apply(s);
Strat ll = (Strat) m.main();
List<PathNode> ch = s.chain(ll.waypoint());
assertEquals(1, ch.size(), base);
assertEquals(0, PlayerStart.startNode(ch, ll.gx(), ll.gz()));
assertEquals(18.5, ch.getFirst().gx(), 1e-3); // 2 cells behind (facing +x)
assertEquals(3.5, ch.getFirst().gy(), 1e-3);
assertEquals(0, ch.getFirst().unk2());
assertNull(ObjectChecks.startCamera(s, ll), base);
assertTrue(ChainOps.contiguous(s));
// the Place tool's rotate knob turns the camera node around the llama
PlaceTool.rotateCommand(ll, 180, null).apply(s);
assertEquals(20.5, ch.getFirst().gx(), 1e-3);
assertEquals(22.5, ch.getFirst().gz(), 1e-3);
}
}
@Test
void prototypeChainOnTheLlamaIsReported() throws Exception {
Bases b = Bases.fromCd(ObjectToolsTest.cd());
// what the old setup did: prototype chain, first node 8 behind, the rest on the llama
for (String base : new String[] {"t1l1m001", "t1l1m002"}) {
Scene s = Project.create(b, "new", base).scene();
Command c = Edits.placeStrat(s, "Llama", 0, 20.5, 3, 20.5, 0.0);
c.apply(s);
Strat ll = Setups.lastStrat(c);
List<PathNode> ch = s.chain(ll.waypoint());
for (int i = 0; i < ch.size(); i++) {
Setups.pos(ch.get(i), 20.5, i == 0 ? 2.6 : 3, i == 0 ? 12.5 : 20.5);
}
String why = ObjectChecks.startCamera(s, ll);
// village 2: node 2 has unk2 ≠ 0 – camera in the head (seen in game); village 1: unk2 = 0,
// the camera starts behind the llama at default distance (seen in game)
if (base.equals("t1l1m002")) {
assertNotNull(why);
assertTrue(why.contains("head"), why);
} else {
assertNull(why, why);
}
}
}
@Test
void movingTheOriginalStartKeepsDoorNodes() throws Exception {
Scene s = ObjectToolsTest.load("t1l1m001");
Strat ll = s.playerStart();
List<PathNode> before = s.chain(ll.waypoint());
double[][] pos = new double[before.size()][];
for (int i = 0; i < pos.length; i++) {
pos[i] = new double[] {before.get(i).gx(), before.get(i).gz()};
}
assertNull(ObjectChecks.startCamera(s, ll));
Strat intro = s.strats().stream()
.filter(o -> o.type().equals("CutSceneTrigger") && Math.hypot(o.gx() - ll.gx(), o.gz() - ll.gz()) < 1)
.findFirst()
.orElseThrow();
assertEquals(0, intro.params()[6]);
History h = new History(s);
h.execute(Setups.player(s, 30.5, 4.25, 40.5, 180).command());
// the intro fly-by trigger moves along and ends behind the llama
assertEquals(30.5, intro.gx(), 0.1);
assertEquals(1 << 12, intro.params()[6]);
List<PathNode> after = s.chain(ll.waypoint());
assertEquals(before.size() + 1, after.size());
for (int i = 0; i < pos.length; i++) {
assertEquals(pos[i][0], after.get(i).gx(), 1e-3);
assertEquals(pos[i][1], after.get(i).gz(), 1e-3);
}
int k = PlayerStart.startNode(after, ll.gx(), ll.gz());
assertEquals(after.size() - 1, k);
assertEquals(42.5, after.get(k).gz(), 1e-3); // behind = +z when facing −z
assertNull(ObjectChecks.startCamera(s, ll));
assertTrue(ChainOps.contiguous(s));
// moving it again a little takes the node along instead of adding one
h.execute(Setups.player(s, 31.5, 4.25, 40.5, 90).command());
after = s.chain(ll.waypoint());
assertEquals(before.size() + 1, after.size());
assertEquals(29.5, after.getLast().gx(), 1e-3);
assertEquals(40.5, after.getLast().gz(), 1e-3);
h.undo();
h.undo();
assertEquals(before.size(), s.chain(ll.waypoint()).size());
}
}