guards need their position inside a pair of waypoint boxes (else the game removes them): placing a guard gives it a box and event bit 0, check and inspector state with box around it button; exit and object checks use the engine's cell rule (tiles listed in the cell, swap models), tile cell from the map loader formula

This commit is contained in:
MineTec committed 2026-11-07 11:57:22 +01:00
1 parent a6708e5fff
commit d9f1b8c2fe
15 files changed
+424 -40

No files matched your search

@@ -0,0 +1,72 @@
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 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 > 0 keeps it hidden until progress
* bit p2 − 1 of global 95 is set; 0 = active as soon as the player exists.
*/
final class Guards {
static final List<String> TYPES = List.of(
"CityGuard",
"CityGuardWithRubyIdol",
"CowGuard",
"CowGuardNoCoin",
"GorillaGuard",
"LizardGuard",
"LizardGuardNoCoin",
"WarthogGuard");
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;
private Guards() {}
static boolean is(String type) {
return type != null && TYPES.contains(type.endsWith("(spawn)") ? type.substring(0, type.length() - 7) : type);
}
/** 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) {
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())) {
return k;
}
}
return -1;
}
/** 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;
}
/** Why the game removes the guard at level start, or null if it stays. */
static String removedBecause(Scene s, Strat st) {
List<PathNode> chain = st.waypoint() != null && s.contains(st.waypoint()) ? s.chain(st.waypoint()) : List.of();
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) {
return "it does not start inside one of its waypoint boxes";
}
return null;
}
}
@@ -5,11 +5,13 @@ 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.Tile;
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;
import com.eliasmueller.llamamod.editor.ui.Problem;
import com.eliasmueller.llamamod.save.Worlds;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Supplier;
@@ -30,7 +32,7 @@ final class ObjectChecks implements LevelCheck {
+ " i+1): run Tools → Repair waypoint chains",
broken));
}
Ground g = null;
List<Tile> doorTiles = null;
int players = 0;
for (Strat st : s.strats()) {
String type = st.type();
@@ -55,7 +57,21 @@ final class ObjectChecks implements LevelCheck {
}
}
}
if (sc.waypoints().required() && chain.size() < sc.waypoints().min()) {
String gone = Guards.is(type) ? Guards.removedBecause(s, st) : null;
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) {
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",
st));
}
if (!Guards.is(type)
&& sc.waypoints().required()
&& chain.size() < sc.waypoints().min()) {
out.add(Problem.warning(
label(st) + " needs " + sc.waypoints().min() + " waypoint(s) ("
+ sc.waypoints().role().label + "), has " + chain.size(),
@@ -67,7 +83,7 @@ final class ObjectChecks implements LevelCheck {
+ chain.size(),
st));
}
if (sc.waypoints().pairs() && chain.size() % 2 == 1) {
if (sc.waypoints().pairs() && chain.size() % 2 == 1 && !Guards.is(type)) {
out.add(Problem.warning(
label(st) + ": box nodes come in pairs, the chain has " + chain.size(), st));
}
@@ -84,14 +100,15 @@ final class ObjectChecks implements LevelCheck {
}
}
if (type.equals("LevelDoor")) {
if (g == null) {
g = Ground.build(
s, s.brushes().isEmpty() ? null : BrushCompiler.compile(s, new java.util.ArrayList<>()));
if (doorTiles == null) {
doorTiles = new ArrayList<>(s.tiles());
if (!s.brushes().isEmpty()) {
doorTiles.addAll(
BrushCompiler.compile(s, new ArrayList<>()).tiles());
}
}
int cx = (int) Math.floor(st.gx()), cz = (int) Math.floor(st.gz());
if (!g.hasMaterial(cx, cz, 16)
&& s.tiles().stream()
.noneMatch(t -> t.cellX() == cx && t.cellZ() == cz && t.swapHasMaterial(16))) {
if (!cellHasMaterial(doorTiles, cx, cz, 16)) {
out.add(Problem.warning(
label(st) + " stands in cell " + cx + ", " + cz + " without exit floor (collision material"
+ " 16): the door never fires",
@@ -126,6 +143,22 @@ final class ObjectChecks implements LevelCheck {
}
}
/**
* The engine's floor test walks only the tiles listed in the player's grid cell (MAP cell lists,
* cell = floor of the tile origin), at any height; a door's exit floor may also be in the swap
* model a door object switches the tile to (village 1: RubyIdolDoor).
*/
static boolean cellHasMaterial(List<Tile> tiles, int cx, int cz, int material) {
for (Tile t : tiles) {
if (t.cellX() == cx
&& t.cellZ() == cz
&& (t.model() != null && t.model().hasCollisionMaterial(material) || t.swapHasMaterial(material))) {
return true;
}
}
return false;
}
static String label(Strat s) {
return s.type() + " (" + s.id() + ")";
}
@@ -238,9 +238,7 @@ public final class PlaceTool implements Tool {
yield null;
}
Double yaw = yawMode() == 3 ? null : yaw();
Command c = Edits.placeStrat(s, ses.type(), ses.variant(), x, y, z, yaw);
Strat st = Setups.lastStrat(c);
yield new Setups.Made(c, st);
yield Setups.object(s, ses.type(), ses.variant(), x, y, z, yaw);
}
};
if (m == null) {
@@ -336,13 +336,33 @@ public final class Schemas {
.add();
// ---------------------------------------------------------- characters & enemies
t("CityGuard", "Guard patrolling its waypoints; chases the player when seen.")
.p("View range", Kind.RADIUS, 0, 50, "distance at which it sees the player")
.p("Chase range", Kind.RADIUS, 0, 50, "distance it chases the player (≥ view range)")
.p("Event bit", Kind.EVENT_BIT, 0, 32, "")
String guardEvent = "0 = active from the start (all village guards). n > 0: hidden and inactive until"
+ " progress bit n (global 95) is set by another object with that event number (a talking"
+ " character, smash statue, lab door pad …)";
String guardBoxes = "walking areas: boxes of node pairs (x/z). The guard must start inside a box,"
+ " otherwise the game removes it at level start; overlapping boxes let it walk on";
t(
"CityGuard",
"Guard walking around inside its waypoint boxes; chases the player within the boxes when"
+ " it sees it.")
.p("View range", Kind.RADIUS, 0, 50, "distance at which it sees the player (in front of it)")
.p("Chase range", Kind.RADIUS, 0, 50, "distance at which it gives up (view range is capped to it)")
.p("Event bit", Kind.EVENT_BIT, 0, 32, guardEvent)
.p("Wall collision", Kind.BOOL, "strat flag 0x40000")
.wp(Role.PATROL, 1, 0, "patrol path (wraps to the first node)")
.wpPairs(Role.BOX, 2, guardBoxes)
.conf("high")
.add();
for (String g : Guards.TYPES) {
if (!g.equals("CityGuard")) {
t(g, "Guard of the CityGuard family: walks inside its waypoint boxes, chases the player.")
.p("View range", Kind.RADIUS, 0, 50, "distance at which it sees the player")
.p("Chase range", Kind.RADIUS, 0, 50, "distance at which it gives up")
.p("Event bit", Kind.EVENT_BIT, 0, 32, guardEvent)
.wpPairs(Role.BOX, 2, guardBoxes)
.conf(g.equals("CityGuardWithRubyIdol") ? "high" : "medium")
.add();
}
}
t("JumpingBeetleBlue", "Jumping beetle following its path.")
.p("Jump time", Kind.FIXED, "")
.p("p1", Kind.RAW, "unused")
@@ -61,13 +61,74 @@ public final class Setups {
p.setRaw("z", Edits.fixed(z));
}
/** Places an object; params with schema kind BOOL named "Snap to floor" are switched on. */
/**
* 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.
*/
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));
}
/** 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);
}
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). */
public static Command guardBox(Scene s, Strat st) {
double h = Guards.NEW_BOX_HALF;
List<Command> cmds = new ArrayList<>();
if (st.waypoint() != null
&& s.contains(st.waypoint())
&& ChainOps.owners(s, st.waypoint()).size() == 1) {
cmds.addAll(ChainOps.removeChain(s, st));
}
cmds.addAll(ChainOps.newChain(
s,
st,
List.of(
ChainOps.newNode(s, st.gx() - h, st.gy(), st.gz() - h, 0, null),
ChainOps.newNode(s, st.gx() + h, st.gy(), st.gz() + h, 0, null))));
return new Batch("Guard box", cmds);
}
/**
* 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).