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

@@ -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);