readable names also in io, checks and texture messages (naming moved to editor.model), tile palette filter by name or number, import preview frames the main mass and zooms with the wheel, object palette right-click no longer switches to place, floorfinder built on ground, levelbuild starts get one camera node behind the llama (golden hashes updated, boot-to-level moves it along)

This commit is contained in:
MineTec committed 2026-11-07 14:42:32 +01:00
1 parent 386a06006d
commit 38c679b319
49 files changed
+819 -470

No files matched your search

@@ -6,6 +6,7 @@ import com.eliasmueller.llamamod.editor.model.LevelSettings;
import com.eliasmueller.llamamod.editor.model.MeshAsset;
import com.eliasmueller.llamamod.editor.model.MeshData;
import com.eliasmueller.llamamod.editor.model.RawChunk;
import com.eliasmueller.llamamod.editor.model.ReadableNames;
import com.eliasmueller.llamamod.editor.model.Scene;
import com.eliasmueller.llamamod.editor.model.TextureEntry;
import com.eliasmueller.llamamod.editor.model.TexturePage;
@@ -208,7 +209,7 @@ public final class BrushCompiler {
&& bb[3] + 1 > cx
&& bb[2] - 1 < cz + 1
&& bb[5] + 1 > cz) {
ids.add(b.id());
ids.add(ReadableNames.name(b));
}
}
return ids.isEmpty()
@@ -260,10 +261,10 @@ public final class BrushCompiler {
replay(lb, b, kills, meshQuads.get(b));
postProcess(lb, b, q0, s0);
} catch (IllegalArgumentException e) {
warn.add(b.label() + ": " + e.getMessage() + " – skipped");
warn.add(ReadableNames.name(b) + ": " + e.getMessage() + " – skipped");
} catch (RuntimeException e) {
// one broken brush must not take all the others with it (live preview and build)
warn.add(b.label() + ": cannot be built (" + e + ") – skipped");
warn.add(ReadableNames.name(b) + ": cannot be built (" + e + ") – skipped");
}
}
byte[] wad;
@@ -674,7 +675,7 @@ public final class BrushCompiler {
List<MeshQuad> triangles(Brush b, List<String> warn) {
MeshAsset a = (MeshAsset) b.ref("asset");
if (a == null) {
warn.add(b.label() + ": no mesh asset");
warn.add(ReadableNames.name(b) + ": no mesh asset");
return List.of();
}
MeshData m = a.mesh();
@@ -682,7 +683,7 @@ public final class BrushCompiler {
try {
m = MeshImport.load(s, a);
} catch (java.io.IOException | RuntimeException e) {
warn.add(b.label() + ": " + a.file() + " cannot be read: " + e.getMessage());
warn.add(ReadableNames.name(b) + ": " + a.file() + " cannot be read: " + e.getMessage());
return List.of();
}
}
@@ -3,6 +3,7 @@ package com.eliasmueller.llamamod.editor.io;
import com.eliasmueller.llamamod.editor.model.Brush;
import com.eliasmueller.llamamod.editor.model.LevelSettings;
import com.eliasmueller.llamamod.editor.model.PathNode;
import com.eliasmueller.llamamod.editor.model.ReadableNames;
import com.eliasmueller.llamamod.editor.model.Scene;
import com.eliasmueller.llamamod.editor.model.Strat;
import com.eliasmueller.llamamod.editor.model.StratCatalog;
@@ -26,6 +27,7 @@ public final class Validator {
/** Errors; warnings are added to warn. */
public static List<String> validate(Scene s, List<String> warn) {
List<String> err = new ArrayList<>();
ReadableNames names = ReadableNames.of(s);
LevelSettings st = s.settings();
int w = st.width();
int h = st.height();
@@ -38,83 +40,86 @@ public final class Validator {
int nTex = s.textures().size();
for (TileModel m : s.tileModels()) {
if (m.vertexCount() > MAX_VERTS) {
err.add(m.id() + ": " + m.vertexCount() + " vertices (engine limit " + MAX_VERTS + ")");
err.add(ReadableNames.modelName(m) + ": " + m.vertexCount() + " vertices (engine limit " + MAX_VERTS
+ ")");
}
if (m.vertices().length % TileModel.VERTEX_STRIDE != 0
|| m.faces().length % TileModel.FACE_STRIDE != 0
|| m.planes().length != m.faceCount() * TileModel.PLANE_STRIDE) {
err.add(m.id() + ": array lengths do not match");
err.add(ReadableNames.modelName(m) + ": array lengths do not match");
continue;
}
for (int g = 0; g < 3; g++) {
if (m.collision(g).length % TileModel.COLLISION_SIZE != 0) {
err.add(m.id() + ": collision group " + g + " is not a multiple of 32 bytes");
err.add(ReadableNames.modelName(m) + ": collision group " + g + " is not a multiple of 32 bytes");
}
}
int nv = m.vertexCount();
for (int f = 0; f < m.faceCount(); f++) {
for (int k = 0; k < 3; k++) {
if (m.faceVertex(f, k) >= nv) {
err.add(m.id() + " face " + f + ": vertex index out of range");
err.add(ReadableNames.modelName(m) + " face " + f + ": vertex index out of range");
break;
}
}
if (m.faceTexture(f) >= nTex) {
warn.add(m.id() + " face " + f + ": texture " + m.faceTexture(f) + " beyond the table (" + nTex
+ ")");
warn.add(ReadableNames.modelName(m) + " face " + f + ": texture " + m.faceTexture(f)
+ " beyond the table (" + nTex + ")");
}
}
}
boolean alts = (st.wfpc() & MapChunk.WF_ALTS) != 0;
for (Tile t : s.tiles()) {
if (t.model() == null || !s.contains(t.model())) {
err.add(t.id() + ": model missing");
err.add(names.nameOf(t) + ": model missing");
continue;
}
int cx = t.cellX();
int cz = t.cellZ();
if (cx < 0 || cx >= w || cz < 0 || cz >= h) {
err.add(t.id() + ": outside the " + w + "×" + h + " grid (cell " + cx + "," + cz + ")");
err.add(names.nameOf(t) + ": outside the " + w + "×" + h + " grid (cell " + cx + "," + cz + ")");
}
if (t.hasSwap()) {
if (!alts) {
err.add(t.id() + ": tile swaps need WFPC flag 0x10000, which this base level does not have");
err.add(names.nameOf(t)
+ ": tile swaps need WFPC flag 0x10000, which this base level does not have");
} else if (t.swapAltModel() == null || !s.contains(t.swapAltModel())) {
err.add(t.id() + ": swap without alternative model");
err.add(names.nameOf(t) + ": swap without alternative model");
}
}
}
for (Strat x : s.strats()) {
if (x.waypoint() != null && !s.contains(x.waypoint())) {
err.add(x.id() + ": waypoint is not part of the level");
err.add(names.nameOf(x) + ": waypoint is not part of the level");
}
StratCatalog.Type t = s.catalog().type(x.type());
if (x.type().isEmpty()) {
warn.add(x.id() + ": unknown strat code 0x" + Integer.toHexString(x.code()));
warn.add(names.nameOf(x) + ": unknown object code 0x" + Integer.toHexString(x.code()));
} else if (t != null && t.variants().stream().noneMatch(v -> v.nParams() == x.params().length)) {
warn.add(x.id() + " (" + x.type() + "): " + x.params().length + " parameters, the type uses "
warn.add(names.nameOf(x) + ": " + x.params().length + " parameters, the type uses "
+ t.variants().getFirst().nParams());
}
double gx = x.gx();
double gz = x.gz();
if (gx < 0 || gx >= w || gz < 0 || gz >= h) {
warn.add(x.id() + " (" + x.type() + ") is outside the grid");
warn.add(names.nameOf(x) + " is outside the grid");
}
}
for (Brush b : s.brushes()) {
String bad = b.enabled() ? BrushCompiler.problem(b, w, h, 8) : null;
if (bad != null) {
err.add(b.id() + " (" + b.label() + "): " + bad);
err.add(names.nameOf(b) + ": " + bad);
}
}
for (PathNode n : s.pathNodes()) {
if (n.next() != null && !s.contains(n.next())) {
err.add(n.id() + ": next node is not part of the level");
err.add(names.nameOf(n) + ": next node is not part of the level");
}
}
for (TexturePage p : s.pages()) {
for (int i = 0; i < s.pages().size(); i++) {
TexturePage p = s.pages().get(i);
if (p.pixels().length != p.width() * p.height()) {
err.add(p.id() + ": pixel count does not match " + p.width() + "×" + p.height());
err.add("Texture page " + i + ": pixel count does not match " + p.width() + "×" + p.height());
}
}
if (s.pages().size() > 127) {
@@ -0,0 +1,198 @@
package com.eliasmueller.llamamod.editor.model;
import java.util.ArrayList;
import java.util.Collections;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Locale;
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, asset browser and in build warnings. 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. The UI uses it as {@code editor.ui.NodeNames}.
*/
public class ReadableNames {
private final Scene scene;
private Map<Node, String> numbered;
private List<PathNode> starts;
private Map<PathNode, int[]> waypoints;
protected ReadableNames(Scene scene) {
this.scene = scene;
}
public static ReadableNames of(Scene scene) {
return new ReadableNames(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(), ReadableNames::typeName);
number(scene.brushes(), ReadableNames::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();
};
}
/**
* Case-insensitive substring match of a readable name; numbers in the query match whole numbers only
* ("floor 1" finds "Floor 1", not "Floor 12"; "2" does not find "Floor 12").
*/
public static boolean matches(String name, String q) {
String n = name.toLowerCase(Locale.ROOT);
q = q.toLowerCase(Locale.ROOT);
if (q.isEmpty()) {
return true;
}
boolean numEnd = Character.isDigit(q.charAt(q.length() - 1));
boolean numStart = Character.isDigit(q.charAt(0));
for (int i = n.indexOf(q); i >= 0; i = n.indexOf(q, i + 1)) {
int end = i + q.length();
if ((!numEnd || end >= n.length() || !Character.isDigit(n.charAt(end)))
&& (!numStart || i == 0 || !Character.isDigit(n.charAt(i - 1)))) {
return true;
}
}
return false;
}
}
@@ -8,6 +8,7 @@ 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.ReadableNames;
import com.eliasmueller.llamamod.editor.model.Scene;
import com.eliasmueller.llamamod.editor.model.Strat;
import com.eliasmueller.llamamod.editor.model.StratCatalog;
@@ -218,7 +219,7 @@ public final class Edits {
for (Map.Entry<Node, Prop> r : s.referencesTo(n)) {
if (!r.getValue().nullable() && !nodes.contains(r.getKey())) {
throw new IllegalStateException(
n.id() + " is still used by " + r.getKey().id());
ReadableNames.name(n) + " is still used by " + ReadableNames.name(r.getKey()));
}
}
}
@@ -2,6 +2,7 @@ package com.eliasmueller.llamamod.editor.model.cmd;
import com.eliasmueller.llamamod.editor.model.Node;
import com.eliasmueller.llamamod.editor.model.Prop;
import com.eliasmueller.llamamod.editor.model.ReadableNames;
import com.eliasmueller.llamamod.editor.model.Scene;
import java.util.ArrayList;
import java.util.List;
@@ -21,8 +22,8 @@ public final class RemoveNode implements Command {
this.node = node;
for (Map.Entry<Node, Prop> r : s.referencesTo(node)) {
if (!r.getValue().nullable()) {
throw new IllegalStateException(node.id() + " is still used by "
+ r.getKey().id() + " (" + r.getValue().label() + ")");
throw new IllegalStateException(ReadableNames.name(node) + " is still used by "
+ ReadableNames.name(r.getKey()) + " (" + r.getValue().label() + ")");
}
unlink.add(SetProp.of(r.getKey(), r.getValue(), null, null));
}
@@ -131,7 +131,8 @@ final class MeshImportDialog extends JDialog {
JPanel left = new JPanel(new BorderLayout());
left.add(preview, BorderLayout.CENTER);
JLabel hint = new JLabel("Drag to orbit · grid = 1 cell · the llama is 0.6 cells tall", JLabel.CENTER);
JLabel hint =
new JLabel("Drag to orbit, wheel to zoom · grid = 1 cell · the llama is 0.6 cells tall", JLabel.CENTER);
hint.setFont(hint.getFont().deriveFont(11f));
left.add(hint, BorderLayout.SOUTH);
left.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 4));
@@ -286,9 +287,14 @@ final class MeshImportDialog extends JDialog {
// ------------------------------------------------------------------ preview
/** Software preview: flat-shaded triangles with a z-buffer ({@link PreviewRaster}), orbit by dragging. */
/**
* Software preview: flat-shaded triangles with a z-buffer ({@link PreviewRaster}), orbit by dragging,
* zoom with the mouse wheel. Framed on the main mass of the mesh ({@link #focusBounds}).
*/
static final class Preview extends JPanel {
private MeshData mesh;
private double[] focus;
private double zoom = 1;
/** Starts looking down at the model's front (+z, as Blender's front view shows it). */
private double yaw = Math.toRadians(215);
@@ -319,9 +325,17 @@ final class MeshImportDialog extends JDialog {
};
addMouseListener(m);
addMouseMotionListener(m);
addMouseWheelListener(e -> {
zoom = Math.max(0.05, Math.min(50, zoom * Math.pow(1.15, -e.getPreciseWheelRotation())));
repaint();
});
}
void set(MeshData m, double turnDeg, boolean mirror) {
if (m != mesh) {
focus = m == null ? null : focusBounds(m.positions());
zoom = 1;
}
mesh = m;
turn = Math.toRadians(turnDeg);
this.mirror = mirror;
@@ -357,10 +371,10 @@ final class MeshImportDialog extends JDialog {
}
Graphics2D g = (Graphics2D) g0;
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
double[] b = mesh.bounds();
double[] b = focus;
double cx = (b[0] + b[3]) / 2, cy = (b[1] + b[4]) / 2, cz = (b[2] + b[5]) / 2;
double size = Math.max(1e-6, Math.max(b[3] - b[0], Math.max(b[4] - b[1], b[5] - b[2])));
double sc = Math.min(getWidth(), getHeight()) * 0.62 / size;
double sc = Math.min(getWidth(), getHeight()) * 0.62 / size * zoom;
double ct = Math.cos(turn), st = Math.sin(turn);
double cyw = Math.cos(yaw), syw = Math.sin(yaw), cp = Math.cos(pitch), sp = Math.sin(pitch);
int w2 = getWidth() / 2, h2 = getHeight() / 2;
@@ -428,6 +442,44 @@ final class MeshImportDialog extends JDialog {
line(g, b[3] - cx + 0.5, gy, 0, b[3] - cx + 0.5, gy + 0.6, 0, ct, st, cyw, syw, cp, sp, sc, w2, h2, true);
}
/**
* Bounds {x0, y0, z0, x1, y1, z1} of the mesh's main mass: per axis the vertices within 2.5
* interquartile ranges of the middle half (Tukey fences); the full bounds when they are not much
* larger (far-off pieces such as a stray triangle or a huge ground plane would otherwise shrink the
* model to a dot).
*/
static double[] focusBounds(double[] pos) {
int n = pos.length / 3;
double[] full = new double[6];
double[] robust = new double[6];
if (n == 0) {
return full;
}
double[] v = new double[n];
boolean far = false;
for (int axis = 0; axis < 3; axis++) {
for (int i = 0; i < n; i++) {
v[i] = pos[3 * i + axis];
}
java.util.Arrays.sort(v);
full[axis] = v[0];
full[axis + 3] = v[n - 1];
double q1 = v[(n - 1) / 4], q3 = v[(3 * (n - 1) + 3) / 4];
double fence = 2.5 * (q3 - q1);
int lo = 0, hi = n - 1;
while (v[lo] < q1 - fence) {
lo++;
}
while (v[hi] > q3 + fence) {
hi--;
}
robust[axis] = v[lo];
robust[axis + 3] = v[hi];
far |= v[n - 1] - v[0] > 1.5 * (robust[axis + 3] - robust[axis]) + 1e-9;
}
return far ? robust : full;
}
private static void line(
Graphics2D g,
double x0,
@@ -2,6 +2,7 @@ package com.eliasmueller.llamamod.editor.tools.geometry;
import com.eliasmueller.llamamod.editor.model.Node;
import com.eliasmueller.llamamod.editor.model.NodeKind;
import com.eliasmueller.llamamod.editor.model.ReadableNames;
import com.eliasmueller.llamamod.editor.model.Scene;
import com.eliasmueller.llamamod.editor.model.Tile;
import com.eliasmueller.llamamod.editor.model.TileModel;
@@ -509,7 +510,7 @@ final class TilePaintTool implements Tool {
JPanel grid = new JPanel(new GridLayout(0, 4, 3, 3));
grid.setBorder(BorderFactory.createEmptyBorder(3, 3, 3, 3));
JTextField filter = new JTextField();
filter.putClientProperty("JTextField.placeholderText", "Filter (model number)");
filter.putClientProperty("JTextField.placeholderText", "Filter by name or number");
JCheckBox used = new JCheckBox("Only models used in the level", true);
used.setFocusable(false);
Runnable fill = () -> {
@@ -517,7 +518,7 @@ final class TilePaintTool implements Tool {
String f = filter.getText().trim();
int n = 0;
for (TileModel m : s.tileModels()) {
if (used.isSelected() && !uses.containsKey(m) || !f.isEmpty() && !m.id().contains(f)) {
if (used.isSelected() && !uses.containsKey(m) || !paletteMatch(m, f)) {
continue;
}
if (n++ >= 240) {
@@ -544,7 +545,13 @@ final class TilePaintTool implements Tool {
fill.run();
used.addActionListener(e -> fill.run());
filter.addActionListener(e -> fill.run());
JScrollPane sp = new JScrollPane(grid);
javax.swing.Timer refill = new javax.swing.Timer(200, e -> fill.run());
refill.setRepeats(false);
com.eliasmueller.llamamod.util.SwingListeners.onTextChange(filter, refill::restart);
// grid at the top: a few filter results keep their size instead of stretching to the viewport
JPanel gridTop = new JPanel(new BorderLayout());
gridTop.add(grid, BorderLayout.NORTH);
JScrollPane sp = new JScrollPane(gridTop);
sp.setPreferredSize(new Dimension(260, 260));
sp.getVerticalScrollBar().setUnitIncrement(24);
sp.setAlignmentX(0);
@@ -565,4 +572,15 @@ final class TilePaintTool implements Tool {
root.setBackground(new Color(0, 0, 0, 0));
return root;
}
/**
* Palette filter: the readable model name ("Model 12" or its own name) or the model number, with the
* outliner's whole-number rule ("12" finds Model 12, not Model 120 or 112).
*/
static boolean paletteMatch(TileModel m, String q) {
q = q.trim();
return q.isEmpty()
|| ReadableNames.matches(NodeNames.modelName(m), q)
|| ReadableNames.matches(NodeNames.number(m.id()), q);
}
}
@@ -9,6 +9,7 @@ import com.eliasmueller.llamamod.editor.render.Camera;
import com.eliasmueller.llamamod.editor.render.ViewportListener;
import com.eliasmueller.llamamod.editor.tools.objects.ParamSchema.Role;
import com.eliasmueller.llamamod.editor.ui.EditorContext;
import com.eliasmueller.llamamod.editor.ui.NodeNames;
import com.eliasmueller.llamamod.editor.ui.ToolEvent;
import java.util.LinkedHashMap;
import java.util.List;
@@ -88,6 +89,7 @@ final class CameraView {
static final class Pilot implements ViewportListener {
private final EditorContext ctx;
private Node target;
private String targetName;
private Object mergeKey;
private boolean writing;
private boolean listening;
@@ -127,7 +129,8 @@ final class CameraView {
}
});
}
ctx.status("Piloting " + target.id() + ": fly with the right mouse button + WASD, the camera follows."
targetName = NodeNames.name(target);
ctx.status("Piloting " + targetName + ": fly with the right mouse button + WASD, the camera follows."
+ " Ctrl+Shift+L ends");
}
@@ -136,7 +139,7 @@ final class CameraView {
return;
}
ctx.viewport().removeViewportListener(this);
ctx.status("Pilot mode ended for " + target.id());
ctx.status("Pilot mode ended for " + targetName);
target = null;
mergeKey = null;
}
@@ -11,7 +11,8 @@ import java.util.Map;
/**
* Floor finder on the collision polygons of all tiles (original tiles and the tiles generated from
* brushes), in game space (x east, y up, z = grid row). Cached per scene revision.
* brushes), in game space (x east, y up, z = grid row). The objects tools cache one per scene revision;
* the play-test start check ({@code playtest.FloorFinder}) uses {@link #cellBelow}.
*
* <p>Follows the engine's floor test (FUN_004036d0, used for the player and for objects): only the
* tiles listed in the grid cell of the point count ({@link Tile#cellX}/{@link Tile#cellZ}), so a
@@ -55,12 +56,35 @@ public final class Ground {
}
}
/** Collision material of death floors (kill planes): never a floor to snap onto. */
public static final int DEATH = 30;
private final Map<Long, List<Floor>> cells = new HashMap<>();
private final Map<Long, List<Floor>> swaps = new HashMap<>();
private final Map<Long, List<Floor>> overlaps = new HashMap<>();
private Ground() {}
/**
* Floors of the scene's tiles and brushes listed in the grid cells x0..x1 / z0..z1 only (brushes that
* do not compile have none): enough for {@link #cellBelow} there, cheap for a single query.
*/
public static Ground ofCells(Scene s, int x0, int z0, int x1, int z1) {
List<Tile> tiles = new ArrayList<>(s.tiles());
try {
tiles.addAll(BrushCompiler.compile(s, new ArrayList<>()).tiles());
} catch (RuntimeException e) {
// brushes that do not compile have no floor
}
Ground g = new Ground();
for (Tile t : tiles) {
if (t.cellX() >= x0 && t.cellX() <= x1 && t.cellZ() >= z0 && t.cellZ() <= z1) {
g.addTile(t);
}
}
return g;
}
public static Ground build(Scene s, BrushCompiler.Output brushTiles) {
Ground g = new Ground();
for (Tile t : s.tiles()) {
@@ -161,18 +185,30 @@ public final class Ground {
return h;
}
/**
* The engine's own floor test: highest floor of the tiles listed in the cell of (x, z), death floors
* included, not more than {@code above} over {@code probeY}; no swap models, no neighbours. Null if none.
*/
public Hit cellBelow(double x, double z, double probeY, double above) {
return best(cells.get(key((int) Math.floor(x), (int) Math.floor(z))), Source.CELL, x, z, probeY, above, true);
}
/** Highest polygon of a neighbouring tile reaching into the cell (what the game ignores there). */
Hit neighbourBelow(double x, double z, double probeY, double above) {
return best(overlaps.get(key((int) Math.floor(x), (int) Math.floor(z))), Source.NEIGHBOUR, x, z, probeY, above);
}
private static Hit best(List<Floor> l, Source src, double x, double z, double probeY, double above) {
return best(l, src, x, z, probeY, above, false);
}
private static Hit best(List<Floor> l, Source src, double x, double z, double probeY, double above, boolean death) {
if (l == null) {
return null;
}
Hit best = null;
for (Floor f : l) {
if (f.material == 30 || !f.contains(x, z)) {
if (!death && f.material == DEATH || !f.contains(x, z)) {
continue;
}
double y = f.y(x, z);
@@ -10,6 +10,7 @@ import com.eliasmueller.llamamod.editor.model.cmd.PlayerStart;
import com.eliasmueller.llamamod.editor.tools.objects.ParamSchema.ParamDef;
import com.eliasmueller.llamamod.editor.tools.objects.ParamSchema.TypeSchema;
import com.eliasmueller.llamamod.editor.ui.LevelCheck;
import com.eliasmueller.llamamod.editor.ui.NodeNames;
import com.eliasmueller.llamamod.editor.ui.Problem;
import com.eliasmueller.llamamod.save.Worlds;
import java.util.ArrayList;
@@ -28,10 +29,12 @@ final class ObjectChecks implements LevelCheck {
@Override
public void check(Scene s, List<Problem> out) {
NodeNames names = NodeNames.of(s);
PathNode broken = ChainOps.firstBroken(s);
if (broken != null) {
out.add(Problem.error(
"Waypoint chain at " + broken.id() + " is not stored in one piece (the game links node i to"
"Waypoint chain at " + names.waypointName(broken, true)
+ " is not stored in one piece (the game links node i to"
+ " i+1): run Tools → Repair waypoint chains",
broken));
}
@@ -45,7 +48,7 @@ final class ObjectChecks implements LevelCheck {
String cam = startCamera(s, st);
if (cam != null) {
out.add(Problem.warning(
label(st) + ": " + cam + " (Place tool → Player start sets a camera node)", st));
names.objectName(st) + ": " + cam + " (Place tool → Player start sets a camera node)", st));
}
}
TypeSchema sc = Schemas.of(type);
@@ -56,13 +59,14 @@ final class ObjectChecks implements LevelCheck {
if (sc != null) {
if (n < sc.paramCount()) {
out.add(Problem.warning(
label(st) + " has " + n + " parameters, " + type + " reads " + sc.paramCount(), st));
names.objectName(st) + " has " + n + " parameters, " + type + " reads " + sc.paramCount(),
st));
}
for (ParamDef d : sc.params()) {
if (d.index() < n) {
String e = d.validate(st.params()[d.index()]);
if (e != null) {
out.add(Problem.warning(label(st) + ": " + e, st));
out.add(Problem.warning(names.objectName(st) + ": " + e, st));
}
}
}
@@ -72,16 +76,17 @@ final class ObjectChecks implements LevelCheck {
: type.equals("AlligatorSpawner") ? "alligators" : null;
if (gone != null && spawned != null) {
out.add(Problem.warning(
label(st) + ": the game removes its " + spawned + " as soon as they spawn because " + gone
names.objectName(st) + ": the game removes its " + spawned
+ " as soon as they spawn because " + gone
+ " (Waypoint tool: give it a box of 2 nodes around its position)",
st));
} else if (gone != null) {
out.add(Problem.error(
label(st) + " never appears: the game removes it at level start because " + gone
names.objectName(st) + " never appears: the game removes it at level start because " + gone
+ " (Waypoint tool: give it a box of 2 nodes around its position)",
st));
} else if (Guards.startWarning(s, st) != null) {
out.add(Problem.warning(label(st) + ": " + Guards.startWarning(s, st), st));
out.add(Problem.warning(names.objectName(st) + ": " + Guards.startWarning(s, st), st));
}
if (gone == null && Guards.eventBit(st) > 0) {
int bit = Guards.eventBit(st);
@@ -89,7 +94,7 @@ final class ObjectChecks implements LevelCheck {
setBits = EventBits.setBits(s);
}
out.add(Problem.info(
label(st) + " stays hidden until event bit " + bit + " is set by another object"
names.objectName(st) + " stays hidden until event bit " + bit + " is set by another object"
+ (setBits.contains(bit)
? ""
: ", but no object in this level sets bit " + bit + " ("
@@ -101,28 +106,29 @@ final class ObjectChecks implements LevelCheck {
&& sc.waypoints().required()
&& chain.size() < sc.waypoints().min()) {
out.add(Problem.warning(
label(st) + " needs " + sc.waypoints().min() + " waypoint(s) ("
names.objectName(st) + " needs " + sc.waypoints().min() + " waypoint(s) ("
+ sc.waypoints().role().label + "), has " + chain.size(),
st));
}
if (sc.waypoints().max() > 0 && chain.size() > sc.waypoints().max()) {
out.add(Problem.info(
label(st) + " uses only " + sc.waypoints().max() + " waypoint(s), the chain has "
+ chain.size(),
names.objectName(st) + " uses only "
+ sc.waypoints().max() + " waypoint(s), the chain has " + chain.size(),
st));
}
if (sc.waypoints().pairs() && chain.size() % 2 == 1 && !Guards.walksBoxes(type)) {
out.add(Problem.warning(
label(st) + ": box nodes come in pairs, the chain has " + chain.size(), st));
names.objectName(st) + ": box nodes come in pairs, the chain has " + chain.size(), st));
}
} else if (stat != null) {
if (n != stat.params()) {
out.add(Problem.info(
label(st) + " has " + n + " parameters, most originals have " + stat.params(), st));
names.objectName(st) + " has " + n + " parameters, most originals have " + stat.params(),
st));
}
if (stat.alwaysWaypoints() && chain.isEmpty()) {
out.add(Problem.warning(
label(st) + " has no waypoints; all " + stat.instances() + " original " + type
names.objectName(st) + " has no waypoints; all " + stat.instances() + " original " + type
+ " objects have a chain (" + stat.minChain() + "–" + stat.maxChain() + " nodes)",
st));
}
@@ -138,8 +144,8 @@ final class ObjectChecks implements LevelCheck {
int cx = (int) Math.floor(st.gx()), cz = (int) Math.floor(st.gz());
if (!cellHasMaterial(doorTiles, cx, cz, 16)) {
out.add(Problem.warning(
label(st) + " stands in cell " + cx + ", " + cz + " without exit floor (collision material"
+ " 16): the door never fires",
names.objectName(st) + " stands in cell " + cx + ", " + cz
+ " without exit floor (collision material" + " 16): the door never fires",
st));
}
int[] p = st.params();
@@ -148,12 +154,15 @@ final class ObjectChecks implements LevelCheck {
boolean fin = t == 7 && w == 7 && c == 7;
if (fin && s.settings().isNewLevel()) {
out.add(Problem.warning(
label(st) + " targets (7,7,7) \"world finished\": in TestLevel (world 9) this unlocks"
names.objectName(st)
+ " targets (7,7,7) \"world finished\": in TestLevel (world 9) this unlocks"
+ " world 10 and crashes the map – pick a chapter instead",
st));
} else if (!fin && (w < 1 || w > Worlds.COUNT || c < 1 || c > Worlds.chapters(w))) {
out.add(Problem.warning(
label(st) + " targets world " + w + " chapter " + c + ", which does not exist", st));
names.objectName(st) + " targets world " + w + " chapter " + c
+ ", which does not exist",
st));
}
}
}
@@ -162,7 +171,8 @@ final class ObjectChecks implements LevelCheck {
double d = Math.hypot(r.gx() - st.gx(), r.gz() - st.gz());
if (d > 3) {
out.add(Problem.info(
label(st) + ": respawn node is " + String.format("%.1f", d) + " cells away", st));
names.objectName(st) + ": respawn node is " + String.format("%.1f", d) + " cells away",
st));
}
}
}
@@ -208,8 +218,4 @@ final class ObjectChecks implements LevelCheck {
}
return null;
}
static String label(Strat s) {
return s.type() + " (" + s.id() + ")";
}
}
@@ -15,9 +15,9 @@ import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.Component;
import java.awt.Dimension;
import java.awt.EventQueue;
import java.awt.Font;
import java.awt.GridLayout;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.awt.image.BufferedImage;
import java.util.ArrayDeque;
@@ -45,8 +45,11 @@ import javax.swing.JTextField;
import javax.swing.JToggleButton;
import javax.swing.ListCellRenderer;
import javax.swing.ListSelectionModel;
import javax.swing.SwingUtilities;
import javax.swing.Timer;
import javax.swing.UIManager;
import javax.swing.event.PopupMenuEvent;
import javax.swing.event.PopupMenuListener;
/**
* Object palette: every placeable type of the level's base with thumbnail, category filter,
@@ -60,7 +63,39 @@ final class ObjectPalette extends JPanel {
private final EditorContext ctx;
private final DefaultListModel<Entry> model = new DefaultListModel<>();
private final JList<Entry> list = new JList<>(model);
/** {@code when} of the mouse press that closed the context menu (it must not select an entry). */
private long dismissPress = -1;
private boolean swallow;
/**
* Only left presses reach the list's UI: a right-click (and the click that closes the context menu)
* must not select an entry, which would switch to the Place tool.
*/
private final JList<Entry> list = new JList<>(model) {
@Override
protected void processMouseEvent(MouseEvent e) {
if (e.getID() == MouseEvent.MOUSE_PRESSED) {
swallow = e.getWhen() == dismissPress || !SwingUtilities.isLeftMouseButton(e);
}
if (e.isPopupTrigger()) {
menu(e);
}
if (swallow && e.getID() != MouseEvent.MOUSE_ENTERED && e.getID() != MouseEvent.MOUSE_EXITED) {
e.consume();
return;
}
super.processMouseEvent(e);
}
@Override
protected void processMouseMotionEvent(MouseEvent e) {
if (!swallow || e.getID() == MouseEvent.MOUSE_MOVED) {
super.processMouseMotionEvent(e);
}
}
};
private final JTextField search = new JTextField();
private final JComboBox<String> category = new JComboBox<>();
private final JToggleButton favOnly = new JToggleButton("★");
@@ -144,38 +179,6 @@ final class ObjectPalette extends JPanel {
choose(en.type);
}
});
list.addMouseListener(new MouseAdapter() {
@Override
public void mousePressed(MouseEvent e) {
menu(e);
}
@Override
public void mouseReleased(MouseEvent e) {
menu(e);
}
private void menu(MouseEvent e) {
int i = list.locationToIndex(e.getPoint());
if (!e.isPopupTrigger() || i < 0) {
return;
}
String type = model.get(i).type;
boolean fav = Session.of(ctx).favourites().contains(type);
JPopupMenu m = new JPopupMenu();
JMenuItem f = new JMenuItem(fav ? "Remove from favourites" : "Add to favourites ★");
f.addActionListener(a -> {
Session.of(ctx).toggleFavourite(type);
list.repaint();
if (favOnly.isSelected()) {
reload();
}
});
m.add(f);
TypeMenus.fill(ctx, type, m);
m.show(list, e.getX(), e.getY());
}
});
JScrollPane sp = new JScrollPane(list);
sp.setBorder(BorderFactory.createMatteBorder(1, 0, 0, 0, UIManager.getColor("Component.borderColor")));
add(sp, BorderLayout.CENTER);
@@ -205,6 +208,41 @@ final class ObjectPalette extends JPanel {
setPreferredSize(new Dimension(260, 400));
}
private void menu(MouseEvent e) {
int i = list.locationToIndex(e.getPoint());
if (i < 0 || !list.getCellBounds(i, i).contains(e.getPoint())) {
return;
}
String type = model.get(i).type;
boolean fav = Session.of(ctx).favourites().contains(type);
JPopupMenu m = new JPopupMenu();
JMenuItem f = new JMenuItem(fav ? "Remove from favourites" : "Add to favourites ★");
f.addActionListener(a -> {
Session.of(ctx).toggleFavourite(type);
list.repaint();
if (favOnly.isSelected()) {
reload();
}
});
m.add(f);
TypeMenus.fill(ctx, type, m);
m.addPopupMenuListener(new PopupMenuListener() {
@Override
public void popupMenuWillBecomeVisible(PopupMenuEvent ev) {}
@Override
public void popupMenuWillBecomeInvisible(PopupMenuEvent ev) {}
@Override
public void popupMenuCanceled(PopupMenuEvent ev) {
if (EventQueue.getCurrentEvent() instanceof MouseEvent me && me.getID() == MouseEvent.MOUSE_PRESSED) {
dismissPress = me.getWhen();
}
}
});
m.show(list, e.getX(), e.getY());
}
private JButton setup(String label, String icon, PlaceTool.Mode mode, String tip) {
JButton b = new JButton(label, Icons.get(icon, 14));
b.setToolTipText(tip);
@@ -10,6 +10,7 @@ import com.eliasmueller.llamamod.editor.model.cmd.Edits;
import com.eliasmueller.llamamod.editor.render.Legend;
import com.eliasmueller.llamamod.editor.render.Overlay;
import com.eliasmueller.llamamod.editor.ui.EditorContext;
import com.eliasmueller.llamamod.editor.ui.NodeNames;
import com.eliasmueller.llamamod.editor.ui.Tool;
import com.eliasmueller.llamamod.editor.ui.ToolEvent;
import java.awt.Cursor;
@@ -196,7 +197,7 @@ public final class PathTool implements Tool {
}
ctx.execute(new Batch("Add waypoint", cmds));
ctx.selection().set(fresh, this);
ctx.status("Waypoint " + fresh.id() + " added (" + (chain.size() + 1) + " nodes)");
ctx.status(NodeNames.of(s).waypointName(fresh, true) + " added (" + (chain.size() + 1) + " nodes)");
return true;
}
@@ -1,22 +1,19 @@
package com.eliasmueller.llamamod.editor.tools.playtest;
import com.eliasmueller.llamamod.editor.io.BrushCompiler;
import com.eliasmueller.llamamod.editor.model.Regions;
import com.eliasmueller.llamamod.editor.model.Scene;
import com.eliasmueller.llamamod.editor.model.Tile;
import com.eliasmueller.llamamod.editor.model.TileModel;
import java.util.ArrayList;
import java.util.List;
import com.eliasmueller.llamamod.editor.tools.objects.Ground;
/**
* Collision floor under a point: the player start must stand on collision, not just on visible
* geometry (trees, roofs and decorations often have none, and the llama would fall through).
* geometry (trees, roofs and decorations often have none, and the llama would fall through). Uses the
* objects tools' {@link Ground} with the engine's strict cell rule.
*/
public final class FloorFinder {
/** Floors at most this far above the probe point still count (the probe may be on a slope). */
static final double ABOVE = 0.6;
static final int DEATH = 30;
static final int DEATH = Ground.DEATH;
/** tileX/tileZ = grid cell of the tile that owns the floor polygon. */
public record Floor(double x, double y, double z, int material, int tileX, int tileZ) {}
@@ -28,13 +25,14 @@ public final class FloorFinder {
* if there is nothing else; null if no floor at all.
*/
public static Floor near(Scene s, double gx, double gy, double gz) {
List<Tile> tiles = tiles(s);
Ground g = Ground.ofCells(s, (int) Math.floor(gx - 1), (int) Math.floor(gz - 1), (int) Math.floor(gx + 1), (int)
Math.floor(gz + 1));
Floor death = null;
for (double r = 0; r <= 0.76; r += 0.25) {
int n = r == 0 ? 1 : 8;
for (int i = 0; i < n; i++) {
double a = i * Math.PI / 4;
Floor f = below(tiles, gx + r * Math.sin(a), gy, gz + r * Math.cos(a));
Floor f = below(g, gx + r * Math.sin(a), gy, gz + r * Math.cos(a));
if (f != null && f.material() != DEATH) {
return f;
}
@@ -51,67 +49,18 @@ public final class FloorFinder {
* {@link #ABOVE}; null if none.
*/
public static Floor below(Scene s, double gx, double gy, double gz) {
return below(tiles(s), gx, gy, gz);
}
/** Scene tiles plus the tiles generated from brushes. */
static List<Tile> tiles(Scene s) {
List<Tile> tiles = new ArrayList<>(s.tiles());
try {
tiles.addAll(BrushCompiler.compile(s, new ArrayList<>()).tiles());
} catch (RuntimeException e) {
// brushes that do not compile have no floor
}
return tiles;
int cx = (int) Math.floor(gx), cz = (int) Math.floor(gz);
return below(Ground.ofCells(s, cx, cz, cx, cz), gx, gy, gz);
}
/**
* Only the tiles listed in the grid cell of (x, z) count: the engine's floor test
* (FUN_004036d0) walks the cell list of the cell the player stands in, so a polygon that
* reaches into the cell from a neighbouring tile carries nothing there.
* reaches into the cell from a neighbouring tile carries nothing there ({@link Ground#cellBelow}).
*/
static Floor below(List<Tile> tiles, double gx, double gy, double gz) {
double ex = gx, ez = -gz;
int cx = (int) Math.floor(gx), cz = (int) Math.floor(gz);
Floor best = null;
for (Tile t : tiles) {
if (t.cellX() != cx || t.cellZ() != cz) {
continue;
}
for (TileModel.PlacedPolygon p : t.worldCollisionPolygons()) {
Double y = heightAt(p.corners(), ex, ez);
if (y == null || y > gy + ABOVE) {
continue;
}
if (best == null || y > best.y()) {
best = new Floor(gx, y, gz, p.material(), t.cellX(), t.cellZ());
}
}
}
return best;
}
/** y of a (convex, roughly horizontal) polygon at (x, z), or null if outside or a wall. */
static Double heightAt(double[][] p, double x, double z) {
double[] a = p[0], b = p[1], c = p[2];
double ux = b[0] - a[0], uy = b[1] - a[1], uz = b[2] - a[2];
double vx = c[0] - a[0], vy = c[1] - a[1], vz = c[2] - a[2];
double nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx;
double len = Math.sqrt(nx * nx + ny * ny + nz * nz);
if (len < 1e-9 || Math.abs(ny) / len < 0.5) {
return null; // wall or steep slope
}
boolean pos = false, neg = false;
for (int i = 0; i < p.length; i++) {
double[] q = p[i], r = p[(i + 1) % p.length];
double cr = (r[0] - q[0]) * (z - q[2]) - (r[2] - q[2]) * (x - q[0]);
pos |= cr > 1e-9;
neg |= cr < -1e-9;
}
if (pos && neg) {
return null;
}
return a[1] - (nx * (x - a[0]) + nz * (z - a[2])) / ny;
static Floor below(Ground g, double gx, double gy, double gz) {
Ground.Hit h = g.cellBelow(gx, gz, gy, ABOVE);
return h == null ? null : new Floor(gx, h.y(), gz, h.material(), (int) Math.floor(gx), (int) Math.floor(gz));
}
/**
@@ -1,5 +1,6 @@
package com.eliasmueller.llamamod.editor.tools.textures;
import com.eliasmueller.llamamod.editor.model.Scene;
import com.eliasmueller.llamamod.editor.model.TextureEntry;
import com.eliasmueller.llamamod.editor.ui.ActionSpec;
import com.eliasmueller.llamamod.editor.ui.Dock;
@@ -73,12 +74,23 @@ public final class TexturesEditorPlugin implements EditorPlugin {
}
for (TextureEntry e : s.textures()) {
if (!e.isFlat() && e.page() >= s.pages().size()) {
out.add(Problem.error(e.label() + " refers to missing page " + e.page(), e));
out.add(Problem.error(missingPage(s, e), e));
}
}
});
}
/** "Texture #5 uses page 7, but the level has only 4 texture pages (0–3)". */
static String missingPage(Scene s, TextureEntry e) {
int n = s.pages().size();
String name = "Texture #" + s.textures().indexOf(e) + (e.name().isEmpty() ? "" : " \"" + e.name() + "\"");
return name + " uses page " + e.page() + ", but the level has "
+ (n == 0
? "no texture pages"
: "only " + n + " texture page" + (n == 1 ? " (0)" : "s (0–" + (n - 1) + ")"))
+ ": change its page in the inspector";
}
private static void icons() {
Icons.register("texpaint", (g, c) -> {
g.setColor(Icons.ACCENT);
@@ -1,188 +1,19 @@
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.ReadableNames;
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.
* outliner, inspector header, status bar and asset browser; the naming itself lives in
* {@link ReadableNames} (also used by editor.io for build warnings).
*/
public final class NodeNames {
private final Scene scene;
private Map<Node, String> numbered;
private List<PathNode> starts;
private Map<PathNode, int[]> waypoints;
public final class NodeNames extends ReadableNames {
private NodeNames(Scene scene) {
this.scene = scene;
super(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();
};
}
}
@@ -7,6 +7,7 @@ import com.eliasmueller.llamamod.editor.model.MeshAsset;
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.ReadableNames;
import com.eliasmueller.llamamod.editor.model.Scene;
import com.eliasmueller.llamamod.editor.model.SceneEvent;
import com.eliasmueller.llamamod.editor.model.Strat;
@@ -301,20 +302,9 @@ public final class OutlinerPanel extends JPanel {
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").
*/
/** Readable-name match with the whole-number rule ({@link ReadableNames#matches}). */
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;
return ReadableNames.matches(name, q);
}
private DefaultMutableTreeNode group(DefaultMutableTreeNode parent, String key, String label, Icon icon) {
@@ -3,6 +3,7 @@ package com.eliasmueller.llamamod.editor.ui.panels;
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.NodeNames;
import com.eliasmueller.llamamod.editor.ui.Problem;
import java.awt.BorderLayout;
import java.awt.Component;
@@ -42,7 +43,7 @@ public final class ProblemsPanel extends JPanel {
14));
c.setBorder(BorderFactory.createEmptyBorder(3, 8, 3, 8));
if (p.node() != null) {
c.setToolTipText("Double-click to select " + p.node().id());
c.setToolTipText("Double-click to select " + NodeNames.name(p.node()));
}
return c;
}
@@ -121,7 +121,7 @@ public final class LevelBuilder {
record Mode(String name) implements Waypoints {}
/** Points {x, z[, y[, rotDeg]]}; y = NaN: ground height. */
/** Points {x, z[, y[, rotDeg[, unk2]]]}; y = NaN: ground height; unk2 default: the prototype node's. */
record Points(List<double[]> pts) implements Waypoints {}
static Waypoints of(List<double[]> pts) {
@@ -959,32 +959,27 @@ public final class LevelBuilder {
return strat(name, x, z, null, null, null, Waypoints.RELATIVE, variant, false);
}
/** Camera node of the player start: this far behind the llama (must stay below the 2.5-cell search range). */
public static final double PLAYER_CAMERA_BACK = 2.0;
/**
* Player start (strat "Llama"). The first waypoint of the llama chain is the camera start:
* placed camDist behind the llama, the others on the llama.
* Player start (strat "Llama") with its start camera: one waypoint camDist behind the llama, 0.5
* above it, field unk2 = 0 (default camera distance and height). The game takes the first node of
* the llama's chain within 2.5 cells as the camera start, looking at the llama (as the editor's
* {@code PlayerStart}); the prototype's chain (one node per entry point of the original level)
* would put the camera into the llama's head on village 2 bases.
*/
public StratSpec player(double x, double z, double rot, Double y, double camDist) {
StratSpec d = strat("Llama", x, z, y, rot, null, Waypoints.HERE, 0, false);
double a = PyNum.radians(rot);
double cx = x - camDist * PyNum.sin(a);
double cz = z - camDist * PyNum.cos(a);
int n = 1;
int k = protos.get("Llama").get(0)[13];
while (k >= 0 && map.nodes.get(k).next() != 0 && n < 200) {
k = map.nodes.get(k).next();
n++;
}
List<double[]> wp = new ArrayList<>();
wp.add(new double[] {cx, cz, d.y - 0.4, rot});
for (int i = 1; i < n; i++) {
wp.add(new double[] {x, z, d.y, rot});
}
d.wp = Waypoints.of(wp);
d.wp = Waypoints.of(List.of(new double[] {cx, cz, d.y + 0.5, rot, 0}));
return d;
}
public StratSpec player(double x, double z, double rot) {
return player(x, z, rot, null, 8.0);
return player(x, z, rot, null, PLAYER_CAMERA_BACK);
}
public StratSpec checkpoint(double x, double z) {
@@ -1534,7 +1529,9 @@ public final class LevelBuilder {
py = h == null || h == 0.0 ? d.y : h;
}
int pr = p.length > 3 ? deg(p[3]) : s[1];
int a = i < chain.size() ? chain.get(i).unk2() : 0;
int a = p.length > 4
? (int) p[4]
: i < chain.size() ? chain.get(i).unk2() : 0;
int b = i < chain.size() ? chain.get(i).unk3() : 0;
fresh.add(new int[] {
0,
@@ -7,6 +7,7 @@ import com.eliasmueller.llamamod.editor.model.PathNode;
import com.eliasmueller.llamamod.editor.model.Scene;
import com.eliasmueller.llamamod.editor.model.Strat;
import com.eliasmueller.llamamod.editor.model.cmd.Edits;
import com.eliasmueller.llamamod.editor.model.cmd.PlayerStart;
import com.eliasmueller.llamamod.editor.tools.playtest.FloorFinder;
import java.io.IOException;
import java.util.ArrayList;
@@ -15,9 +16,9 @@ import java.util.Locale;
/**
* Moves the player start (the Llama strat) of a level WAD. In levelbuild/editor levels its waypoint
* chain is the camera start (first node, 8 cells behind) plus nodes on the llama: then the whole
* chain moves and turns with it. Original levels use the llama's chain as a level-wide point list,
* which stays where it is.
* chain is the start camera: one node 2 cells behind ({@link PlayerStart}), or in older builds a node
* 8 cells behind plus nodes on the llama. Then the whole chain moves and turns with it. Original levels
* use the llama's chain as a level-wide point list, which stays where it is.
*/
public final class StartOverride {
static final double ON_LLAMA = 0.5;
@@ -45,7 +46,12 @@ public final class StartOverride {
for (PathNode p : chain.subList(1, chain.size())) {
onLlama &= Math.hypot(p.gx() - llama.gx(), p.gz() - llama.gz()) <= ON_LLAMA;
}
if (onLlama) {
boolean cameraNode = chain.size() == 1
&& Math.hypot(
chain.getFirst().gx() - llama.gx(),
chain.getFirst().gz() - llama.gz())
< PlayerStart.RANGE;
if (onLlama || cameraNode) {
moved.addAll(chain);
rotate.addAll(chain);
}