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

+9 -2
View File
@@ -304,18 +304,25 @@ The editor knows the parameters of these types (the rest show raw values):
| **ForcePlayerMovement**, **WaterReboundPoints**, **ObjectDontDropInBox**, **FallOffNoHurt**, **RiverCurrentBox** | see [Force and water objects](11-cameras-triggers.md#force-and-water-objects) | points / box |
| **RubyIdolDoor** | Idols needed, Offset (sign distance) | – |
| **PressurePadDoor** | Pad flags (bit field), Door z, Camera, Camera hold | – |
| **CityGuard** | View range, Chase range, Event bit, Wall collision | Patrol path (wraps to the first node) |
| **CityGuard** (and CityGuardWithRubyIdol, CowGuard, GorillaGuard, LizardGuard, WarthogGuard) | View range, Chase range, Event bit (0 = active at once), Wall collision (CityGuard only) | Walking boxes: node pairs; the guard must **start inside one box** |
| **JumpingBeetleBlue**, **JumpingBeetle** | Jump time, Wait, Jump height, Pause | Jump targets |
| **Spider** | Range, Speed, Down time, Up time, Start down | Path |
| **Bunny** | Flee range | – |
> **Guards:** a guard walks around inside the boxes of its waypoint pairs and chases the llama
> only inside them. If it does not start inside one of its boxes, or the node count is odd, the
> game removes it when the level starts – it never appears. Placing a guard gives it one 4×4 box
> around itself and Event bit 0; the Inspector shows a warning and a **Box around it** button
> when the boxes are wrong. An **Event bit** above 0 keeps the guard hidden until another object
> with that event number (a talking character, a smash statue, a door pad …) sets the bit.
> **Tip:** For pickups, leave **Snap to floor** on: the game then drops the coin onto the floor
> when the level starts, so a coin placed slightly too high still ends up in the right place.
## Duplicating, copying and pasting
* **Edit → Duplicate** (**Ctrl+D**) copies the selection one cell east. Objects keep a copy of
their waypoint chain, so a duplicated guard gets its own patrol path.
their waypoint chain, so a duplicated guard gets its own walking boxes.
* **Edit → Copy** / **Paste** (**Ctrl+C** / **Ctrl+V**) work inside the open level.
* **Delete** removes objects; waypoint chains used only by the deleted objects are removed too.
+5 -3
View File
@@ -1,6 +1,6 @@
# Waypoints and paths
Many objects need positions besides their own: a guard walks along a patrol path, a camera zone
Many objects need positions besides their own: a guard walks inside its boxes, a beetle jumps along a patrol path, a camera zone
covers a box, a checkpoint stores where the llama respawns, a cut scene flies its camera along a
route. The game stores all of these as **waypoints** – points linked into **chains**. This
chapter explains how chains work and how to edit them with the **Waypoint paths** tool.
@@ -27,10 +27,10 @@ What the nodes mean depends on the object type. The Inspector names the role in
| Role | Used by | Meaning of the nodes |
|---|---|---|
| **patrol path** | CityGuard, JumpingBeetleBlue, Spider … | Points the character walks or jumps to; patrols wrap from the last node back to the first |
| **patrol path** | JumpingBeetleBlue, Spider … | Points the character walks or jumps to; patrols wrap from the last node back to the first |
| **camera positions** | Llama (first node), cut-scene cameras, CutSceneTrigger | Camera position; node yaw and pitch give the view direction |
| **respawn point** | CheckPointPost | Where the llama reappears, and which way it faces |
| **box area (two opposite corners)** | Secret, CameraSide, CameraLift, ObjectDontDropInBox, FallOffNoHurt … | Two nodes span a rectangle on the ground (x and z only); some types use several pairs |
| **box area (two opposite corners)** | Secret, CameraSide, CameraLift, ObjectDontDropInBox, FallOffNoHurt, guards … | Two nodes span a rectangle on the ground (x and z only); some types use several pairs (guards walk inside their boxes and must start inside one) |
| **point list** | ForcePlayerMovement, WaterReboundPoints | Target points |
| **waypoint chain** | other types | Meaning unknown – keep the original layout |
@@ -162,5 +162,7 @@ The Problems panel reports:
| … needs n waypoint(s) (…), has m | The type reads more nodes than the chain has |
| … uses only n waypoint(s), the chain has m (info) | Extra nodes are ignored by the game |
| … box nodes come in pairs | Box zones need an even number of nodes |
| … never appears: the game removes it at level start (error) | A guard without boxes, with an odd node count or starting outside all of its boxes; use **Box around it** in the Inspector |
| … stays hidden until event bit n is set (info) | A guard waiting for an event; set Event bit to 0 to have it active at once |
| … has no waypoints; all original … objects have a chain | Most likely the type needs one |
| Respawn node is n cells away (info) | A checkpoint's respawn point far from the post |
+23 -2
View File
@@ -76,10 +76,31 @@ values only.
| ObjectDontDropInBox, FallOffNoHurt | box mode (+ game state) | box |
| WaterReboundPoints | active | rebound points |
| RubyIdolDoor | p1 idols needed, p4 sign offset | – |
| CityGuard | p0 view range², p1 chase range², p2 event bit, p3 wall collision | patrol (wraps) |
| CityGuard family (high) | p0 view range², p1 chase range² (view capped to it), p2 event bit (0 = active at once), p3 wall collision (CityGuard only) | **boxes** (node pairs), must start inside one – see below |
| JumpingBeetleBlue, Spider, Bunny | times in seconds (×30), ranges² | patrol path |
### Guards (CityGuard, CityGuardWithRubyIdol, CowGuard(NoCoin), GorillaGuard, LizardGuard(NoCoin), WarthogGuard)
One compiled template (same code in t1l1m002 unit 20 and the others). Start of the script:
1. `if (p2 > 0) { hide; wait until g95 & (1 << (p2−1)); unhide } else wait until playerPtr ≠ 0` –
p2 is an **event bit**: bits of global 95 are set by talking characters (TipoVillage,
ChacaVillage), smash statues, LabBigCutscene, LabSlidingDoorPad … with the same number. All
village guards have p2 = 0; 4 lab guards wait for an event.
2. Waypoint scan: `wpFirst`, then pairs (`wpNext` twice): odd count → debug "Uneven number of
WPs" + **remove**; the start box = first pair whose x/z box (`inWaypointBoxXZ`, op 0x178,
inclusive, fixed point) contains the guard and whose first node has field +0x28 (`wpParam`,
editor `unk2`) = 0; none → "Guard not in WP box" + **remove**. So the waypoints are **walking
areas**, not a patrol path: the guard wanders (random turns) inside the current box; when it
leaves it, `waypointStep(2)` looks for another box containing it (boxes with `wpParam ≠ 0` only
from another such box), else it turns back (`clampToWaypointBox`). Chasing stays clamped to
the boxes; it gives up beyond p1 or when the player leaves.
All 41 original guards satisfy this (unit test `GuardsTest`). The editor: placing a guard gives
it one 4×4 box and p2 = 0; `ObjectChecks` reports an error when the game would remove it and an
info for p2 > 0; the Inspector shows the state plus a **Box around it** button (`Guards`,
`Setups.guardBox`). Verified in game: CityGuard with its box on a new level (base village 2)
appears and attacks the llama; a second one with a 4-node "square path" next to it never appears.
The rest (~150 placed types) shows raw values with the original statistics. Next candidates:
PressurePadDoor (bit field), TargetRopePlatform, CataSwingingBlade (speeds/angles), RiverLog.
Alligator (also box pairs: "Forcing alligator inside first WP box"), PressurePadDoor (bit field), TargetRopePlatform, CataSwingingBlade (speeds/angles), RiverLog.
Tooling used (not in the repo): Ghidra script decompiling the handler table, a Python sweep
disassembler with an op-name table, "parameter use" context dumps per type.
+19 -10
View File
@@ -12,7 +12,7 @@ screenshot tour `ObjectsTour` (test classes).
|---|---|
| **Objects panel** (left dock) | every placeable type of the base with rendered thumbnail, category filter, search (name + schema summary), favourites (right-click, ★ filter), "parameters decoded" marker; drag into the viewport or click → Place tool. Quick setups: Player start, Checkpoint, Exit, Secret, Coin line, Coin arc |
| **Place tool** (P) | click = place on the collision floor under the cursor (snap to floor, grid snap), yaw = camera direction / facing the camera / 0° / type default; click + drag an object = move along the ground (its waypoints move along); drag the yellow knob = rotate (angle snap; a checkpoint's respawn node turns with it). Modes for the setups |
| Setups | **Player start** moves the existing player (exactly one) or places a Llama with its chain (camera node 8 cells behind). **Checkpoint** = CheckPointPost + respawn node 0.4 ahead (floor height). **Exit** = LevelDoor in the cell centre + 1×1 FLOOR brush with collision material 16; target picked in the options bar (world/chapter names, "world finished 7,7,7"). **Secret** = Secret with a 3×3 box (2 nodes) |
| Setups | **Player start** moves the existing player (exactly one) or places a Llama with its chain (camera node 8 cells behind). **Checkpoint** = CheckPointPost + respawn node 0.4 ahead (floor height). **Exit** = LevelDoor in the cell centre + 1×1 FLOOR brush with collision material 16; target picked in the options bar (world/chapter names, "world finished 7,7,7"). **Secret** = Secret with a 3×3 box (2 nodes). Guards (any placement) get one 4×4 box and event bit 0 |
| **Coin tool** (O) | Line: two clicks, coins every *spacing*; Arc: centre + point on the circle, *count* coins over *arc°*; Coin/Coin5, height above floor, follow floor; live preview |
| **Waypoint tool** (N) | select an object (or node): click = add node after the selected node / at the chain end (creates the chain if missing), Ctrl+click = insert before, drag = move (keeps height above floor), Backspace/Del = remove and relink; Reverse; orient new nodes along the path |
| **Zones & triggers** (J) | handles: 4 corners of box zones (node pairs: Secret, CameraSide/Lift, ObjectDontDropInBox, FallOffNoHurt …), radius knob for RADIUS parameters (CutSceneTrigger, guards, CameraFocusPoint) |
@@ -20,7 +20,7 @@ screenshot tour `ObjectsTour` (test classes).
| Inspector "Waypoint chain" | node i of n, owner and role, ◀ ▶, select object, delete node, look through |
| Viewport layers | *Path directions* (arrows, dashed wrap segment for patrol paths, node yaw ticks), *Zone areas & trigger radii* (boxes for all zone objects, fills for the selection), *Camera frusta* (CameraSide/Lift: computed game camera from height/distance/node yaw; cut-scene cameras: a frustum per node from node yaw/pitch) |
| Look through camera (Ctrl+Shift+L, View → Camera) | viewport takes the camera of the selected node / cut-scene camera / camera zone; while active, flying writes back node position + yaw/pitch, or for CameraSide the height/distance params and node yaw (one undo step per pilot session) |
| Problems (Level → Check, Build) | chains not stored contiguously (error, Tools → Repair waypoint chains), param count, out-of-range values, missing required waypoints / odd box pairs, types whose originals always have a chain, LevelDoor without material-16 floor in its cell, impossible exit targets and (7,7,7) in TestLevel, far respawn nodes, several player starts |
| Problems (Level → Check, Build) | guards the game would remove (no start box / odd node count) and guards waiting for an event bit, chains not stored contiguously (error, Tools → Repair waypoint chains), param count, out-of-range values, missing required waypoints / odd box pairs, types whose originals always have a chain, LevelDoor without material-16 floor in its cell, impossible exit targets and (7,7,7) in TestLevel, far respawn nodes, several player starts |
| Copy / paste between levels | Edit → Copy objects (Ctrl+Shift+C) puts JSON (objects + chains + brushes, relative to their centre) on the system clipboard; Paste objects (Ctrl+Shift+V) at the view centre in any level – types are matched by name/variant in the target base (unknown types skipped with a message) |
| Prefabs | Tools → Save selection as prefab… / asset browser tab "Prefabs": `<data>/editor/prefabs/<name>.json` + thumbnail; double-click = place at view centre, drag = place under the cursor |
| Delete | `edit.delete` is re-registered: a selection of waypoints is removed with the chain relinked |
@@ -50,7 +50,7 @@ UI (Xvfb, `ObjectsTour`): `img/editor/obj_camera_zone.jpg`, `obj_cutscene_frusta
In game (apptest, display :7, new level on base t1l1m002 as TestLevel chapter 2; island of brushes,
coin line + coin arc, checkpoint, exit to Village chapter 2, CameraSide zone at the exit,
JumpingBeetleBlue with a 4-node patrol path, CityGuard with a square path):
JumpingBeetleBlue with a 4-node patrol path, CityGuard with two boxes):
* level loads, coins of line and arc stand where placed (`obj_game_coins.jpg`);
* walking through the checkpoint shows "KONTROLLPUNKT"; falling into the sea respawns the llama at
@@ -58,18 +58,27 @@ JumpingBeetleBlue with a 4-node patrol path, CityGuard with a square path):
* entering the camera zone switches to the zone camera (high view, `obj_game_camera_zone.jpg`);
* stepping on the exit floor shows the tally "TestLevel Kapitel 2"; Enter → "Dorf Kapitel 2"
loads = the chosen chapter (`obj_game_exit.jpg`);
* the beetle jumps back and forth along its path (`obj_game_patrol.jpg`).
* the beetle jumps back and forth along its path (`obj_game_patrol.jpg`);
* (2026-11-07, display :25) a CityGuard placed with the Place tool (one box) appears and attacks
the llama (`obj_game_guard.jpg`); one with a square path beside it never appears (editor:
`obj_guard_removed.jpg`). Village 1 with the door tile swapped open: start on the pad 42.5/3.5/49.37
→ tally "Dorf Kapitel 1" at once (`obj_game_village_door.jpg`); at the door's own position
42.5/3.5/49.8 or in the original (door closed) → nothing.
## Known limits
* Parameters are decoded for ~35 common types (confidence per type in the schema doc); the rest
shows raw values with the statistics of the originals.
* The CityGuard (village-2 base) placed in the open did not show up in game – it probably waits for
a scene/event; the patrol test was done with JumpingBeetleBlue.
* In 8 original levels the exit check reports "no material 16 in the door cell": the exit floor
polygons decode one cell off the door (e.g. village door cell 42,49, floor polygon at 43,48) –
original tile collision positions vs. strat positions are not fully understood yet; brush-built
exits are correct (verified in game). Snap-to-floor on original levels uses the same polygons.
* Solved 2026-11-07: the CityGuard "not showing up" was removed by its own script – guards need
their start inside a waypoint box (node pairs), the tour had given it a 4-node square path
(editor-object-schemas.md "Guards"). The exit check "no material 16 in the door cell" in
originals came from the door floor being in the tile's swap model (RubyIdolDoor & co.); the
pad at 43,48 next to the village door is a house entrance. Tile cells follow the engine's
loader exactly (`Tile.cellX/cellZ` = ftol(x − 0.5), −ftol(z + 0.5)); `ObjectChecks` tests the
door cell like the engine (tiles listed in the cell, any height, swap models), same as
`GeometryChecks`. Tests: `GuardsTest`, `OriginalExitsTest`, `OriginalStartsTest`.
* Snap-to-floor (`Ground`) is geometric: it finds polygons that reach into a cell from a
neighbouring tile, which the engine ignores (`FloorFinder` follows the engine for the player).
* Zone heights are estimated (+1.2 above the lower node); the engine tests x/z only.
* Camera preview of CameraSide assumes the player in the box centre and default distance 2 /
height 1 when the parameters are 0; the game's FOV is approximated by 60°.
+3 -1
View File
@@ -114,7 +114,9 @@ Order in the file:
1. `u32 N (tiles), u32 W, u32 H` (grid)
2. **A**: N × `{f32 x, f32 y, f32 z, u32 rot, u32 model, u32 animFlag}` (export space; x,z = cell centre .5).
Only used to sort into the cell lists: cell = floor(x) + floor(−z)·W.
Only used to sort into the cell lists: cell = ftol(x − 0.5) − ftol(z + 0.5)·W (ftol truncates; 0x42ad4a,
constant 0x56e158 = 0.5) = floor(x) + floor(−z)·W for cell-centred tiles (all originals). The player/strat
cell of the collision tests is x>>12, z>>12 (FUN_004036d0).
3. u32 n + n×u32 (list, referenced by strats)
4. u32 n + n×90 bytes (lights/cameras? – objects.md)
5. u32 n + n×34 bytes (path nodes)
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

@@ -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 &gt; 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).
@@ -0,0 +1,130 @@
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.Batch;
import com.eliasmueller.llamamod.editor.model.cmd.History;
import com.eliasmueller.llamamod.editor.ui.Problem;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import org.junit.jupiter.api.Test;
/**
* Guards are removed by their own code unless they start inside a box of a waypoint pair; LevelDoors
* need material 16 in their cell. All originals satisfy the object checks (needs -Dllamamod.cd).
*/
class GuardsTest {
@Test
void originalGuardsAndDoorsPassTheChecks() throws Exception {
int guards = 0, withEvent = 0;
Map<String, Integer> doorWarnings = new TreeMap<>();
try (var files = Files.list(ObjectToolsTest.cd().resolve("wads"))) {
for (Path p : files.filter(f -> f.getFileName().toString().matches("t1l\\dm00\\d\\.wad"))
.sorted()
.toList()) {
String base = p.getFileName().toString().replace(".wad", "");
Scene s = ObjectToolsTest.load(base);
for (Strat st : s.strats()) {
if (Guards.is(st.type())) {
guards++;
assertNull(Guards.removedBecause(s, st), base + " " + st.id());
withEvent += Guards.eventBit(st) > 0 ? 1 : 0;
}
}
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("without exit floor")) {
doorWarnings.merge(base, 1, Integer::sum);
}
}
}
}
assertEquals(41, guards);
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);
}
@Test
void placedGuardStartsInItsBoxAndIsActive() throws Exception {
Scene s = Project.create(Bases.fromCd(ObjectToolsTest.cd()), "new", "t1l1m002")
.scene();
History h = new History(s);
Setups.Made g = Setups.object(s, "CityGuard", 0, 22, 0, 18, 0);
h.execute(g.command());
Strat guard = (Strat) g.main();
List<PathNode> chain = s.chain(guard.waypoint());
assertEquals(2, chain.size());
assertNull(Guards.removedBecause(s, guard));
assertEquals(0, Guards.eventBit(guard));
assertEquals(0, Guards.startBox(chain, guard));
assertTrue(ChainOps.contiguous(s));
assertTrue(problems(s).isEmpty(), problems(s).toString());
// a square "patrol path" around it: pairs (0,1) and (2,3) are flat strips, the guard is in neither
h.execute(new Batch("clear", ChainOps.removeChain(s, guard)));
List<PathNode> sq = new ArrayList<>();
for (double[] q : new double[][] {{20, 15}, {26, 15}, {26, 22}, {20, 22}}) {
sq.add(ChainOps.newNode(s, q[0], 0, q[1], 0, null));
}
h.execute(new Batch("square", ChainOps.newChain(s, guard, sq)));
assertNotNull(Guards.removedBecause(s, guard));
assertTrue(problems(s).stream()
.anyMatch(p ->
p.severity() == Problem.Severity.ERROR && p.message().contains("never appears")));
// the inspector's fix
h.execute(Setups.guardBox(s, guard));
assertNull(Guards.removedBecause(s, guard));
assertEquals(2, s.chain(guard.waypoint()).size());
assertTrue(ChainOps.contiguous(s));
// event bit set: still placed, but hidden (info)
int[] p = guard.params().clone();
p[Guards.EVENT_PARAM] = 3 << 12;
guard.setRaw("params", p);
assertTrue(problems(s).stream()
.anyMatch(q ->
q.severity() == Problem.Severity.INFO && q.message().contains("event bit 3")));
}
@Test
void brushExitPassesTheDoorCheck() throws Exception {
Scene s = Project.create(Bases.fromCd(ObjectToolsTest.cd()), "new", "t1l1m002")
.scene();
Setups.exit(s, 12.2, 0, 26.2, 1, 1, 2).command().apply(s);
List<Problem> out = new ArrayList<>();
new ObjectChecks(() -> null).check(s, out);
assertTrue(out.stream().noneMatch(p -> p.message().contains("exit floor")), out.toString());
// moved one cell off its floor: the door never fires
Strat door = s.strats().stream()
.filter(st -> st.type().equals("LevelDoor"))
.findFirst()
.orElseThrow();
door.setRaw("x", door.x() + 4096);
out.clear();
new ObjectChecks(() -> null).check(s, out);
assertTrue(out.stream().anyMatch(p -> p.message().contains("without exit floor")), out.toString());
}
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()));
return out;
}
}
@@ -51,6 +51,8 @@ public final class ObjectsTour {
sleep(6000);
if (mode.startsWith("beetle:")) {
beetle();
} else if (mode.startsWith("guard:")) {
guards();
} else if (mode.equals("village")) {
village();
} else {
@@ -140,7 +142,7 @@ public final class ObjectsTour {
shot("obj_problems");
}
/** New level on the village-2 base: island, coins, checkpoint, exit, guard with patrol path, camera zone. */
/** New level on the village-2 base: island, coins, checkpoint, exit, guard with two boxes, camera zone. */
static void build() throws Exception {
edt(() -> {
Scene s = w.scene();
@@ -171,7 +173,7 @@ public final class ObjectsTour {
p[2] = 1 << 12;
v.put(c, Map.of("params", p));
w.execute(Edits.set("Camera zone", v, null));
// guard with a square patrol path
// guard with two overlapping walking boxes (node pairs); it must start inside one
Setups.Made g = Setups.object(s, "CityGuard", 0, 22, 0, 18, 0);
w.execute(g.command());
Strat guard = (Strat) g.main();
@@ -179,11 +181,11 @@ public final class ObjectsTour {
new ArrayList<>(ChainOps.removeChain(s, guard));
w.execute(new Batch("clear", cmds));
List<PathNode> path = new ArrayList<>();
double[][] pts = {{20, 15}, {26, 15}, {26, 22}, {20, 22}};
double[][] pts = {{20, 15}, {25, 20}, {23, 19}, {28, 23}};
for (double[] q : pts) {
path.add(ChainOps.newNode(s, q[0], 0, q[1], 0, null));
}
w.execute(new Batch("Patrol path", ChainOps.newChain(s, guard, path)));
w.execute(new Batch("Guard boxes", ChainOps.newChain(s, guard, path)));
w.selection().set(guard, null);
w.setTool(PathTool.ID);
return null;
@@ -243,6 +245,33 @@ public final class ObjectsTour {
shot("obj_newlevel_saved");
}
/** Level with a correct guard (own box) and one outside its boxes: Inspector + Problems. */
static void guards() throws Exception {
edt(() -> {
Camera c = w.viewport().camera();
w.viewport().stopAnimation();
c.position.set(ToolEvent.toExport(19, 10, 8));
c.yaw = 0;
c.pitch = (float) Math.toRadians(-50);
w.viewport().requestRender();
List<Strat> g = w.scene().strats().stream()
.filter(st -> Guards.is(st.type()))
.toList();
w.selection().set(g.getLast(), null);
w.setTool("select");
w.run("level.check");
return null;
});
sleep(2500);
shot("obj_guard_removed");
edt(() -> {
w.selection().set(first("CityGuard"), null);
return null;
});
sleep(1500);
shot("obj_guard_box");
}
static void beetle() throws Exception {
edt(() -> {
Scene s = w.scene();