readable names also in io, checks and texture messages (naming moved to editor.model), tile palette filter by name or number, import preview frames the main mass and zooms with the wheel, object palette right-click no longer switches to place, floorfinder built on ground, levelbuild starts get one camera node behind the llama (golden hashes updated, boot-to-level moves it along)
This commit is contained in:
1 parent
386a06006d
commit
38c679b319
49 files changed
+819
-470
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -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**.
|
||||
|
||||
Binary file not shown.
@@ -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`)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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°.
|
||||
|
||||
@@ -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).
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 101 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 83 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
+10
-4
@@ -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,
|
||||
|
||||
@@ -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 →
|
||||
|
||||
@@ -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<MeshQuad> triangles(Brush b, List<String> 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String> validate(Scene s, List<String> warn) {
|
||||
List<String> 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) {
|
||||
|
||||
@@ -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<Node, String> numbered;
|
||||
private List<PathNode> starts;
|
||||
private Map<PathNode, int[]> 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<PathNode> 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<PathNode> chainStarts() {
|
||||
if (starts != null) {
|
||||
return starts;
|
||||
}
|
||||
Set<PathNode> 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<PathNode> 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 <T extends Node> void number(List<T> nodes, java.util.function.Function<T, String> base) {
|
||||
Map<String, Integer> count = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
|
||||
for (T n : nodes) {
|
||||
count.merge(base.apply(n), 1, Integer::sum);
|
||||
}
|
||||
Map<String, Integer> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<Node, Prop> 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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Node, Prop> 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));
|
||||
}
|
||||
|
||||
+56
-4
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}.
|
||||
*
|
||||
* <p>Follows the engine's floor test (FUN_004036d0, used for the player and for objects): only the
|
||||
* tiles listed in the grid cell of the point count ({@link Tile#cellX}/{@link Tile#cellZ}), so a
|
||||
@@ -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<Long, List<Floor>> cells = new HashMap<>();
|
||||
private final Map<Long, List<Floor>> swaps = new HashMap<>();
|
||||
private final Map<Long, List<Floor>> overlaps = new HashMap<>();
|
||||
|
||||
private Ground() {}
|
||||
|
||||
/**
|
||||
* 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<Tile> 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<Floor> 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<Floor> 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);
|
||||
|
||||
@@ -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<Problem> 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() + ")";
|
||||
}
|
||||
}
|
||||
@@ -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<Entry> model = new DefaultListModel<>();
|
||||
private final JList<Entry> 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<Entry> 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<String> 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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Tile> 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<Tile> tiles(Scene s) {
|
||||
List<Tile> 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<Tile> 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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+13
-1
@@ -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);
|
||||
|
||||
@@ -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<Node, String> numbered;
|
||||
private List<PathNode> starts;
|
||||
private Map<PathNode, int[]> 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<PathNode> 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<PathNode> chainStarts() {
|
||||
if (starts != null) {
|
||||
return starts;
|
||||
}
|
||||
Set<PathNode> 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<PathNode> 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 <T extends Node> void number(List<T> nodes, java.util.function.Function<T, String> base) {
|
||||
Map<String, Integer> count = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
|
||||
for (T n : nodes) {
|
||||
count.merge(base.apply(n), 1, Integer::sum);
|
||||
}
|
||||
Map<String, Integer> 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();
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<double[]> pts) implements Waypoints {}
|
||||
|
||||
static Waypoints of(List<double[]> 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<double[]> 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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<String> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+56
@@ -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);
|
||||
}
|
||||
}
|
||||
+43
@@ -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<PathNode> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String> 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<PathNode> 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));
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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<String, String> 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
|
||||
|
||||
+9
-9
@@ -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]
|
||||
|
||||
Reference in new issue
Block a user