editor: readable names in inspector, status hover, asset browser and outliner filter, waypoint buttons and long inspector labels wrap, library keeps no collision, autosave no longer hides fresher messages, old menu play-test path removed, task runner always finishes (no stuck loading), play-test starts bound to their level
This commit is contained in:
1 parent
4a58910fba
commit
22a61246fc
26 files changed
+804
-271
No files matched your search
@@ -49,8 +49,12 @@ final class MeshLibrary {
|
||||
|
||||
static final String EXT = ".llamamesh";
|
||||
|
||||
/** A tile in world space (export coordinates) with the texture tables it refers to. */
|
||||
record Placed(TileModel model, double x, double y, double z, int rot, int[] colors) {}
|
||||
/** A tile in world space (export coordinates); noCollision = the tile's "No collision" flag. */
|
||||
record Placed(TileModel model, double x, double y, double z, int rot, int[] colors, boolean noCollision) {
|
||||
Placed(TileModel model, double x, double y, double z, int rot, int[] colors) {
|
||||
this(model, x, y, z, rot, colors, false);
|
||||
}
|
||||
}
|
||||
|
||||
/** Texture tables including what the brushes generated (indices ≥ scene size). */
|
||||
record Tables(List<TextureEntry> entries, List<TexturePage> pages) {}
|
||||
@@ -62,7 +66,7 @@ final class MeshLibrary {
|
||||
List<TexturePage> tp = new ArrayList<>(s.pages());
|
||||
for (Node n : sel) {
|
||||
if (n instanceof Tile t && t.model() != null && !GeometryActions.isKillModel(t.model())) {
|
||||
out.add(new Placed(t.model(), t.x(), t.y(), t.z(), t.rot(), firstFrame(t)));
|
||||
out.add(new Placed(t.model(), t.x(), t.y(), t.z(), t.rot(), firstFrame(t), t.noCollision()));
|
||||
}
|
||||
}
|
||||
List<Brush> brushes = new ArrayList<>();
|
||||
@@ -172,6 +176,9 @@ final class MeshLibrary {
|
||||
t.put("z", -p.z() - az);
|
||||
t.put("rot", p.rot());
|
||||
t.put("colors", b64(p.colors()));
|
||||
if (p.noCollision()) {
|
||||
t.put("noCollision", true);
|
||||
}
|
||||
tiles.add(t);
|
||||
}
|
||||
Map<String, byte[]> files = new LinkedHashMap<>();
|
||||
@@ -403,6 +410,7 @@ final class MeshLibrary {
|
||||
t.setRaw("model", md);
|
||||
int[] col = ints((String) m.get("colors"));
|
||||
t.setRaw("colors", col.length == md.vertexCount() ? col : grey(md.vertexCount()));
|
||||
t.setRaw("noCollision", Boolean.TRUE.equals(m.get("noCollision")));
|
||||
cmds.add(new AddNode(t));
|
||||
out.add(t);
|
||||
}
|
||||
@@ -469,6 +477,13 @@ final class MeshLibrary {
|
||||
* z) relative to the anchor cell, with UVs per texture, vertex colours ("v x y z r g b", 0x80 =
|
||||
* 1.0 → 0.5), an MTL and one PNG per texture; collision optionally as a separate OBJ.
|
||||
*/
|
||||
/** Corner order in the file: game space is right-handed, so the export-space winding reverses. */
|
||||
private static final int[] FILE_ORDER = {0, 2, 1};
|
||||
|
||||
/**
|
||||
* OBJ in game space (x, y, z_game = −export z; right-handed, y up – what Import mesh and Blender
|
||||
* expect), relative to the anchor cell.
|
||||
*/
|
||||
static List<Path> exportObj(List<Placed> placed, Tables tables, Path obj, boolean collision) throws IOException {
|
||||
double[] anchor = anchor(placed);
|
||||
double ax = anchor[0], ay = anchor[1], az = -anchor[2];
|
||||
@@ -491,7 +506,7 @@ final class MeshLibrary {
|
||||
"v %.5f %.5f %.5f %.4f %.4f %.4f%n",
|
||||
w[0],
|
||||
w[1],
|
||||
w[2],
|
||||
-w[2],
|
||||
Math.min(1, (c & 0xff) / 128.0),
|
||||
Math.min(1, (c >> 8 & 0xff) / 128.0),
|
||||
Math.min(1, (c >> 16 & 0xff) / 128.0)));
|
||||
@@ -502,7 +517,7 @@ final class MeshLibrary {
|
||||
double[] uv = faceUv(tables, tex, m.faceFlags(f));
|
||||
StringBuilder sb = byTex.computeIfAbsent(tex, k -> new StringBuilder());
|
||||
sb.append("f");
|
||||
for (int k = 0; k < 3; k++) {
|
||||
for (int k : FILE_ORDER) {
|
||||
vt.append(String.format(Locale.ROOT, "vt %.5f %.5f%n", uv[2 * k], 1 - uv[2 * k + 1]));
|
||||
nt++;
|
||||
sb.append(' ')
|
||||
@@ -551,8 +566,10 @@ final class MeshLibrary {
|
||||
for (TileModel.PlacedPolygon poly : p.model().collisionPolygons(xf)) {
|
||||
StringBuilder sb = byMat.computeIfAbsent(poly.material(), x -> new StringBuilder());
|
||||
sb.append('f');
|
||||
for (double[] w : poly.corners()) {
|
||||
cv.append(String.format(Locale.ROOT, "v %.5f %.5f %.5f%n", w[0], w[1], w[2]));
|
||||
double[][] cs = poly.corners();
|
||||
for (int k = 0; k < cs.length; k++) {
|
||||
double[] w = cs[k == 0 ? 0 : cs.length - k];
|
||||
cv.append(String.format(Locale.ROOT, "v %.5f %.5f %.5f%n", w[0], w[1], -w[2]));
|
||||
sb.append(' ').append(++n);
|
||||
}
|
||||
sb.append('\n');
|
||||
|
||||
+2
-1
@@ -14,6 +14,7 @@ import com.eliasmueller.llamamod.editor.ui.EditorWindow;
|
||||
import com.eliasmueller.llamamod.editor.ui.Icons;
|
||||
import com.eliasmueller.llamamod.editor.ui.InspectorSection;
|
||||
import com.eliasmueller.llamamod.editor.ui.LayerSpec;
|
||||
import com.eliasmueller.llamamod.editor.ui.NodeNames;
|
||||
import com.eliasmueller.llamamod.editor.ui.PanelSpec;
|
||||
import com.eliasmueller.llamamod.editor.ui.ToolEvent;
|
||||
import com.eliasmueller.llamamod.editor.ui.tools.EditOps;
|
||||
@@ -258,7 +259,7 @@ public final class ObjectsEditorPlugin implements EditorPlugin {
|
||||
? "not used by an object"
|
||||
: "used by "
|
||||
+ owners.stream()
|
||||
.map(o -> o.type() + " (" + o.id() + ")")
|
||||
.map(NodeNames::name)
|
||||
.reduce((a, b) -> a + ", " + b)
|
||||
.get()
|
||||
+ " · " + Roles.of(owners.getFirst()).label;
|
||||
|
||||
@@ -16,6 +16,7 @@ import com.eliasmueller.llamamod.editor.ui.EditorContext;
|
||||
import com.eliasmueller.llamamod.editor.ui.Icons;
|
||||
import com.eliasmueller.llamamod.editor.ui.InspectorSection;
|
||||
import com.eliasmueller.llamamod.editor.ui.Widgets;
|
||||
import com.eliasmueller.llamamod.editor.ui.WrapLayout;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.Color;
|
||||
import java.awt.FlowLayout;
|
||||
@@ -420,8 +421,11 @@ final class ParamsSection implements InspectorSection {
|
||||
}
|
||||
}
|
||||
|
||||
/** Small unfocusable buttons for an inspector button row. */
|
||||
/** Small unfocusable buttons for an inspector button row that wraps into more rows in a narrow dock. */
|
||||
static void compact(JComponent bar) {
|
||||
if (bar.getLayout() instanceof java.awt.FlowLayout f && !(f instanceof WrapLayout)) {
|
||||
bar.setLayout(new WrapLayout(f.getAlignment(), f.getHgap(), 2));
|
||||
}
|
||||
for (java.awt.Component c : bar.getComponents()) {
|
||||
c.setFocusable(false);
|
||||
((JComponent) c).setFont(((JComponent) c).getFont().deriveFont(11.5f));
|
||||
|
||||
@@ -227,7 +227,10 @@ public final class PlaytestRunner {
|
||||
cfg,
|
||||
bootMod,
|
||||
"start",
|
||||
start == null ? "" : BootToLevelMod.formatStart(start[0], start[1], start[2], start[3]));
|
||||
start == null
|
||||
? ""
|
||||
: BootToLevelMod.formatStart(
|
||||
tgt[0], tgt[1], start[0], start[1], start[2], start[3]));
|
||||
List<Mod> withBoot = new ArrayList<>(mods);
|
||||
withBoot.removeIf(m -> m.id().equals(BootToLevelMod.ID));
|
||||
withBoot.add(bootMod);
|
||||
|
||||
@@ -263,10 +263,10 @@ final class BuiltinEditorPlugin implements EditorPlugin {
|
||||
.build())
|
||||
.enabledWhen(c -> c.document() != null && !w(c).busy())
|
||||
.tip("Validate the level and build its WAD (in the background)"));
|
||||
x.action(ActionSpec.of("level.play", "Level", 20, 30, "Play", "play", "F5", c -> w(c).playTest
|
||||
.play())
|
||||
.enabledWhen(c -> c.document() != null && !w(c).busy())
|
||||
.tip("Build, install as a mod and start the game with this level selected"));
|
||||
// placeholder: the play-test plugin (editor.tools.playtest) registers the real action under this id
|
||||
x.action(ActionSpec.of("level.play", "Level", 20, 30, "Play", "play", "F5", c -> {})
|
||||
.enabledWhen(c -> false)
|
||||
.tip("Play-test is not available (the play-test plugin did not load)"));
|
||||
|
||||
// Help
|
||||
x.action(ActionSpec.of(
|
||||
|
||||
@@ -15,7 +15,6 @@ import com.eliasmueller.llamamod.editor.render.Viewport;
|
||||
import com.eliasmueller.llamamod.editor.render.ViewportInput;
|
||||
import com.eliasmueller.llamamod.editor.render.ViewportListener;
|
||||
import com.eliasmueller.llamamod.editor.ui.view.SceneRenderer;
|
||||
import com.eliasmueller.llamamod.platform.Progress;
|
||||
import com.eliasmueller.llamamod.util.SwingListeners;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.CardLayout;
|
||||
@@ -49,9 +48,7 @@ import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CancellationException;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.function.BooleanSupplier;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Predicate;
|
||||
@@ -76,7 +73,6 @@ import javax.swing.JToggleButton;
|
||||
import javax.swing.JToolBar;
|
||||
import javax.swing.KeyStroke;
|
||||
import javax.swing.SwingUtilities;
|
||||
import javax.swing.SwingWorker;
|
||||
import javax.swing.ToolTipManager;
|
||||
import javax.swing.UIManager;
|
||||
import javax.swing.text.JTextComponent;
|
||||
@@ -117,7 +113,28 @@ public final class EditorWindow extends JFrame implements EditorContext {
|
||||
private boolean flushQueued;
|
||||
private final SceneListener sceneHook = this::onSceneEvent;
|
||||
private KeyEventDispatcher keyDispatcher;
|
||||
private SwingWorker<Object, Object> task;
|
||||
private final TaskRunner tasks = new TaskRunner(new TaskRunner.Ui() {
|
||||
@Override
|
||||
public void progress(String text, double fraction) {
|
||||
status.progress(text, fraction);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void progressDone() {
|
||||
status.progressDone();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void failed(String title, Throwable t) {
|
||||
status.message(title + " failed");
|
||||
error(title, t);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void finished() {
|
||||
refreshActions();
|
||||
}
|
||||
});
|
||||
private final Overlay toolOverlay = new Overlay();
|
||||
private final Overlay toolTop = new Overlay();
|
||||
private JButton playButton;
|
||||
@@ -1224,47 +1241,23 @@ public final class EditorWindow extends JFrame implements EditorContext {
|
||||
}
|
||||
}
|
||||
|
||||
/** Status message of a background job (autosave) that does not replace a fresher user message. */
|
||||
void backgroundStatus(String message) {
|
||||
if (status != null) {
|
||||
status.backgroundMessage(message);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void runTask(String title, Task t, Consumer<Object> done) {
|
||||
if (task != null && !task.isDone()) {
|
||||
if (!tasks.start(title, t, done)) {
|
||||
JOptionPane.showMessageDialog(
|
||||
this, "Please wait: another task is running.", title, JOptionPane.INFORMATION_MESSAGE);
|
||||
return;
|
||||
}
|
||||
status.progress(title, 0);
|
||||
Progress p =
|
||||
(f, m) -> SwingUtilities.invokeLater(() -> status.progress(m == null || m.isBlank() ? title : m, f));
|
||||
task = new SwingWorker<>() {
|
||||
@Override
|
||||
protected Object doInBackground() throws Exception {
|
||||
return t.run(p);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void done() {
|
||||
status.progressDone();
|
||||
try {
|
||||
Object r = get();
|
||||
if (done != null) {
|
||||
done.accept(r);
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
} catch (ExecutionException e) {
|
||||
Throwable c = e.getCause();
|
||||
if (!(c instanceof CancellationException)) {
|
||||
status.message(title + " failed");
|
||||
error(title, c);
|
||||
}
|
||||
}
|
||||
refreshActions();
|
||||
}
|
||||
};
|
||||
task.execute();
|
||||
}
|
||||
|
||||
public boolean busy() {
|
||||
return task != null && !task.isDone();
|
||||
return tasks.busy();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
package com.eliasmueller.llamamod.editor.ui;
|
||||
|
||||
import com.eliasmueller.llamamod.editor.model.Brush;
|
||||
import com.eliasmueller.llamamod.editor.model.EnvZone;
|
||||
import com.eliasmueller.llamamod.editor.model.LevelSettings;
|
||||
import com.eliasmueller.llamamod.editor.model.Light;
|
||||
import com.eliasmueller.llamamod.editor.model.MeshAsset;
|
||||
import com.eliasmueller.llamamod.editor.model.Node;
|
||||
import com.eliasmueller.llamamod.editor.model.PathNode;
|
||||
import com.eliasmueller.llamamod.editor.model.Regions;
|
||||
import com.eliasmueller.llamamod.editor.model.Scene;
|
||||
import com.eliasmueller.llamamod.editor.model.Strat;
|
||||
import com.eliasmueller.llamamod.editor.model.TexturePage;
|
||||
import com.eliasmueller.llamamod.editor.model.Tile;
|
||||
import com.eliasmueller.llamamod.editor.model.TileModel;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeMap;
|
||||
|
||||
/**
|
||||
* Readable names for scene nodes ("Floor 2", "Coin 3", "Waypoint 4 (chain 2)", "Model 12") as used by the
|
||||
* outliner, inspector header, status bar and asset browser. Numbers follow the level order (they shift when
|
||||
* an earlier item of the same kind is deleted). One instance indexes a scene lazily; create a new one after
|
||||
* edits.
|
||||
*/
|
||||
public final class NodeNames {
|
||||
private final Scene scene;
|
||||
private Map<Node, String> numbered;
|
||||
private List<PathNode> starts;
|
||||
private Map<PathNode, int[]> waypoints;
|
||||
|
||||
private NodeNames(Scene scene) {
|
||||
this.scene = scene;
|
||||
}
|
||||
|
||||
public static NodeNames of(Scene scene) {
|
||||
return new NodeNames(scene);
|
||||
}
|
||||
|
||||
/** Readable name of a node in its own scene (falls back to {@link Node#label()} when detached). */
|
||||
public static String name(Node n) {
|
||||
return n == null ? "" : n.scene() == null ? fallback(n) : of(n.scene()).nameOf(n);
|
||||
}
|
||||
|
||||
/** Readable standalone name of a node of this scene. */
|
||||
public String nameOf(Node n) {
|
||||
return switch (n) {
|
||||
case Strat st -> objectName(st);
|
||||
case Brush b -> numbered(b);
|
||||
case Light l -> numbered(l);
|
||||
case PathNode p -> waypointName(p, true);
|
||||
case Tile t -> "Tile " + t.cellX() + ", " + t.cellZ()
|
||||
+ (t.model() == null ? "" : " · " + modelName(t.model()));
|
||||
default -> fallback(n);
|
||||
};
|
||||
}
|
||||
|
||||
/** Object name: its type, numbered when the level has several of that type ("Coin 3"). */
|
||||
public String objectName(Strat st) {
|
||||
return numbered(st);
|
||||
}
|
||||
|
||||
/** "Waypoint 4" (position in its chain), with "(chain 2)" when {@code withChain}. */
|
||||
public String waypointName(PathNode p, boolean withChain) {
|
||||
int[] v = waypointIndex(p);
|
||||
return v == null ? "Waypoint" : "Waypoint " + v[0] + (withChain ? " (chain " + v[1] + ")" : "");
|
||||
}
|
||||
|
||||
/** Chain number (1-based, outliner order) of a waypoint, or 0. */
|
||||
public int chainNumber(PathNode p) {
|
||||
int[] v = waypointIndex(p);
|
||||
return v == null ? 0 : v[1];
|
||||
}
|
||||
|
||||
private int[] waypointIndex(PathNode p) {
|
||||
if (waypoints == null) {
|
||||
waypoints = new IdentityHashMap<>();
|
||||
int nr = 0;
|
||||
for (PathNode start : chainStarts()) {
|
||||
nr++;
|
||||
List<PathNode> ch = scene.chain(start);
|
||||
for (int i = 0; i < ch.size(); i++) {
|
||||
waypoints.putIfAbsent(ch.get(i), new int[] {i + 1, nr});
|
||||
}
|
||||
}
|
||||
}
|
||||
return waypoints.get(p);
|
||||
}
|
||||
|
||||
/**
|
||||
* First waypoint of every chain in level order (waypoints without a predecessor, then one node per loop
|
||||
* that has no start). The outliner numbers chains in this order.
|
||||
*/
|
||||
public List<PathNode> chainStarts() {
|
||||
if (starts != null) {
|
||||
return starts;
|
||||
}
|
||||
Set<PathNode> hasPrev = Collections.newSetFromMap(new IdentityHashMap<>());
|
||||
for (PathNode p : scene.pathNodes()) {
|
||||
if (p.next() != null) {
|
||||
hasPrev.add(p.next());
|
||||
}
|
||||
}
|
||||
starts = new ArrayList<>();
|
||||
for (PathNode p : scene.pathNodes()) {
|
||||
if (!hasPrev.contains(p)) {
|
||||
starts.add(p);
|
||||
}
|
||||
}
|
||||
Set<PathNode> reached = Collections.newSetFromMap(new IdentityHashMap<>());
|
||||
for (PathNode st : starts) {
|
||||
reached.addAll(scene.chain(st));
|
||||
}
|
||||
for (PathNode p : scene.pathNodes()) {
|
||||
if (!reached.contains(p)) {
|
||||
starts.add(p);
|
||||
reached.addAll(scene.chain(p));
|
||||
}
|
||||
}
|
||||
return starts;
|
||||
}
|
||||
|
||||
private String numbered(Node n) {
|
||||
if (numbered == null) {
|
||||
numbered = new IdentityHashMap<>();
|
||||
number(scene.strats(), NodeNames::typeName);
|
||||
number(scene.brushes(), NodeNames::brushName);
|
||||
int nr = 0;
|
||||
for (Light l : scene.lights()) {
|
||||
numbered.put(l, "Light " + ++nr);
|
||||
}
|
||||
}
|
||||
String s = numbered.get(n);
|
||||
return s != null ? s : fallback(n);
|
||||
}
|
||||
|
||||
private <T extends Node> void number(List<T> nodes, java.util.function.Function<T, String> base) {
|
||||
Map<String, Integer> count = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
|
||||
for (T n : nodes) {
|
||||
count.merge(base.apply(n), 1, Integer::sum);
|
||||
}
|
||||
Map<String, Integer> nr = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
|
||||
for (T n : nodes) {
|
||||
String b = base.apply(n);
|
||||
numbered.put(n, count.get(b) > 1 ? b + " " + nr.merge(b, 1, Integer::sum) : b);
|
||||
}
|
||||
}
|
||||
|
||||
/** Object type or "?" (the outliner's type group key). */
|
||||
public static String typeName(Strat st) {
|
||||
return st.type().isEmpty() ? "?" : st.type();
|
||||
}
|
||||
|
||||
public static String brushName(Brush b) {
|
||||
return b.name().isEmpty() ? b.brushKind().label() : b.name();
|
||||
}
|
||||
|
||||
/** Tile model name, or "Model 12" for unnamed models. */
|
||||
public static String modelName(TileModel m) {
|
||||
return m.name().isEmpty() ? "Model " + number(m.id()) : m.name();
|
||||
}
|
||||
|
||||
/** The number part of an id ("model:12" → "12", "page:n3" → "n3"). */
|
||||
public static String number(String id) {
|
||||
return id.substring(id.indexOf(':') + 1);
|
||||
}
|
||||
|
||||
private static String fallback(Node n) {
|
||||
return switch (n) {
|
||||
case LevelSettings s -> "Level settings";
|
||||
case Regions r -> "Visibility regions";
|
||||
case EnvZone z -> z.isDefault() ? "Default zone (fog, sky, music)" : "Zone " + z.cellX() + ", " + z.cellZ();
|
||||
case TileModel m -> modelName(m);
|
||||
case TexturePage p -> p.name().isEmpty() ? "Page " + number(p.id()) : p.name();
|
||||
case MeshAsset m -> m.name().isEmpty() ? m.file() : m.name();
|
||||
case Strat st -> typeName(st);
|
||||
case Brush b -> brushName(b);
|
||||
case PathNode p -> "Waypoint";
|
||||
case Light l -> "Light";
|
||||
case Tile t -> "Tile " + t.cellX() + ", " + t.cellZ();
|
||||
default -> n.label();
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import com.eliasmueller.llamamod.mods.ModRegistry;
|
||||
import com.eliasmueller.llamamod.mods.levels.LevelProjectMod;
|
||||
import com.eliasmueller.llamamod.mods.levels.LevelProjects;
|
||||
import com.eliasmueller.llamamod.platform.Config;
|
||||
import com.eliasmueller.llamamod.platform.GameLauncher;
|
||||
import com.eliasmueller.llamamod.save.Ksv;
|
||||
import com.eliasmueller.llamamod.save.SaveStore;
|
||||
import com.eliasmueller.llamamod.save.Worlds;
|
||||
@@ -27,12 +26,11 @@ import java.util.Locale;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import javax.swing.JOptionPane;
|
||||
|
||||
/**
|
||||
* Build (validate + WAD in the background) and Play-test: the current state is written to {@code
|
||||
* <data>/levels/}, enabled as a level mod next to the active mods, applied, the play-test save slot
|
||||
* is unlocked with the map cursor on the level, and the game is started.
|
||||
* Check and Build (validate + WAD in the background), plus the helpers the play-test plugin
|
||||
* ({@code editor.tools.playtest.PlaytestRunner}) uses to install the level as a mod, pick the mod
|
||||
* selection and prepare the save slot.
|
||||
*/
|
||||
public final class PlayTest {
|
||||
/** Result of a background build. */
|
||||
@@ -178,18 +176,6 @@ public final class PlayTest {
|
||||
return l.stream().filter(p -> p.severity() == s).count();
|
||||
}
|
||||
|
||||
/** World/chapter on the world map for a project: TestLevel chapter, or the original's place. */
|
||||
static int[] target(Document d) {
|
||||
if (d.isNewLevel()) {
|
||||
return new int[] {9, Math.max(1, d.scene().settings().targetChapter())};
|
||||
}
|
||||
Matcher m = Pattern.compile("t1l(\\d)m(\\d{3})").matcher(d.base().toLowerCase(Locale.ROOT));
|
||||
if (m.matches()) {
|
||||
return new int[] {Integer.parseInt(m.group(1)), Integer.parseInt(m.group(2))};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a play-test installs the project in the levels folder: {@code <stem>.llamalevel}, unless
|
||||
* that file is a different project (other kind, base or name) – then {@code <stem>-2…}, so a
|
||||
@@ -217,42 +203,6 @@ public final class PlayTest {
|
||||
}
|
||||
}
|
||||
|
||||
public void play() {
|
||||
Document d = w.document();
|
||||
if (d == null) {
|
||||
return;
|
||||
}
|
||||
EditorEnv env = w.env();
|
||||
if (!env.config().installed()) {
|
||||
JOptionPane.showMessageDialog(
|
||||
w,
|
||||
"The game is not installed. Set it up in LlamaMod first.",
|
||||
"Play-test",
|
||||
JOptionPane.WARNING_MESSAGE);
|
||||
return;
|
||||
}
|
||||
int[] tgt = target(d);
|
||||
if (tgt == null || tgt[0] < 9 && tgt[1] > Worlds.chapters(tgt[0])) {
|
||||
JOptionPane.showMessageDialog(
|
||||
w,
|
||||
"This level (" + d.base() + ") is not reachable from the world map, so it"
|
||||
+ " cannot be play-tested directly.",
|
||||
"Play-test",
|
||||
JOptionPane.INFORMATION_MESSAGE);
|
||||
return;
|
||||
}
|
||||
GameLauncher gl = new GameLauncher(env.paths(), env.config());
|
||||
if (gl.isRunning()) {
|
||||
JOptionPane.showMessageDialog(
|
||||
w,
|
||||
"The game is still running. Quit it first (the level files cannot be replaced while it runs).",
|
||||
"Play-test",
|
||||
JOptionPane.INFORMATION_MESSAGE);
|
||||
return;
|
||||
}
|
||||
build(r -> install(d, tgt));
|
||||
}
|
||||
|
||||
/**
|
||||
* The active mods plus the level's own mod (its TestLevel chapter set to tgt). Mods writing the
|
||||
* same level file are left out and named in disabled.
|
||||
@@ -370,62 +320,4 @@ public final class PlayTest {
|
||||
public static String levelName(int[] tgt) {
|
||||
return tgt[0] == 9 ? "TestLevel – chapter " + tgt[1] : Worlds.name(tgt[0]) + " – Chapter " + tgt[1];
|
||||
}
|
||||
|
||||
private void install(Document d, int[] tgt) {
|
||||
EditorEnv env = w.env();
|
||||
Install inst = install(w, d);
|
||||
if (inst == null) {
|
||||
return;
|
||||
}
|
||||
Path target = inst.target();
|
||||
int slot = slot(w.prefs());
|
||||
String levelName = levelName(tgt);
|
||||
w.runTask(
|
||||
"Play-test " + d.title(),
|
||||
p -> {
|
||||
p.update(0.05, "Installing the level as a mod");
|
||||
inst.write();
|
||||
Config cfg = env.config();
|
||||
List<String> disabled = new ArrayList<>();
|
||||
ModRegistry reg = new ModRegistry();
|
||||
List<Mod> mods = selectionWith(cfg, reg, levelMod(reg, cfg, target, tgt), tgt, disabled, null);
|
||||
cfg.enabledMods = new ArrayList<>(mods.stream().map(Mod::id).toList());
|
||||
p.update(0.15, "Applying mods");
|
||||
env.modManager().apply(mods, p.sub(0.15, 0.85));
|
||||
env.saveConfig();
|
||||
p.update(0.86, "Preparing save slot " + slot);
|
||||
prepareSaveSlot(env, slot, tgt);
|
||||
p.update(0.9, "Starting the game");
|
||||
// tests start the game through their own harness (private display and sound)
|
||||
Process proc = Boolean.getBoolean("llamamod.editor.noLaunch")
|
||||
? null
|
||||
: new GameLauncher(env.paths(), cfg).launch(p.sub(0.9, 1));
|
||||
return new Object[] {proc, disabled};
|
||||
},
|
||||
res -> {
|
||||
Object[] o = (Object[]) res;
|
||||
@SuppressWarnings("unchecked")
|
||||
List<String> disabled = (List<String>) o[1];
|
||||
if (inst.ownFile()) {
|
||||
d.history().markSaved();
|
||||
}
|
||||
env.modsChanged();
|
||||
w.status("Game started – " + levelName + " is selected on the map of save slot " + slot);
|
||||
String extra = disabled.isEmpty()
|
||||
? ""
|
||||
: "<br><br>Turned off (same level file): " + String.join(", ", disabled);
|
||||
JOptionPane.showMessageDialog(
|
||||
w,
|
||||
"<html><body style='width: 400px'><b>The game is starting.</b><br><br>"
|
||||
+ "In the main menu choose <b>Spiel starten</b> and save slot <b>" + slot
|
||||
+ "</b> – the map cursor"
|
||||
+ " is already on <b>" + ProjectController.esc(levelName)
|
||||
+ "</b>; press <b>Enter</b> to play.<br><br>"
|
||||
+ "The level stays installed as the mod \"" + ProjectController.esc(d.title())
|
||||
+ "\" in LlamaMod"
|
||||
+ " (Mods → Levels)." + extra + "</body></html>",
|
||||
"Play-test",
|
||||
JOptionPane.INFORMATION_MESSAGE);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -419,7 +419,7 @@ public final class ProjectController {
|
||||
meta.put("time", LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")));
|
||||
Files.writeString(meta(d.autosave), Json.write(meta));
|
||||
d.autosaveCurrent = true;
|
||||
w.status("Autosaved");
|
||||
w.backgroundStatus("Autosaved");
|
||||
} catch (IOException | RuntimeException e) {
|
||||
System.err.println("editor: autosave failed: " + e);
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ final class StatusBar extends JPanel {
|
||||
private final Timer probe;
|
||||
private final Timer clear;
|
||||
private long messageTime;
|
||||
private String pending;
|
||||
|
||||
StatusBar(EditorWindow w) {
|
||||
super(new BorderLayout(8, 0));
|
||||
@@ -83,6 +84,12 @@ final class StatusBar extends JPanel {
|
||||
probe.setRepeats(false);
|
||||
clear = new Timer(6000, e -> {
|
||||
if (System.currentTimeMillis() - messageTime >= 5900) {
|
||||
if (pending != null) {
|
||||
String m = pending;
|
||||
pending = null;
|
||||
message(m);
|
||||
return;
|
||||
}
|
||||
message.setText(w.document() == null ? "Open a level or create a new one" : "Ready");
|
||||
message.setIcon(Icons.get("info", 14));
|
||||
}
|
||||
@@ -106,12 +113,28 @@ final class StatusBar extends JPanel {
|
||||
}
|
||||
|
||||
void message(String m) {
|
||||
pending = null;
|
||||
message.setText(m);
|
||||
message.setIcon(Icons.get(m.startsWith("Build failed") ? "error" : "info", 14));
|
||||
messageTime = System.currentTimeMillis();
|
||||
clear.restart();
|
||||
}
|
||||
|
||||
/**
|
||||
* A message nobody asked for (autosave): shown right away only when no other message is still visible,
|
||||
* otherwise once that one has expired.
|
||||
*/
|
||||
void backgroundMessage(String m) {
|
||||
if (!progress.isVisible() && System.currentTimeMillis() - messageTime >= 5900) {
|
||||
message(m);
|
||||
return;
|
||||
}
|
||||
pending = m;
|
||||
if (!clear.isRunning() && !progress.isVisible()) {
|
||||
clear.restart();
|
||||
}
|
||||
}
|
||||
|
||||
/** Sticky "game running" state (null = none). */
|
||||
void game(String what) {
|
||||
game.setText(what == null ? "" : "Game running: " + what);
|
||||
@@ -129,7 +152,8 @@ final class StatusBar extends JPanel {
|
||||
}
|
||||
|
||||
void hover(Node n) {
|
||||
hover.setText(n == null ? " " : n.label() + (w.viewport().hoverBackFace() ? " (back side)" : ""));
|
||||
hover.setText(n == null ? " " : NodeNames.name(n) + (w.viewport().hoverBackFace() ? " (back side)" : ""));
|
||||
hover.setToolTipText(n == null ? null : n.id());
|
||||
hover.setIcon(n == null ? null : NodeIcons.icon(n, 13));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.eliasmueller.llamamod.editor.ui;
|
||||
|
||||
import com.eliasmueller.llamamod.platform.Progress;
|
||||
import java.util.concurrent.CancellationException;
|
||||
import java.util.function.Consumer;
|
||||
import javax.swing.SwingUtilities;
|
||||
|
||||
/**
|
||||
* One background task at a time (the window's {@link EditorContext#runTask}). The task runs on its own
|
||||
* daemon thread; progress updates and the completion are posted with {@code invokeLater} from that thread,
|
||||
* so the completion always comes after the last progress update and always runs – also when the task or
|
||||
* the done callback throws an exception or an {@link Error}.
|
||||
*/
|
||||
final class TaskRunner {
|
||||
/** What the runner shows; all calls on the EDT. */
|
||||
interface Ui {
|
||||
void progress(String text, double fraction);
|
||||
|
||||
void progressDone();
|
||||
|
||||
void failed(String title, Throwable t);
|
||||
|
||||
void finished();
|
||||
}
|
||||
|
||||
private final Ui ui;
|
||||
private volatile boolean running;
|
||||
|
||||
TaskRunner(Ui ui) {
|
||||
this.ui = ui;
|
||||
}
|
||||
|
||||
boolean busy() {
|
||||
return running;
|
||||
}
|
||||
|
||||
/** Starts the task (call on the EDT); false when another task is still running. */
|
||||
boolean start(String title, EditorContext.Task task, Consumer<Object> done) {
|
||||
if (running) {
|
||||
return false;
|
||||
}
|
||||
running = true;
|
||||
ui.progress(title, 0);
|
||||
Progress p = (f, m) -> SwingUtilities.invokeLater(() -> {
|
||||
if (running) {
|
||||
ui.progress(m == null || m.isBlank() ? title : m, f);
|
||||
}
|
||||
});
|
||||
Thread t = new Thread(
|
||||
() -> {
|
||||
Object result = null;
|
||||
Throwable failure = null;
|
||||
try {
|
||||
result = task.run(p);
|
||||
} catch (Throwable e) {
|
||||
failure = e;
|
||||
}
|
||||
Object r = result;
|
||||
Throwable f = failure;
|
||||
SwingUtilities.invokeLater(() -> complete(title, r, f, done));
|
||||
},
|
||||
"editor-task: " + title);
|
||||
t.setDaemon(true);
|
||||
try {
|
||||
t.start();
|
||||
} catch (Throwable e) {
|
||||
complete(title, null, e, done);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void complete(String title, Object result, Throwable failure, Consumer<Object> done) {
|
||||
// not busy any more before the callback: it may start the next task (build → play)
|
||||
running = false;
|
||||
try {
|
||||
ui.progressDone();
|
||||
if (failure == null) {
|
||||
if (done != null) {
|
||||
done.accept(result);
|
||||
}
|
||||
} else if (!(failure instanceof CancellationException || failure instanceof InterruptedException)) {
|
||||
ui.failed(title, failure);
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
ui.failed(title, e);
|
||||
} finally {
|
||||
ui.finished();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,10 @@ import java.awt.Insets;
|
||||
import javax.swing.SwingUtilities;
|
||||
|
||||
/** FlowLayout whose preferred size grows to several rows when the container is too narrow. */
|
||||
final class WrapLayout extends FlowLayout {
|
||||
public final class WrapLayout extends FlowLayout {
|
||||
private int lastHeight = -1;
|
||||
|
||||
WrapLayout(int align, int hgap, int vgap) {
|
||||
public WrapLayout(int align, int hgap, int vgap) {
|
||||
super(align, hgap, vgap);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import com.eliasmueller.llamamod.editor.ui.EditorContext;
|
||||
import com.eliasmueller.llamamod.editor.ui.EditorWindow;
|
||||
import com.eliasmueller.llamamod.editor.ui.Icons;
|
||||
import com.eliasmueller.llamamod.editor.ui.NodeIcons;
|
||||
import com.eliasmueller.llamamod.editor.ui.NodeNames;
|
||||
import com.eliasmueller.llamamod.editor.ui.Widgets;
|
||||
import com.eliasmueller.llamamod.editor.ui.tools.EditOps;
|
||||
import com.eliasmueller.llamamod.editor.ui.view.SceneRenderer;
|
||||
@@ -189,7 +190,7 @@ public final class AssetBrowserPanel extends JPanel {
|
||||
for (TileModel m : s.tileModels()) {
|
||||
out.add(new Item(
|
||||
"model:" + m.id(),
|
||||
m.id(),
|
||||
NodeNames.modelName(m),
|
||||
m.faceCount() + " faces · " + uses.getOrDefault(m, 0) + "×",
|
||||
m,
|
||||
"tilemodel",
|
||||
@@ -444,7 +445,7 @@ public final class AssetBrowserPanel extends JPanel {
|
||||
sel
|
||||
? UIManager.getColor("List.selectionForeground")
|
||||
: UIManager.getColor("Label.disabledForeground"));
|
||||
setToolTipText(v.title + " – " + v.subtitle);
|
||||
setToolTipText(v.title + " – " + v.subtitle + (v.data instanceof Node n ? " (" + n.id() + ")" : ""));
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.eliasmueller.llamamod.editor.ui.panels;
|
||||
|
||||
import com.eliasmueller.llamamod.editor.model.Node;
|
||||
import com.eliasmueller.llamamod.editor.model.NodeKind;
|
||||
import com.eliasmueller.llamamod.editor.model.PathNode;
|
||||
import com.eliasmueller.llamamod.editor.model.Prop;
|
||||
import com.eliasmueller.llamamod.editor.model.PropType;
|
||||
import com.eliasmueller.llamamod.editor.model.Scene;
|
||||
@@ -18,6 +19,7 @@ import com.eliasmueller.llamamod.editor.ui.HelpTopics;
|
||||
import com.eliasmueller.llamamod.editor.ui.Icons;
|
||||
import com.eliasmueller.llamamod.editor.ui.InspectorSection;
|
||||
import com.eliasmueller.llamamod.editor.ui.NodeIcons;
|
||||
import com.eliasmueller.llamamod.editor.ui.NodeNames;
|
||||
import com.eliasmueller.llamamod.editor.ui.Tool;
|
||||
import com.eliasmueller.llamamod.editor.ui.Widgets;
|
||||
import com.eliasmueller.llamamod.util.SwingListeners;
|
||||
@@ -38,6 +40,7 @@ import java.awt.event.FocusEvent;
|
||||
import java.awt.event.MouseAdapter;
|
||||
import java.awt.event.MouseEvent;
|
||||
import java.util.ArrayList;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
@@ -179,8 +182,14 @@ public final class InspectorPanel extends JPanel {
|
||||
String title;
|
||||
String sub;
|
||||
if (nodes.size() == 1) {
|
||||
title = p instanceof Strat st && !st.type().isEmpty() ? st.type() : p.label();
|
||||
sub = p.kind().label() + " · " + p.id();
|
||||
title = NodeNames.name(p);
|
||||
sub = p.kind().label();
|
||||
if (p instanceof PathNode pn && p.scene() != null) {
|
||||
// "Waypoint 4" with the chain in the subtitle: the title stays short in a narrow dock
|
||||
NodeNames nn = NodeNames.of(p.scene());
|
||||
title = nn.waypointName(pn, false);
|
||||
sub += nn.chainNumber(pn) > 0 ? " · chain " + nn.chainNumber(pn) : "";
|
||||
}
|
||||
if (p instanceof Strat st) {
|
||||
sub += " · " + NodeIcons.categoryLabel(StratCatalog.category(st.type()));
|
||||
}
|
||||
@@ -196,6 +205,10 @@ public final class InspectorPanel extends JPanel {
|
||||
txt.setOpaque(false);
|
||||
JLabel t = new JLabel(title);
|
||||
t.setFont(t.getFont().deriveFont(Font.BOLD, 15f));
|
||||
if (nodes.size() == 1) {
|
||||
t.setToolTipText(p.id());
|
||||
h.setToolTipText(p.id());
|
||||
}
|
||||
JLabel st = new JLabel(sub);
|
||||
st.setFont(st.getFont().deriveFont(11.5f));
|
||||
st.setForeground(UIManager.getColor("Label.disabledForeground"));
|
||||
@@ -351,14 +364,10 @@ public final class InspectorPanel extends JPanel {
|
||||
c.anchor = GridBagConstraints.WEST;
|
||||
c.insets = new Insets(2, 12, 2, 8);
|
||||
JLabel l = label != null ? label : new JLabel(text);
|
||||
boolean cut = l.getPreferredSize().width > 118;
|
||||
l.setPreferredSize(new Dimension(118, l.getPreferredSize().height));
|
||||
l.setMinimumSize(new Dimension(118, l.getPreferredSize().height));
|
||||
fitLabel(l, LABEL_W);
|
||||
l.setForeground(UIManager.getColor("Label.foreground"));
|
||||
if (tip != null && !tip.isEmpty()) {
|
||||
l.setToolTipText(cut ? l.getText() + " – " + tip : tip);
|
||||
} else if (cut) {
|
||||
l.setToolTipText(l.getText());
|
||||
l.setToolTipText(tip);
|
||||
}
|
||||
grid.add(l, c);
|
||||
c.gridx = 1;
|
||||
@@ -369,6 +378,29 @@ public final class InspectorPanel extends JPanel {
|
||||
return row + 1;
|
||||
}
|
||||
|
||||
private static final int LABEL_W = 118;
|
||||
|
||||
/**
|
||||
* Gives a property label the fixed column width; a longer text wraps onto more lines instead of being cut.
|
||||
* Returns true when it did not fit on one line.
|
||||
*/
|
||||
static boolean fitLabel(JLabel l, int width) {
|
||||
String text = l.getText();
|
||||
int h = l.getPreferredSize().height;
|
||||
boolean wraps = l.getPreferredSize().width > width && text != null && !text.startsWith("<html>");
|
||||
if (wraps) {
|
||||
l.setText("<html>" + text.replace("&", "&").replace("<", "<") + "</html>");
|
||||
if (l.getClientProperty(javax.swing.plaf.basic.BasicHTML.propertyKey) instanceof javax.swing.text.View v) {
|
||||
java.awt.Insets in = l.getInsets();
|
||||
v.setSize(width - in.left - in.right, 0);
|
||||
h = (int) Math.ceil(v.getPreferredSpan(javax.swing.text.View.Y_AXIS)) + in.top + in.bottom;
|
||||
}
|
||||
}
|
||||
l.setPreferredSize(new Dimension(width, h));
|
||||
l.setMinimumSize(new Dimension(width, h));
|
||||
return wraps;
|
||||
}
|
||||
|
||||
private JComponent section(String title, JComponent content, boolean open, String key) {
|
||||
// the height follows the content (wrapped text, rows added later) instead of a fixed maximum
|
||||
JPanel p = new JPanel(new BorderLayout()) {
|
||||
@@ -794,7 +826,8 @@ public final class InspectorPanel extends JPanel {
|
||||
Binding b = () -> {
|
||||
Object v = value(p.name());
|
||||
if (v instanceof Node n) {
|
||||
go.setText(n.label());
|
||||
go.setText(NodeNames.name(n));
|
||||
go.setToolTipText("Select the referenced item (" + n.id() + ")");
|
||||
go.setIcon(NodeIcons.icon(n, 14));
|
||||
} else {
|
||||
go.setText(v == MIXED ? "—" : "(none)");
|
||||
@@ -834,12 +867,17 @@ public final class InspectorPanel extends JPanel {
|
||||
filter.putClientProperty("JTextField.placeholderText", "Filter…");
|
||||
DefaultListModel<Object> model = new DefaultListModel<>();
|
||||
items.forEach(model::addElement);
|
||||
Map<Object, String> names = new IdentityHashMap<>();
|
||||
NodeNames nn = ctx.scene() == null ? null : NodeNames.of(ctx.scene());
|
||||
for (Object o : items) {
|
||||
names.put(o, o instanceof Node n && nn != null ? nn.nameOf(n) : o.toString());
|
||||
}
|
||||
JList<Object> list = new JList<>(model);
|
||||
SwingListeners.onTextChange(filter, () -> {
|
||||
String q = filter.getText().toLowerCase(Locale.ROOT);
|
||||
model.clear();
|
||||
for (Object o : items) {
|
||||
String l = o instanceof Node n ? n.label() : o.toString();
|
||||
String l = names.get(o) + (o instanceof Node n ? " " + n.label() : "");
|
||||
if (l.toLowerCase(Locale.ROOT).contains(q)) {
|
||||
model.addElement(o);
|
||||
}
|
||||
@@ -848,8 +886,10 @@ public final class InspectorPanel extends JPanel {
|
||||
list.setCellRenderer(new DefaultListCellRenderer() {
|
||||
@Override
|
||||
public Component getListCellRendererComponent(JList<?> l, Object v, int i, boolean s, boolean f) {
|
||||
JLabel c = (JLabel) super.getListCellRendererComponent(l, v instanceof Node n ? n.label() : v, i, s, f);
|
||||
JLabel c = (JLabel)
|
||||
super.getListCellRendererComponent(l, names.getOrDefault(v, String.valueOf(v)), i, s, f);
|
||||
c.setIcon(v instanceof Node n ? NodeIcons.icon(n, 14) : null);
|
||||
c.setToolTipText(v instanceof Node n ? n.id() : null);
|
||||
return c;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ import com.eliasmueller.llamamod.editor.ui.EditorContext;
|
||||
import com.eliasmueller.llamamod.editor.ui.EditorWindow;
|
||||
import com.eliasmueller.llamamod.editor.ui.Icons;
|
||||
import com.eliasmueller.llamamod.editor.ui.NodeIcons;
|
||||
import com.eliasmueller.llamamod.editor.ui.NodeNames;
|
||||
import com.eliasmueller.llamamod.editor.ui.Selection;
|
||||
import com.eliasmueller.llamamod.editor.ui.tools.EditOps;
|
||||
import com.eliasmueller.llamamod.util.SwingListeners;
|
||||
@@ -31,9 +32,7 @@ import java.awt.Rectangle;
|
||||
import java.awt.event.MouseAdapter;
|
||||
import java.awt.event.MouseEvent;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Enumeration;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
@@ -98,6 +97,7 @@ public final class OutlinerPanel extends JPanel {
|
||||
private final Map<Node, DefaultMutableTreeNode> byNode = new IdentityHashMap<>();
|
||||
private final Timer rebuildTimer;
|
||||
private boolean syncing;
|
||||
private NodeNames names;
|
||||
|
||||
public OutlinerPanel(EditorContext ctx) {
|
||||
super(new BorderLayout());
|
||||
@@ -264,6 +264,7 @@ public final class OutlinerPanel extends JPanel {
|
||||
Scene s = ctx.scene();
|
||||
String q = filter.getText().trim().toLowerCase(Locale.ROOT);
|
||||
if (s != null) {
|
||||
names = NodeNames.of(s);
|
||||
build(root, s, q);
|
||||
}
|
||||
syncing = true;
|
||||
@@ -292,12 +293,31 @@ public final class OutlinerPanel extends JPanel {
|
||||
return true;
|
||||
}
|
||||
String l = n.label().toLowerCase(Locale.ROOT);
|
||||
if (l.contains(q) || n.id().contains(q)) {
|
||||
if (l.contains(q) || n.id().contains(q) || nameMatches(names.nameOf(n), q)) {
|
||||
return true;
|
||||
}
|
||||
if (n instanceof Tile t && ("cell " + t.cellX() + ", " + t.cellZ()).contains(q)) {
|
||||
return true;
|
||||
}
|
||||
return n instanceof Strat st && StratCatalog.category(st.type()).contains(q);
|
||||
}
|
||||
|
||||
/**
|
||||
* Case-insensitive substring match of a readable name; a query ending in a number matches whole numbers
|
||||
* only ("floor 1" finds "Floor 1", not "Floor 12").
|
||||
*/
|
||||
static boolean nameMatches(String name, String q) {
|
||||
String n = name.toLowerCase(Locale.ROOT);
|
||||
boolean num = !q.isEmpty() && Character.isDigit(q.charAt(q.length() - 1));
|
||||
for (int i = n.indexOf(q); i >= 0; i = n.indexOf(q, i + 1)) {
|
||||
int end = i + q.length();
|
||||
if (!num || end >= n.length() || !Character.isDigit(n.charAt(end))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private DefaultMutableTreeNode group(DefaultMutableTreeNode parent, String key, String label, Icon icon) {
|
||||
Row r = new Row(key, label, "", icon, null);
|
||||
DefaultMutableTreeNode t = new DefaultMutableTreeNode(r);
|
||||
@@ -408,7 +428,7 @@ public final class OutlinerPanel extends JPanel {
|
||||
if (match(st, q)) {
|
||||
String c = StratCatalog.category(st.type());
|
||||
cats.computeIfAbsent(c, k -> new TreeMap<>())
|
||||
.computeIfAbsent(st.type().isEmpty() ? "?" : st.type(), k -> new ArrayList<>())
|
||||
.computeIfAbsent(NodeNames.typeName(st), k -> new ArrayList<>())
|
||||
.add(st);
|
||||
}
|
||||
}
|
||||
@@ -423,10 +443,8 @@ public final class OutlinerPanel extends JPanel {
|
||||
if (t.getValue().size() > 1) {
|
||||
parent = group(cg, "type:" + t.getKey(), t.getKey(), NodeIcons.category(c.getKey(), 14));
|
||||
}
|
||||
int nr = 0;
|
||||
for (Strat st : t.getValue()) {
|
||||
String name = t.getValue().size() > 1 ? t.getKey() + " " + ++nr : t.getKey();
|
||||
leaf(parent, st, name, pos(st.gx(), st.gy(), st.gz()));
|
||||
leaf(parent, st, names.objectName(st), pos(st.gx(), st.gy(), st.gz()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -435,56 +453,32 @@ public final class OutlinerPanel extends JPanel {
|
||||
private void buildChains(DefaultMutableTreeNode root, Scene s, String q) {
|
||||
DefaultMutableTreeNode chains =
|
||||
group(root, "chains", "Waypoint chains", NodeIcons.kind(NodeKind.PATH_NODE, 16));
|
||||
Set<PathNode> hasPrev = Collections.newSetFromMap(new IdentityHashMap<>());
|
||||
for (PathNode p : s.pathNodes()) {
|
||||
if (p.next() != null) {
|
||||
hasPrev.add(p.next());
|
||||
}
|
||||
}
|
||||
Map<PathNode, String> users = new IdentityHashMap<>();
|
||||
for (Strat st : s.strats()) {
|
||||
if (st.waypoint() != null) {
|
||||
users.merge(st.waypoint(), st.type(), (a, b) -> a.contains(b) ? a : a + ", " + b);
|
||||
}
|
||||
}
|
||||
Set<PathNode> done = Collections.newSetFromMap(new IdentityHashMap<>());
|
||||
List<PathNode> starts = new ArrayList<>();
|
||||
for (PathNode p : s.pathNodes()) {
|
||||
if (!hasPrev.contains(p)) {
|
||||
starts.add(p);
|
||||
}
|
||||
}
|
||||
for (PathNode p : s.pathNodes()) {
|
||||
if (!starts.contains(p) && !done.contains(p)) {
|
||||
// loops without a start
|
||||
if (starts.stream().noneMatch(st -> s.chain(st).contains(p))) {
|
||||
starts.add(p);
|
||||
done.addAll(s.chain(p));
|
||||
}
|
||||
}
|
||||
}
|
||||
int chainNr = 0;
|
||||
for (PathNode start : starts) {
|
||||
for (PathNode start : names.chainStarts()) {
|
||||
chainNr++;
|
||||
List<PathNode> ch = s.chain(start);
|
||||
String user = users.get(start);
|
||||
String label = "Chain " + chainNr + (user != null ? " – " + user : "");
|
||||
List<PathNode> m = new ArrayList<>();
|
||||
for (PathNode p : ch) {
|
||||
if (match(p, q)) {
|
||||
m.add(p);
|
||||
for (int i = 0; i < ch.size(); i++) {
|
||||
if (match(ch.get(i), q) || nameMatches("waypoint " + (i + 1), q)) {
|
||||
m.add(ch.get(i));
|
||||
}
|
||||
}
|
||||
String user = users.get(start);
|
||||
if (m.isEmpty() && !(user != null && user.toLowerCase(Locale.ROOT).contains(q))) {
|
||||
if (m.isEmpty() && !nameMatches(label, q)) {
|
||||
continue;
|
||||
}
|
||||
if (m.isEmpty()) {
|
||||
m = ch;
|
||||
}
|
||||
DefaultMutableTreeNode g = group(
|
||||
chains,
|
||||
"chain:" + start.id(),
|
||||
"Chain " + chainNr + (user != null ? " – " + user : ""),
|
||||
NodeIcons.kind(NodeKind.PATH_NODE, 16));
|
||||
DefaultMutableTreeNode g =
|
||||
group(chains, "chain:" + start.id(), label, NodeIcons.kind(NodeKind.PATH_NODE, 16));
|
||||
for (PathNode p : m) {
|
||||
leaf(g, p, "Waypoint " + (ch.indexOf(p) + 1), pos(p.gx(), p.gy(), p.gz()));
|
||||
}
|
||||
@@ -496,34 +490,19 @@ public final class OutlinerPanel extends JPanel {
|
||||
DefaultMutableTreeNode zones = group(root, "zones", "Zones", NodeIcons.kind(NodeKind.ENV_ZONE, 16));
|
||||
for (EnvZone z : s.zones()) {
|
||||
if (match(z, q)) {
|
||||
leaf(
|
||||
zones,
|
||||
z,
|
||||
z.isDefault()
|
||||
? "Default zone (fog, sky, music)"
|
||||
: "Z" + z.label().substring(1),
|
||||
z.isDefault() ? "" : "cell " + z.cellX() + ", " + z.cellZ());
|
||||
leaf(zones, z, names.nameOf(z), z.isDefault() ? "" : "cell " + z.cellX() + ", " + z.cellZ());
|
||||
}
|
||||
}
|
||||
DefaultMutableTreeNode lights = group(root, "lights", "Lights", NodeIcons.kind(NodeKind.LIGHT, 16));
|
||||
int lightNr = 0;
|
||||
for (Light l : s.lights()) {
|
||||
lightNr++;
|
||||
if (match(l, q)) {
|
||||
leaf(lights, l, "Light " + lightNr, l.type() == Light.DIRECTIONAL ? "directional" : "point");
|
||||
leaf(lights, l, names.nameOf(l), l.type() == Light.DIRECTIONAL ? "directional" : "point");
|
||||
}
|
||||
}
|
||||
DefaultMutableTreeNode brushes = group(root, "brushes", "Brushes", NodeIcons.kind(NodeKind.BRUSH, 16));
|
||||
Map<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, sameName.get(name) > 1 ? name + " " + nr : name, brushPlace(b));
|
||||
leaf(brushes, b, names.nameOf(b), brushPlace(b));
|
||||
}
|
||||
}
|
||||
DefaultMutableTreeNode assets = group(root, "assets", "Assets", Icons.get("assets", 16));
|
||||
@@ -547,7 +526,7 @@ public final class OutlinerPanel extends JPanel {
|
||||
leaf(
|
||||
pages,
|
||||
p,
|
||||
p.label().replace("page " + p.id(), "Page " + number(p.id())),
|
||||
p.label().replace("page " + p.id(), "Page " + NodeNames.number(p.id())),
|
||||
p.width() + "×" + p.height());
|
||||
}
|
||||
}
|
||||
@@ -555,16 +534,7 @@ public final class OutlinerPanel extends JPanel {
|
||||
|
||||
/** "model:12 (…)" → "Model 12 (…)" for unnamed tile models. */
|
||||
private static String modelName(TileModel m) {
|
||||
return m.label().replace(m.id(), "Model " + number(m.id()));
|
||||
}
|
||||
|
||||
/** The number part of an id ("model:12" → "12", "page:n3" → "n3"). */
|
||||
private static String number(String id) {
|
||||
return id.substring(id.indexOf(':') + 1);
|
||||
}
|
||||
|
||||
private static String brushName(Brush b) {
|
||||
return b.name().isEmpty() ? b.brushKind().label() : b.name();
|
||||
return m.label().replace(m.id(), "Model " + NodeNames.number(m.id()));
|
||||
}
|
||||
|
||||
/** Centre of a brush (game cells) for the detail column. */
|
||||
|
||||
Reference in new issue
Block a user