diff --git a/docs/manual/06-geometry.md b/docs/manual/06-geometry.md index be079ef..aec6001 100644 --- a/docs/manual/06-geometry.md +++ b/docs/manual/06-geometry.md @@ -243,8 +243,9 @@ levels are built. Start it with **T**, from the toolbar or **Geometry → Tiles ### Tile palette The Inspector shows the **Tile palette**: every tile model of the level with a thumbnail, its -vertex count and how many tiles use it. **Filter (model number)** narrows the list, **Only models -used in the level** hides unused ones. Click a model to make it the current one, or +vertex count and how many tiles use it. **Filter by name or number** narrows the list as you type +(model names as in the Outliner; a number matches whole numbers only, so "12" finds Model 12 but not +Model 120), **Only models used in the level** hides unused ones. Click a model to make it the current one, or **Shift+click** a tile in the viewport to pick it. ### Modes and options diff --git a/docs/manual/07-meshes.md b/docs/manual/07-meshes.md index b204c67..d8d56d8 100644 --- a/docs/manual/07-meshes.md +++ b/docs/manual/07-meshes.md @@ -25,7 +25,9 @@ project**, so the `.llamalevel` file stays complete when you share it (see **File → Import mesh (OBJ, glTF)…** (or **Import mesh…** in the **Imported** tab of the Assets panel) asks for the file and opens the import dialog. The left side is a preview you can orbit by -dragging; the grid squares are 1 cell and the small marker shows the llama's size (0.6 cells). +dragging and zoom with the **mouse wheel**; the grid squares are 1 cell and the small marker shows the +llama's size (0.6 cells). It is framed on the main part of the model, so a few far-off pieces (a stray +vertex, a huge ground plane) do not shrink it to a dot – zoom out to see them. ![The Import mesh dialog](img/geo_import.jpg) diff --git a/docs/manual/09-objects.md b/docs/manual/09-objects.md index 0b56b6f..361aaca 100644 --- a/docs/manual/09-objects.md +++ b/docs/manual/09-objects.md @@ -56,7 +56,7 @@ The **Objects** panel sits in the left dock next to the Outliner – the tab wit * **Click** an entry to make it the current type and switch to the **Place objects** tool (**P**). Then click in the viewport to place it. * **Drag** an entry into the viewport to place one object exactly under the mouse pointer. -* **Right-click** an entry for its menu: **Add to favourites ★** / **Remove from favourites** +* **Right-click** an entry for its menu (this does not pick the type or switch tools): **Add to favourites ★** / **Remove from favourites** (favourites are marked with ★, listed first and remembered between sessions) and, for your own [custom types](22-strats.md), **Edit type…** and **Delete type…**. The same menu (with **Place at view centre**) opens on the types in **Assets → Objects**. diff --git a/examples/testwelt.llamalevel b/examples/testwelt.llamalevel index 2413881..927aa81 100644 Binary files a/examples/testwelt.llamalevel and b/examples/testwelt.llamalevel differ diff --git a/notes/editor-playtest.md b/notes/editor-playtest.md index b52fbdf..58389ed 100644 --- a/notes/editor-playtest.md +++ b/notes/editor-playtest.md @@ -41,7 +41,9 @@ seconds after the game window opens, and **Escape** quits back to the editor. the hit + 0.6 among scene tiles and brush-generated tiles, searching up to 0.75 cells around (rings of 8); material 30 (death floor) is rejected. Needed because render geometry often has no collision (village: the hill edge east of the start renders, but only a death floor at - y 2.25 lies below – the llama fell to a game over). + y 2.25 lies below – the llama fell to a game over). Since 2026-11-07 `FloorFinder` is a thin layer + over the objects tools' `Ground` (`Ground.ofCells(scene, …)` built from the query cell(s) only + `cellBelow`: the cell's own tiles only, death + floors included so a death floor is still reported/used as last resort, no swap models or neighbours). * `-Dllamamod.editor.noLaunch=true` skips the start (tests start the game with apptest). ## Exe mod `boot-to-level` (GAMEPLAY, `modifiesExecutable`) diff --git a/notes/editor-tools-geometry.md b/notes/editor-tools-geometry.md index ad993c4..2e9d6ab 100644 --- a/notes/editor-tools-geometry.md +++ b/notes/editor-tools-geometry.md @@ -55,7 +55,8 @@ a swatch picker, thickness, steps, segments, collision, solid sides, material). ### Tiles tool (T) – original-style levels Modes **Paint / Pick / Erase / Raise-lower / Swap model**, palette of the level's tile models with -thumbnails in the Inspector (filter, "only used models"). Paint puts the palette model into the cell +thumbnails in the Inspector (filter on the readable model name or number with the outliner's +whole-number rule, `ReadableNames.matches`; "only used models"). Paint puts the palette model into the cell under the cursor (on the surface height, or at a fixed height), rotation 0/90/180/270 (`[` `]`), height Page Up/Down. Painting onto a tile at the same height swaps its model instead of stacking (keeps tile indices that strat scripts use). Shift+click = pick (model, height, rotation). New tiles @@ -88,7 +89,9 @@ Invisible collision volumes (Invisible wall/block, colliders) are drawn transluc "Invisible collision volumes"). ### Mesh import (File → Import mesh) -File chooser, then a dialog: rotatable preview (shown as in game; flat shading with a small z-buffer at +File chooser, then a dialog: rotatable preview (mouse wheel zooms; framed on the main mass – +`Preview.focusBounds`: per axis the vertices within 2.5 IQR of the middle half (Tukey fences), the full +bounds unless they are over 1.5× larger; `geo_import_focus.jpg`: house with a triangle 400 cells away, `geo_import_zoomed_out.jpg`) (shown as in game; flat shading with a small z-buffer at twice the panel size, `PreviewRaster` – before 2026-11-07 the triangles were sorted by centroid and hidden faces showed as slivers) with a 1-cell grid and a llama-sized marker, source info, up axis, unit scale and "fit largest side to n cells", turn, mirror, stand on the ground, diff --git a/notes/editor-tools-objects.md b/notes/editor-tools-objects.md index 885ab39..936553d 100644 --- a/notes/editor-tools-objects.md +++ b/notes/editor-tools-objects.md @@ -10,7 +10,7 @@ screenshot tour `ObjectsTour` (test classes). | Part | Content | |---|---| -| **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 | +| **Objects panel** (left dock) | every placeable type of the base with rendered thumbnail, category filter, search (name + schema summary), favourites (right-click – it never selects or switches to the Place tool, nor does the click that closes the menu; ★ 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 one start camera node 2 cells behind (moving keeps door nodes, carries the near nodes and the village fly-by trigger). **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, alligators and spawners get one 4×4 box (and guards event bit 0) on every way of adding an object (`Edits.placeStrat`: Place tool, drag from Objects panel / Assets, double-click) | | **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 | @@ -91,8 +91,9 @@ JumpingBeetleBlue with a 4-node patrol path, CityGuard with two boxes): `GeometryChecks`. Tests: `GuardsTest`, `OriginalExitsTest`, `OriginalStartsTest`. * Snap-to-floor (`Ground`) follows the engine's floor test (FUN_004036d0, used for strats too): only tiles listed in the point's cell; then their swap models; a neighbour tile's polygon only - where the cell has no floor at all (`Hit.source`). `GroundTest` compares it with `FloorFinder` - on three originals. The Place tool drag moves only a player start's nodes within 2.5 cells. + where the cell has no floor at all (`Hit.source`). `cellBelow` is the strict engine test (own cell, + death floors included) that the play-test `FloorFinder` and boot-to-level's crash check use (one + implementation since 2026-11-07). `GroundTest` compares the fallbacks with it on three originals. The Place tool drag moves only a player start's nodes within 2.5 cells. * 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°. diff --git a/notes/editor-ui.md b/notes/editor-ui.md index 387e9fe..cb7f9db 100644 --- a/notes/editor-ui.md +++ b/notes/editor-ui.md @@ -46,6 +46,12 @@ actions (AWT canvases do not get Swing key bindings). (one row while each cell gets ≥ 66 px, else stacked one per row; also the brush rotation spinners); reference buttons (`InspectorPanel.RefButton`) show the longest name that fits ("Waypoint 5 (chain 3)" → "Waypoint 5" → "#5", other names drop a "(…)" suffix, then "…"), full name + id in the tooltip. +* The naming logic lives in `editor.model.ReadableNames` (so editor.io can use it: brush compile warnings + "Floor 2: … – skipped", `Validator` messages, "is still used by" errors); `ui.NodeNames` extends it. + Also: waypoint added / pilot mode status, object check texts ("Coin 3: …", "Waypoint chain at + Waypoint 4 (chain 2)"), missing texture page ("Texture #5 uses page 7, but the level has only 4 texture + pages (0–3)"), Problems tooltip. Numbers in filter queries match whole numbers at both ends ("2" no + longer finds "Floor 12"); the tile palette filter uses the same rule on model names/numbers. * Tool status and check texts use `NodeNames` too (geometry, tile paint, collision, texture paint, lights, brush-outside-grid check, "Placed Model 12 in cell …"); the outliner filter applies the whole-number rule to the old label and the id as well ("tile 3" no longer finds x = 39). diff --git a/notes/img/editor/geo_import_focus.jpg b/notes/img/editor/geo_import_focus.jpg new file mode 100644 index 0000000..0f4aaf5 Binary files /dev/null and b/notes/img/editor/geo_import_focus.jpg differ diff --git a/notes/img/editor/geo_import_zoomed_out.jpg b/notes/img/editor/geo_import_zoomed_out.jpg new file mode 100644 index 0000000..c9bd48b Binary files /dev/null and b/notes/img/editor/geo_import_zoomed_out.jpg differ diff --git a/notes/img/editor/palette_rightclick.jpg b/notes/img/editor/palette_rightclick.jpg new file mode 100644 index 0000000..e04ff69 Binary files /dev/null and b/notes/img/editor/palette_rightclick.jpg differ diff --git a/notes/img/editor/testworld_start_camera.jpg b/notes/img/editor/testworld_start_camera.jpg new file mode 100644 index 0000000..3353cce Binary files /dev/null and b/notes/img/editor/testworld_start_camera.jpg differ diff --git a/notes/img/editor/tile_palette_filter.jpg b/notes/img/editor/tile_palette_filter.jpg new file mode 100644 index 0000000..137100d Binary files /dev/null and b/notes/img/editor/tile_palette_filter.jpg differ diff --git a/notes/levelbau.md b/notes/levelbau.md index d9d6823..8d29ebe 100644 --- a/notes/levelbau.md +++ b/notes/levelbau.md @@ -87,8 +87,8 @@ stairs, platforms register as shadow casters (`solids`). 1.0 = texture colour * `variant`: types with several code entry points (e.g. SmashPot: 2 spawn stubs). * **Waypoints** (`wp`): `'relative'` = move the original's waypoint chain along (offset to the object is kept, y is set to the floor if there is one), `'here'` = all points on - the object, `'none'` = none (−1), or your own list `[(x,z[,y[,deg]]), …]` (extra fields - of the original waypoints are taken over). Chains are renumbered (no references into the + the object, `'none'` = none (−1), or your own list `[(x,z[,y[,deg[,unk2]]]), …]` (other extra + fields of the original waypoints are taken over). Chains are renumbered (no references into the old level). * `player(x,z,rot)` (strat `Llama`), `checkpoint(x,z)` (`CheckPointPost`), `exit(x,z)` (`LevelDoor`), `coins([(x,z),…])`. @@ -131,8 +131,14 @@ stairs, platforms register as shadow casters (`solids`). 1.0 = texture colour * Rot 0° = facing +z, 90° = facing +x. * **Death plane works**: falling into the sea → health gone, respawn at the last checkpoint (or start). -* Player waypoint chain (14 points for the village llama, meaning unclear): `player()` puts the - first one 8 units behind the llama, the rest on the llama, extra fields from the original. +* Player waypoint chain = start camera positions (the game takes the first node within 2.5 cells + of the llama, see `editor.model.cmd.PlayerStart`): `player()` gives the llama ONE node 2 cells + behind it, 0.5 above, unk2 = 0 (default camera distance/height) – like the editor's player start. + Until 2026-11-07 it copied the prototype's 14-node chain (first node 8 behind = out of range, the + rest on the llama): on village 2 bases (node 2 has unk2 ≠ 0) that put the camera into the llama's + head. Waypoint lists take an optional 5th value unk2: `[(x,z,y,deg,unk2)]`. + In game (test world, apptest 2026-11-07): the start camera is behind the llama at the default + distance (`notes/img/editor/testworld_start_camera.jpg`). * **LevelDoor** (exit) – solved, see section "Exit (LevelDoor)" below. * Sizes that work: TRAK 0.6–1.0 MB, up to 1100 models, 5200 tiles, WAD up to 6.9 MB (originals: TRAK ≤ 1 MB, ≤ 5800 tiles, WAD ≤ 6.85 MB). Test world: 628 models, diff --git a/notes/mod-boottolevel.md b/notes/mod-boottolevel.md index c62f5fd..e09dfaf 100644 --- a/notes/mod-boottolevel.md +++ b/notes/mod-boottolevel.md @@ -21,6 +21,11 @@ without "@" applies to whatever level is chosen (old configs, hand-typed values) checked strictly. Tests: `BootToLevelModTest.startOfAnotherLevelIsIgnored`, `parsesStartLevel`. The editor's play-test (`PlaytestRunner.install`) should write the bound form – see the report. +**Start camera along** (`StartOverride.apply`): the llama's chain moves and turns with the start when it +is a levelbuild/editor start camera – one node within 2.5 cells (`PlayerStart`, levelbuild since +2026-11-07) or the older "node 8 behind + nodes on the llama" chain; original chains stay. Test: +`BootToLevelModTest.singleCameraNodeMovesWithTheStart`. + Patches: `jmp` at 0x549C50 (main-menu frame handler) → once: read slots (FUN_0041c0e0), load slot (FUN_0041cc70), set tribe/world/chapter/map 0x584EB4…, music shutdown, state 0x5846E4 = 3, return 1. `quitOnEscape`: `jmp` at 0x549F40 (in-game frame handler): pause bit 0x8 of 0x5FCF08 → diff --git a/src/main/java/com/eliasmueller/llamamod/editor/io/BrushCompiler.java b/src/main/java/com/eliasmueller/llamamod/editor/io/BrushCompiler.java index ee096b6..bd8ce7b 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/io/BrushCompiler.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/io/BrushCompiler.java @@ -6,6 +6,7 @@ import com.eliasmueller.llamamod.editor.model.LevelSettings; import com.eliasmueller.llamamod.editor.model.MeshAsset; import com.eliasmueller.llamamod.editor.model.MeshData; import com.eliasmueller.llamamod.editor.model.RawChunk; +import com.eliasmueller.llamamod.editor.model.ReadableNames; import com.eliasmueller.llamamod.editor.model.Scene; import com.eliasmueller.llamamod.editor.model.TextureEntry; import com.eliasmueller.llamamod.editor.model.TexturePage; @@ -208,7 +209,7 @@ public final class BrushCompiler { && bb[3] + 1 > cx && bb[2] - 1 < cz + 1 && bb[5] + 1 > cz) { - ids.add(b.id()); + ids.add(ReadableNames.name(b)); } } return ids.isEmpty() @@ -260,10 +261,10 @@ public final class BrushCompiler { replay(lb, b, kills, meshQuads.get(b)); postProcess(lb, b, q0, s0); } catch (IllegalArgumentException e) { - warn.add(b.label() + ": " + e.getMessage() + " – skipped"); + warn.add(ReadableNames.name(b) + ": " + e.getMessage() + " – skipped"); } catch (RuntimeException e) { // one broken brush must not take all the others with it (live preview and build) - warn.add(b.label() + ": cannot be built (" + e + ") – skipped"); + warn.add(ReadableNames.name(b) + ": cannot be built (" + e + ") – skipped"); } } byte[] wad; @@ -674,7 +675,7 @@ public final class BrushCompiler { List triangles(Brush b, List warn) { MeshAsset a = (MeshAsset) b.ref("asset"); if (a == null) { - warn.add(b.label() + ": no mesh asset"); + warn.add(ReadableNames.name(b) + ": no mesh asset"); return List.of(); } MeshData m = a.mesh(); @@ -682,7 +683,7 @@ public final class BrushCompiler { try { m = MeshImport.load(s, a); } catch (java.io.IOException | RuntimeException e) { - warn.add(b.label() + ": " + a.file() + " cannot be read: " + e.getMessage()); + warn.add(ReadableNames.name(b) + ": " + a.file() + " cannot be read: " + e.getMessage()); return List.of(); } } diff --git a/src/main/java/com/eliasmueller/llamamod/editor/io/Validator.java b/src/main/java/com/eliasmueller/llamamod/editor/io/Validator.java index 807941d..a289b41 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/io/Validator.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/io/Validator.java @@ -3,6 +3,7 @@ package com.eliasmueller.llamamod.editor.io; import com.eliasmueller.llamamod.editor.model.Brush; import com.eliasmueller.llamamod.editor.model.LevelSettings; import com.eliasmueller.llamamod.editor.model.PathNode; +import com.eliasmueller.llamamod.editor.model.ReadableNames; import com.eliasmueller.llamamod.editor.model.Scene; import com.eliasmueller.llamamod.editor.model.Strat; import com.eliasmueller.llamamod.editor.model.StratCatalog; @@ -26,6 +27,7 @@ public final class Validator { /** Errors; warnings are added to warn. */ public static List validate(Scene s, List warn) { List err = new ArrayList<>(); + ReadableNames names = ReadableNames.of(s); LevelSettings st = s.settings(); int w = st.width(); int h = st.height(); @@ -38,83 +40,86 @@ public final class Validator { int nTex = s.textures().size(); for (TileModel m : s.tileModels()) { if (m.vertexCount() > MAX_VERTS) { - err.add(m.id() + ": " + m.vertexCount() + " vertices (engine limit " + MAX_VERTS + ")"); + err.add(ReadableNames.modelName(m) + ": " + m.vertexCount() + " vertices (engine limit " + MAX_VERTS + + ")"); } if (m.vertices().length % TileModel.VERTEX_STRIDE != 0 || m.faces().length % TileModel.FACE_STRIDE != 0 || m.planes().length != m.faceCount() * TileModel.PLANE_STRIDE) { - err.add(m.id() + ": array lengths do not match"); + err.add(ReadableNames.modelName(m) + ": array lengths do not match"); continue; } for (int g = 0; g < 3; g++) { if (m.collision(g).length % TileModel.COLLISION_SIZE != 0) { - err.add(m.id() + ": collision group " + g + " is not a multiple of 32 bytes"); + err.add(ReadableNames.modelName(m) + ": collision group " + g + " is not a multiple of 32 bytes"); } } int nv = m.vertexCount(); for (int f = 0; f < m.faceCount(); f++) { for (int k = 0; k < 3; k++) { if (m.faceVertex(f, k) >= nv) { - err.add(m.id() + " face " + f + ": vertex index out of range"); + err.add(ReadableNames.modelName(m) + " face " + f + ": vertex index out of range"); break; } } if (m.faceTexture(f) >= nTex) { - warn.add(m.id() + " face " + f + ": texture " + m.faceTexture(f) + " beyond the table (" + nTex - + ")"); + warn.add(ReadableNames.modelName(m) + " face " + f + ": texture " + m.faceTexture(f) + + " beyond the table (" + nTex + ")"); } } } boolean alts = (st.wfpc() & MapChunk.WF_ALTS) != 0; for (Tile t : s.tiles()) { if (t.model() == null || !s.contains(t.model())) { - err.add(t.id() + ": model missing"); + err.add(names.nameOf(t) + ": model missing"); continue; } int cx = t.cellX(); int cz = t.cellZ(); if (cx < 0 || cx >= w || cz < 0 || cz >= h) { - err.add(t.id() + ": outside the " + w + "×" + h + " grid (cell " + cx + "," + cz + ")"); + err.add(names.nameOf(t) + ": outside the " + w + "×" + h + " grid (cell " + cx + "," + cz + ")"); } if (t.hasSwap()) { if (!alts) { - err.add(t.id() + ": tile swaps need WFPC flag 0x10000, which this base level does not have"); + err.add(names.nameOf(t) + + ": tile swaps need WFPC flag 0x10000, which this base level does not have"); } else if (t.swapAltModel() == null || !s.contains(t.swapAltModel())) { - err.add(t.id() + ": swap without alternative model"); + err.add(names.nameOf(t) + ": swap without alternative model"); } } } for (Strat x : s.strats()) { if (x.waypoint() != null && !s.contains(x.waypoint())) { - err.add(x.id() + ": waypoint is not part of the level"); + err.add(names.nameOf(x) + ": waypoint is not part of the level"); } StratCatalog.Type t = s.catalog().type(x.type()); if (x.type().isEmpty()) { - warn.add(x.id() + ": unknown strat code 0x" + Integer.toHexString(x.code())); + warn.add(names.nameOf(x) + ": unknown object code 0x" + Integer.toHexString(x.code())); } else if (t != null && t.variants().stream().noneMatch(v -> v.nParams() == x.params().length)) { - warn.add(x.id() + " (" + x.type() + "): " + x.params().length + " parameters, the type uses " + warn.add(names.nameOf(x) + ": " + x.params().length + " parameters, the type uses " + t.variants().getFirst().nParams()); } double gx = x.gx(); double gz = x.gz(); if (gx < 0 || gx >= w || gz < 0 || gz >= h) { - warn.add(x.id() + " (" + x.type() + ") is outside the grid"); + warn.add(names.nameOf(x) + " is outside the grid"); } } for (Brush b : s.brushes()) { String bad = b.enabled() ? BrushCompiler.problem(b, w, h, 8) : null; if (bad != null) { - err.add(b.id() + " (" + b.label() + "): " + bad); + err.add(names.nameOf(b) + ": " + bad); } } for (PathNode n : s.pathNodes()) { if (n.next() != null && !s.contains(n.next())) { - err.add(n.id() + ": next node is not part of the level"); + err.add(names.nameOf(n) + ": next node is not part of the level"); } } - for (TexturePage p : s.pages()) { + for (int i = 0; i < s.pages().size(); i++) { + TexturePage p = s.pages().get(i); if (p.pixels().length != p.width() * p.height()) { - err.add(p.id() + ": pixel count does not match " + p.width() + "×" + p.height()); + err.add("Texture page " + i + ": pixel count does not match " + p.width() + "×" + p.height()); } } if (s.pages().size() > 127) { diff --git a/src/main/java/com/eliasmueller/llamamod/editor/model/ReadableNames.java b/src/main/java/com/eliasmueller/llamamod/editor/model/ReadableNames.java new file mode 100644 index 0000000..cc24c7f --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/model/ReadableNames.java @@ -0,0 +1,198 @@ +package com.eliasmueller.llamamod.editor.model; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; + +/** + * Readable names for scene nodes ("Floor 2", "Coin 3", "Waypoint 4 (chain 2)", "Model 12") as used by the + * outliner, inspector header, status bar, asset browser and in build warnings. Numbers follow the level + * order (they shift when an earlier item of the same kind is deleted). One instance indexes a scene lazily; + * create a new one after edits. The UI uses it as {@code editor.ui.NodeNames}. + */ +public class ReadableNames { + private final Scene scene; + private Map numbered; + private List starts; + private Map waypoints; + + protected ReadableNames(Scene scene) { + this.scene = scene; + } + + public static ReadableNames of(Scene scene) { + return new ReadableNames(scene); + } + + /** Readable name of a node in its own scene (falls back to {@link Node#label()} when detached). */ + public static String name(Node n) { + return n == null ? "" : n.scene() == null ? fallback(n) : of(n.scene()).nameOf(n); + } + + /** Readable standalone name of a node of this scene. */ + public String nameOf(Node n) { + return switch (n) { + case Strat st -> objectName(st); + case Brush b -> numbered(b); + case Light l -> numbered(l); + case PathNode p -> waypointName(p, true); + case Tile t -> "Tile " + t.cellX() + ", " + t.cellZ() + + (t.model() == null ? "" : " · " + modelName(t.model())); + default -> fallback(n); + }; + } + + /** Object name: its type, numbered when the level has several of that type ("Coin 3"). */ + public String objectName(Strat st) { + return numbered(st); + } + + /** "Waypoint 4" (position in its chain), with "(chain 2)" when {@code withChain}. */ + public String waypointName(PathNode p, boolean withChain) { + int[] v = waypointIndex(p); + return v == null ? "Waypoint" : "Waypoint " + v[0] + (withChain ? " (chain " + v[1] + ")" : ""); + } + + /** Chain number (1-based, outliner order) of a waypoint, or 0. */ + public int chainNumber(PathNode p) { + int[] v = waypointIndex(p); + return v == null ? 0 : v[1]; + } + + private int[] waypointIndex(PathNode p) { + if (waypoints == null) { + waypoints = new IdentityHashMap<>(); + int nr = 0; + for (PathNode start : chainStarts()) { + nr++; + List ch = scene.chain(start); + for (int i = 0; i < ch.size(); i++) { + waypoints.putIfAbsent(ch.get(i), new int[] {i + 1, nr}); + } + } + } + return waypoints.get(p); + } + + /** + * First waypoint of every chain in level order (waypoints without a predecessor, then one node per loop + * that has no start). The outliner numbers chains in this order. + */ + public List chainStarts() { + if (starts != null) { + return starts; + } + Set hasPrev = Collections.newSetFromMap(new IdentityHashMap<>()); + for (PathNode p : scene.pathNodes()) { + if (p.next() != null) { + hasPrev.add(p.next()); + } + } + starts = new ArrayList<>(); + for (PathNode p : scene.pathNodes()) { + if (!hasPrev.contains(p)) { + starts.add(p); + } + } + Set reached = Collections.newSetFromMap(new IdentityHashMap<>()); + for (PathNode st : starts) { + reached.addAll(scene.chain(st)); + } + for (PathNode p : scene.pathNodes()) { + if (!reached.contains(p)) { + starts.add(p); + reached.addAll(scene.chain(p)); + } + } + return starts; + } + + private String numbered(Node n) { + if (numbered == null) { + numbered = new IdentityHashMap<>(); + number(scene.strats(), ReadableNames::typeName); + number(scene.brushes(), ReadableNames::brushName); + int nr = 0; + for (Light l : scene.lights()) { + numbered.put(l, "Light " + ++nr); + } + } + String s = numbered.get(n); + return s != null ? s : fallback(n); + } + + private void number(List nodes, java.util.function.Function base) { + Map count = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + for (T n : nodes) { + count.merge(base.apply(n), 1, Integer::sum); + } + Map nr = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + for (T n : nodes) { + String b = base.apply(n); + numbered.put(n, count.get(b) > 1 ? b + " " + nr.merge(b, 1, Integer::sum) : b); + } + } + + /** Object type or "?" (the outliner's type group key). */ + public static String typeName(Strat st) { + return st.type().isEmpty() ? "?" : st.type(); + } + + public static String brushName(Brush b) { + return b.name().isEmpty() ? b.brushKind().label() : b.name(); + } + + /** Tile model name, or "Model 12" for unnamed models. */ + public static String modelName(TileModel m) { + return m.name().isEmpty() ? "Model " + number(m.id()) : m.name(); + } + + /** The number part of an id ("model:12" → "12", "page:n3" → "n3"). */ + public static String number(String id) { + return id.substring(id.indexOf(':') + 1); + } + + private static String fallback(Node n) { + return switch (n) { + case LevelSettings s -> "Level settings"; + case Regions r -> "Visibility regions"; + case EnvZone z -> z.isDefault() ? "Default zone (fog, sky, music)" : "Zone " + z.cellX() + ", " + z.cellZ(); + case TileModel m -> modelName(m); + case TexturePage p -> p.name().isEmpty() ? "Page " + number(p.id()) : p.name(); + case MeshAsset m -> m.name().isEmpty() ? m.file() : m.name(); + case Strat st -> typeName(st); + case Brush b -> brushName(b); + case PathNode p -> "Waypoint"; + case Light l -> "Light"; + case Tile t -> "Tile " + t.cellX() + ", " + t.cellZ(); + default -> n.label(); + }; + } + + /** + * Case-insensitive substring match of a readable name; numbers in the query match whole numbers only + * ("floor 1" finds "Floor 1", not "Floor 12"; "2" does not find "Floor 12"). + */ + public static boolean matches(String name, String q) { + String n = name.toLowerCase(Locale.ROOT); + q = q.toLowerCase(Locale.ROOT); + if (q.isEmpty()) { + return true; + } + boolean numEnd = Character.isDigit(q.charAt(q.length() - 1)); + boolean numStart = Character.isDigit(q.charAt(0)); + for (int i = n.indexOf(q); i >= 0; i = n.indexOf(q, i + 1)) { + int end = i + q.length(); + if ((!numEnd || end >= n.length() || !Character.isDigit(n.charAt(end))) + && (!numStart || i == 0 || !Character.isDigit(n.charAt(i - 1)))) { + return true; + } + } + return false; + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/model/cmd/Edits.java b/src/main/java/com/eliasmueller/llamamod/editor/model/cmd/Edits.java index 2deebe8..a8ffb7f 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/model/cmd/Edits.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/model/cmd/Edits.java @@ -8,6 +8,7 @@ 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.Prop; +import com.eliasmueller.llamamod.editor.model.ReadableNames; import com.eliasmueller.llamamod.editor.model.Scene; import com.eliasmueller.llamamod.editor.model.Strat; import com.eliasmueller.llamamod.editor.model.StratCatalog; @@ -218,7 +219,7 @@ public final class Edits { for (Map.Entry r : s.referencesTo(n)) { if (!r.getValue().nullable() && !nodes.contains(r.getKey())) { throw new IllegalStateException( - n.id() + " is still used by " + r.getKey().id()); + ReadableNames.name(n) + " is still used by " + ReadableNames.name(r.getKey())); } } } diff --git a/src/main/java/com/eliasmueller/llamamod/editor/model/cmd/RemoveNode.java b/src/main/java/com/eliasmueller/llamamod/editor/model/cmd/RemoveNode.java index b0b9a14..42cc93f 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/model/cmd/RemoveNode.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/model/cmd/RemoveNode.java @@ -2,6 +2,7 @@ package com.eliasmueller.llamamod.editor.model.cmd; import com.eliasmueller.llamamod.editor.model.Node; import com.eliasmueller.llamamod.editor.model.Prop; +import com.eliasmueller.llamamod.editor.model.ReadableNames; import com.eliasmueller.llamamod.editor.model.Scene; import java.util.ArrayList; import java.util.List; @@ -21,8 +22,8 @@ public final class RemoveNode implements Command { this.node = node; for (Map.Entry r : s.referencesTo(node)) { if (!r.getValue().nullable()) { - throw new IllegalStateException(node.id() + " is still used by " - + r.getKey().id() + " (" + r.getValue().label() + ")"); + throw new IllegalStateException(ReadableNames.name(node) + " is still used by " + + ReadableNames.name(r.getKey()) + " (" + r.getValue().label() + ")"); } unlink.add(SetProp.of(r.getKey(), r.getValue(), null, null)); } diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/MeshImportDialog.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/MeshImportDialog.java index 20ff4bd..75c0886 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/MeshImportDialog.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/MeshImportDialog.java @@ -131,7 +131,8 @@ final class MeshImportDialog extends JDialog { JPanel left = new JPanel(new BorderLayout()); left.add(preview, BorderLayout.CENTER); - JLabel hint = new JLabel("Drag to orbit · grid = 1 cell · the llama is 0.6 cells tall", JLabel.CENTER); + JLabel hint = + new JLabel("Drag to orbit, wheel to zoom · grid = 1 cell · the llama is 0.6 cells tall", JLabel.CENTER); hint.setFont(hint.getFont().deriveFont(11f)); left.add(hint, BorderLayout.SOUTH); left.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 4)); @@ -286,9 +287,14 @@ final class MeshImportDialog extends JDialog { // ------------------------------------------------------------------ preview - /** Software preview: flat-shaded triangles with a z-buffer ({@link PreviewRaster}), orbit by dragging. */ + /** + * Software preview: flat-shaded triangles with a z-buffer ({@link PreviewRaster}), orbit by dragging, + * zoom with the mouse wheel. Framed on the main mass of the mesh ({@link #focusBounds}). + */ static final class Preview extends JPanel { private MeshData mesh; + private double[] focus; + private double zoom = 1; /** Starts looking down at the model's front (+z, as Blender's front view shows it). */ private double yaw = Math.toRadians(215); @@ -319,9 +325,17 @@ final class MeshImportDialog extends JDialog { }; addMouseListener(m); addMouseMotionListener(m); + addMouseWheelListener(e -> { + zoom = Math.max(0.05, Math.min(50, zoom * Math.pow(1.15, -e.getPreciseWheelRotation()))); + repaint(); + }); } void set(MeshData m, double turnDeg, boolean mirror) { + if (m != mesh) { + focus = m == null ? null : focusBounds(m.positions()); + zoom = 1; + } mesh = m; turn = Math.toRadians(turnDeg); this.mirror = mirror; @@ -357,10 +371,10 @@ final class MeshImportDialog extends JDialog { } Graphics2D g = (Graphics2D) g0; g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); - double[] b = mesh.bounds(); + double[] b = focus; double cx = (b[0] + b[3]) / 2, cy = (b[1] + b[4]) / 2, cz = (b[2] + b[5]) / 2; double size = Math.max(1e-6, Math.max(b[3] - b[0], Math.max(b[4] - b[1], b[5] - b[2]))); - double sc = Math.min(getWidth(), getHeight()) * 0.62 / size; + double sc = Math.min(getWidth(), getHeight()) * 0.62 / size * zoom; double ct = Math.cos(turn), st = Math.sin(turn); double cyw = Math.cos(yaw), syw = Math.sin(yaw), cp = Math.cos(pitch), sp = Math.sin(pitch); int w2 = getWidth() / 2, h2 = getHeight() / 2; @@ -428,6 +442,44 @@ final class MeshImportDialog extends JDialog { line(g, b[3] - cx + 0.5, gy, 0, b[3] - cx + 0.5, gy + 0.6, 0, ct, st, cyw, syw, cp, sp, sc, w2, h2, true); } + /** + * Bounds {x0, y0, z0, x1, y1, z1} of the mesh's main mass: per axis the vertices within 2.5 + * interquartile ranges of the middle half (Tukey fences); the full bounds when they are not much + * larger (far-off pieces such as a stray triangle or a huge ground plane would otherwise shrink the + * model to a dot). + */ + static double[] focusBounds(double[] pos) { + int n = pos.length / 3; + double[] full = new double[6]; + double[] robust = new double[6]; + if (n == 0) { + return full; + } + double[] v = new double[n]; + boolean far = false; + for (int axis = 0; axis < 3; axis++) { + for (int i = 0; i < n; i++) { + v[i] = pos[3 * i + axis]; + } + java.util.Arrays.sort(v); + full[axis] = v[0]; + full[axis + 3] = v[n - 1]; + double q1 = v[(n - 1) / 4], q3 = v[(3 * (n - 1) + 3) / 4]; + double fence = 2.5 * (q3 - q1); + int lo = 0, hi = n - 1; + while (v[lo] < q1 - fence) { + lo++; + } + while (v[hi] > q3 + fence) { + hi--; + } + robust[axis] = v[lo]; + robust[axis + 3] = v[hi]; + far |= v[n - 1] - v[0] > 1.5 * (robust[axis + 3] - robust[axis]) + 1e-9; + } + return far ? robust : full; + } + private static void line( Graphics2D g, double x0, diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/TilePaintTool.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/TilePaintTool.java index 9a0c076..97f313d 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/TilePaintTool.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/TilePaintTool.java @@ -2,6 +2,7 @@ package com.eliasmueller.llamamod.editor.tools.geometry; import com.eliasmueller.llamamod.editor.model.Node; import com.eliasmueller.llamamod.editor.model.NodeKind; +import com.eliasmueller.llamamod.editor.model.ReadableNames; import com.eliasmueller.llamamod.editor.model.Scene; import com.eliasmueller.llamamod.editor.model.Tile; import com.eliasmueller.llamamod.editor.model.TileModel; @@ -509,7 +510,7 @@ final class TilePaintTool implements Tool { JPanel grid = new JPanel(new GridLayout(0, 4, 3, 3)); grid.setBorder(BorderFactory.createEmptyBorder(3, 3, 3, 3)); JTextField filter = new JTextField(); - filter.putClientProperty("JTextField.placeholderText", "Filter (model number)"); + filter.putClientProperty("JTextField.placeholderText", "Filter by name or number"); JCheckBox used = new JCheckBox("Only models used in the level", true); used.setFocusable(false); Runnable fill = () -> { @@ -517,7 +518,7 @@ final class TilePaintTool implements Tool { String f = filter.getText().trim(); int n = 0; for (TileModel m : s.tileModels()) { - if (used.isSelected() && !uses.containsKey(m) || !f.isEmpty() && !m.id().contains(f)) { + if (used.isSelected() && !uses.containsKey(m) || !paletteMatch(m, f)) { continue; } if (n++ >= 240) { @@ -544,7 +545,13 @@ final class TilePaintTool implements Tool { fill.run(); used.addActionListener(e -> fill.run()); filter.addActionListener(e -> fill.run()); - JScrollPane sp = new JScrollPane(grid); + javax.swing.Timer refill = new javax.swing.Timer(200, e -> fill.run()); + refill.setRepeats(false); + com.eliasmueller.llamamod.util.SwingListeners.onTextChange(filter, refill::restart); + // grid at the top: a few filter results keep their size instead of stretching to the viewport + JPanel gridTop = new JPanel(new BorderLayout()); + gridTop.add(grid, BorderLayout.NORTH); + JScrollPane sp = new JScrollPane(gridTop); sp.setPreferredSize(new Dimension(260, 260)); sp.getVerticalScrollBar().setUnitIncrement(24); sp.setAlignmentX(0); @@ -565,4 +572,15 @@ final class TilePaintTool implements Tool { root.setBackground(new Color(0, 0, 0, 0)); return root; } + + /** + * Palette filter: the readable model name ("Model 12" or its own name) or the model number, with the + * outliner's whole-number rule ("12" finds Model 12, not Model 120 or 112). + */ + static boolean paletteMatch(TileModel m, String q) { + q = q.trim(); + return q.isEmpty() + || ReadableNames.matches(NodeNames.modelName(m), q) + || ReadableNames.matches(NodeNames.number(m.id()), q); + } } diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/objects/CameraView.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/objects/CameraView.java index f202e40..89a909f 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/tools/objects/CameraView.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/objects/CameraView.java @@ -9,6 +9,7 @@ import com.eliasmueller.llamamod.editor.render.Camera; import com.eliasmueller.llamamod.editor.render.ViewportListener; import com.eliasmueller.llamamod.editor.tools.objects.ParamSchema.Role; import com.eliasmueller.llamamod.editor.ui.EditorContext; +import com.eliasmueller.llamamod.editor.ui.NodeNames; import com.eliasmueller.llamamod.editor.ui.ToolEvent; import java.util.LinkedHashMap; import java.util.List; @@ -88,6 +89,7 @@ final class CameraView { static final class Pilot implements ViewportListener { private final EditorContext ctx; private Node target; + private String targetName; private Object mergeKey; private boolean writing; private boolean listening; @@ -127,7 +129,8 @@ final class CameraView { } }); } - ctx.status("Piloting " + target.id() + ": fly with the right mouse button + WASD, the camera follows." + targetName = NodeNames.name(target); + ctx.status("Piloting " + targetName + ": fly with the right mouse button + WASD, the camera follows." + " Ctrl+Shift+L ends"); } @@ -136,7 +139,7 @@ final class CameraView { return; } ctx.viewport().removeViewportListener(this); - ctx.status("Pilot mode ended for " + target.id()); + ctx.status("Pilot mode ended for " + targetName); target = null; mergeKey = null; } diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/objects/Ground.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/objects/Ground.java index 9972601..9880e58 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/tools/objects/Ground.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/objects/Ground.java @@ -11,7 +11,8 @@ 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. + * brushes), in game space (x east, y up, z = grid row). The objects tools cache one per scene revision; + * the play-test start check ({@code playtest.FloorFinder}) uses {@link #cellBelow}. * *

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 @@ -55,12 +56,35 @@ public final class Ground { } } + /** Collision material of death floors (kill planes): never a floor to snap onto. */ + public static final int DEATH = 30; + private final Map> cells = new HashMap<>(); private final Map> swaps = new HashMap<>(); private final Map> overlaps = new HashMap<>(); private Ground() {} + /** + * Floors of the scene's tiles and brushes listed in the grid cells x0..x1 / z0..z1 only (brushes that + * do not compile have none): enough for {@link #cellBelow} there, cheap for a single query. + */ + public static Ground ofCells(Scene s, int x0, int z0, int x1, int z1) { + List tiles = new ArrayList<>(s.tiles()); + try { + tiles.addAll(BrushCompiler.compile(s, new ArrayList<>()).tiles()); + } catch (RuntimeException e) { + // brushes that do not compile have no floor + } + Ground g = new Ground(); + for (Tile t : tiles) { + if (t.cellX() >= x0 && t.cellX() <= x1 && t.cellZ() >= z0 && t.cellZ() <= z1) { + g.addTile(t); + } + } + return g; + } + public static Ground build(Scene s, BrushCompiler.Output brushTiles) { Ground g = new Ground(); for (Tile t : s.tiles()) { @@ -161,18 +185,30 @@ public final class Ground { return h; } + /** + * The engine's own floor test: highest floor of the tiles listed in the cell of (x, z), death floors + * included, not more than {@code above} over {@code probeY}; no swap models, no neighbours. Null if none. + */ + public Hit cellBelow(double x, double z, double probeY, double above) { + return best(cells.get(key((int) Math.floor(x), (int) Math.floor(z))), Source.CELL, x, z, probeY, above, true); + } + /** 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 l, Source src, double x, double z, double probeY, double above) { + return best(l, src, x, z, probeY, above, false); + } + + private static Hit best(List l, Source src, double x, double z, double probeY, double above, boolean death) { if (l == null) { return null; } Hit best = null; for (Floor f : l) { - if (f.material == 30 || !f.contains(x, z)) { + if (!death && f.material == DEATH || !f.contains(x, z)) { continue; } double y = f.y(x, z); diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/objects/ObjectChecks.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/objects/ObjectChecks.java index 5a02396..5a5296f 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/tools/objects/ObjectChecks.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/objects/ObjectChecks.java @@ -10,6 +10,7 @@ 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; +import com.eliasmueller.llamamod.editor.ui.NodeNames; import com.eliasmueller.llamamod.editor.ui.Problem; import com.eliasmueller.llamamod.save.Worlds; import java.util.ArrayList; @@ -28,10 +29,12 @@ final class ObjectChecks implements LevelCheck { @Override public void check(Scene s, List out) { + NodeNames names = NodeNames.of(s); PathNode broken = ChainOps.firstBroken(s); if (broken != null) { out.add(Problem.error( - "Waypoint chain at " + broken.id() + " is not stored in one piece (the game links node i to" + "Waypoint chain at " + names.waypointName(broken, true) + + " is not stored in one piece (the game links node i to" + " i+1): run Tools → Repair waypoint chains", broken)); } @@ -45,7 +48,7 @@ final class ObjectChecks implements LevelCheck { String cam = startCamera(s, st); if (cam != null) { out.add(Problem.warning( - label(st) + ": " + cam + " (Place tool → Player start sets a camera node)", st)); + names.objectName(st) + ": " + cam + " (Place tool → Player start sets a camera node)", st)); } } TypeSchema sc = Schemas.of(type); @@ -56,13 +59,14 @@ final class ObjectChecks implements LevelCheck { if (sc != null) { if (n < sc.paramCount()) { out.add(Problem.warning( - label(st) + " has " + n + " parameters, " + type + " reads " + sc.paramCount(), st)); + names.objectName(st) + " has " + n + " parameters, " + type + " reads " + sc.paramCount(), + st)); } for (ParamDef d : sc.params()) { if (d.index() < n) { String e = d.validate(st.params()[d.index()]); if (e != null) { - out.add(Problem.warning(label(st) + ": " + e, st)); + out.add(Problem.warning(names.objectName(st) + ": " + e, st)); } } } @@ -72,16 +76,17 @@ final class ObjectChecks implements LevelCheck { : 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 + names.objectName(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 + names.objectName(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.startWarning(s, st) != null) { - out.add(Problem.warning(label(st) + ": " + Guards.startWarning(s, st), st)); + out.add(Problem.warning(names.objectName(st) + ": " + Guards.startWarning(s, st), st)); } if (gone == null && Guards.eventBit(st) > 0) { int bit = Guards.eventBit(st); @@ -89,7 +94,7 @@ final class ObjectChecks implements LevelCheck { setBits = EventBits.setBits(s); } out.add(Problem.info( - label(st) + " stays hidden until event bit " + bit + " is set by another object" + names.objectName(st) + " stays hidden until event bit " + bit + " is set by another object" + (setBits.contains(bit) ? "" : ", but no object in this level sets bit " + bit + " (" @@ -101,28 +106,29 @@ final class ObjectChecks implements LevelCheck { && sc.waypoints().required() && chain.size() < sc.waypoints().min()) { out.add(Problem.warning( - label(st) + " needs " + sc.waypoints().min() + " waypoint(s) (" + names.objectName(st) + " needs " + sc.waypoints().min() + " waypoint(s) (" + sc.waypoints().role().label + "), has " + chain.size(), st)); } if (sc.waypoints().max() > 0 && chain.size() > sc.waypoints().max()) { out.add(Problem.info( - label(st) + " uses only " + sc.waypoints().max() + " waypoint(s), the chain has " - + chain.size(), + names.objectName(st) + " uses only " + + sc.waypoints().max() + " waypoint(s), the chain has " + chain.size(), st)); } 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)); + names.objectName(st) + ": box nodes come in pairs, the chain has " + chain.size(), st)); } } else if (stat != null) { if (n != stat.params()) { out.add(Problem.info( - label(st) + " has " + n + " parameters, most originals have " + stat.params(), st)); + names.objectName(st) + " has " + n + " parameters, most originals have " + stat.params(), + st)); } if (stat.alwaysWaypoints() && chain.isEmpty()) { out.add(Problem.warning( - label(st) + " has no waypoints; all " + stat.instances() + " original " + type + names.objectName(st) + " has no waypoints; all " + stat.instances() + " original " + type + " objects have a chain (" + stat.minChain() + "–" + stat.maxChain() + " nodes)", st)); } @@ -138,8 +144,8 @@ final class ObjectChecks implements LevelCheck { int cx = (int) Math.floor(st.gx()), cz = (int) Math.floor(st.gz()); 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", + names.objectName(st) + " stands in cell " + cx + ", " + cz + + " without exit floor (collision material" + " 16): the door never fires", st)); } int[] p = st.params(); @@ -148,12 +154,15 @@ final class ObjectChecks implements LevelCheck { boolean fin = t == 7 && w == 7 && c == 7; if (fin && s.settings().isNewLevel()) { out.add(Problem.warning( - label(st) + " targets (7,7,7) \"world finished\": in TestLevel (world 9) this unlocks" + names.objectName(st) + + " targets (7,7,7) \"world finished\": in TestLevel (world 9) this unlocks" + " world 10 and crashes the map – pick a chapter instead", st)); } else if (!fin && (w < 1 || w > Worlds.COUNT || c < 1 || c > Worlds.chapters(w))) { out.add(Problem.warning( - label(st) + " targets world " + w + " chapter " + c + ", which does not exist", st)); + names.objectName(st) + " targets world " + w + " chapter " + c + + ", which does not exist", + st)); } } } @@ -162,7 +171,8 @@ final class ObjectChecks implements LevelCheck { double d = Math.hypot(r.gx() - st.gx(), r.gz() - st.gz()); if (d > 3) { out.add(Problem.info( - label(st) + ": respawn node is " + String.format("%.1f", d) + " cells away", st)); + names.objectName(st) + ": respawn node is " + String.format("%.1f", d) + " cells away", + st)); } } } @@ -208,8 +218,4 @@ final class ObjectChecks implements LevelCheck { } return null; } - - static String label(Strat s) { - return s.type() + " (" + s.id() + ")"; - } } diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/objects/ObjectPalette.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/objects/ObjectPalette.java index b15cc69..795a157 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/tools/objects/ObjectPalette.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/objects/ObjectPalette.java @@ -15,9 +15,9 @@ import java.awt.BorderLayout; import java.awt.Color; import java.awt.Component; import java.awt.Dimension; +import java.awt.EventQueue; import java.awt.Font; import java.awt.GridLayout; -import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; import java.awt.image.BufferedImage; import java.util.ArrayDeque; @@ -45,8 +45,11 @@ import javax.swing.JTextField; import javax.swing.JToggleButton; import javax.swing.ListCellRenderer; import javax.swing.ListSelectionModel; +import javax.swing.SwingUtilities; import javax.swing.Timer; import javax.swing.UIManager; +import javax.swing.event.PopupMenuEvent; +import javax.swing.event.PopupMenuListener; /** * Object palette: every placeable type of the level's base with thumbnail, category filter, @@ -60,7 +63,39 @@ final class ObjectPalette extends JPanel { private final EditorContext ctx; private final DefaultListModel model = new DefaultListModel<>(); - private final JList list = new JList<>(model); + /** {@code when} of the mouse press that closed the context menu (it must not select an entry). */ + private long dismissPress = -1; + + private boolean swallow; + + /** + * Only left presses reach the list's UI: a right-click (and the click that closes the context menu) + * must not select an entry, which would switch to the Place tool. + */ + private final JList list = new JList<>(model) { + @Override + protected void processMouseEvent(MouseEvent e) { + if (e.getID() == MouseEvent.MOUSE_PRESSED) { + swallow = e.getWhen() == dismissPress || !SwingUtilities.isLeftMouseButton(e); + } + if (e.isPopupTrigger()) { + menu(e); + } + if (swallow && e.getID() != MouseEvent.MOUSE_ENTERED && e.getID() != MouseEvent.MOUSE_EXITED) { + e.consume(); + return; + } + super.processMouseEvent(e); + } + + @Override + protected void processMouseMotionEvent(MouseEvent e) { + if (!swallow || e.getID() == MouseEvent.MOUSE_MOVED) { + super.processMouseMotionEvent(e); + } + } + }; + private final JTextField search = new JTextField(); private final JComboBox category = new JComboBox<>(); private final JToggleButton favOnly = new JToggleButton("★"); @@ -144,38 +179,6 @@ final class ObjectPalette extends JPanel { choose(en.type); } }); - list.addMouseListener(new MouseAdapter() { - @Override - public void mousePressed(MouseEvent e) { - 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); @@ -205,6 +208,41 @@ final class ObjectPalette extends JPanel { setPreferredSize(new Dimension(260, 400)); } + private void menu(MouseEvent e) { + int i = list.locationToIndex(e.getPoint()); + if (i < 0 || !list.getCellBounds(i, i).contains(e.getPoint())) { + 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.addPopupMenuListener(new PopupMenuListener() { + @Override + public void popupMenuWillBecomeVisible(PopupMenuEvent ev) {} + + @Override + public void popupMenuWillBecomeInvisible(PopupMenuEvent ev) {} + + @Override + public void popupMenuCanceled(PopupMenuEvent ev) { + if (EventQueue.getCurrentEvent() instanceof MouseEvent me && me.getID() == MouseEvent.MOUSE_PRESSED) { + dismissPress = me.getWhen(); + } + } + }); + m.show(list, e.getX(), e.getY()); + } + private JButton setup(String label, String icon, PlaceTool.Mode mode, String tip) { JButton b = new JButton(label, Icons.get(icon, 14)); b.setToolTipText(tip); diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/objects/PathTool.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/objects/PathTool.java index e1ddc57..07f1f22 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/tools/objects/PathTool.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/objects/PathTool.java @@ -10,6 +10,7 @@ import com.eliasmueller.llamamod.editor.model.cmd.Edits; import com.eliasmueller.llamamod.editor.render.Legend; import com.eliasmueller.llamamod.editor.render.Overlay; import com.eliasmueller.llamamod.editor.ui.EditorContext; +import com.eliasmueller.llamamod.editor.ui.NodeNames; import com.eliasmueller.llamamod.editor.ui.Tool; import com.eliasmueller.llamamod.editor.ui.ToolEvent; import java.awt.Cursor; @@ -196,7 +197,7 @@ public final class PathTool implements Tool { } ctx.execute(new Batch("Add waypoint", cmds)); ctx.selection().set(fresh, this); - ctx.status("Waypoint " + fresh.id() + " added (" + (chain.size() + 1) + " nodes)"); + ctx.status(NodeNames.of(s).waypointName(fresh, true) + " added (" + (chain.size() + 1) + " nodes)"); return true; } diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/playtest/FloorFinder.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/playtest/FloorFinder.java index 00205a8..ee96a01 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/tools/playtest/FloorFinder.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/playtest/FloorFinder.java @@ -1,22 +1,19 @@ package com.eliasmueller.llamamod.editor.tools.playtest; -import com.eliasmueller.llamamod.editor.io.BrushCompiler; import com.eliasmueller.llamamod.editor.model.Regions; import com.eliasmueller.llamamod.editor.model.Scene; -import com.eliasmueller.llamamod.editor.model.Tile; -import com.eliasmueller.llamamod.editor.model.TileModel; -import java.util.ArrayList; -import java.util.List; +import com.eliasmueller.llamamod.editor.tools.objects.Ground; /** * Collision floor under a point: the player start must stand on collision, not just on visible - * geometry (trees, roofs and decorations often have none, and the llama would fall through). + * geometry (trees, roofs and decorations often have none, and the llama would fall through). Uses the + * objects tools' {@link Ground} with the engine's strict cell rule. */ public final class FloorFinder { /** Floors at most this far above the probe point still count (the probe may be on a slope). */ static final double ABOVE = 0.6; - static final int DEATH = 30; + static final int DEATH = Ground.DEATH; /** tileX/tileZ = grid cell of the tile that owns the floor polygon. */ public record Floor(double x, double y, double z, int material, int tileX, int tileZ) {} @@ -28,13 +25,14 @@ public final class FloorFinder { * if there is nothing else; null if no floor at all. */ public static Floor near(Scene s, double gx, double gy, double gz) { - List tiles = tiles(s); + Ground g = Ground.ofCells(s, (int) Math.floor(gx - 1), (int) Math.floor(gz - 1), (int) Math.floor(gx + 1), (int) + Math.floor(gz + 1)); Floor death = null; for (double r = 0; r <= 0.76; r += 0.25) { int n = r == 0 ? 1 : 8; for (int i = 0; i < n; i++) { double a = i * Math.PI / 4; - Floor f = below(tiles, gx + r * Math.sin(a), gy, gz + r * Math.cos(a)); + Floor f = below(g, gx + r * Math.sin(a), gy, gz + r * Math.cos(a)); if (f != null && f.material() != DEATH) { return f; } @@ -51,67 +49,18 @@ public final class FloorFinder { * {@link #ABOVE}; null if none. */ public static Floor below(Scene s, double gx, double gy, double gz) { - return below(tiles(s), gx, gy, gz); - } - - /** Scene tiles plus the tiles generated from brushes. */ - static List tiles(Scene s) { - List tiles = new ArrayList<>(s.tiles()); - try { - tiles.addAll(BrushCompiler.compile(s, new ArrayList<>()).tiles()); - } catch (RuntimeException e) { - // brushes that do not compile have no floor - } - return tiles; + int cx = (int) Math.floor(gx), cz = (int) Math.floor(gz); + return below(Ground.ofCells(s, cx, cz, cx, cz), gx, gy, gz); } /** * Only the tiles listed in the grid cell of (x, z) count: the engine's floor test * (FUN_004036d0) walks the cell list of the cell the player stands in, so a polygon that - * reaches into the cell from a neighbouring tile carries nothing there. + * reaches into the cell from a neighbouring tile carries nothing there ({@link Ground#cellBelow}). */ - static Floor below(List tiles, double gx, double gy, double gz) { - double ex = gx, ez = -gz; - int cx = (int) Math.floor(gx), cz = (int) Math.floor(gz); - Floor best = null; - for (Tile t : tiles) { - if (t.cellX() != cx || t.cellZ() != cz) { - continue; - } - for (TileModel.PlacedPolygon p : t.worldCollisionPolygons()) { - Double y = heightAt(p.corners(), ex, ez); - if (y == null || y > gy + ABOVE) { - continue; - } - if (best == null || y > best.y()) { - best = new Floor(gx, y, gz, p.material(), t.cellX(), t.cellZ()); - } - } - } - return best; - } - - /** y of a (convex, roughly horizontal) polygon at (x, z), or null if outside or a wall. */ - static Double heightAt(double[][] p, double x, double z) { - double[] a = p[0], b = p[1], c = p[2]; - double ux = b[0] - a[0], uy = b[1] - a[1], uz = b[2] - a[2]; - double vx = c[0] - a[0], vy = c[1] - a[1], vz = c[2] - a[2]; - double nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx; - double len = Math.sqrt(nx * nx + ny * ny + nz * nz); - if (len < 1e-9 || Math.abs(ny) / len < 0.5) { - return null; // wall or steep slope - } - boolean pos = false, neg = false; - for (int i = 0; i < p.length; i++) { - double[] q = p[i], r = p[(i + 1) % p.length]; - double cr = (r[0] - q[0]) * (z - q[2]) - (r[2] - q[2]) * (x - q[0]); - pos |= cr > 1e-9; - neg |= cr < -1e-9; - } - if (pos && neg) { - return null; - } - return a[1] - (nx * (x - a[0]) + nz * (z - a[2])) / ny; + static Floor below(Ground g, double gx, double gy, double gz) { + Ground.Hit h = g.cellBelow(gx, gz, gy, ABOVE); + return h == null ? null : new Floor(gx, h.y(), gz, h.material(), (int) Math.floor(gx), (int) Math.floor(gz)); } /** diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/textures/TexturesEditorPlugin.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/textures/TexturesEditorPlugin.java index e416f5a..731c037 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/tools/textures/TexturesEditorPlugin.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/textures/TexturesEditorPlugin.java @@ -1,5 +1,6 @@ package com.eliasmueller.llamamod.editor.tools.textures; +import com.eliasmueller.llamamod.editor.model.Scene; import com.eliasmueller.llamamod.editor.model.TextureEntry; import com.eliasmueller.llamamod.editor.ui.ActionSpec; import com.eliasmueller.llamamod.editor.ui.Dock; @@ -73,12 +74,23 @@ public final class TexturesEditorPlugin implements EditorPlugin { } for (TextureEntry e : s.textures()) { if (!e.isFlat() && e.page() >= s.pages().size()) { - out.add(Problem.error(e.label() + " refers to missing page " + e.page(), e)); + out.add(Problem.error(missingPage(s, e), e)); } } }); } + /** "Texture #5 uses page 7, but the level has only 4 texture pages (0–3)". */ + static String missingPage(Scene s, TextureEntry e) { + int n = s.pages().size(); + String name = "Texture #" + s.textures().indexOf(e) + (e.name().isEmpty() ? "" : " \"" + e.name() + "\""); + return name + " uses page " + e.page() + ", but the level has " + + (n == 0 + ? "no texture pages" + : "only " + n + " texture page" + (n == 1 ? " (0)" : "s (0–" + (n - 1) + ")")) + + ": change its page in the inspector"; + } + private static void icons() { Icons.register("texpaint", (g, c) -> { g.setColor(Icons.ACCENT); diff --git a/src/main/java/com/eliasmueller/llamamod/editor/ui/NodeNames.java b/src/main/java/com/eliasmueller/llamamod/editor/ui/NodeNames.java index d9653ac..e137c46 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/ui/NodeNames.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/ui/NodeNames.java @@ -1,188 +1,19 @@ package com.eliasmueller.llamamod.editor.ui; -import com.eliasmueller.llamamod.editor.model.Brush; -import com.eliasmueller.llamamod.editor.model.EnvZone; -import com.eliasmueller.llamamod.editor.model.LevelSettings; -import com.eliasmueller.llamamod.editor.model.Light; -import com.eliasmueller.llamamod.editor.model.MeshAsset; -import com.eliasmueller.llamamod.editor.model.Node; -import com.eliasmueller.llamamod.editor.model.PathNode; -import com.eliasmueller.llamamod.editor.model.Regions; +import com.eliasmueller.llamamod.editor.model.ReadableNames; import com.eliasmueller.llamamod.editor.model.Scene; -import com.eliasmueller.llamamod.editor.model.Strat; -import com.eliasmueller.llamamod.editor.model.TexturePage; -import com.eliasmueller.llamamod.editor.model.Tile; -import com.eliasmueller.llamamod.editor.model.TileModel; -import java.util.ArrayList; -import java.util.Collections; -import java.util.IdentityHashMap; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.TreeMap; /** * Readable names for scene nodes ("Floor 2", "Coin 3", "Waypoint 4 (chain 2)", "Model 12") as used by the - * outliner, inspector header, status bar and asset browser. Numbers follow the level order (they shift when - * an earlier item of the same kind is deleted). One instance indexes a scene lazily; create a new one after - * edits. + * outliner, inspector header, status bar and asset browser; the naming itself lives in + * {@link ReadableNames} (also used by editor.io for build warnings). */ -public final class NodeNames { - private final Scene scene; - private Map numbered; - private List starts; - private Map waypoints; - +public final class NodeNames extends ReadableNames { private NodeNames(Scene scene) { - this.scene = scene; + super(scene); } public static NodeNames of(Scene scene) { return new NodeNames(scene); } - - /** Readable name of a node in its own scene (falls back to {@link Node#label()} when detached). */ - public static String name(Node n) { - return n == null ? "" : n.scene() == null ? fallback(n) : of(n.scene()).nameOf(n); - } - - /** Readable standalone name of a node of this scene. */ - public String nameOf(Node n) { - return switch (n) { - case Strat st -> objectName(st); - case Brush b -> numbered(b); - case Light l -> numbered(l); - case PathNode p -> waypointName(p, true); - case Tile t -> "Tile " + t.cellX() + ", " + t.cellZ() - + (t.model() == null ? "" : " · " + modelName(t.model())); - default -> fallback(n); - }; - } - - /** Object name: its type, numbered when the level has several of that type ("Coin 3"). */ - public String objectName(Strat st) { - return numbered(st); - } - - /** "Waypoint 4" (position in its chain), with "(chain 2)" when {@code withChain}. */ - public String waypointName(PathNode p, boolean withChain) { - int[] v = waypointIndex(p); - return v == null ? "Waypoint" : "Waypoint " + v[0] + (withChain ? " (chain " + v[1] + ")" : ""); - } - - /** Chain number (1-based, outliner order) of a waypoint, or 0. */ - public int chainNumber(PathNode p) { - int[] v = waypointIndex(p); - return v == null ? 0 : v[1]; - } - - private int[] waypointIndex(PathNode p) { - if (waypoints == null) { - waypoints = new IdentityHashMap<>(); - int nr = 0; - for (PathNode start : chainStarts()) { - nr++; - List ch = scene.chain(start); - for (int i = 0; i < ch.size(); i++) { - waypoints.putIfAbsent(ch.get(i), new int[] {i + 1, nr}); - } - } - } - return waypoints.get(p); - } - - /** - * First waypoint of every chain in level order (waypoints without a predecessor, then one node per loop - * that has no start). The outliner numbers chains in this order. - */ - public List chainStarts() { - if (starts != null) { - return starts; - } - Set hasPrev = Collections.newSetFromMap(new IdentityHashMap<>()); - for (PathNode p : scene.pathNodes()) { - if (p.next() != null) { - hasPrev.add(p.next()); - } - } - starts = new ArrayList<>(); - for (PathNode p : scene.pathNodes()) { - if (!hasPrev.contains(p)) { - starts.add(p); - } - } - Set reached = Collections.newSetFromMap(new IdentityHashMap<>()); - for (PathNode st : starts) { - reached.addAll(scene.chain(st)); - } - for (PathNode p : scene.pathNodes()) { - if (!reached.contains(p)) { - starts.add(p); - reached.addAll(scene.chain(p)); - } - } - return starts; - } - - private String numbered(Node n) { - if (numbered == null) { - numbered = new IdentityHashMap<>(); - number(scene.strats(), NodeNames::typeName); - number(scene.brushes(), NodeNames::brushName); - int nr = 0; - for (Light l : scene.lights()) { - numbered.put(l, "Light " + ++nr); - } - } - String s = numbered.get(n); - return s != null ? s : fallback(n); - } - - private void number(List nodes, java.util.function.Function base) { - Map count = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); - for (T n : nodes) { - count.merge(base.apply(n), 1, Integer::sum); - } - Map nr = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); - for (T n : nodes) { - String b = base.apply(n); - numbered.put(n, count.get(b) > 1 ? b + " " + nr.merge(b, 1, Integer::sum) : b); - } - } - - /** Object type or "?" (the outliner's type group key). */ - public static String typeName(Strat st) { - return st.type().isEmpty() ? "?" : st.type(); - } - - public static String brushName(Brush b) { - return b.name().isEmpty() ? b.brushKind().label() : b.name(); - } - - /** Tile model name, or "Model 12" for unnamed models. */ - public static String modelName(TileModel m) { - return m.name().isEmpty() ? "Model " + number(m.id()) : m.name(); - } - - /** The number part of an id ("model:12" → "12", "page:n3" → "n3"). */ - public static String number(String id) { - return id.substring(id.indexOf(':') + 1); - } - - private static String fallback(Node n) { - return switch (n) { - case LevelSettings s -> "Level settings"; - case Regions r -> "Visibility regions"; - case EnvZone z -> z.isDefault() ? "Default zone (fog, sky, music)" : "Zone " + z.cellX() + ", " + z.cellZ(); - case TileModel m -> modelName(m); - case TexturePage p -> p.name().isEmpty() ? "Page " + number(p.id()) : p.name(); - case MeshAsset m -> m.name().isEmpty() ? m.file() : m.name(); - case Strat st -> typeName(st); - case Brush b -> brushName(b); - case PathNode p -> "Waypoint"; - case Light l -> "Light"; - case Tile t -> "Tile " + t.cellX() + ", " + t.cellZ(); - default -> n.label(); - }; - } } diff --git a/src/main/java/com/eliasmueller/llamamod/editor/ui/panels/OutlinerPanel.java b/src/main/java/com/eliasmueller/llamamod/editor/ui/panels/OutlinerPanel.java index cb25ef1..5379540 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/ui/panels/OutlinerPanel.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/ui/panels/OutlinerPanel.java @@ -7,6 +7,7 @@ import com.eliasmueller.llamamod.editor.model.MeshAsset; 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.ReadableNames; import com.eliasmueller.llamamod.editor.model.Scene; import com.eliasmueller.llamamod.editor.model.SceneEvent; import com.eliasmueller.llamamod.editor.model.Strat; @@ -301,20 +302,9 @@ public final class OutlinerPanel extends JPanel { return n instanceof Strat st && StratCatalog.category(st.type()).contains(q); } - /** - * Case-insensitive substring match of a readable name; a query ending in a number matches whole numbers - * only ("floor 1" finds "Floor 1", not "Floor 12"). - */ + /** Readable-name match with the whole-number rule ({@link ReadableNames#matches}). */ static boolean nameMatches(String name, String q) { - String n = name.toLowerCase(Locale.ROOT); - boolean num = !q.isEmpty() && Character.isDigit(q.charAt(q.length() - 1)); - for (int i = n.indexOf(q); i >= 0; i = n.indexOf(q, i + 1)) { - int end = i + q.length(); - if (!num || end >= n.length() || !Character.isDigit(n.charAt(end))) { - return true; - } - } - return false; + return ReadableNames.matches(name, q); } private DefaultMutableTreeNode group(DefaultMutableTreeNode parent, String key, String label, Icon icon) { diff --git a/src/main/java/com/eliasmueller/llamamod/editor/ui/panels/ProblemsPanel.java b/src/main/java/com/eliasmueller/llamamod/editor/ui/panels/ProblemsPanel.java index d3b975d..c60f598 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/ui/panels/ProblemsPanel.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/ui/panels/ProblemsPanel.java @@ -3,6 +3,7 @@ package com.eliasmueller.llamamod.editor.ui.panels; 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.NodeNames; import com.eliasmueller.llamamod.editor.ui.Problem; import java.awt.BorderLayout; import java.awt.Component; @@ -42,7 +43,7 @@ public final class ProblemsPanel extends JPanel { 14)); c.setBorder(BorderFactory.createEmptyBorder(3, 8, 3, 8)); if (p.node() != null) { - c.setToolTipText("Double-click to select " + p.node().id()); + c.setToolTipText("Double-click to select " + NodeNames.name(p.node())); } return c; } diff --git a/src/main/java/com/eliasmueller/llamamod/levelbuild/LevelBuilder.java b/src/main/java/com/eliasmueller/llamamod/levelbuild/LevelBuilder.java index 44c7a0b..9d4b1f8 100644 --- a/src/main/java/com/eliasmueller/llamamod/levelbuild/LevelBuilder.java +++ b/src/main/java/com/eliasmueller/llamamod/levelbuild/LevelBuilder.java @@ -121,7 +121,7 @@ public final class LevelBuilder { record Mode(String name) implements Waypoints {} - /** Points {x, z[, y[, rotDeg]]}; y = NaN: ground height. */ + /** Points {x, z[, y[, rotDeg[, unk2]]]}; y = NaN: ground height; unk2 default: the prototype node's. */ record Points(List pts) implements Waypoints {} static Waypoints of(List pts) { @@ -959,32 +959,27 @@ public final class LevelBuilder { return strat(name, x, z, null, null, null, Waypoints.RELATIVE, variant, false); } + /** Camera node of the player start: this far behind the llama (must stay below the 2.5-cell search range). */ + public static final double PLAYER_CAMERA_BACK = 2.0; + /** - * Player start (strat "Llama"). The first waypoint of the llama chain is the camera start: - * placed camDist behind the llama, the others on the llama. + * Player start (strat "Llama") with its start camera: one waypoint camDist behind the llama, 0.5 + * above it, field unk2 = 0 (default camera distance and height). The game takes the first node of + * the llama's chain within 2.5 cells as the camera start, looking at the llama (as the editor's + * {@code PlayerStart}); the prototype's chain (one node per entry point of the original level) + * would put the camera into the llama's head on village 2 bases. */ public StratSpec player(double x, double z, double rot, Double y, double camDist) { StratSpec d = strat("Llama", x, z, y, rot, null, Waypoints.HERE, 0, false); double a = PyNum.radians(rot); double cx = x - camDist * PyNum.sin(a); double cz = z - camDist * PyNum.cos(a); - int n = 1; - int k = protos.get("Llama").get(0)[13]; - while (k >= 0 && map.nodes.get(k).next() != 0 && n < 200) { - k = map.nodes.get(k).next(); - n++; - } - List wp = new ArrayList<>(); - wp.add(new double[] {cx, cz, d.y - 0.4, rot}); - for (int i = 1; i < n; i++) { - wp.add(new double[] {x, z, d.y, rot}); - } - d.wp = Waypoints.of(wp); + d.wp = Waypoints.of(List.of(new double[] {cx, cz, d.y + 0.5, rot, 0})); return d; } public StratSpec player(double x, double z, double rot) { - return player(x, z, rot, null, 8.0); + return player(x, z, rot, null, PLAYER_CAMERA_BACK); } public StratSpec checkpoint(double x, double z) { @@ -1534,7 +1529,9 @@ public final class LevelBuilder { py = h == null || h == 0.0 ? d.y : h; } int pr = p.length > 3 ? deg(p[3]) : s[1]; - int a = i < chain.size() ? chain.get(i).unk2() : 0; + int a = p.length > 4 + ? (int) p[4] + : i < chain.size() ? chain.get(i).unk2() : 0; int b = i < chain.size() ? chain.get(i).unk3() : 0; fresh.add(new int[] { 0, diff --git a/src/main/java/com/eliasmueller/llamamod/mods/boottolevel/StartOverride.java b/src/main/java/com/eliasmueller/llamamod/mods/boottolevel/StartOverride.java index f1ead1c..e7a32fb 100644 --- a/src/main/java/com/eliasmueller/llamamod/mods/boottolevel/StartOverride.java +++ b/src/main/java/com/eliasmueller/llamamod/mods/boottolevel/StartOverride.java @@ -7,6 +7,7 @@ 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.Edits; +import com.eliasmueller.llamamod.editor.model.cmd.PlayerStart; import com.eliasmueller.llamamod.editor.tools.playtest.FloorFinder; import java.io.IOException; import java.util.ArrayList; @@ -15,9 +16,9 @@ import java.util.Locale; /** * Moves the player start (the Llama strat) of a level WAD. In levelbuild/editor levels its waypoint - * chain is the camera start (first node, 8 cells behind) plus nodes on the llama: then the whole - * chain moves and turns with it. Original levels use the llama's chain as a level-wide point list, - * which stays where it is. + * chain is the start camera: one node 2 cells behind ({@link PlayerStart}), or in older builds a node + * 8 cells behind plus nodes on the llama. Then the whole chain moves and turns with it. Original levels + * use the llama's chain as a level-wide point list, which stays where it is. */ public final class StartOverride { static final double ON_LLAMA = 0.5; @@ -45,7 +46,12 @@ public final class StartOverride { for (PathNode p : chain.subList(1, chain.size())) { onLlama &= Math.hypot(p.gx() - llama.gx(), p.gz() - llama.gz()) <= ON_LLAMA; } - if (onLlama) { + boolean cameraNode = chain.size() == 1 + && Math.hypot( + chain.getFirst().gx() - llama.gx(), + chain.getFirst().gz() - llama.gz()) + < PlayerStart.RANGE; + if (onLlama || cameraNode) { moved.addAll(chain); rotate.addAll(chain); } diff --git a/src/test/java/com/eliasmueller/llamamod/editor/bugs/EditorRegressionTest.java b/src/test/java/com/eliasmueller/llamamod/editor/bugs/EditorRegressionTest.java index 243dca0..4f53716 100644 --- a/src/test/java/com/eliasmueller/llamamod/editor/bugs/EditorRegressionTest.java +++ b/src/test/java/com/eliasmueller/llamamod/editor/bugs/EditorRegressionTest.java @@ -20,6 +20,7 @@ import com.eliasmueller.llamamod.editor.model.cmd.AddNode; import com.eliasmueller.llamamod.editor.model.cmd.Edits; import com.eliasmueller.llamamod.editor.model.cmd.History; import com.eliasmueller.llamamod.editor.model.cmd.SetProp; +import com.eliasmueller.llamamod.editor.ui.NodeNames; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.ArrayList; @@ -97,7 +98,8 @@ class EditorRegressionTest { f.prop("points").setRaw(f, new double[] {2, 0, 6, 0}); s.add(f, -1); IllegalStateException e = assertThrows(IllegalStateException.class, () -> WadBuilder.build(s)); - assertTrue(e.getMessage().contains(f.id()), e.getMessage()); + assertTrue(e.getMessage().contains(NodeNames.name(f)), e.getMessage()); + assertFalse(e.getMessage().contains(f.id()), e.getMessage()); } /** B6/B7: absurd parameters must not hang or exhaust memory; they are reported instead. */ @@ -129,7 +131,7 @@ class EditorRegressionTest { assertTrue(warn.stream().anyMatch(w -> w.contains("skipped")), c + ": " + warn); List err = Validator.validate(s, new ArrayList<>()); if (!c.getValue().containsKey("top")) { - assertTrue(err.stream().anyMatch(x -> x.startsWith(b.id())), c + ": " + err); + assertTrue(err.stream().anyMatch(x -> x.startsWith(NodeNames.name(b) + ":")), c + ": " + err); } } } diff --git a/src/test/java/com/eliasmueller/llamamod/editor/io/HandednessDemo.java b/src/test/java/com/eliasmueller/llamamod/editor/io/HandednessDemo.java index f94d8dd..1baba07 100644 --- a/src/test/java/com/eliasmueller/llamamod/editor/io/HandednessDemo.java +++ b/src/test/java/com/eliasmueller/llamamod/editor/io/HandednessDemo.java @@ -3,12 +3,9 @@ package com.eliasmueller.llamamod.editor.io; import com.eliasmueller.llamamod.editor.model.Brush; import com.eliasmueller.llamamod.editor.model.BrushKind; import com.eliasmueller.llamamod.editor.model.MeshAsset; -import com.eliasmueller.llamamod.editor.model.PathNode; import com.eliasmueller.llamamod.editor.model.Scene; 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 java.nio.file.Path; /** @@ -48,17 +45,7 @@ public final class HandednessDemo { mesh(s, obj, 19.5, 13, 180, "F obj turned"); mesh(s, glb, 22, 13, 180, "F glb turned"); double px = 16, pz = 4, yaw = 0; - Batch b = (Batch) Edits.placeStrat(s, "Llama", 0, px, 0, pz, yaw); - int k = 0; - for (Command c : b.commands()) { - if (c instanceof AddNode an && an.node() instanceof PathNode p) { - p.setRaw("x", Edits.fixed(px)); - p.setRaw("y", Edits.fixed(k == 0 ? 1.5 : 0)); - p.setRaw("z", Edits.fixed(k == 0 ? pz - 8 : pz)); - k++; - } - } - b.apply(s); + PlayerStart.place(s, px, 0, pz, yaw).apply(s); WadBuilder.Result r = WadBuilder.buildWithReport(s); System.out.println("built " + r.wad().length + " bytes, " + r.stats() + ", warnings " + r.warnings()); ProjectIO.save(pr, Path.of(a[3]), cd); diff --git a/src/test/java/com/eliasmueller/llamamod/editor/io/NoCollisionDemo.java b/src/test/java/com/eliasmueller/llamamod/editor/io/NoCollisionDemo.java index df1b944..d97ea84 100644 --- a/src/test/java/com/eliasmueller/llamamod/editor/io/NoCollisionDemo.java +++ b/src/test/java/com/eliasmueller/llamamod/editor/io/NoCollisionDemo.java @@ -3,15 +3,13 @@ package com.eliasmueller.llamamod.editor.io; import com.eliasmueller.llamamod.editor.model.Brush; import com.eliasmueller.llamamod.editor.model.BrushKind; import com.eliasmueller.llamamod.editor.model.NodeKind; -import com.eliasmueller.llamamod.editor.model.PathNode; import com.eliasmueller.llamamod.editor.model.Prop; import com.eliasmueller.llamamod.editor.model.Scene; import com.eliasmueller.llamamod.editor.model.Tile; 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.format.Level; import java.nio.file.Path; import java.util.ArrayList; @@ -93,17 +91,7 @@ public final class NoCollisionDemo { System.out.println("north wall tiles (visual only): " + bake(s, north, true)); System.out.println("south wall tiles (solid): " + bake(s, south, false)); double px = 10.5, pz = 8.5, yaw = 90; - Batch b = (Batch) Edits.placeStrat(s, "Llama", 0, px, 0, pz, yaw); - int k = 0; - for (Command c : b.commands()) { - if (c instanceof AddNode an && an.node() instanceof PathNode p) { - p.setRaw("x", Edits.fixed(k == 0 ? px - 8 : px)); - p.setRaw("y", Edits.fixed(k == 0 ? 1.5 : 0)); - p.setRaw("z", Edits.fixed(pz)); - k++; - } - } - b.apply(s); + PlayerStart.place(s, px, 0, pz, yaw).apply(s); WadBuilder.Result r = WadBuilder.buildWithReport(s); Level l = Level.parse(r.wad()); int noColl = 0; diff --git a/src/test/java/com/eliasmueller/llamamod/editor/model/ReadableNamesTest.java b/src/test/java/com/eliasmueller/llamamod/editor/model/ReadableNamesTest.java new file mode 100644 index 0000000..7ee6229 --- /dev/null +++ b/src/test/java/com/eliasmueller/llamamod/editor/model/ReadableNamesTest.java @@ -0,0 +1,22 @@ +package com.eliasmueller.llamamod.editor.model; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class ReadableNamesTest { + @Test + void numbersMatchWholeNumbersOnly() { + assertTrue(ReadableNames.matches("Floor 1", "floor 1")); + assertFalse(ReadableNames.matches("Floor 12", "floor 1")); + assertTrue(ReadableNames.matches("Model 12", "12")); + assertFalse(ReadableNames.matches("Model 120", "12")); + assertFalse(ReadableNames.matches("Model 112", "12")); + assertFalse(ReadableNames.matches("Floor 12", "2")); + assertTrue(ReadableNames.matches("Coin 3 (strat:12)", "12")); + assertTrue(ReadableNames.matches("Treetop", "TREE")); + assertTrue(ReadableNames.matches("Model 7", "")); + assertFalse(ReadableNames.matches("Rock 12 big", "rock 1")); + } +} diff --git a/src/test/java/com/eliasmueller/llamamod/editor/tools/catalog/XobjDemo.java b/src/test/java/com/eliasmueller/llamamod/editor/tools/catalog/XobjDemo.java index 4fc2d71..7bd20d1 100644 --- a/src/test/java/com/eliasmueller/llamamod/editor/tools/catalog/XobjDemo.java +++ b/src/test/java/com/eliasmueller/llamamod/editor/tools/catalog/XobjDemo.java @@ -4,12 +4,10 @@ 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.BrushKind; -import com.eliasmueller.llamamod.editor.model.PathNode; import com.eliasmueller.llamamod.editor.model.Scene; -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 java.nio.file.Path; import java.util.ArrayList; import java.util.List; @@ -21,17 +19,7 @@ import java.util.Map; */ public final class XobjDemo { static Command placePlayer(Scene s, double x, double y, double z) { - Batch b = (Batch) Edits.placeStrat(s, "Llama", 0, x, y, z, 0.0); - int k = 0; - for (Command c : b.commands()) { - if (c instanceof AddNode an && an.node() instanceof PathNode p) { - p.prop("x").setRaw(p, Edits.fixed(x)); - p.prop("y").setRaw(p, Edits.fixed(k == 0 ? y - 0.4 : y)); - p.prop("z").setRaw(p, Edits.fixed(k == 0 ? z - 8 : z)); - k++; - } - } - return b; + return PlayerStart.place(s, x, y, z, 0); } public static void main(String[] a) throws Exception { diff --git a/src/test/java/com/eliasmueller/llamamod/editor/tools/geometry/GeoDemo.java b/src/test/java/com/eliasmueller/llamamod/editor/tools/geometry/GeoDemo.java index 9a70bce..b47c76e 100644 --- a/src/test/java/com/eliasmueller/llamamod/editor/tools/geometry/GeoDemo.java +++ b/src/test/java/com/eliasmueller/llamamod/editor/tools/geometry/GeoDemo.java @@ -9,12 +9,10 @@ import com.eliasmueller.llamamod.editor.io.WadBuilder; import com.eliasmueller.llamamod.editor.model.Brush; import com.eliasmueller.llamamod.editor.model.BrushKind; import com.eliasmueller.llamamod.editor.model.MeshAsset; -import com.eliasmueller.llamamod.editor.model.PathNode; import com.eliasmueller.llamamod.editor.model.Scene; 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 java.nio.file.Path; import java.util.ArrayList; import java.util.List; @@ -116,18 +114,7 @@ public final class GeoDemo { pz = Double.parseDouble(st[1]); yaw = Double.parseDouble(st[2]); } - Batch b = (Batch) Edits.placeStrat(s, "Llama", 0, px, 0, pz, yaw); - int k = 0; - for (Command c : b.commands()) { - if (c instanceof AddNode an && an.node() instanceof PathNode p) { - double r = Math.toRadians(yaw); - p.prop("x").setRaw(p, Edits.fixed(k == 0 ? px - 8 * Math.sin(r) : px)); - p.prop("y").setRaw(p, Edits.fixed(k == 0 ? -0.4 : 0)); - p.prop("z").setRaw(p, Edits.fixed(k == 0 ? pz - 8 * Math.cos(r) : pz)); - k++; - } - } - b.apply(s); + PlayerStart.place(s, px, 0, pz, yaw).apply(s); Edits.placeStrat(s, "LevelDoor", 0, 39.5, 2.0, 15.5, 0.0).apply(s); WadBuilder.Result r = WadBuilder.buildWithReport(s); System.out.println("built " + r.wad().length + " bytes, warnings " + r.warnings()); diff --git a/src/test/java/com/eliasmueller/llamamod/editor/tools/geometry/GeoLibDemo.java b/src/test/java/com/eliasmueller/llamamod/editor/tools/geometry/GeoLibDemo.java index 3a58c9d..aab1ee3 100644 --- a/src/test/java/com/eliasmueller/llamamod/editor/tools/geometry/GeoLibDemo.java +++ b/src/test/java/com/eliasmueller/llamamod/editor/tools/geometry/GeoLibDemo.java @@ -9,13 +9,11 @@ import com.eliasmueller.llamamod.editor.model.Brush; import com.eliasmueller.llamamod.editor.model.BrushKind; import com.eliasmueller.llamamod.editor.model.MeshAsset; 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.Tile; 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 java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; @@ -48,17 +46,7 @@ public final class GeoLibDemo { if (!s.catalog().has("Llama")) { return p; } - Batch b = (Batch) Edits.placeStrat(s, "Llama", 0, 8.5, 0, 12.5, 90.0); - int n = 0; - for (Command c : b.commands()) { - if (c instanceof AddNode an && an.node() instanceof PathNode pn) { - pn.prop("x").setRaw(pn, Edits.fixed(n == 0 ? 0.5 : 8.5)); - pn.prop("y").setRaw(pn, Edits.fixed(n == 0 ? -0.4 : 0)); - pn.prop("z").setRaw(pn, Edits.fixed(12.5)); - n++; - } - } - b.apply(s); + PlayerStart.place(s, 8.5, 0, 12.5, 90).apply(s); return p; } diff --git a/src/test/java/com/eliasmueller/llamamod/editor/tools/geometry/PaletteAndPreviewTest.java b/src/test/java/com/eliasmueller/llamamod/editor/tools/geometry/PaletteAndPreviewTest.java new file mode 100644 index 0000000..2263f6f --- /dev/null +++ b/src/test/java/com/eliasmueller/llamamod/editor/tools/geometry/PaletteAndPreviewTest.java @@ -0,0 +1,56 @@ +package com.eliasmueller.llamamod.editor.tools.geometry; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.eliasmueller.llamamod.editor.model.TileModel; +import org.junit.jupiter.api.Test; + +class PaletteAndPreviewTest { + @Test + void paletteFilterUsesReadableNamesAndWholeNumbers() { + TileModel m12 = new TileModel("model:12"); + TileModel m120 = new TileModel("model:120"); + TileModel named = new TileModel("model:5"); + named.prop("name").setRaw(named, "Bridge plank"); + assertTrue(TilePaintTool.paletteMatch(m12, "12")); + assertFalse(TilePaintTool.paletteMatch(m120, "12")); + assertTrue(TilePaintTool.paletteMatch(m12, "model 12")); + assertFalse(TilePaintTool.paletteMatch(m120, "model 12")); + assertTrue(TilePaintTool.paletteMatch(named, "plank")); + assertTrue(TilePaintTool.paletteMatch(named, "5")); + assertTrue(TilePaintTool.paletteMatch(m120, " ")); + } + + @Test + void previewFramesTheMainMass() { + // a 1-cell cube of 200 vertices plus one stray vertex 500 cells away + double[] pos = new double[3 * 201]; + for (int i = 0; i < 200; i++) { + pos[3 * i] = (i % 2); + pos[3 * i + 1] = (i / 2 % 2); + pos[3 * i + 2] = (i / 4 % 2); + } + pos[600] = 500; + double[] f = MeshImportDialog.Preview.focusBounds(pos); + assertTrue(f[3] < 2, "x max " + f[3]); + assertEquals(0, f[0], 0.2); + // without far-off pieces: the full bounds + double[] cube = java.util.Arrays.copyOf(pos, 600); + assertArrayEquals(new double[] {0, 0, 0, 1, 1, 1}, MeshImportDialog.Preview.focusBounds(cube), 1e-9); + // a small model (24 vertices) with a far-off triangle (3 vertices, over 10 % of them) + double[] small = new double[3 * 27]; + for (int i = 0; i < 24; i++) { + small[3 * i] = i % 2 * 2; + small[3 * i + 1] = i / 2 % 2; + small[3 * i + 2] = i / 4 % 3; + } + for (int i = 24; i < 27; i++) { + small[3 * i] = 400 + i % 2; + } + double[] g = MeshImportDialog.Preview.focusBounds(small); + assertArrayEquals(new double[] {0, 0, 0, 2, 1, 2}, g, 1e-9); + } +} diff --git a/src/test/java/com/eliasmueller/llamamod/editor/tools/objects/LevelBuilderStartTest.java b/src/test/java/com/eliasmueller/llamamod/editor/tools/objects/LevelBuilderStartTest.java new file mode 100644 index 0000000..547a3e1 --- /dev/null +++ b/src/test/java/com/eliasmueller/llamamod/editor/tools/objects/LevelBuilderStartTest.java @@ -0,0 +1,43 @@ +package com.eliasmueller.llamamod.editor.tools.objects; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import com.eliasmueller.llamamod.editor.io.WadImporter; +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.PlayerStart; +import com.eliasmueller.llamamod.levelbuild.LevelBuilder; +import java.nio.file.Files; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** + * levelbuild player starts carry the editor's start camera (one node 2 cells behind, unk2 = 0) on both + * village bases; the prototype's chain put the camera into the llama's head on village 2. Needs + * -Dllamamod.cd. + */ +class LevelBuilderStartTest { + @Test + void playerStartHasOneCameraNodeBehind() throws Exception { + for (String base : new String[] {"t1l1m001", "t1l1m002"}) { + byte[] wad = Files.readAllBytes(ObjectToolsTest.cd().resolve("wads/" + base + ".wad")); + LevelBuilder lb = new LevelBuilder(wad, rel -> null, "Start", 1, Map.of()); + lb.floor(8, 8, 14, 14, 1.0, "grass"); + lb.player(11, 11, 90); + Scene s = WadImporter.importWad(lb.build(), base, null); + Strat ll = s.playerStart(); + List ch = s.chain(ll.waypoint()); + assertEquals(1, ch.size(), base); + PathNode cam = ch.getFirst(); + assertEquals(0, PlayerStart.startNode(ch, ll.gx(), ll.gz()), base); + assertEquals(11 - LevelBuilder.PLAYER_CAMERA_BACK, cam.gx(), 1e-3, base); // facing +x + assertEquals(11, cam.gz(), 1e-3, base); + assertEquals(ll.gy() + 0.5, cam.gy(), 1e-3, base); + assertEquals(0, cam.unk2(), base); + assertNull(ObjectChecks.startCamera(s, ll), base); + } + } +} diff --git a/src/test/java/com/eliasmueller/llamamod/editor/tools/objects/ObjectPaletteTest.java b/src/test/java/com/eliasmueller/llamamod/editor/tools/objects/ObjectPaletteTest.java new file mode 100644 index 0000000..5081bdb --- /dev/null +++ b/src/test/java/com/eliasmueller/llamamod/editor/tools/objects/ObjectPaletteTest.java @@ -0,0 +1,81 @@ +package com.eliasmueller.llamamod.editor.tools.objects; + +import static org.junit.jupiter.api.Assertions.assertEquals; +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.Scene; +import com.eliasmueller.llamamod.editor.ui.EditorContext; +import com.eliasmueller.llamamod.editor.ui.EditorPrefs; +import java.awt.Rectangle; +import java.awt.event.InputEvent; +import java.awt.event.MouseEvent; +import java.lang.reflect.Proxy; +import java.util.ArrayList; +import java.util.List; +import javax.swing.JList; +import javax.swing.SwingUtilities; +import org.junit.jupiter.api.Test; + +/** Right-clicking an object type opens its menu only: no selection, no switch to the Place tool. */ +class ObjectPaletteTest { + @Test + void rightClickDoesNotPickTheType() throws Exception { + Scene s = Project.create(Bases.fromCd(ObjectToolsTest.cd()), "new", "t1l1m001") + .scene(); + List tools = new ArrayList<>(); + EditorPrefs prefs = new EditorPrefs(null); + EditorContext[] self = new EditorContext[1]; + self[0] = (EditorContext) Proxy.newProxyInstance( + EditorContext.class.getClassLoader(), new Class[] {EditorContext.class}, (p, m, a) -> { + return switch (m.getName()) { + case "scene" -> s; + case "prefs" -> prefs; + case "setTool" -> tools.add((String) a[0]); + case "hashCode" -> System.identityHashCode(p); + case "equals" -> p == a[0]; + case "toString" -> "ctx"; + default -> m.getReturnType() == boolean.class + ? false + : m.getReturnType() == int.class ? 0 : null; + }; + }); + SwingUtilities.invokeAndWait(() -> { + ObjectPalette pal = new ObjectPalette(self[0]); + JList list = findList(pal); + list.setSize(260, 4000); + Rectangle r = list.getCellBounds(1, 1); + int x = r.x + 20, y = r.y + r.height / 2; + long t = System.currentTimeMillis(); + for (int id : new int[] {MouseEvent.MOUSE_PRESSED, MouseEvent.MOUSE_RELEASED, MouseEvent.MOUSE_CLICKED}) { + list.dispatchEvent(new MouseEvent( + list, id, t++, InputEvent.BUTTON3_DOWN_MASK, x, y, 1, false, MouseEvent.BUTTON3)); + } + assertNull(list.getSelectedValue()); + assertTrue(tools.isEmpty(), tools.toString()); + for (int id : new int[] {MouseEvent.MOUSE_PRESSED, MouseEvent.MOUSE_RELEASED, MouseEvent.MOUSE_CLICKED}) { + list.dispatchEvent(new MouseEvent( + list, id, t++, InputEvent.BUTTON1_DOWN_MASK, x, y, 1, false, MouseEvent.BUTTON1)); + } + assertEquals(1, list.getSelectedIndex()); + assertEquals(List.of(PlaceTool.ID), tools); + }); + } + + private static JList findList(java.awt.Container c) { + for (java.awt.Component k : c.getComponents()) { + if (k instanceof JList l) { + return l; + } + if (k instanceof java.awt.Container cc) { + JList l = findList(cc); + if (l != null) { + return l; + } + } + } + return null; + } +} diff --git a/src/test/java/com/eliasmueller/llamamod/editor/tools/textures/TextureLibraryTest.java b/src/test/java/com/eliasmueller/llamamod/editor/tools/textures/TextureLibraryTest.java index 9aad4fd..89bbb9f 100644 --- a/src/test/java/com/eliasmueller/llamamod/editor/tools/textures/TextureLibraryTest.java +++ b/src/test/java/com/eliasmueller/llamamod/editor/tools/textures/TextureLibraryTest.java @@ -112,4 +112,17 @@ class TextureLibraryTest { assertEquals(Blend.SEMI.pageFlags, s.pages().getLast().flags()); assertEquals(Blend.SEMI.entryFlags, s.textures().getLast().flags()); } + + @Test + void missingPageIsNamedInUserTerms() throws Exception { + Scene s = village(); + TextureEntry e = s.textures().get(5); + e.prop("page").setRaw(e, 99); + int n = s.pages().size(); + String msg = TexturesEditorPlugin.missingPage(s, e); + assertTrue(msg.startsWith("Texture #5"), msg); + assertTrue( + msg.contains("uses page 99, but the level has only " + n + " texture pages (0–" + (n - 1) + ")"), msg); + assertTrue(!msg.contains("tex:") && !msg.contains("page:"), msg); + } } diff --git a/src/test/java/com/eliasmueller/llamamod/levelbuild/BrushRecorder.java b/src/test/java/com/eliasmueller/llamamod/levelbuild/BrushRecorder.java index 2307bc0..590686c 100644 --- a/src/test/java/com/eliasmueller/llamamod/levelbuild/BrushRecorder.java +++ b/src/test/java/com/eliasmueller/llamamod/levelbuild/BrushRecorder.java @@ -364,7 +364,7 @@ public final class BrushRecorder { py = h == null || h == 0.0 ? d.y : h; } int pr = p.length > 3 ? deg(p[3]) : ry; - int a = i < chain.size() ? chain.get(i)[7] : 0; + int a = p.length > 4 ? (int) p[4] : i < chain.size() ? chain.get(i)[7] : 0; int b = i < chain.size() ? chain.get(i)[8] : 0; fresh.add(new int[] { 0, diff --git a/src/test/java/com/eliasmueller/llamamod/mods/boottolevel/BootToLevelModTest.java b/src/test/java/com/eliasmueller/llamamod/mods/boottolevel/BootToLevelModTest.java index 0059460..5dde48f 100644 --- a/src/test/java/com/eliasmueller/llamamod/mods/boottolevel/BootToLevelModTest.java +++ b/src/test/java/com/eliasmueller/llamamod/mods/boottolevel/BootToLevelModTest.java @@ -16,6 +16,7 @@ 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.exe.ExeImage; import com.eliasmueller.llamamod.mods.GameData; import com.eliasmueller.llamamod.mods.ModContext; @@ -182,4 +183,21 @@ class BootToLevelModTest { assertEquals(58.2, chain.getFirst().gz(), 1e-2); assertEquals(34.2, chain.get(1).gx(), 1e-2); } + + @Test + void singleCameraNodeMovesWithTheStart() throws Exception { + Bases b = Bases.fromCd(GameData.cd()); + Scene s = WadImporter.importWad(b.wad("t1l1m001"), "t1l1m001", null); + Edits.delete(s, List.of(s.playerStart())).apply(s); + // editor and levelbuild starts: one camera node 2 cells behind the llama + PlayerStart.place(s, 10.5, 2, 10.5, 0).apply(s); + byte[] wad = WadBuilder.build(s); + Scene moved = WadImporter.importWad(StartOverride.apply(wad, new double[] {34.2, 7.35, 58.2, 90}), "x", null); + List chain = moved.chain(moved.playerStart().waypoint()); + assertEquals(1, chain.size()); + // yaw 90 = facing +x: the camera node is 2 cells behind at x − 2 + assertEquals(34.2 - PlayerStart.BACK, chain.getFirst().gx(), 1e-2); + assertEquals(58.2, chain.getFirst().gz(), 1e-2); + assertEquals(0, PlayerStart.startNode(chain, 34.2, 58.2)); + } } diff --git a/src/test/java/com/eliasmueller/llamamod/mods/testworld/TestWorldGoldenTest.java b/src/test/java/com/eliasmueller/llamamod/mods/testworld/TestWorldGoldenTest.java index d46d591..2f995cc 100644 --- a/src/test/java/com/eliasmueller/llamamod/mods/testworld/TestWorldGoldenTest.java +++ b/src/test/java/com/eliasmueller/llamamod/mods/testworld/TestWorldGoldenTest.java @@ -24,10 +24,14 @@ import org.junit.jupiter.api.Test; * tools/png2cor.py ladebild.png OUT). The test world has no Python counterpart any more (its * all-worlds gallery is imported with format.stpcmerge): its SHA-256 pins the current output, so a * change of the level (or of the cross-world merge) has to be confirmed here deliberately. + * + *

2026-11-07: all three levels changed only in the llama's waypoint chain (LevelBuilder.player: + * one start camera node 2 cells behind, 0.5 up, unk2 = 0, instead of the prototype's 14-node chain; + * the two strats with chains after it get lower waypoint indices). Jump lab = Python output again. */ class TestWorldGoldenTest { - private static final String TEST_WORLD_SHA = "786a8f9e907a6bbd22a08798e7eb1c07c2d8d96893fbbe6fdb37363c02b03643"; - private static final String PARKOUR_SHA = "858f69883c04763b19ee98d23bef68df5563a093e1f37af864458286d98bcbc8"; + private static final String TEST_WORLD_SHA = "4abdc4fbc4a4a5f0dc2e88f532240e9a33d7932ee6a234a97be26853a4b3b7f6"; + private static final String PARKOUR_SHA = "3776cc25d16a0f6327eaffc482b81d220d1eb1a954418e53acc115f6bb2ec5c4"; private static final int GALLERY_SIZE = 253; private static byte[] build(Path cd, LevelScript script, Map opts) throws IOException { @@ -86,7 +90,7 @@ class TestWorldGoldenTest { @Test void jumpLab() throws IOException { byte[] out = build(GameData.cd(), new JumpLab(), Map.of()); - check(out, "sprunglabor.wad", "1f8fb96322fc62dcf1e2468748d912f3ab0713b004273c63db186bd3e67f7895"); + check(out, "sprunglabor.wad", "18db0b69274432b2281d69f8758d3ed00f9c186a6abe81a1c4c0c95311d8faf1"); } @Test diff --git a/tools/levelbuild.py b/tools/levelbuild.py index bde8a31..ee87614 100644 --- a/tools/levelbuild.py +++ b/tools/levelbuild.py @@ -419,7 +419,7 @@ class Level: """Place an object. y=None: put it on the highest surface at (x,z). rot: degrees (None = as in the original). params: list of u32 (None = original). wp: 'relative' (move the original's waypoint chain along, y snapped to the ground), - 'here' (all waypoints on the object), 'none' (-1) or list [(x,z[,y[,rot]]),...].""" + 'here' (all waypoints on the object), 'none' (-1) or list [(x,z[,y[,rot[,unk2]]]),...].""" if name not in self.protos: raise KeyError('strat type %r not in base level (available: %s)' % (name, ', '.join(self.types()))) if y is None: @@ -430,17 +430,15 @@ class Level: self.strats.append(d) return d - def player(self, x, z, rot=0, y=None, cam_dist=8.0): - """Player start. The first waypoint of the llama chain is (in the original) the camera's - start position -> put it behind the llama (distance cam_dist), the rest on the llama.""" + def player(self, x, z, rot=0, y=None, cam_dist=2.0): + """Player start with its start camera: one waypoint cam_dist behind the llama, 0.5 above it, + unk2 = 0 (default camera distance/height). The game takes the first node of the llama chain + within 2.5 cells as the camera start (keep cam_dist below that); the prototype's chain would put + the camera into the llama's head on village 2 bases.""" d = self.strat('Llama', x, z, y, rot, wp='here') a = math.radians(rot or 0) cx, cz = x - cam_dist * math.sin(a), z - cam_dist * math.cos(a) - n = 1 - k = self.protos['Llama'][0][13] - while k >= 0 and self.map['nodes'][k][0] and n < 200: - k = self.map['nodes'][k][0]; n += 1 - d['wp'] = [(cx, cz, d['y'] - 0.4, rot or 0)] + [(x, z, d['y'], rot or 0)] * (n - 1) + d['wp'] = [(cx, cz, d['y'] + 0.5, rot or 0, 0)] return d def checkpoint(self, x, z, rot=None, y=None): @@ -773,6 +771,8 @@ class Level: pr = deg(p[3]) if len(p) > 3 else s[1] # copy extra fields (wait time/mode etc.) from the original waypoint a_, b_ = (chain[i][8], chain[i][9]) if i < len(chain) else (0, 0) + if len(p) > 4: + a_ = p[4] new.append([0, 0, 0, pr, 0, int(px * FX), int(py * FX), int(pz * FX), a_, b_]) elif chain and wp != 'none': ox, oy, oz = proto[3], proto[4], proto[5]