Compare commits

5 Commits
13 changed files with 189 additions and 24 deletions

No files matched your search

+8
View File
@@ -1,5 +1,13 @@
# Changelog
## 0.2.3 – 2026-11-07
- **Editor:** flying with the right mouse button + W A S D / Q E now works even when another part of
the window (a text field, a dock) still had the keyboard focus.
- **Editor:** the block, pillar and invisible-box icons sat too low in the toolbars (drawn outside
their box); a test now keeps every icon inside its 16 × 16 box.
- **Tutorials** say where the Objects panel is (the ⊙ tab next to *Outliner*).
## 0.2.2 – 2026-11-07
- **English game version supported.** All exe mods (boot to level, cheats, intro skip, practice
+1 -1
View File
@@ -5,7 +5,7 @@ plugins {
group = 'com.eliasmueller.llamamod'
// Release builds pass -PreleaseVersion=<tag without v> (see .gitea/workflows/release.yml).
version = findProperty('releaseVersion') ?: '0.2.2'
version = findProperty('releaseVersion') ?: '0.2.3'
repositories {
mavenCentral()
+6
View File
@@ -144,6 +144,12 @@ Turns the hidden TestLevel (world 9, chapter 1) into a playground island: a gall
*Id* `testworld`
#### Village Chapter 1 (play-test)
Temporary play-test copy from the level editor – removed when the play-test ends. Changes the original level t1l1m001.
*Id* `playtest-village-chapter-1`
### Gameplay mods
#### Boot to level
+3 -2
View File
@@ -40,8 +40,9 @@ Every change can be undone with **Ctrl+Z**.
## Step 4: A coin trail
1. Open the **Objects** panel (left) and click **Coin line** under the quick setups – or press
**O** for the **Coin line / arc** tool.
1. Open the **Objects** tab in the left dock – the ⊙ icon right next to *Outliner* (inactive tabs show only
their icon) and click **Coin line** under **Quick setups** – or press **O** for the
**Coin line / arc** tool.
2. Click once at the start of the trail and once at its end. Coins appear every *spacing* cells
on the ground (also up the ramp).
+2 -1
View File
@@ -32,7 +32,8 @@ Every drag is one undo step – **Ctrl+Z** takes it back.
## Step 3: Add a checkpoint
1. Open the **Objects** panel (left dock) and click the quick setup **Checkpoint**.
1. Open the **Objects** tab in the left dock – the ⊙ icon right next to *Outliner* (inactive tabs show only
their icon) and click the quick setup **Checkpoint**.
2. The Place tool (**P**) is now active. Click on the ground where the checkpoint should stand.
The editor places a checkpoint post together with its respawn point a little in front of it.
+2 -1
View File
@@ -69,7 +69,8 @@ plane shows as material 30. Look for:
The llama should not have to start over after every fall.
1. Open the **Objects** panel (left dock) and click **Checkpoint** in **Quick setups**.
1. Open the **Objects** tab in the left dock – the ⊙ icon right next to *Outliner* (inactive tabs show only
their icon) and click **Checkpoint** in **Quick setups**.
2. Click on a platform in the middle of the course.
You get a checkpoint post with its respawn point just in front of it. Falling after passing it
+37 -15
View File
@@ -81,27 +81,49 @@ nothing solid right behind the llama (the camera moves aside).
| cameras | `CameraSide`, `CutSceneCameraDirectional`, `CutSceneTrigger` | camera zones and cut scenes |
| always present | `CheatTeleport`, `Wompy`, `CutSceneTrigger` | |
### The exit (LevelDoor) – *certain*
### The exit (LevelDoor)
The LevelDoor has no model. Every frame it checks:
An exit is **two parts working together** (decompiled strat code; the details below were checked
in game in Jungle Day 2 with a debugger, the basic rule also in Village 1):
* The **LevelDoor** object (no model) writes its three parameters – the next level (tribe, world,
chapter) – to globals g101–g103 and its grid cell to g104/g105. It does **not** end the level
itself in the cases we tested (see the details below).
* **Kuzco's own script** ends the level when **all** of these hold:
```
game state is "running"
and the floor polygon under the llama has collision material 16
and floor(llama.x) == floor(door.x) and floor(llama.z) == floor(door.z) ; same grid cell
-> endLevel(tribe, world, chapter) ; its three parameters
floor material under Kuzco == 16 ; the remembered floor (see "floor test" below)
and floor(x), floor(z) == the cell in g104/g105 ; the exit's grid cell, any height
and Kuzco is at most ~0.6 above that floor's height
-> level change to g101..g103 (tally screen)
```
So an exit needs **a floor with collision material 16 in the door's grid cell** – height does not
matter. In several original levels the pad only appears when a tile swap opens the door (Village 1:
the RubyIdolDoor swaps the tile to a model that has the material-16 pad). The parameters are the
next level; the originals use (1, world, chapter + 1), and **(7, 7, 7)** in the last chapter of a
world means "world finished" – in TestLevel that crashes, so custom levels use (1, 9, chapter).
`endLevel` shows the tally (coins x / LNFO total, secrets) and unlocks the next chapter.
On a material-16 floor **outside** the exit cell the same check is a house door: fade-out and
the teleport of the environment zone of that cell.
A second path in the Llama's own code ends the level when it stands on material 16 in the cell
stored in globals g104/g105 with the target in g101–g103 – the arena uses this
([Arena](../strats/arena.md)).
So a normal exit needs **a floor with collision material 16 in the exit's grid cell**. In several
original levels that floor only exists after a tile swap opens the door (Village 1 and Jungle Day 2:
the RubyIdolDoor swaps the tile to a model that has the material-16 pad; checked in game in Village
1). The parameters of the originals are (1, world, chapter + 1); **(7, 7, 7)** in the last chapter of
a world means "world finished" – in TestLevel that crashes, so custom levels use (1, 9, chapter).
The tally shows coins x / LNFO total and secrets and unlocks the next chapter. The arena sets
g101–g105 itself to make its own exit ([Arena](../strats/arena.md)).
**Details checked in game (Jungle Day 2, t1l4m002, 2026-11-07):**
* The LevelDoor stores its parameters and cell in g101–g105, then checks global 0x21 **once**
(after 5 ticks); if it is not 3 the door removes itself. In Jungle Day 2 it was gone after the
start, so the Llama's check is what ends the level. That check needs floor material 16, the
door's cell, and the llama no more than 0.6 above the recorded floor height. Off the exit cell
the same material-16 check is the house door (fade-out and zone teleport).
* The floor test only looks at tiles in the player's cell and takes the **nearest upward floor
below the player, at any distance**. Material 17 is ignored for the llama. A llama hovering 3.7
units above a house-door pad records material 16.
* Each tick runs all strat scripts first and then the floor tests. A position change made after
the floor tests is seen by the next tick's scripts with the **old** floor record. So hovering
above any house-door pad and then moving the llama straight to floor height in the exit cell
ends the level, even though the exit cell has no material 16 yet (it appears only in the swap
model when the RubyIdolDoor opens). Moving there from normal ground does nothing.
## Environment zones (90 bytes, *certain* for the fields below)
Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

+45
View File
@@ -173,6 +173,11 @@ stairs, platforms register as shadow casters (`solids`). 1.0 = texture colour
## Exit (LevelDoor) – how it works
> **Corrected 2026-11-07 (in game, Jungle Day 2):** the listing below was the first reading. The
> LevelDoor only sets g101–g105 and checks global 0x21 *once*; the level is ended by the **Llama's
> script** (material 16 + exit cell + at most ~0.6 above the floor). See "Exit via stale floor
> material" further down and docs/reference/objects.md.
Strat bytecode (village, STPC 0x430332, 3 parameters, invisible, no model), decoded via the
VM `FUN_0054d180` (opcode < 0x45: table 0x5796bc with 16-bit operand, otherwise 0x578ff4+op·4):
```
@@ -199,6 +204,46 @@ loop: if g21 == 3 ; game state "running"
(zone teleport). t1l3m003: 2 of the 4 doors (cells 71,48/71,49) have no pad at all.
* Second path (strat code at 0x4252aa, player logic [likely]): floor material 16 and cell =
g104/g105 (set by the LevelDoor at start, Llama sets them to −1) → level change(g101..g103).
### Exit via stale floor material (hover + position jump, t1l4m002) – checked 2026-11-07
Speedrun video (position/hover hack): Kuzco hovers above a house-door stump, is moved to the exit and
the level ends. Reproduced in game (apptest, gdb through `winedbg --gdb`, position written by gdb):
* **Jungle Day chapter 2 = t1l4m002** (LNFO 95 coins / 2 secrets; tally "Dschungeltag Kapitel 2,
0/95, 0/2"). LevelDoor at 44.5/25.5 (cell 44,25), params (1,4,3); RubyIdolDoor in the same
cell. Cell 44,25 (tile 255) has material 16 **only in swap model 95** (pad x 44.67–45.0,
z 25.25–25.75, y 2.25); main model 94 has none → with the door closed the exit cannot fire.
House-door pads (material 16, main models) in cells 38,20 / 51,0 / 52,5 / 41,24 / 44,27 /
50,27 / 41,29 / 57,55 / 41,38 / 41,44 plus swap pads in 22,21 / 26,21 / 23,23 / 50,1 / 43,21 /
46,30 / 58,49 / 31,50; the zones pair them (e.g. 44,27 → 50.5/27.5, 50,27 → 44.5/27.5).
* **Floor test (FUN_004036d0):** only tiles of the player's own cell; per upward polygon the plane
height at (x,z) is compared with a probe point (y + 0.25, or y + (floor record +0x18)/2 with
+0xe8 flag 0x200000 as for the llama);
the **nearest floor below the probe wins, with no distance limit**. Material 17 is skipped for
the llama (+0xe8 = 0x24882a, no 0x8000000). If nothing is below: for the llama (+0xe8 & 0x40000)
material/height are **not** updated (stale) and x/z go back to the previous position.
In game: llama held at 44.9/**6.0**/27.5 (3.7 above the pad of the stump at 44,27) → floor
record h 2.30, **material 16**, tile 282 poly 15, every frame.
* **Order per tick (FUN_0054c4a0):** all strat scripts (VM FUN_0054d180) first, then the
collision pass (FUN_00403020 for every strat with +0xe8 & 0x8000). A position written after the
collision pass is seen by the scripts of the next tick with the **old floor record**.
In game: after hovering above the 44,27 pad, llama set to 44.5/2.3/25.5 right after its floor
test → next event endLevel `FUN_0054d620` (called from the VM, floor record still 16), before
any new floor test → tally (twice with screenshot, once traced). Control: same jump from normal
ground (material 0) → no exit; jump written just *before* the floor test → record becomes 0, no exit.
* **Who ends the level here: the Llama, not the LevelDoor.** LevelDoor bytecode: wait 5 ticks,
g101..g105 = params + cell, then **once** `if global 0x21 != 3 → end` (so "g21 == 3" above is a
one-time check, and global 0x21 is not the game state 0x5846e4), then the per-tick loop. In
t1l4m002 the LevelDoor was gone from the strat list (scan of 0x6d9e38) while g101..g105 =
1,4,3,44,25 and global 0x21 = 0. The Llama block at STPC 0x39ff4c–0x3a0064: if floorMaterial
== 16 and health > 0 and (y < floorHeight + 0.6 and global 0x21 != 2, or y < floorHeight + 0.025
and (g1 == 0 or global 0x23)): cell == (g104, g105) → level change(g101..g103), else fade-out +
`getDoorPos` (op 0x100, zone teleport = house door) + `forceCollision`. The 0.6 limit is why
hovering high above a house pad does not trigger the house teleport (bytecode; not separately
tested), while landing at floor height in the exit cell with the stale record does.
* Not explained: in two sessions with a moved player start (boot-to-level `start` 43.3/25.5) the
same jump did not end the level (Llama script ran with record 16 in cell 44,25, no endLevel).
* Images: `img/debug/exit-hover-trick.jpg` (top: video; bottom: our pool/door platform, tally).
* Parameters in the originals: (1, world, chapter+1) = next chapter; in the last chapter of a
world **(7,7,7)** = "world completed" → back to the selection map, unlock the next world.
* `FUN_0054d620`: remembers the current level (0x585008..14), sets the next (0x584eb4/b8/bc), shows the
@@ -7,6 +7,8 @@ import java.awt.Color;
import java.awt.Component;
import java.awt.Cursor;
import java.awt.Graphics;
import java.awt.KeyEventDispatcher;
import java.awt.KeyboardFocusManager;
import java.awt.event.ComponentAdapter;
import java.awt.event.ComponentEvent;
import java.awt.event.InputEvent;
@@ -93,6 +95,38 @@ public final class Viewport extends JPanel {
private boolean dirty = true;
private long lastTick = System.nanoTime();
private final Set<Integer> keys = new HashSet<>();
/**
* While flying, WASD/QE/Shift reach the fly loop even if another component (a text field, a
* dock) still has the keyboard focus: with some window managers the heavyweight GL canvas does
* not get the focus from a right-click.
*/
private final KeyEventDispatcher flyKeys = e -> {
if (!this.flying) {
return false;
}
if (e.getID() == KeyEvent.KEY_PRESSED) {
keys.add(e.getKeyCode());
} else if (e.getID() == KeyEvent.KEY_RELEASED) {
keys.remove(e.getKeyCode());
}
// modifiers pass on (others track Shift, e.g. the play-here crosshair); typed keys do not
int k = e.getKeyCode();
return k != KeyEvent.VK_SHIFT && k != KeyEvent.VK_CONTROL && k != KeyEvent.VK_ALT && k != KeyEvent.VK_META;
};
@Override
public void addNotify() {
super.addNotify();
KeyboardFocusManager.getCurrentKeyboardFocusManager().addKeyEventDispatcher(flyKeys);
}
@Override
public void removeNotify() {
KeyboardFocusManager.getCurrentKeyboardFocusManager().removeKeyEventDispatcher(flyKeys);
super.removeNotify();
}
private boolean flying;
private BufferedImage capture;
private boolean captureNext;
@@ -1131,6 +1165,7 @@ public final class Viewport extends JPanel {
}
if (drag == Drag.FLY) {
flying = false;
keys.clear(); // a key released after the button may have gone to another component
canvas.setCursor(Cursor.getDefaultCursor());
}
if (drag != Drag.NONE && buttonOf(drag, e)) {
@@ -19,8 +19,8 @@ final class GeoIcons {
private static final Color RED = new Color(0xD93F3F);
static void register() {
Icons.register("prim-box", (g, fg) -> block(g, 2, 5, 12, 8, TOP, SIDE));
Icons.register("prim-platform", (g, fg) -> block(g, 1, 4, 14, 4, TOP, SIDE));
Icons.register("prim-box", (g, fg) -> block(g, 2, 1, 12, 8, TOP, SIDE));
Icons.register("prim-platform", (g, fg) -> block(g, 1, 2.5, 14, 4, TOP, SIDE));
Icons.register("prim-floor", (g, fg) -> {
Path2D p = poly(1, 10, 8, 6.5, 15, 10, 8, 13.5);
g.setColor(TOP);
@@ -47,7 +47,7 @@ final class GeoIcons {
g.setColor(fg);
g.draw(p);
});
Icons.register("prim-pillar", (g, fg) -> block(g, 5, 2, 6, 12, STONE, STONE.darker()));
Icons.register("prim-pillar", (g, fg) -> block(g, 5, 0.5, 6, 12, STONE, STONE.darker()));
Icons.register("prim-cylinder", (g, fg) -> {
g.setColor(STONE.darker());
g.fill(new java.awt.geom.Rectangle2D.Double(2, 6, 12, 5));
@@ -122,7 +122,7 @@ final class GeoIcons {
Icons.register("prim-invbox", (g, fg) -> {
g.setStroke(
new BasicStroke(1.2f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND, 1, new float[] {2, 1.5f}, 0));
outlineBlock(g, 2, 5, 12, 8, new Color(255, 60, 60, 60), RED);
outlineBlock(g, 2, 1, 12, 8, new Color(255, 60, 60, 60), RED);
});
Icons.register("geo-collision", (g, fg) -> {
Path2D p = poly(1, 10, 8, 6.5, 15, 10, 8, 13.5);
@@ -221,6 +221,7 @@ final class GeoIcons {
}
/** Isometric block: x, y of the top-left, width w, side height h. */
/** An isometric block filling y .. y + w / 2 + h; keep that within the 16 × 16 icon box. */
private static void block(Graphics2D g, double x, double y, double w, double h, Color top, Color side) {
Color fg = g.getColor();
double d = w / 4;
@@ -40,6 +40,11 @@ public final class Icons {
private Icons() {}
/** Names of all registered icons (sorted). */
public static java.util.List<String> names() {
return PAINTERS.keySet().stream().sorted().toList();
}
public static void register(String name, Painter p) {
PAINTERS.put(name, p);
CACHE.keySet().removeIf(k -> k.startsWith(name + "@"));
@@ -0,0 +1,40 @@
package com.eliasmueller.llamamod.editor.tools.geometry;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.eliasmueller.llamamod.editor.ui.Icons;
import java.awt.Graphics2D;
import java.awt.image.BufferedImage;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;
/** Every vector icon stays inside its 16 × 16 box, so toolbar icons line up. */
class IconBoundsTest {
@Test
void iconsStayInsideTheirBox() {
GeoIcons.register();
List<String> outside = new ArrayList<>();
for (String name : Icons.names()) {
BufferedImage img = new BufferedImage(32, 32, BufferedImage.TYPE_INT_ARGB);
Graphics2D g = img.createGraphics();
Icons.get(name, 16).paintIcon(null, g, 8, 8);
g.dispose();
int minY = 32, maxY = -1, minX = 32, maxX = -1;
for (int y = 0; y < 32; y++) {
for (int x = 0; x < 32; x++) {
if ((img.getRGB(x, y) >>> 24) > 40) {
minY = Math.min(minY, y);
maxY = Math.max(maxY, y);
minX = Math.min(minX, x);
maxX = Math.max(maxX, x);
}
}
}
if (maxY >= 0 && (minY < 7 || maxY > 24 || minX < 7 || maxX > 24)) {
outside.add(name + " x " + (minX - 8) + ".." + (maxX - 8) + " y " + (minY - 8) + ".." + (maxY - 8));
}
}
assertTrue(outside.isEmpty(), "icons drawn outside their 16x16 box: " + outside);
}
}