editor: readable names in inspector, status hover, asset browser and outliner filter, waypoint buttons and long inspector labels wrap, library keeps no collision, autosave no longer hides fresher messages, old menu play-test path removed, task runner always finishes (no stuck loading), play-test starts bound to their level
This commit is contained in:
1 parent
4a58910fba
commit
22a61246fc
26 files changed
+804
-271
No files matched your search
@@ -66,11 +66,14 @@ The Outliner lists everything in the level as a tree: **Level** (level settings,
|
||||
visibility regions), **Tiles** (grouped **by model** or **by cell** – choose in the box at the
|
||||
top), **Objects** (by category and type), **Waypoint chains**, **Zones**, **Lights**,
|
||||
**Brushes** and **Assets** (tile models, imported meshes, texture pages). The filter field finds
|
||||
items by name, type or id.
|
||||
items by the name shown in the tree (*Floor 2*, *Waypoint 4*, *Cell 12, 30* – a number at the
|
||||
end matches whole numbers, so *Floor 2* does not find *Floor 20*), type or id.
|
||||
|
||||
Several items of the same kind are numbered in level order – *Coin 1*, *Coin 2*, *Floor 1*,
|
||||
*Floor 2* – and the grey column on the right shows where they are (position in cells, for tiles
|
||||
the height). Hover a row to see its internal id (e.g. `brush:n19`), which problem messages use.
|
||||
the height). The numbers are not fixed: deleting *Coin 1* makes the old *Coin 2* the new *Coin 1*.
|
||||
The Inspector header and the status bar use the same names. Hover a row (or the Inspector title)
|
||||
to see the internal id (e.g. `brush:n19`), which problem messages use.
|
||||
|
||||
* Click selects, **Shift+click** and **Ctrl+click** add to the selection, double-click frames
|
||||
the item in the viewport.
|
||||
@@ -158,7 +161,8 @@ Navigation → Viewport preferences…** ([Viewport preferences](04-navigation.m
|
||||
picture in the game.
|
||||
* **File → Open recent** lists the last ten projects.
|
||||
* Autosave keeps unsaved changes in the data folder. If the editor or the computer crashes, the
|
||||
next start offers **Recover**, **Discard** or **Later**.
|
||||
next start offers **Recover**, **Discard** or **Later**. Its "Autosaved" note waits until the
|
||||
status bar has shown your last message (for example an export result) for a few seconds.
|
||||
* **File → Close level** (**Ctrl+W**) and **Close editor** (**Ctrl+Q**) ask before throwing away
|
||||
changes.
|
||||
|
||||
|
||||
@@ -40,6 +40,9 @@ cells.
|
||||
| **Largest side** … **cells** + **Fit** | sets the unit scale so the largest side becomes this many cells (default 4) |
|
||||
| **Turn** | 0°, 90°, 180° or 270° about the vertical axis |
|
||||
| **Mirror (left ↔ right)** | mirrors the model |
|
||||
|
||||
The model appears in the game exactly as in Blender (or any other 3D program): its front stays
|
||||
its front, and text or an "F" reads the right way round. The preview shows it as the game will.
|
||||
| **Stand on the ground (bottom at the click point)** | puts the lowest point of the model on the surface you click |
|
||||
|
||||
### Textures
|
||||
@@ -78,6 +81,10 @@ and how many triangles land in the densest cell. It warns in red when
|
||||
|
||||
The status bar then says "Placed … – move/turn/scale it with the gizmo tools".
|
||||
|
||||
> **Meshes from older projects:** meshes imported with LlamaMod 0.2.0 or earlier were read mirrored. They
|
||||
> stay as they are, so your levels don't change. To show such a mesh the way it looks in Blender,
|
||||
> select it in the **Imported** tab and set **Handedness** to **right** in the Inspector.
|
||||
|
||||
## The engine's texture rules
|
||||
|
||||
> **Engine limit:** the engine has no free texture coordinates. Every triangle always shows one
|
||||
@@ -142,6 +149,8 @@ every base level – a village house in a jungle level, a set of platforms you b
|
||||
The asset is written to `<data>/library/<name>.llamamesh` (the LlamaMod data folder). It contains
|
||||
the tile models with their collision and vertex colours and every texture used, as pictures.
|
||||
Brushes and meshes are stored in their built form (tiles). Death-plane tiles are left out.
|
||||
Tiles marked **No collision** keep that mark: inserted again, they are still walk-through
|
||||
(the model itself keeps its collision, so you can turn the mark off later).
|
||||
|
||||
### Inserting from the library
|
||||
|
||||
@@ -172,8 +181,8 @@ OBJ for Blender:
|
||||
* with **Also write the collision polygons (…_collision.obj)** a second OBJ with the collision
|
||||
polygons, grouped by material.
|
||||
|
||||
The coordinates use the importer's convention, so the file can be imported again unchanged: edit
|
||||
it in Blender, then **File → Import mesh** it back (round trip).
|
||||
Blender shows the geometry the same way round as the game, and the file can be imported again
|
||||
unchanged: edit it in Blender, then **File → Import mesh** it back (round trip). 1 unit is 1 cell.
|
||||
|
||||
> **Note:** The exported textures are game data – fine for your own editing, please don't share
|
||||
> them.
|
||||
@@ -192,9 +201,9 @@ it in Blender, then **File → Import mesh** it back (round trip).
|
||||
|---|---|
|
||||
| **Object models** | also the models of placed objects (coins, characters …) as the viewport shows them |
|
||||
| **Leave out hidden items** | items hidden in the editor (**H**) are not exported |
|
||||
| **Game orientation** | mirrors the level so that Blender and viewers show it the same way round as the game. Off: the same axes as the OBJ export and **Import mesh**, so the file can be imported again unchanged |
|
||||
|
||||
Tiles with **Exclude from build** are left out. 1 unit is 1 cell.
|
||||
Tiles with **Exclude from build** are left out. 1 unit is 1 cell. Viewers show the level the same
|
||||
way round as the game, and **Import mesh** reads the file back unchanged.
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@ 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 with a 1-cell grid and a llama-sized marker, source
|
||||
File chooser, then a dialog: rotatable preview (shown as in game) 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,
|
||||
texture size and UV precision, double-sided, lit, collision **from the mesh / simplified invisible
|
||||
box / none** + material, warnings (densest cell vs. the 8000-vertex limit, larger than the grid).
|
||||
@@ -102,12 +102,14 @@ and keeps the placement.
|
||||
→ `<data>/library/<name>.llamamesh`: a zip with `library.json` (models: vertices, faces, planes,
|
||||
three collision groups; tiles: offsets from the anchor cell, rotation, vertex colours) and one PNG
|
||||
per used texture (pixels embedded – owner decision: game data may be embedded). Death-plane tiles
|
||||
are skipped.
|
||||
are skipped. Tiles with **No collision** store `"noCollision": true` (the model keeps its collision, so
|
||||
the inserted tile shares/dedups the model and the flag can be switched off again); insert sets the
|
||||
flag (`GeometryChecksTest.libraryKeepsNoCollision`). Brush-compiled tiles have no such flag.
|
||||
* **Insert**: drag an entry from the Library tab into the viewport (anchor = cell under the drop
|
||||
point) or double-click / Insert (view centre). Textures with identical pixels in the target level
|
||||
are reused (same base: nothing new), the others are packed onto new texture pages (blend mode
|
||||
kept); identical tile models are reused. Works on any base level.
|
||||
* **File → Export selection as OBJ…**: OBJ (export space = the importer's convention) + MTL + one
|
||||
* **File → Export selection as OBJ…**: OBJ (game space = file space, the importer's convention) + MTL + one
|
||||
PNG per texture, UVs per triangle (the engine's texture-rectangle halves), vertex colours as
|
||||
`v x y z r g b` (0x80 = 1.0), optionally `…_collision.obj` (polygons grouped by material). The
|
||||
dialog notes that the textures are game data (personal use). The importer reads it back
|
||||
@@ -115,7 +117,7 @@ and keeps the placement.
|
||||
library tiles, left the re-imported OBJ as a mesh brush).
|
||||
|
||||
### glTF export (File → Export level as glTF…, `editor.tools.gltf`)
|
||||
* `GltfExport.collect(scene, Options(objects, gameView), skip)` runs a private `SceneRenderer` on the
|
||||
* `GltfExport.collect(scene, Options(objects), skip)` runs a private `SceneRenderer` on the
|
||||
scene and takes exactly what the viewport draws: tile instances (scene tiles + compiled brushes and
|
||||
meshes), optionally object models; `excluded` tiles and (option) hidden nodes are skipped.
|
||||
Vertices are baked to world (export) space, deduplicated per primitive; one primitive per
|
||||
@@ -127,9 +129,9 @@ and keeps the placement.
|
||||
with cut-out pixels, `BLEND` for alpha/additive classes (glTF has no additive), `doubleSided`,
|
||||
`KHR_materials_unlit` (optional extension). u16 indices up to 65535 vertices, else u32.
|
||||
* `.glb` (JSON + BIN chunk) or `.gltf` (one file, buffer as base64 data URI).
|
||||
* Axes: export space (y up, z = −game z) like the OBJ export and the mesh importer, so a round trip
|
||||
through **Import mesh** is exact. **Game orientation** mirrors z (and winding) for viewers – see
|
||||
the open handedness question below.
|
||||
* Axes: game space (right-handed, y up, z = −export z, winding reversed) like the OBJ export and
|
||||
the mesh importer, so viewers show the level as the game does and a round trip through **Import
|
||||
mesh** is exact (see "Handedness of files"; the former "Game orientation" option is gone).
|
||||
* Verified: `GltfExportTest` (structural validation as the Khronos validator would: GLB header and
|
||||
chunk padding, view/accessor bounds, equal attribute counts, exact POSITION min/max, unit normals,
|
||||
UV range, index range, PNGs decode; .gltf data URI; round trip through `MeshImport` with the same
|
||||
@@ -141,6 +143,47 @@ and keeps the placement.
|
||||
(26 403 triangles, 16 materials, 15 textured, `img/editor/geo_gltf_export.jpg`).
|
||||
Village: 36 087 triangles with objects, 2.9 MB .glb; without objects 2.1 MB.
|
||||
|
||||
### Handedness of files (fix 2026-11-07)
|
||||
* **Problem (verified)**: the importer and both exports used export space as file space. The game
|
||||
(and the editor view) shows export space left-handed, Blender and glTF are right-handed, so
|
||||
everything crossing a file was mirrored: an "F" modelled in Blender (red front, blue +x end, as
|
||||
OBJ with Blender's default *Forward −Z, Up Y* and as .glb) showed as "ꟻ" on its red side in game,
|
||||
and the old glTF export of that level rendered in Blender (same camera) was the game picture
|
||||
flipped left-right (`img/editor/handed_before.jpg`: Blender front view | game | Blender render
|
||||
of the exported level). Editor round trips were exact (mirrored twice).
|
||||
* **Convention now**: file space = **game space** (right-handed, y up; 1 unit = 1 cell): file
|
||||
(x, y, z) = game (x, y, z) = export (x, y, −z); z-up files: game (x, z, −y). The conversion
|
||||
mirrors, so triangle corners 1 and 2 swap (`MeshImport.Builder.tri`; exports write (0, 2, 1)).
|
||||
Same in `MeshLibrary.exportObj` (also `_collision.obj`), `GltfExport` (positions, normals,
|
||||
winding; option "Game orientation" removed – it is now always so) and the Python tools
|
||||
(`geomlib.to_file/file_order`; `geom_export`/`geom_import --export-space` = old convention,
|
||||
`geom_demo` writes file space, `vorschau/render_level.py` reads it).
|
||||
* **Saved projects don't flip**: projects store the source file and re-parse it on load, so
|
||||
`MeshAsset` got a property **`handedness`** ("right" | "left", Inspector "Handedness"). The field
|
||||
default is "left" (old reading) – projects saved before have no such key and load as before;
|
||||
`MeshImport.importFile` sets "right" on every new import; Re-import keeps the old asset's value.
|
||||
Switching an old asset to "right" un-mirrors it. No format-version bump (older editors keep the
|
||||
unknown key and show the mesh mirrored).
|
||||
* Import dialog preview: screen x is now −x (game space is right-handed), so it shows the model
|
||||
as the game does (before it showed the mirror image of the game); it starts at yaw 215°, pitch
|
||||
−25°, i.e. from above at the model's front (before it looked from below – positive pitch tilted
|
||||
the view up). Drag left/right is inverted to keep the orbit direction. `PreviewShot` (test
|
||||
sources) paints it to a PNG. The manual's export screenshot had its "Game orientation" row
|
||||
painted out.
|
||||
* Python round trip unchanged: `geom_export` → `geom_import` of the village gives a WAD
|
||||
byte-identical to the old tools (also `geom_demo` → `geom_import`); `--export-space` writes the
|
||||
old files byte-identically.
|
||||
* Tests: `MeshHandednessTest` (asymmetric triangle: y-up and z-up OBJ and glTF keep coordinates
|
||||
and face +z; mirroring glTF node; "left" stays mirrored; new imports are "right"; a project whose
|
||||
JSON lacks the key loads as "left"), `ObjExportRoundTripTest` (box brush → OBJ (+collision) →
|
||||
Import mesh: same size, game z, every face outward), `GltfExportTest` (game-space bounds, box
|
||||
round trip at game z 10..13 with outward faces), `MeshImportTest` updated.
|
||||
* **Verified after the fix** (apptest :23, `HandednessDemo`: TestLevel 7, four F meshes at game
|
||||
z 13 – OBJ and .glb as imported, then both turned 180° – llama at (16, 0, 4) facing +z): the
|
||||
unturned Fs now show their grey back reading "ꟻ" (correct from behind), the turned ones their red
|
||||
front reading "F"; the new glTF export of the level rendered in Blender from the game camera
|
||||
matches the game picture (`img/editor/handed_after.jpg`).
|
||||
|
||||
### Checks (Level → Check, Build)
|
||||
Tiles outside the grid; tile models (also compiled brush/mesh tiles) over **8000 vertices**;
|
||||
**overlapping** identical tiles; **no floor collision under the player start** (cell-based like the
|
||||
@@ -203,10 +246,6 @@ between neighbouring floors at the same height; brush compiler warnings.
|
||||
* Library insert keeps tiles cell-aligned (anchor = whole cells); objects are not part of the library
|
||||
(see the objects agent's prefabs / object catalogue).
|
||||
* glTF export: whole level only (no "selection" variant), no collision export (the OBJ export has it),
|
||||
no texture animations, additive surfaces become plain alpha blending. **Handedness**: the editor
|
||||
view is left-handed like the game (editor-render.md), but the mesh importer and the OBJ/glTF
|
||||
exports treat file coordinates as export space – so a right-handed tool (Blender) shows exported
|
||||
levels mirrored relative to the game, and an asymmetric model imported from Blender probably
|
||||
appears mirrored in game. Not verified in game; "Game orientation" in the glTF export is the
|
||||
workaround for viewing.
|
||||
no texture animations, additive surfaces become plain alpha blending. (Handedness: fixed, see
|
||||
"Handedness of files" above.)
|
||||
* `TextureImport` packs imported textures at one size per insert (largest texture, ≤ 256).
|
||||
+15
-1
@@ -33,6 +33,16 @@ Ctrl+Z/Y/Shift+Z, Ctrl+X/C/V, Ctrl+A, Ctrl+B build, F5 play, F1 help. Single key
|
||||
text field has the focus; while the GL canvas has the focus a `KeyEventDispatcher` maps keys to
|
||||
actions (AWT canvases do not get Swing key bindings).
|
||||
|
||||
* Status messages: `ctx.status(msg)` shows a message for ~6 s; the autosave note uses
|
||||
`backgroundStatus`, which waits until a fresher user message has expired (it no longer replaces e.g.
|
||||
the glTF export result).
|
||||
* Inspector property labels have a fixed 118 px column; longer labels wrap onto more lines
|
||||
(`InspectorPanel.fitLabel`) instead of being cut ("Exclude from build").
|
||||
* Readable names everywhere (`img/editor/ui_readable_names.jpg`: outliner filter "coin 3", inspector
|
||||
"Coin 3", status bar hover "Coin 3"/"Tile 41, 66 · Model 87", asset tab "Model 0"); inspector button
|
||||
rows wrap without clipping in a narrow dock (`ui_waypoint_buttons_narrow.jpg`); long property labels
|
||||
wrap (`ui_inspector_label_wrap.jpg`).
|
||||
|
||||
## Architecture
|
||||
|
||||
| Class | Role |
|
||||
@@ -44,7 +54,9 @@ actions (AWT canvases do not get Swing key bindings).
|
||||
| `Document` | `Project` + `History` + file + autosave state; `dirty()` |
|
||||
| `Selection` | ordered node set, primary = last, listeners get the source (avoid echo loops), auto-pruned |
|
||||
| `ProjectController` | new/open/save/save as/recent, autosave + recovery, thumbnail, import/export |
|
||||
| `PlayTest` | check, background build, play-test |
|
||||
| `PlayTest` | check, background build, install/mod-selection/save-slot helpers for the play-test plugin (the old menu-based `play()` is gone; `level.play` is a disabled placeholder that `PlaytestEditorPlugin` replaces) |
|
||||
| `TaskRunner` | `runTask`: one background task on its own daemon thread; progress and completion are posted with `invokeLater` from that thread, so the completion always runs after the last progress update, also when the task or the done callback throws (replaced `SwingWorker`, whose `done()` goes through a Swing timer) |
|
||||
| `NodeNames` | readable names shared by outliner, inspector header, status-bar hover, reference buttons and the Tile models asset tab ("Floor 2", "Coin 3", "Waypoint 4 (chain 2)", "Tile 3, 4 · Model 12", "Model 12"); numbers follow the level order |
|
||||
| `view.SceneRenderer` | scene model → `RenderScene`, live, stable pick ids |
|
||||
| `view.Thumbnails` | software rasteriser for asset thumbnails |
|
||||
| `tools.SelectTool`, `TransformSupport`, `Transformers`, `EditOps` | selection/transform tools and edit operations |
|
||||
@@ -113,6 +125,8 @@ same id again replaces (panels, tools, actions, asset tabs, layers).
|
||||
| `LevelTemplate` | `fill(Scene)` adds nodes directly (no undo) before the editor shows the new level |
|
||||
| `LevelCheck` | adds `Problem.error/warning/info(msg, node)`; errors block Build/Play |
|
||||
| `Icons` | vector icons by name; `Icons.register(name, painter)` for your own (16×16 Java2D); `Icons.swatch(colour, w, h)` |
|
||||
| `NodeNames` | `NodeNames.name(node)` (one node) or `NodeNames.of(scene)` (index for many: `nameOf`, `objectName`, `waypointName`, `chainStarts`) – use it instead of `Node.label()` in UI texts; tooltips may show `node.id()` |
|
||||
| `WrapLayout` | public `FlowLayout` that grows to more rows in a narrow container (inspector button rows: `ParamsSection.compact` switches a FlowLayout bar to it) |
|
||||
| `Widgets`, `util.SwingListeners` | `Widgets.button(text, icon, tip, run)`, `Widgets.listCell(c, list, selected)`, `Widgets.wrappingLabel(html)` (help text that wraps at the dock width – use it instead of `<body style='width:…'>`); `onTextChange`, `onMenuOpen` |
|
||||
| `ErrorDialog` | `ctx.error(title, t)` uses it: plain sentence (`ErrorDialog.plain(t)`, no exception class names) + collapsible **Details** (stack trace) |
|
||||
| Game state | `ctx.setGameRunning(what, stop)` (default no-op): while a play-test game runs the status bar shows "Game running: what" and the toolbar Play button becomes **Stop game** (runs `stop`); `null` resets. `PlaytestRunner` sets it on launch and clears it when its watcher sees the game exit |
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 51 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 111 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 121 KiB |
@@ -49,8 +49,12 @@ final class MeshLibrary {
|
||||
|
||||
static final String EXT = ".llamamesh";
|
||||
|
||||
/** A tile in world space (export coordinates) with the texture tables it refers to. */
|
||||
record Placed(TileModel model, double x, double y, double z, int rot, int[] colors) {}
|
||||
/** A tile in world space (export coordinates); noCollision = the tile's "No collision" flag. */
|
||||
record Placed(TileModel model, double x, double y, double z, int rot, int[] colors, boolean noCollision) {
|
||||
Placed(TileModel model, double x, double y, double z, int rot, int[] colors) {
|
||||
this(model, x, y, z, rot, colors, false);
|
||||
}
|
||||
}
|
||||
|
||||
/** Texture tables including what the brushes generated (indices ≥ scene size). */
|
||||
record Tables(List<TextureEntry> entries, List<TexturePage> pages) {}
|
||||
@@ -62,7 +66,7 @@ final class MeshLibrary {
|
||||
List<TexturePage> tp = new ArrayList<>(s.pages());
|
||||
for (Node n : sel) {
|
||||
if (n instanceof Tile t && t.model() != null && !GeometryActions.isKillModel(t.model())) {
|
||||
out.add(new Placed(t.model(), t.x(), t.y(), t.z(), t.rot(), firstFrame(t)));
|
||||
out.add(new Placed(t.model(), t.x(), t.y(), t.z(), t.rot(), firstFrame(t), t.noCollision()));
|
||||
}
|
||||
}
|
||||
List<Brush> brushes = new ArrayList<>();
|
||||
@@ -172,6 +176,9 @@ final class MeshLibrary {
|
||||
t.put("z", -p.z() - az);
|
||||
t.put("rot", p.rot());
|
||||
t.put("colors", b64(p.colors()));
|
||||
if (p.noCollision()) {
|
||||
t.put("noCollision", true);
|
||||
}
|
||||
tiles.add(t);
|
||||
}
|
||||
Map<String, byte[]> files = new LinkedHashMap<>();
|
||||
@@ -403,6 +410,7 @@ final class MeshLibrary {
|
||||
t.setRaw("model", md);
|
||||
int[] col = ints((String) m.get("colors"));
|
||||
t.setRaw("colors", col.length == md.vertexCount() ? col : grey(md.vertexCount()));
|
||||
t.setRaw("noCollision", Boolean.TRUE.equals(m.get("noCollision")));
|
||||
cmds.add(new AddNode(t));
|
||||
out.add(t);
|
||||
}
|
||||
@@ -469,6 +477,13 @@ final class MeshLibrary {
|
||||
* z) relative to the anchor cell, with UVs per texture, vertex colours ("v x y z r g b", 0x80 =
|
||||
* 1.0 → 0.5), an MTL and one PNG per texture; collision optionally as a separate OBJ.
|
||||
*/
|
||||
/** Corner order in the file: game space is right-handed, so the export-space winding reverses. */
|
||||
private static final int[] FILE_ORDER = {0, 2, 1};
|
||||
|
||||
/**
|
||||
* OBJ in game space (x, y, z_game = −export z; right-handed, y up – what Import mesh and Blender
|
||||
* expect), relative to the anchor cell.
|
||||
*/
|
||||
static List<Path> exportObj(List<Placed> placed, Tables tables, Path obj, boolean collision) throws IOException {
|
||||
double[] anchor = anchor(placed);
|
||||
double ax = anchor[0], ay = anchor[1], az = -anchor[2];
|
||||
@@ -491,7 +506,7 @@ final class MeshLibrary {
|
||||
"v %.5f %.5f %.5f %.4f %.4f %.4f%n",
|
||||
w[0],
|
||||
w[1],
|
||||
w[2],
|
||||
-w[2],
|
||||
Math.min(1, (c & 0xff) / 128.0),
|
||||
Math.min(1, (c >> 8 & 0xff) / 128.0),
|
||||
Math.min(1, (c >> 16 & 0xff) / 128.0)));
|
||||
@@ -502,7 +517,7 @@ final class MeshLibrary {
|
||||
double[] uv = faceUv(tables, tex, m.faceFlags(f));
|
||||
StringBuilder sb = byTex.computeIfAbsent(tex, k -> new StringBuilder());
|
||||
sb.append("f");
|
||||
for (int k = 0; k < 3; k++) {
|
||||
for (int k : FILE_ORDER) {
|
||||
vt.append(String.format(Locale.ROOT, "vt %.5f %.5f%n", uv[2 * k], 1 - uv[2 * k + 1]));
|
||||
nt++;
|
||||
sb.append(' ')
|
||||
@@ -551,8 +566,10 @@ final class MeshLibrary {
|
||||
for (TileModel.PlacedPolygon poly : p.model().collisionPolygons(xf)) {
|
||||
StringBuilder sb = byMat.computeIfAbsent(poly.material(), x -> new StringBuilder());
|
||||
sb.append('f');
|
||||
for (double[] w : poly.corners()) {
|
||||
cv.append(String.format(Locale.ROOT, "v %.5f %.5f %.5f%n", w[0], w[1], w[2]));
|
||||
double[][] cs = poly.corners();
|
||||
for (int k = 0; k < cs.length; k++) {
|
||||
double[] w = cs[k == 0 ? 0 : cs.length - k];
|
||||
cv.append(String.format(Locale.ROOT, "v %.5f %.5f %.5f%n", w[0], w[1], -w[2]));
|
||||
sb.append(' ').append(++n);
|
||||
}
|
||||
sb.append('\n');
|
||||
|
||||
+2
-1
@@ -14,6 +14,7 @@ import com.eliasmueller.llamamod.editor.ui.EditorWindow;
|
||||
import com.eliasmueller.llamamod.editor.ui.Icons;
|
||||
import com.eliasmueller.llamamod.editor.ui.InspectorSection;
|
||||
import com.eliasmueller.llamamod.editor.ui.LayerSpec;
|
||||
import com.eliasmueller.llamamod.editor.ui.NodeNames;
|
||||
import com.eliasmueller.llamamod.editor.ui.PanelSpec;
|
||||
import com.eliasmueller.llamamod.editor.ui.ToolEvent;
|
||||
import com.eliasmueller.llamamod.editor.ui.tools.EditOps;
|
||||
@@ -258,7 +259,7 @@ public final class ObjectsEditorPlugin implements EditorPlugin {
|
||||
? "not used by an object"
|
||||
: "used by "
|
||||
+ owners.stream()
|
||||
.map(o -> o.type() + " (" + o.id() + ")")
|
||||
.map(NodeNames::name)
|
||||
.reduce((a, b) -> a + ", " + b)
|
||||
.get()
|
||||
+ " · " + Roles.of(owners.getFirst()).label;
|
||||
|
||||
@@ -16,6 +16,7 @@ import com.eliasmueller.llamamod.editor.ui.EditorContext;
|
||||
import com.eliasmueller.llamamod.editor.ui.Icons;
|
||||
import com.eliasmueller.llamamod.editor.ui.InspectorSection;
|
||||
import com.eliasmueller.llamamod.editor.ui.Widgets;
|
||||
import com.eliasmueller.llamamod.editor.ui.WrapLayout;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.Color;
|
||||
import java.awt.FlowLayout;
|
||||
@@ -420,8 +421,11 @@ final class ParamsSection implements InspectorSection {
|
||||
}
|
||||
}
|
||||
|
||||
/** Small unfocusable buttons for an inspector button row. */
|
||||
/** Small unfocusable buttons for an inspector button row that wraps into more rows in a narrow dock. */
|
||||
static void compact(JComponent bar) {
|
||||
if (bar.getLayout() instanceof java.awt.FlowLayout f && !(f instanceof WrapLayout)) {
|
||||
bar.setLayout(new WrapLayout(f.getAlignment(), f.getHgap(), 2));
|
||||
}
|
||||
for (java.awt.Component c : bar.getComponents()) {
|
||||
c.setFocusable(false);
|
||||
((JComponent) c).setFont(((JComponent) c).getFont().deriveFont(11.5f));
|
||||
|
||||
@@ -227,7 +227,10 @@ public final class PlaytestRunner {
|
||||
cfg,
|
||||
bootMod,
|
||||
"start",
|
||||
start == null ? "" : BootToLevelMod.formatStart(start[0], start[1], start[2], start[3]));
|
||||
start == null
|
||||
? ""
|
||||
: BootToLevelMod.formatStart(
|
||||
tgt[0], tgt[1], start[0], start[1], start[2], start[3]));
|
||||
List<Mod> withBoot = new ArrayList<>(mods);
|
||||
withBoot.removeIf(m -> m.id().equals(BootToLevelMod.ID));
|
||||
withBoot.add(bootMod);
|
||||
|
||||
@@ -263,10 +263,10 @@ final class BuiltinEditorPlugin implements EditorPlugin {
|
||||
.build())
|
||||
.enabledWhen(c -> c.document() != null && !w(c).busy())
|
||||
.tip("Validate the level and build its WAD (in the background)"));
|
||||
x.action(ActionSpec.of("level.play", "Level", 20, 30, "Play", "play", "F5", c -> w(c).playTest
|
||||
.play())
|
||||
.enabledWhen(c -> c.document() != null && !w(c).busy())
|
||||
.tip("Build, install as a mod and start the game with this level selected"));
|
||||
// placeholder: the play-test plugin (editor.tools.playtest) registers the real action under this id
|
||||
x.action(ActionSpec.of("level.play", "Level", 20, 30, "Play", "play", "F5", c -> {})
|
||||
.enabledWhen(c -> false)
|
||||
.tip("Play-test is not available (the play-test plugin did not load)"));
|
||||
|
||||
// Help
|
||||
x.action(ActionSpec.of(
|
||||
|
||||
@@ -15,7 +15,6 @@ import com.eliasmueller.llamamod.editor.render.Viewport;
|
||||
import com.eliasmueller.llamamod.editor.render.ViewportInput;
|
||||
import com.eliasmueller.llamamod.editor.render.ViewportListener;
|
||||
import com.eliasmueller.llamamod.editor.ui.view.SceneRenderer;
|
||||
import com.eliasmueller.llamamod.platform.Progress;
|
||||
import com.eliasmueller.llamamod.util.SwingListeners;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.CardLayout;
|
||||
@@ -49,9 +48,7 @@ import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CancellationException;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.function.BooleanSupplier;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Predicate;
|
||||
@@ -76,7 +73,6 @@ import javax.swing.JToggleButton;
|
||||
import javax.swing.JToolBar;
|
||||
import javax.swing.KeyStroke;
|
||||
import javax.swing.SwingUtilities;
|
||||
import javax.swing.SwingWorker;
|
||||
import javax.swing.ToolTipManager;
|
||||
import javax.swing.UIManager;
|
||||
import javax.swing.text.JTextComponent;
|
||||
@@ -117,7 +113,28 @@ public final class EditorWindow extends JFrame implements EditorContext {
|
||||
private boolean flushQueued;
|
||||
private final SceneListener sceneHook = this::onSceneEvent;
|
||||
private KeyEventDispatcher keyDispatcher;
|
||||
private SwingWorker<Object, Object> task;
|
||||
private final TaskRunner tasks = new TaskRunner(new TaskRunner.Ui() {
|
||||
@Override
|
||||
public void progress(String text, double fraction) {
|
||||
status.progress(text, fraction);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void progressDone() {
|
||||
status.progressDone();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void failed(String title, Throwable t) {
|
||||
status.message(title + " failed");
|
||||
error(title, t);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void finished() {
|
||||
refreshActions();
|
||||
}
|
||||
});
|
||||
private final Overlay toolOverlay = new Overlay();
|
||||
private final Overlay toolTop = new Overlay();
|
||||
private JButton playButton;
|
||||
@@ -1224,47 +1241,23 @@ public final class EditorWindow extends JFrame implements EditorContext {
|
||||
}
|
||||
}
|
||||
|
||||
/** Status message of a background job (autosave) that does not replace a fresher user message. */
|
||||
void backgroundStatus(String message) {
|
||||
if (status != null) {
|
||||
status.backgroundMessage(message);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void runTask(String title, Task t, Consumer<Object> done) {
|
||||
if (task != null && !task.isDone()) {
|
||||
if (!tasks.start(title, t, done)) {
|
||||
JOptionPane.showMessageDialog(
|
||||
this, "Please wait: another task is running.", title, JOptionPane.INFORMATION_MESSAGE);
|
||||
return;
|
||||
}
|
||||
status.progress(title, 0);
|
||||
Progress p =
|
||||
(f, m) -> SwingUtilities.invokeLater(() -> status.progress(m == null || m.isBlank() ? title : m, f));
|
||||
task = new SwingWorker<>() {
|
||||
@Override
|
||||
protected Object doInBackground() throws Exception {
|
||||
return t.run(p);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void done() {
|
||||
status.progressDone();
|
||||
try {
|
||||
Object r = get();
|
||||
if (done != null) {
|
||||
done.accept(r);
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
} catch (ExecutionException e) {
|
||||
Throwable c = e.getCause();
|
||||
if (!(c instanceof CancellationException)) {
|
||||
status.message(title + " failed");
|
||||
error(title, c);
|
||||
}
|
||||
}
|
||||
refreshActions();
|
||||
}
|
||||
};
|
||||
task.execute();
|
||||
}
|
||||
|
||||
public boolean busy() {
|
||||
return task != null && !task.isDone();
|
||||
return tasks.busy();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
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.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.
|
||||
*/
|
||||
public final class NodeNames {
|
||||
private final Scene scene;
|
||||
private Map<Node, String> numbered;
|
||||
private List<PathNode> starts;
|
||||
private Map<PathNode, int[]> waypoints;
|
||||
|
||||
private NodeNames(Scene scene) {
|
||||
this.scene = 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();
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import com.eliasmueller.llamamod.mods.ModRegistry;
|
||||
import com.eliasmueller.llamamod.mods.levels.LevelProjectMod;
|
||||
import com.eliasmueller.llamamod.mods.levels.LevelProjects;
|
||||
import com.eliasmueller.llamamod.platform.Config;
|
||||
import com.eliasmueller.llamamod.platform.GameLauncher;
|
||||
import com.eliasmueller.llamamod.save.Ksv;
|
||||
import com.eliasmueller.llamamod.save.SaveStore;
|
||||
import com.eliasmueller.llamamod.save.Worlds;
|
||||
@@ -27,12 +26,11 @@ import java.util.Locale;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import javax.swing.JOptionPane;
|
||||
|
||||
/**
|
||||
* Build (validate + WAD in the background) and Play-test: the current state is written to {@code
|
||||
* <data>/levels/}, enabled as a level mod next to the active mods, applied, the play-test save slot
|
||||
* is unlocked with the map cursor on the level, and the game is started.
|
||||
* Check and Build (validate + WAD in the background), plus the helpers the play-test plugin
|
||||
* ({@code editor.tools.playtest.PlaytestRunner}) uses to install the level as a mod, pick the mod
|
||||
* selection and prepare the save slot.
|
||||
*/
|
||||
public final class PlayTest {
|
||||
/** Result of a background build. */
|
||||
@@ -178,18 +176,6 @@ public final class PlayTest {
|
||||
return l.stream().filter(p -> p.severity() == s).count();
|
||||
}
|
||||
|
||||
/** World/chapter on the world map for a project: TestLevel chapter, or the original's place. */
|
||||
static int[] target(Document d) {
|
||||
if (d.isNewLevel()) {
|
||||
return new int[] {9, Math.max(1, d.scene().settings().targetChapter())};
|
||||
}
|
||||
Matcher m = Pattern.compile("t1l(\\d)m(\\d{3})").matcher(d.base().toLowerCase(Locale.ROOT));
|
||||
if (m.matches()) {
|
||||
return new int[] {Integer.parseInt(m.group(1)), Integer.parseInt(m.group(2))};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a play-test installs the project in the levels folder: {@code <stem>.llamalevel}, unless
|
||||
* that file is a different project (other kind, base or name) – then {@code <stem>-2…}, so a
|
||||
@@ -217,42 +203,6 @@ public final class PlayTest {
|
||||
}
|
||||
}
|
||||
|
||||
public void play() {
|
||||
Document d = w.document();
|
||||
if (d == null) {
|
||||
return;
|
||||
}
|
||||
EditorEnv env = w.env();
|
||||
if (!env.config().installed()) {
|
||||
JOptionPane.showMessageDialog(
|
||||
w,
|
||||
"The game is not installed. Set it up in LlamaMod first.",
|
||||
"Play-test",
|
||||
JOptionPane.WARNING_MESSAGE);
|
||||
return;
|
||||
}
|
||||
int[] tgt = target(d);
|
||||
if (tgt == null || tgt[0] < 9 && tgt[1] > Worlds.chapters(tgt[0])) {
|
||||
JOptionPane.showMessageDialog(
|
||||
w,
|
||||
"This level (" + d.base() + ") is not reachable from the world map, so it"
|
||||
+ " cannot be play-tested directly.",
|
||||
"Play-test",
|
||||
JOptionPane.INFORMATION_MESSAGE);
|
||||
return;
|
||||
}
|
||||
GameLauncher gl = new GameLauncher(env.paths(), env.config());
|
||||
if (gl.isRunning()) {
|
||||
JOptionPane.showMessageDialog(
|
||||
w,
|
||||
"The game is still running. Quit it first (the level files cannot be replaced while it runs).",
|
||||
"Play-test",
|
||||
JOptionPane.INFORMATION_MESSAGE);
|
||||
return;
|
||||
}
|
||||
build(r -> install(d, tgt));
|
||||
}
|
||||
|
||||
/**
|
||||
* The active mods plus the level's own mod (its TestLevel chapter set to tgt). Mods writing the
|
||||
* same level file are left out and named in disabled.
|
||||
@@ -370,62 +320,4 @@ public final class PlayTest {
|
||||
public static String levelName(int[] tgt) {
|
||||
return tgt[0] == 9 ? "TestLevel – chapter " + tgt[1] : Worlds.name(tgt[0]) + " – Chapter " + tgt[1];
|
||||
}
|
||||
|
||||
private void install(Document d, int[] tgt) {
|
||||
EditorEnv env = w.env();
|
||||
Install inst = install(w, d);
|
||||
if (inst == null) {
|
||||
return;
|
||||
}
|
||||
Path target = inst.target();
|
||||
int slot = slot(w.prefs());
|
||||
String levelName = levelName(tgt);
|
||||
w.runTask(
|
||||
"Play-test " + d.title(),
|
||||
p -> {
|
||||
p.update(0.05, "Installing the level as a mod");
|
||||
inst.write();
|
||||
Config cfg = env.config();
|
||||
List<String> disabled = new ArrayList<>();
|
||||
ModRegistry reg = new ModRegistry();
|
||||
List<Mod> mods = selectionWith(cfg, reg, levelMod(reg, cfg, target, tgt), tgt, disabled, null);
|
||||
cfg.enabledMods = new ArrayList<>(mods.stream().map(Mod::id).toList());
|
||||
p.update(0.15, "Applying mods");
|
||||
env.modManager().apply(mods, p.sub(0.15, 0.85));
|
||||
env.saveConfig();
|
||||
p.update(0.86, "Preparing save slot " + slot);
|
||||
prepareSaveSlot(env, slot, tgt);
|
||||
p.update(0.9, "Starting the game");
|
||||
// tests start the game through their own harness (private display and sound)
|
||||
Process proc = Boolean.getBoolean("llamamod.editor.noLaunch")
|
||||
? null
|
||||
: new GameLauncher(env.paths(), cfg).launch(p.sub(0.9, 1));
|
||||
return new Object[] {proc, disabled};
|
||||
},
|
||||
res -> {
|
||||
Object[] o = (Object[]) res;
|
||||
@SuppressWarnings("unchecked")
|
||||
List<String> disabled = (List<String>) o[1];
|
||||
if (inst.ownFile()) {
|
||||
d.history().markSaved();
|
||||
}
|
||||
env.modsChanged();
|
||||
w.status("Game started – " + levelName + " is selected on the map of save slot " + slot);
|
||||
String extra = disabled.isEmpty()
|
||||
? ""
|
||||
: "<br><br>Turned off (same level file): " + String.join(", ", disabled);
|
||||
JOptionPane.showMessageDialog(
|
||||
w,
|
||||
"<html><body style='width: 400px'><b>The game is starting.</b><br><br>"
|
||||
+ "In the main menu choose <b>Spiel starten</b> and save slot <b>" + slot
|
||||
+ "</b> – the map cursor"
|
||||
+ " is already on <b>" + ProjectController.esc(levelName)
|
||||
+ "</b>; press <b>Enter</b> to play.<br><br>"
|
||||
+ "The level stays installed as the mod \"" + ProjectController.esc(d.title())
|
||||
+ "\" in LlamaMod"
|
||||
+ " (Mods → Levels)." + extra + "</body></html>",
|
||||
"Play-test",
|
||||
JOptionPane.INFORMATION_MESSAGE);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -419,7 +419,7 @@ public final class ProjectController {
|
||||
meta.put("time", LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")));
|
||||
Files.writeString(meta(d.autosave), Json.write(meta));
|
||||
d.autosaveCurrent = true;
|
||||
w.status("Autosaved");
|
||||
w.backgroundStatus("Autosaved");
|
||||
} catch (IOException | RuntimeException e) {
|
||||
System.err.println("editor: autosave failed: " + e);
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ final class StatusBar extends JPanel {
|
||||
private final Timer probe;
|
||||
private final Timer clear;
|
||||
private long messageTime;
|
||||
private String pending;
|
||||
|
||||
StatusBar(EditorWindow w) {
|
||||
super(new BorderLayout(8, 0));
|
||||
@@ -83,6 +84,12 @@ final class StatusBar extends JPanel {
|
||||
probe.setRepeats(false);
|
||||
clear = new Timer(6000, e -> {
|
||||
if (System.currentTimeMillis() - messageTime >= 5900) {
|
||||
if (pending != null) {
|
||||
String m = pending;
|
||||
pending = null;
|
||||
message(m);
|
||||
return;
|
||||
}
|
||||
message.setText(w.document() == null ? "Open a level or create a new one" : "Ready");
|
||||
message.setIcon(Icons.get("info", 14));
|
||||
}
|
||||
@@ -106,12 +113,28 @@ final class StatusBar extends JPanel {
|
||||
}
|
||||
|
||||
void message(String m) {
|
||||
pending = null;
|
||||
message.setText(m);
|
||||
message.setIcon(Icons.get(m.startsWith("Build failed") ? "error" : "info", 14));
|
||||
messageTime = System.currentTimeMillis();
|
||||
clear.restart();
|
||||
}
|
||||
|
||||
/**
|
||||
* A message nobody asked for (autosave): shown right away only when no other message is still visible,
|
||||
* otherwise once that one has expired.
|
||||
*/
|
||||
void backgroundMessage(String m) {
|
||||
if (!progress.isVisible() && System.currentTimeMillis() - messageTime >= 5900) {
|
||||
message(m);
|
||||
return;
|
||||
}
|
||||
pending = m;
|
||||
if (!clear.isRunning() && !progress.isVisible()) {
|
||||
clear.restart();
|
||||
}
|
||||
}
|
||||
|
||||
/** Sticky "game running" state (null = none). */
|
||||
void game(String what) {
|
||||
game.setText(what == null ? "" : "Game running: " + what);
|
||||
@@ -129,7 +152,8 @@ final class StatusBar extends JPanel {
|
||||
}
|
||||
|
||||
void hover(Node n) {
|
||||
hover.setText(n == null ? " " : n.label() + (w.viewport().hoverBackFace() ? " (back side)" : ""));
|
||||
hover.setText(n == null ? " " : NodeNames.name(n) + (w.viewport().hoverBackFace() ? " (back side)" : ""));
|
||||
hover.setToolTipText(n == null ? null : n.id());
|
||||
hover.setIcon(n == null ? null : NodeIcons.icon(n, 13));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.eliasmueller.llamamod.editor.ui;
|
||||
|
||||
import com.eliasmueller.llamamod.platform.Progress;
|
||||
import java.util.concurrent.CancellationException;
|
||||
import java.util.function.Consumer;
|
||||
import javax.swing.SwingUtilities;
|
||||
|
||||
/**
|
||||
* One background task at a time (the window's {@link EditorContext#runTask}). The task runs on its own
|
||||
* daemon thread; progress updates and the completion are posted with {@code invokeLater} from that thread,
|
||||
* so the completion always comes after the last progress update and always runs – also when the task or
|
||||
* the done callback throws an exception or an {@link Error}.
|
||||
*/
|
||||
final class TaskRunner {
|
||||
/** What the runner shows; all calls on the EDT. */
|
||||
interface Ui {
|
||||
void progress(String text, double fraction);
|
||||
|
||||
void progressDone();
|
||||
|
||||
void failed(String title, Throwable t);
|
||||
|
||||
void finished();
|
||||
}
|
||||
|
||||
private final Ui ui;
|
||||
private volatile boolean running;
|
||||
|
||||
TaskRunner(Ui ui) {
|
||||
this.ui = ui;
|
||||
}
|
||||
|
||||
boolean busy() {
|
||||
return running;
|
||||
}
|
||||
|
||||
/** Starts the task (call on the EDT); false when another task is still running. */
|
||||
boolean start(String title, EditorContext.Task task, Consumer<Object> done) {
|
||||
if (running) {
|
||||
return false;
|
||||
}
|
||||
running = true;
|
||||
ui.progress(title, 0);
|
||||
Progress p = (f, m) -> SwingUtilities.invokeLater(() -> {
|
||||
if (running) {
|
||||
ui.progress(m == null || m.isBlank() ? title : m, f);
|
||||
}
|
||||
});
|
||||
Thread t = new Thread(
|
||||
() -> {
|
||||
Object result = null;
|
||||
Throwable failure = null;
|
||||
try {
|
||||
result = task.run(p);
|
||||
} catch (Throwable e) {
|
||||
failure = e;
|
||||
}
|
||||
Object r = result;
|
||||
Throwable f = failure;
|
||||
SwingUtilities.invokeLater(() -> complete(title, r, f, done));
|
||||
},
|
||||
"editor-task: " + title);
|
||||
t.setDaemon(true);
|
||||
try {
|
||||
t.start();
|
||||
} catch (Throwable e) {
|
||||
complete(title, null, e, done);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void complete(String title, Object result, Throwable failure, Consumer<Object> done) {
|
||||
// not busy any more before the callback: it may start the next task (build → play)
|
||||
running = false;
|
||||
try {
|
||||
ui.progressDone();
|
||||
if (failure == null) {
|
||||
if (done != null) {
|
||||
done.accept(result);
|
||||
}
|
||||
} else if (!(failure instanceof CancellationException || failure instanceof InterruptedException)) {
|
||||
ui.failed(title, failure);
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
ui.failed(title, e);
|
||||
} finally {
|
||||
ui.finished();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,10 @@ import java.awt.Insets;
|
||||
import javax.swing.SwingUtilities;
|
||||
|
||||
/** FlowLayout whose preferred size grows to several rows when the container is too narrow. */
|
||||
final class WrapLayout extends FlowLayout {
|
||||
public final class WrapLayout extends FlowLayout {
|
||||
private int lastHeight = -1;
|
||||
|
||||
WrapLayout(int align, int hgap, int vgap) {
|
||||
public WrapLayout(int align, int hgap, int vgap) {
|
||||
super(align, hgap, vgap);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ 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.NodeIcons;
|
||||
import com.eliasmueller.llamamod.editor.ui.NodeNames;
|
||||
import com.eliasmueller.llamamod.editor.ui.Widgets;
|
||||
import com.eliasmueller.llamamod.editor.ui.tools.EditOps;
|
||||
import com.eliasmueller.llamamod.editor.ui.view.SceneRenderer;
|
||||
@@ -189,7 +190,7 @@ public final class AssetBrowserPanel extends JPanel {
|
||||
for (TileModel m : s.tileModels()) {
|
||||
out.add(new Item(
|
||||
"model:" + m.id(),
|
||||
m.id(),
|
||||
NodeNames.modelName(m),
|
||||
m.faceCount() + " faces · " + uses.getOrDefault(m, 0) + "×",
|
||||
m,
|
||||
"tilemodel",
|
||||
@@ -444,7 +445,7 @@ public final class AssetBrowserPanel extends JPanel {
|
||||
sel
|
||||
? UIManager.getColor("List.selectionForeground")
|
||||
: UIManager.getColor("Label.disabledForeground"));
|
||||
setToolTipText(v.title + " – " + v.subtitle);
|
||||
setToolTipText(v.title + " – " + v.subtitle + (v.data instanceof Node n ? " (" + n.id() + ")" : ""));
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.eliasmueller.llamamod.editor.ui.panels;
|
||||
|
||||
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.PropType;
|
||||
import com.eliasmueller.llamamod.editor.model.Scene;
|
||||
@@ -18,6 +19,7 @@ import com.eliasmueller.llamamod.editor.ui.HelpTopics;
|
||||
import com.eliasmueller.llamamod.editor.ui.Icons;
|
||||
import com.eliasmueller.llamamod.editor.ui.InspectorSection;
|
||||
import com.eliasmueller.llamamod.editor.ui.NodeIcons;
|
||||
import com.eliasmueller.llamamod.editor.ui.NodeNames;
|
||||
import com.eliasmueller.llamamod.editor.ui.Tool;
|
||||
import com.eliasmueller.llamamod.editor.ui.Widgets;
|
||||
import com.eliasmueller.llamamod.util.SwingListeners;
|
||||
@@ -38,6 +40,7 @@ import java.awt.event.FocusEvent;
|
||||
import java.awt.event.MouseAdapter;
|
||||
import java.awt.event.MouseEvent;
|
||||
import java.util.ArrayList;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
@@ -179,8 +182,14 @@ public final class InspectorPanel extends JPanel {
|
||||
String title;
|
||||
String sub;
|
||||
if (nodes.size() == 1) {
|
||||
title = p instanceof Strat st && !st.type().isEmpty() ? st.type() : p.label();
|
||||
sub = p.kind().label() + " · " + p.id();
|
||||
title = NodeNames.name(p);
|
||||
sub = p.kind().label();
|
||||
if (p instanceof PathNode pn && p.scene() != null) {
|
||||
// "Waypoint 4" with the chain in the subtitle: the title stays short in a narrow dock
|
||||
NodeNames nn = NodeNames.of(p.scene());
|
||||
title = nn.waypointName(pn, false);
|
||||
sub += nn.chainNumber(pn) > 0 ? " · chain " + nn.chainNumber(pn) : "";
|
||||
}
|
||||
if (p instanceof Strat st) {
|
||||
sub += " · " + NodeIcons.categoryLabel(StratCatalog.category(st.type()));
|
||||
}
|
||||
@@ -196,6 +205,10 @@ public final class InspectorPanel extends JPanel {
|
||||
txt.setOpaque(false);
|
||||
JLabel t = new JLabel(title);
|
||||
t.setFont(t.getFont().deriveFont(Font.BOLD, 15f));
|
||||
if (nodes.size() == 1) {
|
||||
t.setToolTipText(p.id());
|
||||
h.setToolTipText(p.id());
|
||||
}
|
||||
JLabel st = new JLabel(sub);
|
||||
st.setFont(st.getFont().deriveFont(11.5f));
|
||||
st.setForeground(UIManager.getColor("Label.disabledForeground"));
|
||||
@@ -351,14 +364,10 @@ public final class InspectorPanel extends JPanel {
|
||||
c.anchor = GridBagConstraints.WEST;
|
||||
c.insets = new Insets(2, 12, 2, 8);
|
||||
JLabel l = label != null ? label : new JLabel(text);
|
||||
boolean cut = l.getPreferredSize().width > 118;
|
||||
l.setPreferredSize(new Dimension(118, l.getPreferredSize().height));
|
||||
l.setMinimumSize(new Dimension(118, l.getPreferredSize().height));
|
||||
fitLabel(l, LABEL_W);
|
||||
l.setForeground(UIManager.getColor("Label.foreground"));
|
||||
if (tip != null && !tip.isEmpty()) {
|
||||
l.setToolTipText(cut ? l.getText() + " – " + tip : tip);
|
||||
} else if (cut) {
|
||||
l.setToolTipText(l.getText());
|
||||
l.setToolTipText(tip);
|
||||
}
|
||||
grid.add(l, c);
|
||||
c.gridx = 1;
|
||||
@@ -369,6 +378,29 @@ public final class InspectorPanel extends JPanel {
|
||||
return row + 1;
|
||||
}
|
||||
|
||||
private static final int LABEL_W = 118;
|
||||
|
||||
/**
|
||||
* Gives a property label the fixed column width; a longer text wraps onto more lines instead of being cut.
|
||||
* Returns true when it did not fit on one line.
|
||||
*/
|
||||
static boolean fitLabel(JLabel l, int width) {
|
||||
String text = l.getText();
|
||||
int h = l.getPreferredSize().height;
|
||||
boolean wraps = l.getPreferredSize().width > width && text != null && !text.startsWith("<html>");
|
||||
if (wraps) {
|
||||
l.setText("<html>" + text.replace("&", "&").replace("<", "<") + "</html>");
|
||||
if (l.getClientProperty(javax.swing.plaf.basic.BasicHTML.propertyKey) instanceof javax.swing.text.View v) {
|
||||
java.awt.Insets in = l.getInsets();
|
||||
v.setSize(width - in.left - in.right, 0);
|
||||
h = (int) Math.ceil(v.getPreferredSpan(javax.swing.text.View.Y_AXIS)) + in.top + in.bottom;
|
||||
}
|
||||
}
|
||||
l.setPreferredSize(new Dimension(width, h));
|
||||
l.setMinimumSize(new Dimension(width, h));
|
||||
return wraps;
|
||||
}
|
||||
|
||||
private JComponent section(String title, JComponent content, boolean open, String key) {
|
||||
// the height follows the content (wrapped text, rows added later) instead of a fixed maximum
|
||||
JPanel p = new JPanel(new BorderLayout()) {
|
||||
@@ -794,7 +826,8 @@ public final class InspectorPanel extends JPanel {
|
||||
Binding b = () -> {
|
||||
Object v = value(p.name());
|
||||
if (v instanceof Node n) {
|
||||
go.setText(n.label());
|
||||
go.setText(NodeNames.name(n));
|
||||
go.setToolTipText("Select the referenced item (" + n.id() + ")");
|
||||
go.setIcon(NodeIcons.icon(n, 14));
|
||||
} else {
|
||||
go.setText(v == MIXED ? "—" : "(none)");
|
||||
@@ -834,12 +867,17 @@ public final class InspectorPanel extends JPanel {
|
||||
filter.putClientProperty("JTextField.placeholderText", "Filter…");
|
||||
DefaultListModel<Object> model = new DefaultListModel<>();
|
||||
items.forEach(model::addElement);
|
||||
Map<Object, String> names = new IdentityHashMap<>();
|
||||
NodeNames nn = ctx.scene() == null ? null : NodeNames.of(ctx.scene());
|
||||
for (Object o : items) {
|
||||
names.put(o, o instanceof Node n && nn != null ? nn.nameOf(n) : o.toString());
|
||||
}
|
||||
JList<Object> list = new JList<>(model);
|
||||
SwingListeners.onTextChange(filter, () -> {
|
||||
String q = filter.getText().toLowerCase(Locale.ROOT);
|
||||
model.clear();
|
||||
for (Object o : items) {
|
||||
String l = o instanceof Node n ? n.label() : o.toString();
|
||||
String l = names.get(o) + (o instanceof Node n ? " " + n.label() : "");
|
||||
if (l.toLowerCase(Locale.ROOT).contains(q)) {
|
||||
model.addElement(o);
|
||||
}
|
||||
@@ -848,8 +886,10 @@ public final class InspectorPanel extends JPanel {
|
||||
list.setCellRenderer(new DefaultListCellRenderer() {
|
||||
@Override
|
||||
public Component getListCellRendererComponent(JList<?> l, Object v, int i, boolean s, boolean f) {
|
||||
JLabel c = (JLabel) super.getListCellRendererComponent(l, v instanceof Node n ? n.label() : v, i, s, f);
|
||||
JLabel c = (JLabel)
|
||||
super.getListCellRendererComponent(l, names.getOrDefault(v, String.valueOf(v)), i, s, f);
|
||||
c.setIcon(v instanceof Node n ? NodeIcons.icon(n, 14) : null);
|
||||
c.setToolTipText(v instanceof Node n ? n.id() : null);
|
||||
return c;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ 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.NodeIcons;
|
||||
import com.eliasmueller.llamamod.editor.ui.NodeNames;
|
||||
import com.eliasmueller.llamamod.editor.ui.Selection;
|
||||
import com.eliasmueller.llamamod.editor.ui.tools.EditOps;
|
||||
import com.eliasmueller.llamamod.util.SwingListeners;
|
||||
@@ -31,9 +32,7 @@ import java.awt.Rectangle;
|
||||
import java.awt.event.MouseAdapter;
|
||||
import java.awt.event.MouseEvent;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Enumeration;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
@@ -98,6 +97,7 @@ public final class OutlinerPanel extends JPanel {
|
||||
private final Map<Node, DefaultMutableTreeNode> byNode = new IdentityHashMap<>();
|
||||
private final Timer rebuildTimer;
|
||||
private boolean syncing;
|
||||
private NodeNames names;
|
||||
|
||||
public OutlinerPanel(EditorContext ctx) {
|
||||
super(new BorderLayout());
|
||||
@@ -264,6 +264,7 @@ public final class OutlinerPanel extends JPanel {
|
||||
Scene s = ctx.scene();
|
||||
String q = filter.getText().trim().toLowerCase(Locale.ROOT);
|
||||
if (s != null) {
|
||||
names = NodeNames.of(s);
|
||||
build(root, s, q);
|
||||
}
|
||||
syncing = true;
|
||||
@@ -292,12 +293,31 @@ public final class OutlinerPanel extends JPanel {
|
||||
return true;
|
||||
}
|
||||
String l = n.label().toLowerCase(Locale.ROOT);
|
||||
if (l.contains(q) || n.id().contains(q)) {
|
||||
if (l.contains(q) || n.id().contains(q) || nameMatches(names.nameOf(n), q)) {
|
||||
return true;
|
||||
}
|
||||
if (n instanceof Tile t && ("cell " + t.cellX() + ", " + t.cellZ()).contains(q)) {
|
||||
return true;
|
||||
}
|
||||
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").
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
private DefaultMutableTreeNode group(DefaultMutableTreeNode parent, String key, String label, Icon icon) {
|
||||
Row r = new Row(key, label, "", icon, null);
|
||||
DefaultMutableTreeNode t = new DefaultMutableTreeNode(r);
|
||||
@@ -408,7 +428,7 @@ public final class OutlinerPanel extends JPanel {
|
||||
if (match(st, q)) {
|
||||
String c = StratCatalog.category(st.type());
|
||||
cats.computeIfAbsent(c, k -> new TreeMap<>())
|
||||
.computeIfAbsent(st.type().isEmpty() ? "?" : st.type(), k -> new ArrayList<>())
|
||||
.computeIfAbsent(NodeNames.typeName(st), k -> new ArrayList<>())
|
||||
.add(st);
|
||||
}
|
||||
}
|
||||
@@ -423,10 +443,8 @@ public final class OutlinerPanel extends JPanel {
|
||||
if (t.getValue().size() > 1) {
|
||||
parent = group(cg, "type:" + t.getKey(), t.getKey(), NodeIcons.category(c.getKey(), 14));
|
||||
}
|
||||
int nr = 0;
|
||||
for (Strat st : t.getValue()) {
|
||||
String name = t.getValue().size() > 1 ? t.getKey() + " " + ++nr : t.getKey();
|
||||
leaf(parent, st, name, pos(st.gx(), st.gy(), st.gz()));
|
||||
leaf(parent, st, names.objectName(st), pos(st.gx(), st.gy(), st.gz()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -435,56 +453,32 @@ public final class OutlinerPanel extends JPanel {
|
||||
private void buildChains(DefaultMutableTreeNode root, Scene s, String q) {
|
||||
DefaultMutableTreeNode chains =
|
||||
group(root, "chains", "Waypoint chains", NodeIcons.kind(NodeKind.PATH_NODE, 16));
|
||||
Set<PathNode> hasPrev = Collections.newSetFromMap(new IdentityHashMap<>());
|
||||
for (PathNode p : s.pathNodes()) {
|
||||
if (p.next() != null) {
|
||||
hasPrev.add(p.next());
|
||||
}
|
||||
}
|
||||
Map<PathNode, String> users = new IdentityHashMap<>();
|
||||
for (Strat st : s.strats()) {
|
||||
if (st.waypoint() != null) {
|
||||
users.merge(st.waypoint(), st.type(), (a, b) -> a.contains(b) ? a : a + ", " + b);
|
||||
}
|
||||
}
|
||||
Set<PathNode> done = Collections.newSetFromMap(new IdentityHashMap<>());
|
||||
List<PathNode> starts = new ArrayList<>();
|
||||
for (PathNode p : s.pathNodes()) {
|
||||
if (!hasPrev.contains(p)) {
|
||||
starts.add(p);
|
||||
}
|
||||
}
|
||||
for (PathNode p : s.pathNodes()) {
|
||||
if (!starts.contains(p) && !done.contains(p)) {
|
||||
// loops without a start
|
||||
if (starts.stream().noneMatch(st -> s.chain(st).contains(p))) {
|
||||
starts.add(p);
|
||||
done.addAll(s.chain(p));
|
||||
}
|
||||
}
|
||||
}
|
||||
int chainNr = 0;
|
||||
for (PathNode start : starts) {
|
||||
for (PathNode start : names.chainStarts()) {
|
||||
chainNr++;
|
||||
List<PathNode> ch = s.chain(start);
|
||||
String user = users.get(start);
|
||||
String label = "Chain " + chainNr + (user != null ? " – " + user : "");
|
||||
List<PathNode> m = new ArrayList<>();
|
||||
for (PathNode p : ch) {
|
||||
if (match(p, q)) {
|
||||
m.add(p);
|
||||
for (int i = 0; i < ch.size(); i++) {
|
||||
if (match(ch.get(i), q) || nameMatches("waypoint " + (i + 1), q)) {
|
||||
m.add(ch.get(i));
|
||||
}
|
||||
}
|
||||
String user = users.get(start);
|
||||
if (m.isEmpty() && !(user != null && user.toLowerCase(Locale.ROOT).contains(q))) {
|
||||
if (m.isEmpty() && !nameMatches(label, q)) {
|
||||
continue;
|
||||
}
|
||||
if (m.isEmpty()) {
|
||||
m = ch;
|
||||
}
|
||||
DefaultMutableTreeNode g = group(
|
||||
chains,
|
||||
"chain:" + start.id(),
|
||||
"Chain " + chainNr + (user != null ? " – " + user : ""),
|
||||
NodeIcons.kind(NodeKind.PATH_NODE, 16));
|
||||
DefaultMutableTreeNode g =
|
||||
group(chains, "chain:" + start.id(), label, NodeIcons.kind(NodeKind.PATH_NODE, 16));
|
||||
for (PathNode p : m) {
|
||||
leaf(g, p, "Waypoint " + (ch.indexOf(p) + 1), pos(p.gx(), p.gy(), p.gz()));
|
||||
}
|
||||
@@ -496,34 +490,19 @@ public final class OutlinerPanel extends JPanel {
|
||||
DefaultMutableTreeNode zones = group(root, "zones", "Zones", NodeIcons.kind(NodeKind.ENV_ZONE, 16));
|
||||
for (EnvZone z : s.zones()) {
|
||||
if (match(z, q)) {
|
||||
leaf(
|
||||
zones,
|
||||
z,
|
||||
z.isDefault()
|
||||
? "Default zone (fog, sky, music)"
|
||||
: "Z" + z.label().substring(1),
|
||||
z.isDefault() ? "" : "cell " + z.cellX() + ", " + z.cellZ());
|
||||
leaf(zones, z, names.nameOf(z), z.isDefault() ? "" : "cell " + z.cellX() + ", " + z.cellZ());
|
||||
}
|
||||
}
|
||||
DefaultMutableTreeNode lights = group(root, "lights", "Lights", NodeIcons.kind(NodeKind.LIGHT, 16));
|
||||
int lightNr = 0;
|
||||
for (Light l : s.lights()) {
|
||||
lightNr++;
|
||||
if (match(l, q)) {
|
||||
leaf(lights, l, "Light " + lightNr, l.type() == Light.DIRECTIONAL ? "directional" : "point");
|
||||
leaf(lights, l, names.nameOf(l), l.type() == Light.DIRECTIONAL ? "directional" : "point");
|
||||
}
|
||||
}
|
||||
DefaultMutableTreeNode brushes = group(root, "brushes", "Brushes", NodeIcons.kind(NodeKind.BRUSH, 16));
|
||||
Map<String, Integer> sameName = new HashMap<>();
|
||||
for (Brush b : s.brushes()) {
|
||||
sameName.merge(brushName(b), 1, Integer::sum);
|
||||
}
|
||||
Map<String, Integer> brushNr = new HashMap<>();
|
||||
for (Brush b : s.brushes()) {
|
||||
String name = brushName(b);
|
||||
int nr = brushNr.merge(name, 1, Integer::sum);
|
||||
if (match(b, q)) {
|
||||
leaf(brushes, b, sameName.get(name) > 1 ? name + " " + nr : name, brushPlace(b));
|
||||
leaf(brushes, b, names.nameOf(b), brushPlace(b));
|
||||
}
|
||||
}
|
||||
DefaultMutableTreeNode assets = group(root, "assets", "Assets", Icons.get("assets", 16));
|
||||
@@ -547,7 +526,7 @@ public final class OutlinerPanel extends JPanel {
|
||||
leaf(
|
||||
pages,
|
||||
p,
|
||||
p.label().replace("page " + p.id(), "Page " + number(p.id())),
|
||||
p.label().replace("page " + p.id(), "Page " + NodeNames.number(p.id())),
|
||||
p.width() + "×" + p.height());
|
||||
}
|
||||
}
|
||||
@@ -555,16 +534,7 @@ public final class OutlinerPanel extends JPanel {
|
||||
|
||||
/** "model:12 (…)" → "Model 12 (…)" for unnamed tile models. */
|
||||
private static String modelName(TileModel m) {
|
||||
return m.label().replace(m.id(), "Model " + number(m.id()));
|
||||
}
|
||||
|
||||
/** The number part of an id ("model:12" → "12", "page:n3" → "n3"). */
|
||||
private static String number(String id) {
|
||||
return id.substring(id.indexOf(':') + 1);
|
||||
}
|
||||
|
||||
private static String brushName(Brush b) {
|
||||
return b.name().isEmpty() ? b.brushKind().label() : b.name();
|
||||
return m.label().replace(m.id(), "Model " + NodeNames.number(m.id()));
|
||||
}
|
||||
|
||||
/** Centre of a brush (game cells) for the detail column. */
|
||||
|
||||
@@ -115,4 +115,38 @@ class GeometryChecksTest {
|
||||
assertTrue(r.textures().size() > rt);
|
||||
assertTrue(com.eliasmueller.llamamod.editor.io.WadBuilder.build(r).length > 0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void libraryKeepsNoCollision() throws Exception {
|
||||
Scene v = Project.create(cd(), "edit", "t1l1m001").scene();
|
||||
List<Node> sel = new ArrayList<>();
|
||||
for (Tile o : v.tiles()) {
|
||||
if (o.model() != null
|
||||
&& !GeometryActions.isKillModel(o.model())
|
||||
&& o.model().faceCount() > 0) {
|
||||
sel.add(o);
|
||||
if (sel.size() == 3) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
((Tile) sel.get(1)).setRaw("noCollision", true);
|
||||
MeshLibrary.Tables[] tb = new MeshLibrary.Tables[1];
|
||||
var placed = MeshLibrary.collect(v, sel, tb);
|
||||
assertEquals(
|
||||
List.of(false, true, false),
|
||||
placed.stream().map(MeshLibrary.Placed::noCollision).toList());
|
||||
MeshLibrary.Asset a = MeshLibrary.load(MeshLibrary.save("piece", "t1l1m001", placed, tb[0]));
|
||||
Scene r = Project.create(cd(), "new", "t1l4m001").scene();
|
||||
List<Node> out = new ArrayList<>();
|
||||
new Batch("insert", MeshLibrary.insert(r, a, 10, 10, 0, out)).apply(r);
|
||||
assertEquals(
|
||||
List.of(false, true, false),
|
||||
out.stream().map(n -> ((Tile) n).noCollision()).toList());
|
||||
// the model keeps its collision; only the flagged tile is built without it
|
||||
assertEquals(
|
||||
((Tile) sel.get(1)).model().collisionCount(),
|
||||
((Tile) out.get(1)).model().collisionCount());
|
||||
assertTrue(((Tile) out.get(1)).worldCollisionPolygons().isEmpty());
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,7 @@ public final class EditorStress {
|
||||
case "nocd" -> noCd();
|
||||
case "crash" -> crash();
|
||||
case "recover" -> recover();
|
||||
case "status" -> statusMessages();
|
||||
default -> System.out.println("unknown step " + s);
|
||||
}
|
||||
}
|
||||
@@ -164,7 +165,14 @@ public final class EditorStress {
|
||||
System.out.println("open " + f.getFileName() + " -> doc=" + doc + " dialogs="
|
||||
+ dialogs.subList(before, dialogs.size()) + " status=" + st + " "
|
||||
+ (System.currentTimeMillis() - t0) + " ms");
|
||||
if (st.startsWith("Loading")) {
|
||||
if (st.startsWith("Loading") || st.equals("Opening " + f.getFileName())) {
|
||||
// keep the evidence of a stuck task (which thread waits where)
|
||||
Thread.getAllStackTraces().forEach((t, trace) -> {
|
||||
System.out.println("thread " + t.getName() + " " + t.getState());
|
||||
for (StackTraceElement e : trace) {
|
||||
System.out.println(" at " + e);
|
||||
}
|
||||
});
|
||||
throw new AssertionError("status still says " + st + " after a failed open");
|
||||
}
|
||||
}
|
||||
@@ -368,6 +376,32 @@ public final class EditorStress {
|
||||
}));
|
||||
}
|
||||
|
||||
/** An autosave note does not replace a fresh message from a user action (e.g. an export result). */
|
||||
static void statusMessages() throws Exception {
|
||||
String st = EditorTour.edt(() -> {
|
||||
EditorTour.w.status("Exported level.glb");
|
||||
EditorTour.w.backgroundStatus("Autosaved");
|
||||
return EditorTour.w.statusTexts()[0];
|
||||
});
|
||||
if (!st.equals("Exported level.glb")) {
|
||||
throw new AssertionError("user message replaced: " + st);
|
||||
}
|
||||
EditorTour.sleep(6600);
|
||||
st = EditorTour.edt(() -> EditorTour.w.statusTexts()[0]);
|
||||
if (!st.equals("Autosaved")) {
|
||||
throw new AssertionError("autosave note not shown afterwards: " + st);
|
||||
}
|
||||
EditorTour.sleep(6600);
|
||||
String idle = EditorTour.edt(() -> {
|
||||
EditorTour.w.backgroundStatus("Autosaved again");
|
||||
return EditorTour.w.statusTexts()[0];
|
||||
});
|
||||
if (!idle.equals("Autosaved again")) {
|
||||
throw new AssertionError("autosave note not shown on an idle status bar: " + idle);
|
||||
}
|
||||
System.out.println("status messages ok");
|
||||
}
|
||||
|
||||
static void noCd() throws Exception {
|
||||
int before = dialogs.size();
|
||||
EditorTour.edt(() -> {
|
||||
|
||||
@@ -48,6 +48,48 @@ class OutlinerAndErrorsTest {
|
||||
assertTrue(rows.stream().anyMatch(r -> r.matches("[A-Za-z]+ 2")), rows.toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void readableNamesAreSharedAndFilterable() throws Exception {
|
||||
Project p = Project.create(EditorShellTest.cd(), "edit", "t1l1m001");
|
||||
Scene s = p.scene();
|
||||
BuiltinEditorPlugin.brush(s, BrushKind.FLOOR, Map.of("x0", 2.0, "z0", 2.0, "x1", 10.0, "z1", 10.0));
|
||||
BuiltinEditorPlugin.brush(s, BrushKind.FLOOR, Map.of("x0", 20.0, "z0", 2.0, "x1", 30.0, "z1", 10.0));
|
||||
var second = s.brushes().getLast();
|
||||
assertEquals("Floor 2", NodeNames.name(second));
|
||||
var tile = s.tiles().getFirst();
|
||||
assertTrue(NodeNames.name(tile).startsWith("Tile " + tile.cellX() + ", " + tile.cellZ()), NodeNames.name(tile));
|
||||
assertFalse(NodeNames.name(tile).contains("model:"), NodeNames.name(tile));
|
||||
NodeNames names = NodeNames.of(s);
|
||||
var chain = s.chain(names.chainStarts().getFirst());
|
||||
assertTrue(chain.size() >= 2);
|
||||
assertEquals("Waypoint 2 (chain 1)", names.nameOf(chain.get(1)));
|
||||
for (var n : List.<com.eliasmueller.llamamod.editor.model.Node>of(
|
||||
s.strats().getFirst(),
|
||||
second,
|
||||
tile,
|
||||
chain.get(1),
|
||||
s.lights().getFirst(),
|
||||
s.tileModels().getFirst())) {
|
||||
assertFalse(
|
||||
names.nameOf(n).matches(".*\\b(strat|brush|tile|path|light|model|wp):n?\\d+.*"), names.nameOf(n));
|
||||
}
|
||||
OutlinerPanel o = new OutlinerPanel(new EditorShellTest.Ctx(p));
|
||||
javax.swing.JTextField filter = find(o, javax.swing.JTextField.class);
|
||||
JTree tree = find(o, JTree.class);
|
||||
assertEquals(List.of("Floor 2"), filtered(filter, tree, "Floor 2"));
|
||||
List<String> wps = filtered(filter, tree, "waypoint 2");
|
||||
assertTrue(!wps.isEmpty() && wps.stream().allMatch(r -> r.equals("Waypoint 2")), wps.toString());
|
||||
String obj = names.objectName(s.strats().getFirst());
|
||||
assertTrue(filtered(filter, tree, obj).contains(obj), obj);
|
||||
}
|
||||
|
||||
private static List<String> filtered(javax.swing.JTextField f, JTree t, String q) throws Exception {
|
||||
javax.swing.SwingUtilities.invokeAndWait(() -> f.setText(q));
|
||||
Thread.sleep(500);
|
||||
javax.swing.SwingUtilities.invokeAndWait(() -> {});
|
||||
return rows(t);
|
||||
}
|
||||
|
||||
@Test
|
||||
void errorMessagesArePlain() throws Exception {
|
||||
List<Path> files = new ArrayList<>();
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
package com.eliasmueller.llamamod.editor.ui;
|
||||
|
||||
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 java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import javax.swing.SwingUtilities;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/** The completion of a background task always runs and always comes after its progress updates. */
|
||||
class TaskRunnerTest {
|
||||
private final List<String> log = new CopyOnWriteArrayList<>();
|
||||
private CountDownLatch finished;
|
||||
|
||||
private final TaskRunner runner = new TaskRunner(new TaskRunner.Ui() {
|
||||
@Override
|
||||
public void progress(String text, double fraction) {
|
||||
log.add("progress " + text);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void progressDone() {
|
||||
log.add("done");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void failed(String title, Throwable t) {
|
||||
log.add("failed " + t.getClass().getSimpleName());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void finished() {
|
||||
log.add("finished");
|
||||
finished.countDown();
|
||||
}
|
||||
});
|
||||
|
||||
private List<String> run(EditorContext.Task task, java.util.function.Consumer<Object> done) throws Exception {
|
||||
log.clear();
|
||||
finished = new CountDownLatch(1);
|
||||
AtomicBoolean started = new AtomicBoolean();
|
||||
SwingUtilities.invokeAndWait(() -> started.set(runner.start("Task", task, done)));
|
||||
assertTrue(started.get());
|
||||
assertTrue(finished.await(10, TimeUnit.SECONDS), "completion did not run: " + log);
|
||||
SwingUtilities.invokeAndWait(() -> {});
|
||||
assertFalse(runner.busy());
|
||||
return List.copyOf(log);
|
||||
}
|
||||
|
||||
@Test
|
||||
void completionAlwaysRuns() throws Exception {
|
||||
List<String> ok = run(
|
||||
p -> {
|
||||
p.update(0.5, "Loading x");
|
||||
return 42;
|
||||
},
|
||||
r -> log.add("result " + r));
|
||||
assertEquals(List.of("progress Task", "progress Loading x", "done", "result 42", "finished"), ok);
|
||||
assertEquals(
|
||||
List.of("progress Task", "progress Loading x", "done", "failed IOException", "finished"),
|
||||
run(
|
||||
p -> {
|
||||
p.update(0.2, "Loading x");
|
||||
throw new IOException("not a zip");
|
||||
},
|
||||
r -> log.add("unexpected")));
|
||||
assertTrue(run(
|
||||
p -> {
|
||||
throw new StackOverflowError();
|
||||
},
|
||||
null)
|
||||
.contains("failed StackOverflowError"));
|
||||
assertTrue(run(
|
||||
p -> {
|
||||
throw new NoClassDefFoundError("x");
|
||||
},
|
||||
null)
|
||||
.contains("failed NoClassDefFoundError"));
|
||||
// the done callback fails: reported, and the runner is free again
|
||||
assertEquals(List.of("progress Task", "done", "failed IllegalStateException", "finished"), run(p -> 1, r -> {
|
||||
throw new IllegalStateException("setDocument failed");
|
||||
}));
|
||||
}
|
||||
|
||||
@Test
|
||||
void oneTaskAtATimeAndChaining() throws Exception {
|
||||
CountDownLatch release = new CountDownLatch(1);
|
||||
finished = new CountDownLatch(2);
|
||||
AtomicBoolean second = new AtomicBoolean(true);
|
||||
AtomicBoolean chained = new AtomicBoolean();
|
||||
SwingUtilities.invokeAndWait(() -> {
|
||||
runner.start(
|
||||
"Build",
|
||||
p -> {
|
||||
release.await();
|
||||
return null;
|
||||
},
|
||||
// a done callback may start the next task (build → play)
|
||||
r -> chained.set(runner.start("Play", p -> null, null)));
|
||||
second.set(runner.start("Other", p -> null, null));
|
||||
});
|
||||
assertFalse(second.get());
|
||||
assertTrue(runner.busy());
|
||||
release.countDown();
|
||||
assertTrue(finished.await(10, TimeUnit.SECONDS), log.toString());
|
||||
assertTrue(chained.get());
|
||||
SwingUtilities.invokeAndWait(() -> {});
|
||||
assertFalse(runner.busy());
|
||||
}
|
||||
|
||||
@Test
|
||||
void stress() throws Exception {
|
||||
for (int i = 0; i < 200; i++) {
|
||||
int k = i;
|
||||
List<String> l = run(
|
||||
p -> {
|
||||
p.update(0.1, "step");
|
||||
if (k % 2 == 0) {
|
||||
throw new IOException("bad");
|
||||
}
|
||||
return k;
|
||||
},
|
||||
r -> {});
|
||||
assertEquals("finished", l.getLast());
|
||||
assertEquals("done", l.get(l.size() - (k % 2 == 0 ? 3 : 2)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user