editor: game running badge and stop game button during play-tests, crosshair for play from camera position while shift is held, readable outliner names, plain error dialogs with details and no stuck loading status, inspector hints wrap with the dock

This commit is contained in:
MineTec committed 2026-11-07 11:57:22 +01:00
1 parent d9f1b8c2fe
commit 59531a60b8
26 files changed
+984 -67

No files matched your search

+5 -1
View File
@@ -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.
+8
View File
@@ -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
+4 -2
View File
@@ -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.
+6 -2
View File
@@ -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 `<body style='width:…'>`); `onTextChange`, `onMenuOpen` |
| `ErrorDialog` | `ctx.error(title, t)` uses it: plain sentence (`ErrorDialog.plain(t)`, no exception class names) + collapsible **Details** (stack trace) |
| Game state | `ctx.setGameRunning(what, stop)` (default no-op): while a play-test game runs the status bar shows "Game running: what" and the toolbar Play button becomes **Stop game** (runs `stop`); `null` resets. `PlaytestRunner` sets it on launch and clears it when its watcher sees the game exit |
| `AssetDrop` | `register(kind, handler)`; `dragSource(list, value → payload(kind, data))` makes a list draggable into the viewport |
| `EditorPrefs` | `ctx.prefs()` JSON settings in `<data>/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).
Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

+4 -4
View File
@@ -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: <level>" 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) |
@@ -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("<html><body style='width:230px'>" + text + "</body></html>");
JLabel l = Widgets.wrappingLabel(text);
l.setForeground(UIManager.getColor("Label.disabledForeground"));
l.setFont(l.getFont().deriveFont(l.getFont().getSize2D() - 1f));
return full(l);
@@ -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;
}
@@ -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><body style='width: 230px'>" + html + "</body></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
? "<b>Never appears:</b> the game removes it at level start because " + gone + "."
: bit > 0
? "Starts inside its box. <b>Hidden</b> 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(
"<html><body style='width: 170px'>" + txt + "</body></html>",
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);
@@ -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);
}
}
@@ -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. */
@@ -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);
}
@@ -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);
@@ -120,6 +120,9 @@ public final class EditorWindow extends JFrame implements EditorContext {
private SwingWorker<Object, Object> 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<String> 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,
"<html><body style='width: 420px'>" + ProjectController.esc(msg).replace("\n", "<br>")
+ "</body></html>",
title,
JOptionPane.ERROR_MESSAGE);
ErrorDialog.show(this, title, t);
}
@Override
@@ -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("<html><body style='width: 380px'>"
+ ProjectController.esc(plain).replace("\n", "<br>") + "</body></html>");
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;
}
}
@@ -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));
@@ -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));
@@ -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>" + html + "</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 extends JComponent> C listCell(C c, JList<?> list, boolean selected) {
c.setOpaque(selected);
@@ -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("<html><body style='width: 220px'>" + text + "</body></html>");
JLabel d = Widgets.wrappingLabel(text);
d.setForeground(UIManager.getColor("Label.disabledForeground"));
p.add(t, BorderLayout.NORTH);
p.add(d, BorderLayout.CENTER);
@@ -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<Long, List<Tile>> 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<PathNode> ch = s.chain(start);
List<PathNode> 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<String, Integer> sameName = new HashMap<>();
for (Brush b : s.brushes()) {
sameName.merge(brushName(b), 1, Integer::sum);
}
Map<String, Integer> brushNr = new HashMap<>();
for (Brush b : s.brushes()) {
String name = brushName(b);
int nr = brushNr.merge(name, 1, Integer::sum);
if (match(b, q)) {
leaf(brushes, b, 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);
@@ -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");
}
@@ -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<String> 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<Path> 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<String> rows(JTree t) {
List<String> 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> T find(Container c, Class<T> 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;
}
}