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

+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 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