diff --git a/docs/manual/03-editor-window.md b/docs/manual/03-editor-window.md index 3de8a40..79a2f40 100644 --- a/docs/manual/03-editor-window.md +++ b/docs/manual/03-editor-window.md @@ -11,7 +11,7 @@ The window has six areas: | **Options bar** | Above the viewport: the active tool with its options, then **Snap**, the move step, the angle step, **World / Local**, the camera view and **Layers**. | | **Viewport** | The level in 3D. See [Navigating the viewport](04-navigation.md). | | **Panels** | Docked around the viewport: Outliner, Objects, Environment, History on the left, Inspector and Visibility on the right, Assets, Textures and Problems at the bottom. | -| **Status bar** | Messages, the item under the mouse pointer, the cursor position in cells and the grid cell, counts (tiles · objects · waypoints · selected), the result of the last build (click it to open Problems) and the progress of background work. | +| **Status bar** | Messages (and a green **Game running** badge during a play-test), the item under the mouse pointer, the cursor position in cells and the grid cell, counts (tiles · objects · waypoints · selected), the result of the last build (click it to open Problems) and the progress of background work. | ## The start page @@ -68,6 +68,10 @@ top), **Objects** (by category and type), **Waypoint chains**, **Zones**, **Ligh **Brushes** and **Assets** (tile models, imported meshes, texture pages). The filter field finds items by name, 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. + * Click selects, **Shift+click** and **Ctrl+click** add to the selection, double-click frames the item in the viewport. * The eye button on a row shows or hides the item in the viewport. diff --git a/docs/manual/15-build-play.md b/docs/manual/15-build-play.md index 5d3bc7e..e0f77d5 100644 --- a/docs/manual/15-build-play.md +++ b/docs/manual/15-build-play.md @@ -53,6 +53,10 @@ status bar keeps the result of the last build; click it to open the Problems pan ![The game started straight in the level](img/play_boot.jpg) +While the game runs, the editor says so: the status bar shows a green **Game running: …** +badge and the toolbar's **Play** button turns into **Stop game** (click it to quit the game). Both +go back to normal a moment after the game has ended. + In the game, press **Escape** to quit straight back to the editor. When the game has ended, LlamaMod switches your normal mod selection back on and deletes the temporary copy – play-tests do not fill LlamaMod's mod list. While the game runs, the copy shows up in LlamaMod as @@ -69,6 +73,10 @@ in LlamaMod**. of the view, facing the way the camera looks. Point the view at the spot you want to test – a difficult jump, the end of a parkour – and press **Shift+F5**. +While you hold **Shift** (or point at **Play from camera position** in the toolbar or the Level +menu), a small **crosshair** marks the middle of the view: **green** means the llama can start +there, **red** means it cannot – the status bar says why. + ![The llama starting where the editor camera looked](img/play_from_here.jpg) The editor checks that there is walkable **collision** at that point: many things that look solid diff --git a/docs/manual/20-troubleshooting.md b/docs/manual/20-troubleshooting.md index c80c04e..f6f3e38 100644 --- a/docs/manual/20-troubleshooting.md +++ b/docs/manual/20-troubleshooting.md @@ -21,6 +21,7 @@ | The editor is slow in a big level | Hide what you do not need (**H**, or switch layers off), close the Textures window, use the top view for large moves. | | A key does nothing | Single-letter keys do not work while a text field has the focus – click into the viewport first. Some keys are shared by two tools (see [Keys](19-shortcuts.md#keys)); use the toolbar button. | | I lost my changes after a crash | Start the editor again and choose **Recover** – autosave keeps unsaved changes (Edit → Preferences sets how often). | +| A project does not open ("…is damaged", "…does not exist") | The current level stays open. Try the autosave (start the editor again → **Recover**) or an older copy of the file. When you report a bug, click **Details** in the message and copy the text. | | A panel is gone | **View → Panels** opens it again. | | An object type I want is not in the list | The Objects list shows the base level's types and the ones you imported. Import the type from **Assets → All worlds** ([Importing objects from other worlds](09-objects.md#importing-objects-from-other-worlds)). | @@ -31,7 +32,7 @@ | Build or Play is refused | The Problems panel lists errors; fix them first (double-click a message to find the item). | | "The game is still running" | Level files cannot be replaced while the game runs – click **Quit game and play** in the message, or quit the game yourself. | | The llama falls through the floor | Show collision (**C**). The floor needs collision: check the brush's collision faces in **Brush shape**, or paint the floor with the **Collision** tool. | -| **Play from camera position** says there is no floor | Point the middle of the view at walkable ground. Tree crowns, roofs and decoration often have no collision. | +| **Play from camera position** says there is no floor | Point the middle of the view at walkable ground (hold **Shift**: the crosshair turns green on a valid spot). Tree crowns, roofs and decoration often have no collision. | | The exit does not work | The LevelDoor needs **exit floor (16)** in its cell. The **Exit** setup in the Objects panel places both together. | | The level does not load / the game crashes | **Level → Check for problems**; keep tiles inside the grid; exactly one player start; no tile model over 8000 vertices. | | My level is missing on the world map | New levels are in world 9 (TestLevel). Unlock it in LlamaMod's **Save games** tab, or play with **F5**. | @@ -69,7 +70,8 @@ extra wrapper should be needed. * The game runs through **Wine**; install it from your distribution. **Settings → Wine command** points LlamaMod at a specific Wine (leave it empty for the installed one); the line below it - says whether that Wine was found. + says whether that Wine was found and its version. Wine older than 5.0 is marked in red, and + **Play** warns once before starting the game with it (you can still play anyway). * The first **Play** prepares Wine for the game, which can take a minute. * **Settings → Run in a window** (Wine virtual desktop) is recommended – otherwise the game switches the whole screen to its resolution. diff --git a/notes/editor-ui.md b/notes/editor-ui.md index 8be2bc5..97f3059 100644 --- a/notes/editor-ui.md +++ b/notes/editor-ui.md @@ -113,7 +113,9 @@ 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)` | -| `Widgets`, `util.SwingListeners` | `Widgets.button(text, icon, tip, run)`, `Widgets.listCell(c, list, selected)`; `onTextChange`, `onMenuOpen` | +| `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 | | `AssetDrop` | `register(kind, handler)`; `dragSource(list, value → payload(kind, data))` makes a list draggable into the viewport | | `EditorPrefs` | `ctx.prefs()` JSON settings in `/editor/settings.json`; prefix your keys | | `NodeCopy` | duplicate/snapshot nodes (fresh ids) | @@ -124,6 +126,7 @@ game space (`ToolEvent.toGame` / `toExport`). Drop/placement points: `ctx.ground ### Render additions used by tools (backwards compatible, `editor.render`) +`Viewport.setHudOverlay(pixels)` (second pixel overlay independent of the tool, e.g. the Shift+F5 crosshair), `Viewport.setInput(ViewportInput)` (tool hook: left-button press/drag/release not on the gizmo, moves, keys), `setToolOverlay(depthTested, onTop)`, `setScreenOverlay(pixels)` (marquee), `unproject(x, y, depth)` public, `isFlying()`, `canvas()`; `RenderScene.overlaysChanged()`; `LevelSceneAdapter.mesh/page/collision` @@ -157,7 +160,8 @@ public; invisible tile instances are no longer baked into chunks; `History.undoL 3; backup first) unlocked up to the level if needed and map cursor on it → start the game. * **Boot to level** (phase 3, `editor.tools.playtest`, `notes/editor-playtest.md`): Play now also applies the exe mod `boot-to-level`, so the game loads the level directly (no menus) and Escape - quits; Shift+F5 = play from the camera position. The old menu-based run is still available + quits; Shift+F5 = play from the camera position (holding Shift or hovering the action shows a green/red + crosshair at that point). While the game runs: "Game running" badge + **Stop game** button (see Game state). The old menu-based run is still available (Level → *Play boots straight into the level* off). `-Dllamamod.editor.noLaunch=true` skips the launch (tests start the game through apptest). diff --git a/notes/img/editor/ui_error_details.jpg b/notes/img/editor/ui_error_details.jpg new file mode 100644 index 0000000..47180a6 Binary files /dev/null and b/notes/img/editor/ui_error_details.jpg differ diff --git a/notes/img/editor/ui_inspector_wrap.jpg b/notes/img/editor/ui_inspector_wrap.jpg new file mode 100644 index 0000000..3a032de Binary files /dev/null and b/notes/img/editor/ui_inspector_wrap.jpg differ diff --git a/notes/img/editor/ui_outliner_names.jpg b/notes/img/editor/ui_outliner_names.jpg new file mode 100644 index 0000000..3946ef7 Binary files /dev/null and b/notes/img/editor/ui_outliner_names.jpg differ diff --git a/notes/img/editor/ui_playhere_crosshair.jpg b/notes/img/editor/ui_playhere_crosshair.jpg new file mode 100644 index 0000000..a8f8e6b Binary files /dev/null and b/notes/img/editor/ui_playhere_crosshair.jpg differ diff --git a/notes/img/editor/ui_playtest_running.jpg b/notes/img/editor/ui_playtest_running.jpg new file mode 100644 index 0000000..afaab44 Binary files /dev/null and b/notes/img/editor/ui_playtest_running.jpg differ diff --git a/notes/ux-review-editor.md b/notes/ux-review-editor.md index 856e598..ba01064 100644 --- a/notes/ux-review-editor.md +++ b/notes/ux-review-editor.md @@ -59,10 +59,10 @@ Severity: **H** = blocks or seriously misleads; **M** = friction every session; | 20 | L | Open original level | The description was clipped (the dialog was packed before the text was set), and the selected row's id was grey on orange. | **Fixed** | | 21 | L | Status bar | The last-build label was cut ("Built 03:37:16 · 6158 KB · 2 w…"). | **Fixed**: "Built 03:37 · 6.0 MB · 2 warnings" | | 22 | M | New level | A new level keeps the base level's 13 zones and 30 lights. Their outlines float far from the 16×14 floor (pink boxes in the sky), which is confusing in an empty level. | **Fixed (0.2.0)**: new projects keep only the start zone and the directional lights; teleport zones, point lights, waypoints and region masks of the base are removed (stored as edits, old projects unchanged). Directional light markers are drawn above the player start | -| 23 | M | Play-test | While the game runs, nothing in the editor says so: the status line falls back to "Ready" after 6 s. | Not fixed. It needs a sticky status or a "Playing…" state of the Play button (a shell API addition) | -| 24 | L | Shift+F5 | There is no mark in the viewport showing which point "the middle of the view" is. | Not fixed. Suggestion: a small crosshair while Shift is held | -| 25 | L | Outliner | Rows show technical ids (`brush:n19`, `strat:6`). Several brushes are all called "Floor". | Not fixed (cosmetic) | -| 26 | L | Inspector hints | Help texts in tool sections use fixed 215–230 px HTML widths, so they don't use a wider dock. | Not fixed (cosmetic) | +| 23 | M | Play-test | While the game runs, nothing in the editor says so: the status line falls back to "Ready" after 6 s. | Fixed (edui): `EditorContext.setGameRunning(what, stop)`; while the play-test game runs the status bar shows a green "Game running: " badge and the toolbar Play button becomes **Stop game**; both reset when the watcher sees the game exit (`img/editor/ui_playtest_running.jpg`) | +| 24 | L | Shift+F5 | There is no mark in the viewport showing which point "the middle of the view" is. | Fixed (edui): crosshair in the middle of the viewport while Shift is held (300 ms delay, hidden while a mouse button is down) or while *Play from camera position* is hovered; green = valid start, red = not (status bar says why) – `tools.playtest.PlayHereMarker`, `Viewport.setHudOverlay` | +| 25 | L | Outliner | Rows show technical ids (`brush:n19`, `strat:6`). Several brushes are all called "Floor". | Fixed (edui): rows read "Coin 3", "Floor 2", "Waypoint 4", "Chain 1 – Llama", "Light 2", "Model 12 (…)", position/height in the grey column; the id is the row tooltip (filter still matches ids) | +| 26 | L | Inspector hints | Help texts in tool sections use fixed 215–230 px HTML widths, so they don't use a wider dock. | Fixed (edui): `Widgets.wrappingLabel(html)` wraps at the container/viewport width (Inspector hints, `GeoWidgets.hint`, environment `Ui.Form.note`, `ParamsSection.note`) | | 27 | L | Problems panel | It shows "No problems." before any check has run. | Not fixed | | 28 | L | Duplicates | Objects can be placed from both the Objects panel (left) and Assets → Objects (bottom). | Kept: both are documented, and drag & drop needs the grid | | 29 | L | Menus | Key names come from AWT ("Ctrl+Open Bracket"), while the manual writes "Ctrl+[". | Not fixed (would need a custom accelerator renderer) | diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/environment/Ui.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/environment/Ui.java index 666115b..d33c911 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/tools/environment/Ui.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/environment/Ui.java @@ -1,6 +1,7 @@ package com.eliasmueller.llamamod.editor.tools.environment; import com.eliasmueller.llamamod.editor.ui.Icons; +import com.eliasmueller.llamamod.editor.ui.Widgets; import java.awt.BasicStroke; import java.awt.Color; import java.awt.Component; @@ -110,7 +111,7 @@ final class Ui { } Form note(String text) { - JLabel l = new JLabel("" + text + ""); + JLabel l = Widgets.wrappingLabel(text); l.setForeground(UIManager.getColor("Label.disabledForeground")); l.setFont(l.getFont().deriveFont(l.getFont().getSize2D() - 1f)); return full(l); 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 02161d4..bd47227 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 @@ -290,6 +290,8 @@ public final class ObjectsEditorPlugin implements EditorPlugin { look.addActionListener(e -> ctx.run("objects.pilot")); b.add(look); ParamsSection.compact(b); + // same alignment as the note above (BoxLayout aligns mixed alignments on one axis) + b.setAlignmentX(0); root.add(b); return root; } 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 231f38a..eb9dd09 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 @@ -15,6 +15,7 @@ import com.eliasmueller.llamamod.editor.tools.objects.ParamSchema.TypeSchema; 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 java.awt.BorderLayout; import java.awt.Color; import java.awt.FlowLayout; @@ -132,6 +133,9 @@ final class ParamsSection implements InspectorSection { if (st.type().equals("LevelDoor") || st.type().equals("VillageEndDoor")) { exitRow(st); } + if (Guards.is(st.type())) { + guardRow(st); + } for (int i = 0; i < Math.max(n, sc != null ? sc.paramCount() : 0); i++) { ParamDef d = sc != null ? sc.param(i) : null; if (d == null) { @@ -172,7 +176,7 @@ final class ParamsSection implements InspectorSection { } static JLabel note(String html) { - JLabel l = new JLabel("" + html + ""); + JLabel l = Widgets.wrappingLabel(html); l.setFont(l.getFont().deriveFont(11.5f)); l.setForeground(UIManager.getColor("Label.disabledForeground")); return l; @@ -322,6 +326,39 @@ final class ParamsSection implements InspectorSection { + " floor (collision material 16) in its grid cell.")); } + private void guardRow(Strat st) { + Scene s = ctx.scene(); + String gone = Guards.removedBecause(s, st); + int bit = Guards.eventBit(st); + String txt = gone != null + ? "Never appears: the game removes it at level start because " + gone + "." + : bit > 0 + ? "Starts inside its box. Hidden until event bit " + bit + + " is set by another object (0 = active from the start)." + : "Starts inside its box; active from the start."; + if (gone == null) { + full(note(txt)); + return; + } + JPanel w = new JPanel(new BorderLayout(6, 0)); + w.setOpaque(false); + w.add( + new JLabel( + "" + txt + "", + Icons.get("warning", 14), + JLabel.LEFT), + BorderLayout.CENTER); + JButton fix = new JButton("Box around it"); + fix.setFocusable(false); + fix.setToolTipText("Replace its waypoints with one 4×4 box around the guard"); + fix.addActionListener(e -> { + ctx.execute(Setups.guardBox(s, st)); + javax.swing.SwingUtilities.invokeLater(this::refresh); + }); + w.add(fix, BorderLayout.EAST); + full(w); + } + private void waypointRow(Strat st, TypeSchema sc, TypeStats.Stat stats) { Scene s = ctx.scene(); Role role = Roles.of(st); diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/playtest/PlayHereMarker.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/playtest/PlayHereMarker.java new file mode 100644 index 0000000..f8eba10 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/playtest/PlayHereMarker.java @@ -0,0 +1,225 @@ +package com.eliasmueller.llamamod.editor.tools.playtest; + +import com.eliasmueller.llamamod.editor.render.Legend; +import com.eliasmueller.llamamod.editor.render.Overlay; +import com.eliasmueller.llamamod.editor.render.Viewport; +import com.eliasmueller.llamamod.editor.ui.ActionSpec; +import com.eliasmueller.llamamod.editor.ui.EditorContext; +import java.awt.AWTEvent; +import java.awt.Component; +import java.awt.Container; +import java.awt.KeyboardFocusManager; +import java.awt.Toolkit; +import java.awt.Window; +import java.awt.event.AWTEventListener; +import java.awt.event.KeyEvent; +import java.awt.event.MouseEvent; +import java.awt.event.WindowAdapter; +import java.awt.event.WindowEvent; +import java.util.Locale; +import javax.swing.AbstractButton; +import javax.swing.Action; +import javax.swing.ButtonModel; +import javax.swing.JFrame; +import javax.swing.JMenu; +import javax.swing.SwingUtilities; +import javax.swing.Timer; +import javax.swing.text.JTextComponent; + +/** + * Crosshair in the middle of the viewport while Shift is held (or "Play from camera position" is + * hovered in the toolbar/menu): the point where Shift+F5 puts the llama. Green = valid start, + * red = no usable floor there (the status bar says why). + */ +final class PlayHereMarker { + static final String ACTION = "level.playHere"; + private static final int OK = Legend.withAlpha(Legend.rgb(80, 230, 90), 255); + private static final int BAD = Legend.withAlpha(Legend.rgb(240, 70, 60), 255); + private static final int SHADOW = Legend.withAlpha(Legend.rgb(0, 0, 0), 170); + + private final EditorContext ctx; + private final Timer delay; + private final Timer refresh; + private boolean shift; + private boolean shiftArmed; + private boolean hover; + private boolean mouseDown; + private boolean shown; + private String lastStatus; + + private PlayHereMarker(EditorContext ctx) { + this.ctx = ctx; + // Shift+click / Shift+drag should not flash the crosshair + delay = new Timer(300, e -> { + shiftArmed = true; + update(); + }); + delay.setRepeats(false); + refresh = new Timer(200, e -> draw()); + } + + static void install(EditorContext ctx) { + JFrame f = ctx.frame(); + if (f == null || ctx.viewport() == null) { + return; + } + PlayHereMarker m = new PlayHereMarker(ctx); + AWTEventListener l = m::event; + Toolkit.getDefaultToolkit().addAWTEventListener(l, AWTEvent.KEY_EVENT_MASK | AWTEvent.MOUSE_EVENT_MASK); + f.addWindowFocusListener(new WindowAdapter() { + @Override + public void windowLostFocus(WindowEvent e) { + m.shift = false; + m.update(); + } + }); + f.addWindowListener(new WindowAdapter() { + @Override + public void windowClosed(WindowEvent e) { + Toolkit.getDefaultToolkit().removeAWTEventListener(l); + m.delay.stop(); + m.refresh.stop(); + } + }); + m.hookButtons(f.getJMenuBar()); + m.hookButtons(f.getContentPane()); + } + + /** Tracks hovering of the toolbar button and menu item of "Play from camera position". */ + private void hookButtons(Container c) { + if (c == null) { + return; + } + Component[] kids = c instanceof JMenu m ? m.getMenuComponents() : c.getComponents(); + for (Component k : kids) { + if (k instanceof AbstractButton b && !(k instanceof JMenu) && isPlayHere(b.getAction())) { + ButtonModel bm = b.getModel(); + bm.addChangeListener(e -> { + boolean h = bm.isRollover() || bm.isArmed(); + if (h != hover) { + hover = h; + update(); + } + }); + } + if (k instanceof Container cc) { + hookButtons(cc); + } + } + } + + private static boolean isPlayHere(Action a) { + return a != null && a.getValue("spec") instanceof ActionSpec s && ACTION.equals(s.id()); + } + + private void event(AWTEvent e) { + if (!(e.getSource() instanceof Component c) || window(c) != ctx.frame()) { + return; + } + if (e instanceof KeyEvent k) { + if (k.getKeyCode() == KeyEvent.VK_SHIFT) { + if (k.getID() == KeyEvent.KEY_PRESSED && !shift && !typing()) { + shift = true; + shiftArmed = false; + delay.restart(); + } else if (k.getID() == KeyEvent.KEY_RELEASED) { + shift = false; + update(); + } + } else if (k.getID() == KeyEvent.KEY_PRESSED && shift && k.getKeyCode() != KeyEvent.VK_F5) { + // Shift+Z, Shift+A, …: a shortcut, not "where would I start" + shift = false; + update(); + } + } else if (e instanceof MouseEvent m) { + if (m.getID() == MouseEvent.MOUSE_PRESSED) { + mouseDown = true; + update(); + } else if (m.getID() == MouseEvent.MOUSE_RELEASED) { + mouseDown = false; + update(); + } + } + } + + private static Window window(Component c) { + return c instanceof Window w ? w : SwingUtilities.getWindowAncestor(c); + } + + private static boolean typing() { + return KeyboardFocusManager.getCurrentKeyboardFocusManager().getFocusOwner() instanceof JTextComponent; + } + + /** Shown while hovered, or while Shift is held (after a short delay, without a mouse button). */ + boolean wanted() { + return ctx.document() != null && (hover || shift && shiftArmed && !mouseDown); + } + + private void update() { + boolean want = wanted(); + if (want == shown) { + return; + } + shown = want; + if (want) { + lastStatus = null; + draw(); + refresh.start(); + } else { + refresh.stop(); + ctx.viewport().setHudOverlay(null); + } + } + + private void draw() { + Viewport vp = ctx.viewport(); + if (!shown || ctx.scene() == null) { + return; + } + double[] st = new double[4]; + String problem = PlaytestEditorPlugin.startProblem(ctx, st); + int w = vp.canvas().getWidth(), h = vp.canvas().getHeight(); + vp.setHudOverlay(crosshair(w / 2f, h / 2f, problem == null ? OK : BAD)); + String msg = problem == null + ? String.format( + Locale.ROOT, + "Shift+F5 starts the llama at the crosshair (%.1f, %.1f, %.1f)", + st[0], + st[1], + st[2]) + : "Shift+F5: " + problem; + if (!msg.equals(lastStatus)) { + lastStatus = msg; + ctx.status(msg); + } + } + + /** Four arms with a gap and a centre dot, with a dark outline so it shows on any ground. */ + static Overlay crosshair(float cx, float cy, int rgba) { + Overlay o = new Overlay(); + float a = 5, b = 17, t = 1.5f; + float[][] bars = { + {cx - b, cy - t, cx - a, cy + t}, + {cx + a, cy - t, cx + b, cy + t}, + {cx - t, cy - b, cx + t, cy - a}, + {cx - t, cy + a, cx + t, cy + b}, + {cx - t, cy - t, cx + t, cy + t} + }; + // outline as separate strips: overlapping overlay triangles at equal depth may hide each other + for (float[] r : bars) { + rect(o, r[0] - 1, r[1] - 1, r[2] + 1, r[1], SHADOW); + rect(o, r[0] - 1, r[3], r[2] + 1, r[3] + 1, SHADOW); + rect(o, r[0] - 1, r[1], r[0], r[3], SHADOW); + rect(o, r[2], r[1], r[2] + 1, r[3], SHADOW); + } + for (float[] r : bars) { + rect(o, r[0], r[1], r[2], r[3], rgba); + } + return o; + } + + private static void rect(Overlay o, float x0, float y0, float x1, float y1, int c) { + o.triangle(new double[] {x0, y0, 0}, new double[] {x1, y0, 0}, new double[] {x1, y1, 0}, c); + o.triangle(new double[] {x0, y0, 0}, new double[] {x1, y1, 0}, new double[] {x0, y1, 0}, c); + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/playtest/PlaytestEditorPlugin.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/playtest/PlaytestEditorPlugin.java index 95698dc..e50c17c 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/tools/playtest/PlaytestEditorPlugin.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/playtest/PlaytestEditorPlugin.java @@ -98,6 +98,7 @@ public final class PlaytestEditorPlugin implements EditorPlugin { + " chapter or original slot), to play it from LlamaMod or share the mod list")); x.toolbarAction("level.playHere", "build"); x.onOpen(PlaytestRunner::resumeRestore); + x.onOpen(PlayHereMarker::install); } private static void toggle(EditorContext c, String pref) { @@ -114,37 +115,47 @@ public final class PlaytestEditorPlugin implements EditorPlugin { } static void playHere(EditorContext ctx) { + double[] start = new double[4]; + String problem = startProblem(ctx, start); + if (problem != null) { + info(ctx, problem); + return; + } + PlaytestRunner.play(ctx, start); + } + + /** + * Why the middle of the view is no valid start, or null: then {@code out} = {x, y, z, yaw} of the + * floor point there (game cells). + */ + static String startProblem(EditorContext ctx, double[] out) { + if (ctx.scene() == null) { + return "No level is open."; + } double[] start = cameraStart(ctx.viewport()); if (start == null) { - info( - ctx, - "Point the view at the ground where the llama should start (the middle of the viewport" - + " must show level geometry)."); - return; + return "Point the view at the ground where the llama should start (the middle of the viewport" + + " must show level geometry)."; } FloorFinder.Floor floor = FloorFinder.near(ctx.scene(), start[0], start[1], start[2]); - if (floor == null || floor.material() == FloorFinder.DEATH) { - info( - ctx, - floor == null - ? "There is no collision floor under the middle of the view (decorations such as trees" - + " and roofs often have none) – the llama would fall through. Point the view at" - + " walkable ground." - : "The ground in the middle of the view is a death floor (material 30)."); - return; + if (floor == null) { + return "There is no collision floor under the middle of the view (decorations such as trees" + + " and roofs often have none) – the llama would fall through. Point the view at" + + " walkable ground."; + } + if (floor.material() == FloorFinder.DEATH) { + return "The ground in the middle of the view is a death floor (material 30)."; } if (!FloorFinder.visible(ctx.scene(), floor, floor.x(), floor.z())) { - info( - ctx, - "That spot is outside the visible part of the level: its floor belongs to a tile that the" - + " visibility regions do not draw from there (the game would show an empty" - + " world). Choose a spot on the normal walkable ground."); - return; + return "That spot is outside the visible part of the level: its floor belongs to a tile that the" + + " visibility regions do not draw from there (the game would show an empty" + + " world). Choose a spot on the normal walkable ground."; } - start[0] = floor.x(); - start[1] = floor.y() + 0.05; - start[2] = floor.z(); - PlaytestRunner.play(ctx, start); + out[0] = floor.x(); + out[1] = floor.y() + 0.05; + out[2] = floor.z(); + out[3] = start[3]; + return null; } /** Ground point in the middle of the view (game cells) + camera heading; null if no geometry there. */ 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 400edde..0f179bd 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 @@ -275,6 +275,7 @@ public final class PlaytestRunner { JOptionPane.INFORMATION_MESSAGE); } if (!noLaunch) { + w.setGameRunning(levelName, () -> stopGame(env)); watch(w, env, session, true); } }); @@ -299,6 +300,7 @@ public final class PlaytestRunner { private static void watch(EditorWindow w, EditorEnv env, Session s, boolean launching) { Thread t = new Thread( () -> { + Thread me = Thread.currentThread(); try { GameLauncher gl = new GameLauncher(env.paths(), env.config()); long t0 = System.currentTimeMillis(); @@ -311,6 +313,7 @@ public final class PlaytestRunner { break; } } + SwingUtilities.invokeLater(() -> ended(w, me)); restore(env, s); SwingUtilities.invokeLater(() -> { env.modsChanged(); @@ -319,8 +322,11 @@ public final class PlaytestRunner { } catch (InterruptedException e) { Thread.currentThread().interrupt(); } catch (IOException | RuntimeException e) { - SwingUtilities.invokeLater(() -> w.status("Could not restore the mod selection after the" - + " play-test (apply it in LlamaMod): " + e.getMessage())); + SwingUtilities.invokeLater(() -> { + ended(w, me); + w.status("Could not restore the mod selection after the" + + " play-test (apply it in LlamaMod): " + e.getMessage()); + }); } }, "playtest-restore"); @@ -333,6 +339,20 @@ public final class PlaytestRunner { t.start(); } + /** Clears the "game running" state unless a newer play-test has started meanwhile. */ + private static void ended(EditorWindow w, Thread me) { + if (watcher == me) { + w.setGameRunning(null, null); + } + } + + /** Stop button: quits the game; the watcher then restores the mod selection. */ + static void stopGame(EditorEnv env) { + Thread t = new Thread(() -> new GameLauncher(env.paths(), env.config()).kill(), "playtest-stop"); + t.setDaemon(true); + t.start(); + } + static void restore(EditorEnv env, Session s) throws IOException { Config cfg = env.config(); synchronized (PlaytestRunner.class) { @@ -416,6 +436,9 @@ public final class PlaytestRunner { } return; } + if (new GameLauncher(env.paths(), env.config()).isRunning()) { + w.setGameRunning("the previous play-test", () -> stopGame(env)); + } watch(w, env, s, false); } diff --git a/src/main/java/com/eliasmueller/llamamod/editor/ui/EditorContext.java b/src/main/java/com/eliasmueller/llamamod/editor/ui/EditorContext.java index 1bf5896..89e505a 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/ui/EditorContext.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/ui/EditorContext.java @@ -131,6 +131,13 @@ public interface EditorContext { void error(String title, Throwable t); + /** + * Play-test state: {@code what} (e.g. the level name) while a game started from the editor runs, + * null once it has ended. The window shows it until then (status bar, the Play button becomes + * Stop); {@code stop} quits the game (null = no Stop button). + */ + default void setGameRunning(String what, Runnable stop) {} + /** Shows (and focuses) a panel. */ void showPanel(String id); 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 bf0ca34..5b8ec6d 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/ui/EditorWindow.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/ui/EditorWindow.java @@ -120,6 +120,9 @@ public final class EditorWindow extends JFrame implements EditorContext { private SwingWorker task; private final Overlay toolOverlay = new Overlay(); private final Overlay toolTop = new Overlay(); + private JButton playButton; + private String gameRunning; + private Runnable gameStop; public EditorWindow(EditorEnv env) { super("LlamaMod Level Editor"); @@ -460,11 +463,8 @@ public final class EditorWindow extends JFrame implements EditorContext { b.setFocusable(false); b.setFont(b.getFont().deriveFont(Font.BOLD)); if (id.equals("level.play")) { - b.putClientProperty( - "FlatLaf.style", - "background: #C85A1E; foreground: #FFFFFF; borderColor: #A8441A;" - + " hoverBackground: #D86A2E; pressedBackground: #A8441A; arc: 10"); - b.setIcon(Icons.tinted("play", 16, Color.WHITE)); + playButton = b; + stylePlayButton(); } b.setMargin(new Insets(4, 10, 4, 12)); tb.add(Box.createHorizontalStrut(6)); @@ -474,6 +474,56 @@ public final class EditorWindow extends JFrame implements EditorContext { return tb; } + private void stylePlayButton() { + JButton b = playButton; + if (gameRunning == null) { + b.setAction(actions.get("level.play")); + b.putClientProperty( + "FlatLaf.style", + "background: #C85A1E; foreground: #FFFFFF; borderColor: #A8441A;" + + " hoverBackground: #D86A2E; pressedBackground: #A8441A; arc: 10"); + b.setIcon(Icons.tinted("play", 16, Color.WHITE)); + return; + } + Runnable stop = gameStop; + b.setAction(new AbstractAction("Stop game") { + @Override + public void actionPerformed(ActionEvent e) { + status("Quitting the game…"); + stop.run(); + } + }); + b.setToolTipText("The play-test of " + gameRunning + " is running – click to quit the game"); + b.putClientProperty( + "FlatLaf.style", + "background: #3F7F4A; foreground: #FFFFFF; borderColor: #2F6A3A;" + + " hoverBackground: #4C9358; pressedBackground: #2F6A3A; arc: 10"); + b.setIcon(Icons.tinted("stop", 16, Color.WHITE)); + } + + /** Status bar texts for tests: {message, sticky game state or null}. */ + String[] statusTexts() { + return new String[] {status.messageText(), status.gameText()}; + } + + /** The play-test game started from this window, or null (see {@link #setGameRunning}). */ + public String gameRunning() { + return gameRunning; + } + + @Override + public void setGameRunning(String what, Runnable stop) { + gameRunning = what; + gameStop = stop; + if (status != null) { + status.game(what); + } + if (playButton != null) { + stylePlayButton(); + playButton.setEnabled(what == null ? actions.get("level.play").isEnabled() : stop != null); + } + } + private void addSection(JToolBar tb, List ids) { if (ids == null) { return; @@ -1203,6 +1253,7 @@ public final class EditorWindow extends JFrame implements EditorContext { } catch (ExecutionException e) { Throwable c = e.getCause(); if (!(c instanceof CancellationException)) { + status.message(title + " failed"); error(title, c); } } @@ -1233,16 +1284,7 @@ public final class EditorWindow extends JFrame implements EditorContext { @Override public void error(String title, Throwable t) { t.printStackTrace(); - String msg = t.getMessage() == null ? t.toString() : t.getMessage(); - if (msg.length() > 1500) { - msg = msg.substring(0, 1500) + " …"; - } - JOptionPane.showMessageDialog( - this, - "" + ProjectController.esc(msg).replace("\n", "
") - + "", - title, - JOptionPane.ERROR_MESSAGE); + ErrorDialog.show(this, title, t); } @Override diff --git a/src/main/java/com/eliasmueller/llamamod/editor/ui/ErrorDialog.java b/src/main/java/com/eliasmueller/llamamod/editor/ui/ErrorDialog.java new file mode 100644 index 0000000..c7e2f3e --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/ui/ErrorDialog.java @@ -0,0 +1,181 @@ +package com.eliasmueller.llamamod.editor.ui; + +import java.awt.BorderLayout; +import java.awt.Component; +import java.awt.Dimension; +import java.awt.FlowLayout; +import java.awt.Font; +import java.awt.Window; +import java.io.FileNotFoundException; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.io.UncheckedIOException; +import java.lang.reflect.InvocationTargetException; +import java.nio.file.AccessDeniedException; +import java.nio.file.FileSystemException; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import java.util.Locale; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ExecutionException; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.zip.ZipException; +import javax.swing.BorderFactory; +import javax.swing.JComponent; +import javax.swing.JLabel; +import javax.swing.JOptionPane; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.JTextArea; +import javax.swing.JToggleButton; +import javax.swing.SwingUtilities; + +/** Error message for users: a plain sentence, the technical text (exception, stack trace) behind "Details". */ +public final class ErrorDialog { + /** Client property of the message panel holding the plain text (tests). */ + public static final String MESSAGE_KEY = "llamamod.errorMessage"; + + // "java.lang.ClassCastException", "ClassCastException", "NumberFormatException" + private static final Pattern TECHNICAL = Pattern.compile( + "(?:\\b[a-z_$][\\w$]*\\.)+[A-Z][\\w$]*(?:Exception|Error)\\b|\\b[A-Z]\\w*(?:Exception|Error)\\b"); + + private ErrorDialog() {} + + public static void show(Component parent, String title, Throwable t) { + JOptionPane.showMessageDialog(parent, panel(t), title, JOptionPane.ERROR_MESSAGE); + } + + static JComponent panel(Throwable t) { + String plain = plain(t); + JPanel p = new JPanel(new BorderLayout(0, 8)); + p.putClientProperty(MESSAGE_KEY, plain); + JLabel l = new JLabel("" + + ProjectController.esc(plain).replace("\n", "
") + ""); + p.add(l, BorderLayout.NORTH); + JTextArea ta = new JTextArea(details(t), 12, 60); + ta.setEditable(false); + ta.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 11)); + ta.setCaretPosition(0); + JScrollPane sp = new JScrollPane(ta); + sp.setPreferredSize(new Dimension(560, 200)); + sp.setVisible(false); + JToggleButton more = new JToggleButton("Details", Icons.get("chevron-right", 12)); + more.setSelectedIcon(Icons.get("chevron-down", 12)); + more.putClientProperty("JButton.buttonType", "borderless"); + more.setFocusable(false); + more.setToolTipText("Technical details (useful for a bug report)"); + more.setBorder(BorderFactory.createEmptyBorder(2, 0, 2, 4)); + more.addActionListener(e -> { + sp.setVisible(more.isSelected()); + Window w = SwingUtilities.getWindowAncestor(p); + if (w != null) { + w.pack(); + } + }); + JPanel south = new JPanel(new BorderLayout(0, 4)); + JPanel toggle = new JPanel(new FlowLayout(FlowLayout.LEFT, 0, 0)); + toggle.add(more); + south.add(toggle, BorderLayout.NORTH); + south.add(sp, BorderLayout.CENTER); + p.add(south, BorderLayout.CENTER); + return p; + } + + /** One plain sentence without exception class names. */ + public static String plain(Throwable t) { + t = unwrap(t); + if (t instanceof NoSuchFileException f) { + return "The file " + name(f.getFile()) + " does not exist."; + } + if (t instanceof AccessDeniedException f) { + return "No permission to access " + name(f.getFile()) + "."; + } + if (t instanceof FileSystemException f) { + return "The file " + name(f.getFile()) + " cannot be accessed" + + (f.getReason() == null ? "." : ": " + sentence(f.getReason())); + } + if (t instanceof FileNotFoundException f) { + String m = f.getMessage() == null ? "" : f.getMessage(); + int i = m.indexOf(" ("); + return "The file " + name(i > 0 ? m.substring(0, i) : m) + " cannot be opened" + + (i > 0 ? ": " + sentence(m.substring(i + 2).replaceFirst("\\)$", "")) : "."); + } + if (t instanceof ZipException) { + return "The file is damaged (not a readable zip archive)."; + } + if (t instanceof OutOfMemoryError) { + return "Not enough memory for this operation."; + } + String msg = t.getMessage(); + Throwable c = t.getCause(); + // "level.json is damaged: Index 34 out of bounds…": the cause's text is for the details + if (msg != null && c instanceof RuntimeException && c.getMessage() != null && msg.endsWith(c.getMessage())) { + msg = msg.substring(0, msg.length() - c.getMessage().length()); + } + String m = clean(msg); + if (m != null) { + return m; + } + return "Something went wrong (an internal error). The details below can help to report it."; + } + + /** The message up to the first exception class name, as a sentence; null if nothing readable is left. */ + static String clean(String msg) { + if (msg == null || msg.isBlank()) { + return null; + } + Matcher m = TECHNICAL.matcher(msg); + String s = m.find() ? msg.substring(0, m.start()) : msg; + s = s.strip().replaceAll("[\\s:;,(–-]+$", "").strip(); + if (s.length() < 3) { + return null; + } + if (s.length() > 400) { + s = s.substring(0, 400) + " …"; + } + return sentence(s); + } + + private static String sentence(String s) { + s = s.strip(); + if (s.isEmpty()) { + return s; + } + s = s.substring(0, 1).toUpperCase(Locale.ROOT) + s.substring(1); + return s.matches("(?s).*[.!?…)]$") ? s : s + "."; + } + + private static String name(String file) { + if (file == null || file.isBlank()) { + return ""; + } + try { + Path p = Path.of(file).getFileName(); + return p == null ? file : p.toString(); + } catch (RuntimeException e) { + return file; + } + } + + private static Throwable unwrap(Throwable t) { + while (t.getCause() != null + && (t instanceof ExecutionException + || t instanceof CompletionException + || t instanceof InvocationTargetException + || t instanceof UncheckedIOException + || t.getMessage() == null + || t.getMessage().equals(t.getCause().toString()))) { + t = t.getCause(); + } + return t; + } + + /** Exception chain with stack traces. */ + static String details(Throwable t) { + StringWriter sw = new StringWriter(); + t.printStackTrace(new PrintWriter(sw)); + String s = sw.toString(); + return s.length() > 20_000 ? s.substring(0, 20_000) + "\n…" : s; + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/ui/Icons.java b/src/main/java/com/eliasmueller/llamamod/editor/ui/Icons.java index fc1c003..b8acd82 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/ui/Icons.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/ui/Icons.java @@ -295,6 +295,10 @@ public final class Icons { g.setColor(GREEN.darker()); g.draw(p); }); + register("stop", (g, c) -> { + g.setColor(c); + g.fill(new RoundRectangle2D.Double(3.5, 3.5, 9, 9, 2.5, 2.5)); + }); register("cut", (g, c) -> { g.draw(new Ellipse2D.Double(2, 10, 4, 4)); g.draw(new Ellipse2D.Double(10, 10, 4, 4)); 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 174b1ee..1aec696 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/ui/StatusBar.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/ui/StatusBar.java @@ -4,9 +4,11 @@ import com.eliasmueller.llamamod.editor.model.Node; import com.eliasmueller.llamamod.editor.model.Scene; import com.eliasmueller.llamamod.editor.render.Camera; import java.awt.BorderLayout; +import java.awt.Color; import java.awt.Cursor; import java.awt.Dimension; import java.awt.FlowLayout; +import java.awt.Font; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; import java.util.Locale; @@ -24,6 +26,7 @@ import org.joml.Vector3f; final class StatusBar extends JPanel { private final EditorWindow w; private final JLabel message = new JLabel(" "); + private final JLabel game = new JLabel(); private final JLabel hover = label(220); private final JLabel coords = label(190); private final JLabel cell = label(86); @@ -44,6 +47,15 @@ final class StatusBar extends JPanel { BorderFactory.createEmptyBorder(3, 10, 3, 8))); message.setIcon(Icons.get("info", 14)); add(message, BorderLayout.CENTER); + game.setVisible(false); + game.setOpaque(true); + game.setBackground(new Color(0x3F, 0x7F, 0x4A)); + game.setForeground(Color.WHITE); + game.setFont(game.getFont().deriveFont(Font.BOLD)); + game.setIcon(Icons.tinted("play", 13, Color.WHITE)); + game.setBorder(BorderFactory.createEmptyBorder(1, 6, 1, 8)); + game.setToolTipText("A play-test game started from the editor is running; the Stop game button quits it"); + add(game, BorderLayout.WEST); JPanel right = new JPanel(new FlowLayout(FlowLayout.RIGHT, 6, 0)); progress.setPreferredSize(new Dimension(160, 12)); progress.setVisible(false); @@ -100,6 +112,22 @@ final class StatusBar extends JPanel { clear.restart(); } + /** Sticky "game running" state (null = none). */ + void game(String what) { + game.setText(what == null ? "" : "Game running: " + what); + game.setVisible(what != null); + revalidate(); + repaint(); + } + + String gameText() { + return game.isVisible() ? game.getText() : null; + } + + String messageText() { + return message.getText(); + } + void hover(Node n) { hover.setText(n == null ? " " : n.label() + (w.viewport().hoverBackFace() ? " (back side)" : "")); hover.setIcon(n == null ? null : NodeIcons.icon(n, 13)); diff --git a/src/main/java/com/eliasmueller/llamamod/editor/ui/Widgets.java b/src/main/java/com/eliasmueller/llamamod/editor/ui/Widgets.java index 4fa8e3d..7f669f4 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/ui/Widgets.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/ui/Widgets.java @@ -1,9 +1,19 @@ package com.eliasmueller.llamamod.editor.ui; +import java.awt.Container; +import java.awt.Dimension; +import java.awt.Insets; +import java.awt.event.ComponentAdapter; +import java.awt.event.ComponentEvent; import javax.swing.JButton; import javax.swing.JComponent; +import javax.swing.JLabel; import javax.swing.JList; +import javax.swing.JViewport; +import javax.swing.SwingUtilities; import javax.swing.UIManager; +import javax.swing.plaf.basic.BasicHTML; +import javax.swing.text.View; /** Small Swing building blocks shared by panels and plugins. */ public final class Widgets { @@ -18,6 +28,116 @@ public final class Widgets { return b; } + /** + * Help text (HTML body content) that wraps at the width of its container, so it uses a wider + * dock instead of a fixed pixel width. Font and colour can be set as on any label. + */ + public static JLabel wrappingLabel(String html) { + return new WrapLabel("" + html + ""); + } + + private static final class WrapLabel extends JLabel { + private static final int FALLBACK = 220; + private int laidOutFor = -1; + private String naturalKey; + private float naturalWidth; + private final ComponentAdapter follow = new ComponentAdapter() { + @Override + public void componentResized(ComponentEvent e) { + if (available() != laidOutFor) { + revalidate(); + } + } + }; + + private JViewport viewport; + + WrapLabel(String html) { + super(html); + setAlignmentX(LEFT_ALIGNMENT); + } + + /** + * Width of the parent's content area, but at most what the enclosing scroll viewport shows + * (a parent may be wider than the dock, e.g. next to a long row of buttons). + */ + private int available() { + Container p = getParent(); + if (p == null || p.getWidth() <= 0) { + return -1; + } + Insets in = p.getInsets(); + int w = p.getWidth() - in.left - in.right; + if (viewport != null && viewport.getWidth() > 0) { + int v = viewport.getExtentSize().width; + for (Container c = p; c != null && c != viewport; c = c.getParent()) { + Insets ci = c.getInsets(); + v -= ci.left + ci.right; + } + w = Math.min(w, v); + } + return w; + } + + @Override + public void addNotify() { + super.addNotify(); + getParent().addComponentListener(follow); + viewport = (JViewport) SwingUtilities.getAncestorOfClass(JViewport.class, this); + if (viewport != null) { + viewport.addComponentListener(follow); + } + } + + @Override + public void removeNotify() { + getParent().removeComponentListener(follow); + if (viewport != null) { + viewport.removeComponentListener(follow); + viewport = null; + } + super.removeNotify(); + } + + @Override + public Dimension getPreferredSize() { + View v = (View) getClientProperty(BasicHTML.propertyKey); + if (v == null || isPreferredSizeSet()) { + return super.getPreferredSize(); + } + int avail = available(); + laidOutFor = avail; + int w = avail > 0 ? avail : FALLBACK; + Insets in = getInsets(); + float tw = Math.max(40, w - in.left - in.right); + float natural = natural(); + if (natural <= tw) { + // undo an earlier narrower layout (the label paints with the view's last width) + v.setSize(natural, 0); + return super.getPreferredSize(); + } + v.setSize(tw, 0); + int h = (int) Math.ceil(v.getPreferredSpan(View.Y_AXIS)); + return new Dimension((int) tw + in.left + in.right, h + in.top + in.bottom); + } + + /** Unwrapped text width (a laid-out view reports its layout width instead). */ + private float natural() { + String key = getText() + "\u0000" + getFont(); + if (!key.equals(naturalKey)) { + naturalKey = key; + naturalWidth = BasicHTML.createHTMLView(this, getText()).getPreferredSpan(View.X_AXIS); + } + return naturalWidth; + } + + @Override + public Dimension getMinimumSize() { + Dimension d = getPreferredSize(); + return new Dimension(Math.min(d.width, 40), d.height); + } + } + /** List cell look for a fresh renderer component: selection colours only when selected. */ public static C listCell(C c, JList list, boolean selected) { c.setOpaque(selected); 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 1ba8724..8f45677 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 @@ -19,6 +19,7 @@ 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.Tool; +import com.eliasmueller.llamamod.editor.ui.Widgets; import com.eliasmueller.llamamod.util.SwingListeners; import java.awt.BorderLayout; import java.awt.Color; @@ -218,7 +219,7 @@ public final class InspectorPanel extends JPanel { JLabel t = new JLabel(title); t.setFont(t.getFont().deriveFont(Font.BOLD, 14f)); t.setForeground(UIManager.getColor("Label.disabledForeground")); - JLabel d = new JLabel("" + text + ""); + JLabel d = Widgets.wrappingLabel(text); d.setForeground(UIManager.getColor("Label.disabledForeground")); p.add(t, BorderLayout.NORTH); p.add(d, BorderLayout.CENTER); 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 2a60d2f..39ef0c0 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 @@ -33,6 +33,7 @@ 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; @@ -119,6 +120,7 @@ public final class OutlinerPanel extends JPanel { tree.setShowsRootHandles(true); tree.setRowHeight(22); tree.setCellRenderer(new RowRenderer()); + javax.swing.ToolTipManager.sharedInstance().registerComponent(tree); tree.getSelectionModel().setSelectionMode(TreeSelectionModel.DISCONTIGUOUS_TREE_SELECTION); tree.putClientProperty("JTree.lineStyle", "None"); tree.setToggleClickCount(0); @@ -368,13 +370,13 @@ public final class OutlinerPanel extends JPanel { } TileModel m = e.getKey(); DefaultMutableTreeNode g = - group(tiles, "tm:" + m.id(), m.label(), NodeIcons.kind(NodeKind.TILE_MODEL, 16)); + group(tiles, "tm:" + m.id(), modelName(m), NodeIcons.kind(NodeKind.TILE_MODEL, 16)); for (Tile t : e.getValue()) { - leaf(g, t, "Cell " + t.cellX() + ", " + t.cellZ(), t.id()); + leaf(g, t, "Cell " + t.cellX() + ", " + t.cellZ(), height(t)); } } for (Tile t : none) { - leaf(tiles, t, t.label(), t.id()); + leaf(tiles, t, "Cell " + t.cellX() + ", " + t.cellZ(), height(t)); } } else { Map> cells = new TreeMap<>(); @@ -389,7 +391,7 @@ public final class OutlinerPanel extends JPanel { DefaultMutableTreeNode g = group(tiles, "cell:" + cx + "," + cz, "Cell " + cx + ", " + cz, Icons.get("cell", 16)); for (Tile t : e.getValue()) { - leaf(g, t, t.model() == null ? "?" : t.model().label(), t.id()); + leaf(g, t, t.model() == null ? "?" : modelName(t.model()), height(t)); } } } @@ -421,8 +423,10 @@ 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()) { - leaf(parent, st, t.getValue().size() > 1 ? st.id() : st.type(), pos(st.gx(), st.gy(), st.gz())); + String name = t.getValue().size() > 1 ? t.getKey() + " " + ++nr : t.getKey(); + leaf(parent, st, name, pos(st.gx(), st.gy(), st.gz())); } } } @@ -459,7 +463,9 @@ public final class OutlinerPanel extends JPanel { } } } + int chainNr = 0; for (PathNode start : starts) { + chainNr++; List ch = s.chain(start); List m = new ArrayList<>(); for (PathNode p : ch) { @@ -477,10 +483,10 @@ public final class OutlinerPanel extends JPanel { DefaultMutableTreeNode g = group( chains, "chain:" + start.id(), - "Chain " + start.id() + (user != null ? " – " + user : ""), + "Chain " + chainNr + (user != null ? " – " + user : ""), NodeIcons.kind(NodeKind.PATH_NODE, 16)); for (PathNode p : m) { - leaf(g, p, p.id(), pos(p.gx(), p.gy(), p.gz())); + leaf(g, p, "Waypoint " + (ch.indexOf(p) + 1), pos(p.gx(), p.gy(), p.gz())); } } } @@ -493,20 +499,31 @@ public final class OutlinerPanel extends JPanel { leaf( zones, z, - z.isDefault() ? "Default zone (fog, sky, music)" : z.label(), + z.isDefault() + ? "Default zone (fog, sky, music)" + : "Z" + z.label().substring(1), 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, l.label(), l.type() == Light.DIRECTIONAL ? "directional" : "point"); + leaf(lights, l, "Light " + lightNr, 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, b.name().isEmpty() ? b.brushKind().label() : b.name(), b.id()); + leaf(brushes, b, sameName.get(name) > 1 ? name + " " + nr : name, brushPlace(b)); } } DefaultMutableTreeNode assets = group(root, "assets", "Assets", Icons.get("assets", 16)); @@ -514,7 +531,7 @@ public final class OutlinerPanel extends JPanel { group(assets, "assets:models", "Tile models", NodeIcons.kind(NodeKind.TILE_MODEL, 16)); for (TileModel m : s.tileModels()) { if (match(m, q)) { - leaf(models, m, m.label(), m.faceCount() + " faces"); + leaf(models, m, modelName(m), m.faceCount() + " faces"); } } DefaultMutableTreeNode meshes = @@ -527,11 +544,42 @@ public final class OutlinerPanel extends JPanel { DefaultMutableTreeNode pages = group(assets, "assets:pages", "Texture pages", Icons.get("texture", 16)); for (TexturePage p : s.pages()) { if (match(p, q)) { - leaf(pages, p, p.label(), p.width() + "×" + p.height()); + leaf( + pages, + p, + p.label().replace("page " + p.id(), "Page " + number(p.id())), + p.width() + "×" + p.height()); } } } + /** "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(); + } + + /** Centre of a brush (game cells) for the detail column. */ + static String brushPlace(Brush b) { + double[] bb = b.bounds(); + if (!Double.isFinite(bb[0]) || !Double.isFinite(bb[3])) { + return ""; + } + return pos((bb[0] + bb[3]) / 2, bb[1], (bb[2] + bb[5]) / 2); + } + + private static String height(Tile t) { + return String.format(Locale.ROOT, "y %.1f", t.y()); + } + static String pos(double x, double y, double z) { return String.format(Locale.ROOT, "%.1f, %.1f, %.1f", x, y, z); } @@ -648,6 +696,7 @@ public final class OutlinerPanel extends JPanel { lock.setIcon(Icons.get(allLocked || anyLocked ? "lock" : "unlock", 13)); text.setText(r.label); text.setIcon(r.icon); + setToolTipText(r.node == null ? null : r.node.id()); text.setFont(t.getFont().deriveFont(r.node == null ? Font.BOLD : Font.PLAIN)); Color fg = sel ? UIManager.getColor("Tree.selectionForeground") : UIManager.getColor("Tree.textForeground"); text.setForeground(allHidden ? UIManager.getColor("Label.disabledForeground") : fg); 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 30c955a..9151cef 100644 --- a/src/test/java/com/eliasmueller/llamamod/editor/ui/EditorStress.java +++ b/src/test/java/com/eliasmueller/llamamod/editor/ui/EditorStress.java @@ -99,7 +99,12 @@ public final class EditorStress { for (Window x : Window.getWindows()) { if (x instanceof JDialog d && d.isShowing() && d.isModal()) { JOptionPane op = EditorTour.find(d.getContentPane(), JOptionPane.class, o -> true); - String msg = op == null ? "" : String.valueOf(op.getMessage()); + String msg = op == null + ? "" + : op.getMessage() instanceof javax.swing.JComponent jc + && jc.getClientProperty(ErrorDialog.MESSAGE_KEY) != null + ? String.valueOf(jc.getClientProperty(ErrorDialog.MESSAGE_KEY)) + : String.valueOf(op.getMessage()); msg = msg.replaceAll("<[^>]+>", " ").replaceAll("\\s+", " "); dialogs.add(d.getTitle() + ": " + msg.substring(0, Math.min(220, msg.length()))); d.dispose(); @@ -155,8 +160,13 @@ public final class EditorStress { String doc = EditorTour.edt(() -> EditorTour.w.document() == null ? "none" : EditorTour.w.document().title()); + String st = EditorTour.edt(() -> EditorTour.w.statusTexts()[0]); System.out.println("open " + f.getFileName() + " -> doc=" + doc + " dialogs=" - + dialogs.subList(before, dialogs.size()) + " " + (System.currentTimeMillis() - t0) + " ms"); + + dialogs.subList(before, dialogs.size()) + " status=" + st + " " + + (System.currentTimeMillis() - t0) + " ms"); + if (st.startsWith("Loading")) { + throw new AssertionError("status still says " + st + " after a failed open"); + } } EditorTour.shot("stress_corrupt"); } diff --git a/src/test/java/com/eliasmueller/llamamod/editor/ui/OutlinerAndErrorsTest.java b/src/test/java/com/eliasmueller/llamamod/editor/ui/OutlinerAndErrorsTest.java new file mode 100644 index 0000000..0744764 --- /dev/null +++ b/src/test/java/com/eliasmueller/llamamod/editor/ui/OutlinerAndErrorsTest.java @@ -0,0 +1,158 @@ +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.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.eliasmueller.llamamod.editor.io.Project; +import com.eliasmueller.llamamod.editor.io.ProjectIO; +import com.eliasmueller.llamamod.editor.model.BrushKind; +import com.eliasmueller.llamamod.editor.model.Scene; +import com.eliasmueller.llamamod.editor.ui.panels.OutlinerPanel; +import java.awt.Component; +import java.awt.Container; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; +import javax.swing.JComponent; +import javax.swing.JTree; +import javax.swing.tree.DefaultMutableTreeNode; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** Outliner row names (no technical ids) and plain error messages for damaged/missing files. */ +class OutlinerAndErrorsTest { + @TempDir + Path tmp; + + @Test + void outlinerShowsReadableNames() 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)); + OutlinerPanel o = new OutlinerPanel(new EditorShellTest.Ctx(p)); + List rows = rows(find(o, JTree.class)); + assertTrue(rows.contains("Floor 1") && rows.contains("Floor 2"), rows.toString()); + for (String r : rows) { + assertFalse(r.matches(".*\\b(strat|brush|tile|path|light):n?\\d+.*"), "technical id in row: " + r); + } + // several objects of one type are numbered + assertTrue(rows.stream().anyMatch(r -> r.matches("[A-Za-z]+ 2")), rows.toString()); + } + + @Test + void errorMessagesArePlain() throws Exception { + List files = new ArrayList<>(); + Path random = tmp.resolve("random.llamalevel"); + Files.write(random, new byte[] {1, 2, 3, 4, 5, 6, 7, 8, 9}); + files.add(random); + files.add(zip("types.llamalevel", "{\"formatVersion\": \"x\", \"kind\": 3}")); + files.add(zip( + "badnode.llamalevel", + "{\"formatVersion\":1,\"kind\":\"edit\",\"base\":\"t1l1m001\",\"nodes\":{\"brush\":{\"added\":" + + "[{\"id\":\"brush:n1\",\"brushKind\":\"NOPE\"}]}}}")); + files.add(zip("badjson.llamalevel", "{\"formatVersion\": 1, \"nodes\": [1,2")); + files.add(tmp.resolve("missing.llamalevel")); + for (Path f : files) { + try { + ProjectIO.load(f, EditorShellTest.cd()); + throw new AssertionError("loaded " + f); + } catch (IOException | RuntimeException e) { + String m = ErrorDialog.plain(e); + assertFalse(m.matches("(?s).*(Exception|Error\\b|java\\.).*"), f.getFileName() + ": " + m); + assertTrue(m.length() > 10 && Character.isUpperCase(m.charAt(0)), m); + JComponent panel = ErrorDialog.panel(e); + assertEquals(m, panel.getClientProperty(ErrorDialog.MESSAGE_KEY)); + assertTrue(ErrorDialog.details(e).contains(e.getClass().getName())); + } + } + assertEquals( + "The file x.llamalevel does not exist.", ErrorDialog.plain(new NoSuchFileException("/a/x.llamalevel"))); + assertEquals( + "The project file is damaged.", + ErrorDialog.plain(new IOException( + "the project file is damaged: java.lang.ClassCastException: class A cannot be cast"))); + assertTrue(ErrorDialog.plain(new NullPointerException()).startsWith("Something went wrong")); + assertEquals( + "Level.json is damaged.", + ErrorDialog.plain(new IOException( + "level.json is damaged: Index 34 out of bounds for length 34", + new StringIndexOutOfBoundsException("Index 34 out of bounds for length 34")))); + assertNotNull(ErrorDialog.plain(new RuntimeException(new IllegalStateException("boom")))); + } + + @Test + void hintsWrapAtTheContainerWidth() { + String text = "A long help text for the inspector that should wrap at whatever width the dock has," + + " instead of a fixed 220 pixels, so a wide dock shows fewer lines."; + javax.swing.JLabel l = Widgets.wrappingLabel(text); + javax.swing.JPanel p = new javax.swing.JPanel(new java.awt.BorderLayout()); + p.add(l); + p.setSize(260, 400); + java.awt.Dimension narrow = l.getPreferredSize(); + p.setSize(700, 400); + java.awt.Dimension wide = l.getPreferredSize(); + assertTrue(narrow.width <= 260 && wide.width <= 700 && wide.width > 400, narrow + " " + wide); + assertTrue(narrow.height > wide.height, narrow + " " + wide); + // narrow first, then wide enough for one line: back to one line + javax.swing.JLabel mid = Widgets.wrappingLabel("Create a new level or open one (File menu)."); + p.removeAll(); + p.add(mid); + p.setSize(60, 400); + int narrowH = mid.getPreferredSize().height; + p.setSize(700, 400); + assertTrue(mid.getPreferredSize().height < narrowH); + javax.swing.text.View v = + (javax.swing.text.View) mid.getClientProperty(javax.swing.plaf.basic.BasicHTML.propertyKey); + assertTrue(v.getPreferredSpan(javax.swing.text.View.X_AXIS) > 150, "view keeps the narrow layout"); + javax.swing.JLabel shortText = Widgets.wrappingLabel("Short"); + p.removeAll(); + p.add(shortText); + assertTrue(shortText.getPreferredSize().width < 100); + } + + private Path zip(String name, String json) throws IOException { + Path f = tmp.resolve(name); + try (ZipOutputStream z = new ZipOutputStream(Files.newOutputStream(f))) { + z.putNextEntry(new ZipEntry("level.json")); + z.write(json.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + z.closeEntry(); + } + return f; + } + + static List rows(JTree t) { + List out = new ArrayList<>(); + var e = ((DefaultMutableTreeNode) t.getModel().getRoot()).depthFirstEnumeration(); + while (e.hasMoreElements()) { + DefaultMutableTreeNode n = (DefaultMutableTreeNode) e.nextElement(); + if (n.getUserObject() != null && n.isLeaf()) { + out.add(n.getUserObject().toString()); + } + } + return out; + } + + static T find(Container c, Class type) { + for (Component x : c.getComponents()) { + if (type.isInstance(x)) { + return type.cast(x); + } + if (x instanceof Container cc) { + T r = find(cc, type); + if (r != null) { + return r; + } + } + } + return null; + } +}