diff --git a/docs/manual/03-editor-window.md b/docs/manual/03-editor-window.md index 79a2f40..dd4270c 100644 --- a/docs/manual/03-editor-window.md +++ b/docs/manual/03-editor-window.md @@ -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. diff --git a/docs/manual/07-meshes.md b/docs/manual/07-meshes.md index 7ad1ca4..400d364 100644 --- a/docs/manual/07-meshes.md +++ b/docs/manual/07-meshes.md @@ -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 `/library/.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. ![glTF export dialog and the exported village read back by Import mesh](img/geo_gltf_export.jpg) diff --git a/notes/editor-tools-geometry.md b/notes/editor-tools-geometry.md index dbf1060..2b8b409 100644 --- a/notes/editor-tools-geometry.md +++ b/notes/editor-tools-geometry.md @@ -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. → `/library/.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). diff --git a/notes/editor-ui.md b/notes/editor-ui.md index 97f3059..566dbb9 100644 --- a/notes/editor-ui.md +++ b/notes/editor-ui.md @@ -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 ``); `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 | diff --git a/notes/img/editor/ui_inspector_label_wrap.jpg b/notes/img/editor/ui_inspector_label_wrap.jpg new file mode 100644 index 0000000..344dcca Binary files /dev/null and b/notes/img/editor/ui_inspector_label_wrap.jpg differ diff --git a/notes/img/editor/ui_readable_names.jpg b/notes/img/editor/ui_readable_names.jpg new file mode 100644 index 0000000..9b69b51 Binary files /dev/null and b/notes/img/editor/ui_readable_names.jpg differ diff --git a/notes/img/editor/ui_waypoint_buttons_narrow.jpg b/notes/img/editor/ui_waypoint_buttons_narrow.jpg new file mode 100644 index 0000000..2cd2345 Binary files /dev/null and b/notes/img/editor/ui_waypoint_buttons_narrow.jpg differ diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/MeshLibrary.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/MeshLibrary.java index 5cbaa8b..b98be4a 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/MeshLibrary.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/geometry/MeshLibrary.java @@ -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 entries, List pages) {} @@ -62,7 +66,7 @@ final class MeshLibrary { List 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 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 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 exportObj(List 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'); diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/objects/ObjectsEditorPlugin.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/objects/ObjectsEditorPlugin.java index bd47227..6fb4c37 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/tools/objects/ObjectsEditorPlugin.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/objects/ObjectsEditorPlugin.java @@ -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; diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/objects/ParamsSection.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/objects/ParamsSection.java index eb9dd09..dad477d 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/tools/objects/ParamsSection.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/objects/ParamsSection.java @@ -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)); diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/playtest/PlaytestRunner.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/playtest/PlaytestRunner.java index 0f179bd..6584c26 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/tools/playtest/PlaytestRunner.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/playtest/PlaytestRunner.java @@ -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 withBoot = new ArrayList<>(mods); withBoot.removeIf(m -> m.id().equals(BootToLevelMod.ID)); withBoot.add(bootMod); diff --git a/src/main/java/com/eliasmueller/llamamod/editor/ui/BuiltinEditorPlugin.java b/src/main/java/com/eliasmueller/llamamod/editor/ui/BuiltinEditorPlugin.java index 64106cd..9c7baa0 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/ui/BuiltinEditorPlugin.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/ui/BuiltinEditorPlugin.java @@ -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( diff --git a/src/main/java/com/eliasmueller/llamamod/editor/ui/EditorWindow.java b/src/main/java/com/eliasmueller/llamamod/editor/ui/EditorWindow.java index 5b8ec6d..9d3b452 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/ui/EditorWindow.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/ui/EditorWindow.java @@ -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 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 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 diff --git a/src/main/java/com/eliasmueller/llamamod/editor/ui/NodeNames.java b/src/main/java/com/eliasmueller/llamamod/editor/ui/NodeNames.java new file mode 100644 index 0000000..d9653ac --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/ui/NodeNames.java @@ -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 numbered; + private List starts; + private Map 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 ch = scene.chain(start); + for (int i = 0; i < ch.size(); i++) { + waypoints.putIfAbsent(ch.get(i), new int[] {i + 1, nr}); + } + } + } + return waypoints.get(p); + } + + /** + * First waypoint of every chain in level order (waypoints without a predecessor, then one node per loop + * that has no start). The outliner numbers chains in this order. + */ + public List chainStarts() { + if (starts != null) { + return starts; + } + Set hasPrev = Collections.newSetFromMap(new IdentityHashMap<>()); + for (PathNode p : scene.pathNodes()) { + if (p.next() != null) { + hasPrev.add(p.next()); + } + } + starts = new ArrayList<>(); + for (PathNode p : scene.pathNodes()) { + if (!hasPrev.contains(p)) { + starts.add(p); + } + } + Set reached = Collections.newSetFromMap(new IdentityHashMap<>()); + for (PathNode st : starts) { + reached.addAll(scene.chain(st)); + } + for (PathNode p : scene.pathNodes()) { + if (!reached.contains(p)) { + starts.add(p); + reached.addAll(scene.chain(p)); + } + } + return starts; + } + + private String numbered(Node n) { + if (numbered == null) { + numbered = new IdentityHashMap<>(); + number(scene.strats(), NodeNames::typeName); + number(scene.brushes(), NodeNames::brushName); + int nr = 0; + for (Light l : scene.lights()) { + numbered.put(l, "Light " + ++nr); + } + } + String s = numbered.get(n); + return s != null ? s : fallback(n); + } + + private void number(List nodes, java.util.function.Function base) { + Map count = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + for (T n : nodes) { + count.merge(base.apply(n), 1, Integer::sum); + } + Map nr = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + for (T n : nodes) { + String b = base.apply(n); + numbered.put(n, count.get(b) > 1 ? b + " " + nr.merge(b, 1, Integer::sum) : b); + } + } + + /** Object type or "?" (the outliner's type group key). */ + public static String typeName(Strat st) { + return st.type().isEmpty() ? "?" : st.type(); + } + + public static String brushName(Brush b) { + return b.name().isEmpty() ? b.brushKind().label() : b.name(); + } + + /** Tile model name, or "Model 12" for unnamed models. */ + public static String modelName(TileModel m) { + return m.name().isEmpty() ? "Model " + number(m.id()) : m.name(); + } + + /** The number part of an id ("model:12" → "12", "page:n3" → "n3"). */ + public static String number(String id) { + return id.substring(id.indexOf(':') + 1); + } + + private static String fallback(Node n) { + return switch (n) { + case LevelSettings s -> "Level settings"; + case Regions r -> "Visibility regions"; + case EnvZone z -> z.isDefault() ? "Default zone (fog, sky, music)" : "Zone " + z.cellX() + ", " + z.cellZ(); + case TileModel m -> modelName(m); + case TexturePage p -> p.name().isEmpty() ? "Page " + number(p.id()) : p.name(); + case MeshAsset m -> m.name().isEmpty() ? m.file() : m.name(); + case Strat st -> typeName(st); + case Brush b -> brushName(b); + case PathNode p -> "Waypoint"; + case Light l -> "Light"; + case Tile t -> "Tile " + t.cellX() + ", " + t.cellZ(); + default -> n.label(); + }; + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/ui/PlayTest.java b/src/main/java/com/eliasmueller/llamamod/editor/ui/PlayTest.java index c8cda68..81aaa46 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/ui/PlayTest.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/ui/PlayTest.java @@ -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 - * /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 .llamalevel}, unless * that file is a different project (other kind, base or name) – then {@code -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 disabled = new ArrayList<>(); - ModRegistry reg = new ModRegistry(); - List 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 disabled = (List) 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() - ? "" - : "

Turned off (same level file): " + String.join(", ", disabled); - JOptionPane.showMessageDialog( - w, - "The game is starting.

" - + "In the main menu choose Spiel starten and save slot " + slot - + " – the map cursor" - + " is already on " + ProjectController.esc(levelName) - + "; press Enter to play.

" - + "The level stays installed as the mod \"" + ProjectController.esc(d.title()) - + "\" in LlamaMod" - + " (Mods → Levels)." + extra + "", - "Play-test", - JOptionPane.INFORMATION_MESSAGE); - }); - } } diff --git a/src/main/java/com/eliasmueller/llamamod/editor/ui/ProjectController.java b/src/main/java/com/eliasmueller/llamamod/editor/ui/ProjectController.java index dcdc662..c737fec 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/ui/ProjectController.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/ui/ProjectController.java @@ -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); } diff --git a/src/main/java/com/eliasmueller/llamamod/editor/ui/StatusBar.java b/src/main/java/com/eliasmueller/llamamod/editor/ui/StatusBar.java index 1aec696..2c44c6f 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/ui/StatusBar.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/ui/StatusBar.java @@ -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)); } diff --git a/src/main/java/com/eliasmueller/llamamod/editor/ui/TaskRunner.java b/src/main/java/com/eliasmueller/llamamod/editor/ui/TaskRunner.java new file mode 100644 index 0000000..57348fe --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/ui/TaskRunner.java @@ -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 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 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(); + } + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/ui/WrapLayout.java b/src/main/java/com/eliasmueller/llamamod/editor/ui/WrapLayout.java index 62571a5..b5a0070 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/ui/WrapLayout.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/ui/WrapLayout.java @@ -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); } diff --git a/src/main/java/com/eliasmueller/llamamod/editor/ui/panels/AssetBrowserPanel.java b/src/main/java/com/eliasmueller/llamamod/editor/ui/panels/AssetBrowserPanel.java index a081204..62a2798 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/ui/panels/AssetBrowserPanel.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/ui/panels/AssetBrowserPanel.java @@ -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; } diff --git a/src/main/java/com/eliasmueller/llamamod/editor/ui/panels/InspectorPanel.java b/src/main/java/com/eliasmueller/llamamod/editor/ui/panels/InspectorPanel.java index 8f45677..35e630b 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/ui/panels/InspectorPanel.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/ui/panels/InspectorPanel.java @@ -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(""); + if (wraps) { + l.setText("" + text.replace("&", "&").replace("<", "<") + ""); + 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 model = new DefaultListModel<>(); items.forEach(model::addElement); + Map 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 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; } }); diff --git a/src/main/java/com/eliasmueller/llamamod/editor/ui/panels/OutlinerPanel.java b/src/main/java/com/eliasmueller/llamamod/editor/ui/panels/OutlinerPanel.java index 39ef0c0..401dc45 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/ui/panels/OutlinerPanel.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/ui/panels/OutlinerPanel.java @@ -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 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 hasPrev = Collections.newSetFromMap(new IdentityHashMap<>()); - for (PathNode p : s.pathNodes()) { - if (p.next() != null) { - hasPrev.add(p.next()); - } - } Map 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 done = Collections.newSetFromMap(new IdentityHashMap<>()); - List 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 ch = s.chain(start); + String user = users.get(start); + String label = "Chain " + chainNr + (user != null ? " – " + user : ""); List 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 sameName = new HashMap<>(); for (Brush b : s.brushes()) { - sameName.merge(brushName(b), 1, Integer::sum); - } - Map 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. */ diff --git a/src/test/java/com/eliasmueller/llamamod/editor/tools/geometry/GeometryChecksTest.java b/src/test/java/com/eliasmueller/llamamod/editor/tools/geometry/GeometryChecksTest.java index b4fa6f2..b49cdfc 100644 --- a/src/test/java/com/eliasmueller/llamamod/editor/tools/geometry/GeometryChecksTest.java +++ b/src/test/java/com/eliasmueller/llamamod/editor/tools/geometry/GeometryChecksTest.java @@ -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 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 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()); + } } diff --git a/src/test/java/com/eliasmueller/llamamod/editor/ui/EditorStress.java b/src/test/java/com/eliasmueller/llamamod/editor/ui/EditorStress.java index 9151cef..dd88805 100644 --- a/src/test/java/com/eliasmueller/llamamod/editor/ui/EditorStress.java +++ b/src/test/java/com/eliasmueller/llamamod/editor/ui/EditorStress.java @@ -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(() -> { diff --git a/src/test/java/com/eliasmueller/llamamod/editor/ui/OutlinerAndErrorsTest.java b/src/test/java/com/eliasmueller/llamamod/editor/ui/OutlinerAndErrorsTest.java index 0744764..ad65fd0 100644 --- a/src/test/java/com/eliasmueller/llamamod/editor/ui/OutlinerAndErrorsTest.java +++ b/src/test/java/com/eliasmueller/llamamod/editor/ui/OutlinerAndErrorsTest.java @@ -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.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 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 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 files = new ArrayList<>(); diff --git a/src/test/java/com/eliasmueller/llamamod/editor/ui/TaskRunnerTest.java b/src/test/java/com/eliasmueller/llamamod/editor/ui/TaskRunnerTest.java new file mode 100644 index 0000000..44f8898 --- /dev/null +++ b/src/test/java/com/eliasmueller/llamamod/editor/ui/TaskRunnerTest.java @@ -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 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 run(EditorContext.Task task, java.util.function.Consumer 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 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 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))); + } + } +}