19 KiB
Level editor – application shell (editor.ui, phase 2)
The Swing/FlatLaf editor around the phase-1 core (editor-api.md, editor-format.md) and viewport
(editor-render.md). Package com.eliasmueller.llamamod.editor.ui (+ .panels, .tools, .view).
Design brief: editor-design.md.
Launch
- LlamaMod header: Level Editor button next to Play (
ui/MainFrame.openEditor). Same JVM, sameAppPaths/Config/ModManagerobjects; after a play-test LlamaMod refreshes (EditorEnv.setModsChanged). llamamod --editor [project.llamalevel | t1l3m002]– standalone (exits when closed). Without an argument it offers crash recovery.-Dllamamod.editor.cd=DIRuses another CD folder,-Dllamamod.editor.msaa=0disables multisampling (software GL).- Programmatic:
EditorApp.open(new EditorEnv(paths, cfg, modManager, false), arg)on the EDT.
Window
| Area | Content |
|---|---|
| Menu | File (New level, Open original level, Open project, Open recent, Save, Save as, Close, Import mesh/texture, Export WAD), Edit (Undo/Redo with labels, History, Cut/Copy/Paste/Duplicate/Delete, Select all/none/similar, Hide/Show/Lock, Preferences), View (Frame, Camera ›, local space, snap, collision, dark theme, Panels ›, Layers ›), Level (Settings, Check, Build, Play), Help (Keyboard shortcuts, About) |
| Toolbar | new/open original/open/save · undo/redo · Select Q / Move W / Rotate E / Scale R · duplicate/delete/frame · Build · Play |
| Options bar | active tool + its options · Snap toggle, move step (1…1/16 cell), angle step · World/Local · camera view · Layers popup |
| Docks | left: Outliner, History · right: Inspector · bottom: Assets, Problems. Tabs can be closed, moved to another dock (tab context menu), reopened in View → Panels; layout and split sizes are remembered |
| Viewport | render.Viewport with the live scene; empty state ("Open a level or create a new one", recent projects) while no level is open |
| Status bar | messages, hovered item, cursor position (game cells) and grid cell, counts, last build (click → Problems), progress of background tasks |
Shortcuts (Help → Keyboard shortcuts lists all, incl. plugins): RMB+WASDQE fly, MMB orbit,
Shift+MMB pan, wheel zoom, F frame, Home frame level, numpad 7/1/3/5 views, Q/W/E/R tools, X local
space, G snap, C collision, H hide, Alt+H show all, L lock, Ctrl+D duplicate, Del delete,
Ctrl+Z/Y/Shift+Z, Ctrl+X/C/V, Ctrl+A, Ctrl+B build, F5 play, F1 help. Single keys never fire while a
text field has the focus; while the GL canvas has the focus a KeyEventDispatcher maps keys to
actions (AWT canvases do not get Swing key bindings).
- Status messages:
ctx.status(msg)shows a message for ~6 s; the autosave note usesbackgroundStatus, which waits until a fresher user message has expired (it no longer replaces e.g. the glTF export result). - Inspector property labels have a fixed 118 px column; longer labels wrap onto more lines
(
InspectorPanel.fitLabel) instead of being cut ("Exclude from build"). - Readable names everywhere (
img/editor/ui_readable_names.jpg: outliner filter "coin 3", inspector "Coin 3", status bar hover "Coin 3"/"Tile 41, 66 · Model 87", asset tab "Model 0"); inspector button rows wrap without clipping in a narrow dock (ui_waypoint_buttons_narrow.jpg); long property labels wrap (ui_inspector_label_wrap.jpg). - Narrow inspector (2026-11-07,
ui_inspector_narrow.jpg): Position/Rotation X/Y/Z useAxisLayout(one row while each cell gets ≥ 66 px, else stacked one per row; also the brush rotation spinners); reference buttons (InspectorPanel.RefButton) show the longest name that fits ("Waypoint 5 (chain 3)" → "Waypoint 5" → "#5", other names drop a "(…)" suffix, then "…"), full name + id in the tooltip. - Tool status and check texts use
NodeNamestoo (geometry, tile paint, collision, texture paint, lights, brush-outside-grid check, "Placed Model 12 in cell …"); the outliner filter applies the whole-number rule to the old label and the id as well ("tile 3" no longer finds x = 39).
Architecture
| Class | Role |
|---|---|
EditorWindow |
the JFrame; implements EditorContext; owns selection, viewport, SceneRenderer, docks, actions, key dispatch, document switching |
EditorContext |
the API plugins use (document/scene/history, selection, viewport, pick helpers, execute, hidden/locked, layers, tools, status, background tasks, problems, panels) |
EditorExtensions / EditorPlugin / PluginLoader |
registry + discovery (below) |
BuiltinEditorPlugin |
everything built in, registered through the same API |
Document |
Project + History + file + autosave state; dirty() |
Selection |
ordered node set, primary = last, listeners get the source (avoid echo loops), auto-pruned |
ProjectController |
new/open/save/save as/recent, autosave + recovery, thumbnail, import/export |
PlayTest |
check, background build, install/mod-selection/save-slot helpers for the play-test plugin (the old menu-based play() is gone; level.play is a disabled placeholder that PlaytestEditorPlugin replaces) |
TaskRunner |
runTask: one background task on its own daemon thread; progress and completion are posted with invokeLater from that thread, so the completion always runs after the last progress update, also when the task or the done callback throws (replaced SwingWorker, whose done() goes through a Swing timer) |
NodeNames |
readable names shared by outliner, inspector header, status-bar hover, reference buttons and the Tile models asset tab ("Floor 2", "Coin 3", "Waypoint 4 (chain 2)", "Tile 3, 4 · Model 12", "Model 12"); numbers follow the level order |
view.SceneRenderer |
scene model → RenderScene, live, stable pick ids |
view.Thumbnails |
software rasteriser for asset thumbnails |
tools.SelectTool, TransformSupport, Transformers, EditOps |
selection/transform tools and edit operations |
panels.* |
Outliner, Inspector, AssetBrowser, Problems, History |
All edits go through ctx.execute(Command) (→ History); drags use one merge key per gesture, so a
drag is one undo step (TransformSupport, inspector drag-scrub). Scene events arrive synchronously;
the window collects them and flushes the renderer once per EDT turn.
Scene → render adapter (view.SceneRenderer)
- Pick ids
kind<<24 | n(PickId.TILE/OBJECT/WAYPOINT/ZONE/LIGHT,PICK_BRUSH=6,PICK_OTHER=7), assigned per node on first use, stable for the document (idOf,nodeOf). - Tiles: one instance per tile (mesh per
TileModel, collision overlay), transform + first colour frame; a CHANGED tile only updates its instance (RenderScene.changed(inst)→ one chunk rebuild); a changed model rebuilds its mesh and its tiles. Objects: model viaStratModelsfrom the STPC raw chunk (same rules asLevelSceneAdapter), markers for logic objects. Markers/overlays (waypoints, zones + teleport targets, lights, regions, brush boxes) are rebuilt as a whole (RenderScene.overlaysChanged(), cheap). Texture/STPC changes or RELOADED rebuild everything (~0.3 s for the village). - Brushes:
BrushCompiler.compile(the build's own code) for a live preview; generated tiles get the pick id of the smallest enabled brush whose bounds contain them, plus an outline box and handle marker. - Waypoints default to "selection only" (chains of selected objects/waypoints), View → Layers can show all.
- Hidden nodes: instance/marker
visible=false(renderer change: invisible tiles leave the static batches); locked nodes are drawn butpickNodeignores them.
Extension API (phase 3)
Rule for parallel agents: never edit editor.ui itself. Create a package of your own, e.g.
com.eliasmueller.llamamod.editor.tools.waypoints, with a class whose simple name ends in
EditorPlugin:
public final class WaypointEditorPlugin implements EditorPlugin {
@Override public void install(EditorExtensions x) {
x.tool(new WaypointTool()); // toolbar + shortcut
x.panel(new PanelSpec("waypoints", "Waypoints", "waypoint", Dock.RIGHT, 30, false, WaypointPanel::new));
x.action(ActionSpec.of("wp.newChain", "Tools", 10, 10, "New waypoint chain", "waypoint", "ctrl shift W",
ctx -> ...).enabledWhen(ctx -> ctx.document() != null));
x.inspector(new ChainSection()); // extra inspector content
x.layer(new LayerSpec("wpLabels", "Waypoint labels", "waypoint", false, 55));
x.renderContributor(new ChainLabels()); // overlay geometry in that layer
x.check((scene, out) -> ...); // build/check problems
x.transformer(new MyNodeTransformer()); // gizmo support for a node kind
x.template(new MyLevelTemplate()); // "New level" start content
x.assetTab(new AssetTab("prefabs", "Prefabs", "assets", 50, PrefabTab::new));
x.onOpen(ctx -> ctx.addSceneListener(e -> ...)); // runs once the window exists
AssetDrop.register("prefab", (ctx, data, ground) -> ...); // drag & drop into the viewport
}
}
Discovery: PluginLoader scans the classpath (jar or class folders) for *EditorPlugin.class below
com/eliasmueller/llamamod/editor/ (no shared registration file); -Dllamamod.editor.plugins=a.B,c.D
adds others. A plugin that throws is skipped with a message. Built-ins install first; registering the
same id again replaces (panels, tools, actions, asset tabs, layers).
| API | Notes |
|---|---|
Tool |
id/name/icon/tooltip/shortcut/group/order; activate/deactivate; mousePressed(ToolEvent) → true = owns the drag (else default click-select); mouseDragged/Released/Moved, keyPressed; gizmoMode() (null = no gizmo) + gizmo(ctx, GizmoEvent) + placeGizmo(ctx); drawOverlay(depthTested, onTop) (export space, call ctx.repaintTool()); optionsBar(ctx); inspectorSection(ctx, sel); cursor(). Right/middle mouse and Alt always stay with the camera |
ToolEvent |
x/y/shift/ctrl/alt/clickCount, lazy pick() (respects layers/hidden/locked), surface() (geometry point), onPlane(y), ground(), toGame(p) / toExport(x, y, z) |
ActionSpec |
menu path "Tools" or "View/Camera", group/order, label, icon, KeyStroke string, run, enabledWhen, checked (check box item), tip; x.toolbarAction(id, section) |
PanelSpec |
id, title, icon, default Dock, order, visible by default, factory ctx → JComponent (created once; follow addDocumentListener/selection().addListener/addSceneListener) |
InspectorSection |
appliesTo(sel), create(ctx, sel) (rebuilt on selection change), replacesProperties() |
RenderContributor |
layer(), affectedBy(SceneEvent), followsSelection(), build(ctx, builder) → builder.overlay() lines/triangles, builder.marker(node, x, y, z, rgba) (pickable → selects node) |
NodeTransformer |
position (game cells), yaw (deg or NaN), scale (or null), set(node, pos, yaw, scale) → prop map. set must compute from the node it gets (the shell passes a drag-start snapshot) |
LevelTemplate |
fill(Scene) adds nodes directly (no undo) before the editor shows the new level |
LevelCheck |
adds Problem.error/warning/info(msg, node); errors block Build/Play |
Icons |
vector icons by name; Icons.register(name, painter) for your own (16×16 Java2D); Icons.swatch(colour, w, h) |
NodeNames |
NodeNames.name(node) (one node) or NodeNames.of(scene) (index for many: nameOf, objectName, waypointName, chainStarts) – use it instead of Node.label() in UI texts; tooltips may show node.id() |
AxisLayout |
public layout for X/Y/Z cells: one row, or stacked when a cell would be narrower than its minimum (new AxisLayout(AxisLayout.CELL_MIN)) |
WrapLayout |
public FlowLayout that grows to more rows in a narrow container (inspector button rows: ParamsSection.compact switches a FlowLayout bar to it) |
Widgets, util.SwingListeners |
Widgets.button(text, icon, tip, run), Widgets.listCell(c, list, selected), Widgets.wrappingLabel(html) (help text that wraps at the dock width – use it instead of <body style='width:…'>); onTextChange, onMenuOpen |
ErrorDialog |
ctx.error(title, t) uses it: plain sentence (ErrorDialog.plain(t), no exception class names) + collapsible Details (stack trace) |
| Game state | ctx.setGameRunning(what, stop) (default no-op): while a play-test game runs the status bar shows "Game running: what" and the toolbar Play button becomes Stop game (runs stop); null resets. PlaytestRunner sets it on launch and clears it when its watcher sees the game exit |
AssetDrop |
register(kind, handler); dragSource(list, value → payload(kind, data)) makes a list draggable into the viewport |
EditorPrefs |
ctx.prefs() JSON settings in <data>/editor/settings.json; prefix your keys |
NodeCopy |
duplicate/snapshot nodes (fresh ids) |
EditOps (tools) |
placeTile, placeObject, copies, duplicate, delete, … reusable from plugins |
Coordinate reminder: render/viewport points are export space (z = −game z); model positions are
game space (ToolEvent.toGame / toExport). Drop/placement points: ctx.groundAt(x, y), ctx.viewCentre(). Commands: editor-api.md (Edits, SetProp, Batch with merge key).
Render additions used by tools (backwards compatible, editor.render)
Viewport.setHudOverlay(pixels) (second pixel overlay independent of the tool, e.g. the Shift+F5 crosshair),
Viewport.setInput(ViewportInput) (tool hook: left-button press/drag/release not on the gizmo, moves,
keys), setToolOverlay(depthTested, onTop), setScreenOverlay(pixels) (marquee), unproject(x, y, depth) public, isFlying(), canvas(); RenderScene.overlaysChanged(); LevelSceneAdapter.mesh/page/collision
public; invisible tile instances are no longer baked into chunks; History.undoLabels()/redoLabels().
Project lifecycle
- New level: dialog (name, author, base level by name with its loading picture, TestLevel
chapter = next free one, template Empty / Flat floor / Island). Templates add brushes, a Llama
(one start camera node 2 cells behind,
PlayerStart) and a LevelDoor on a material-16 floor. - Open original level: list "Village – Chapter 1" … "Lab – Chapter 5" (+ unused/front-end WADs)
with the CD loading pictures (
OriginalLevels,Cor). - Save (
.llamalevel, default folder<data>/levels= listed as mods in LlamaMod), Save as, Open recent (10), dirty marker "•" in the title, confirm on close/new/open. Thumbnail: viewport screenshot 640×360 PNG on every save (also the TestLevel loading picture). - Autosave every 90 s (Preferences) while dirty →
<data>/editor/autosave/*.llamalevel+.json(title, original file, time); deleted on save/close/discard. After a crash the next start offers Recover / Discard / Later (verified: kill during editing → recovered with all edits).
Build & Play-test
- Check:
Validator+ checks (no player start = error, no exit in a new level = warning, no geometry, brushes outside the grid). Messages with node ids are double-clickable in Problems. - Build (Ctrl+B): validates, snapshots the project (
ProjectIO.write), then reads + builds the WAD in the background (UI stays usable) → Problems: "Build OK: tiles (from brushes), objects, waypoints, KB, time" + warnings. - Play (F5): build → write the current state to
<data>/levels/<stem>.llamalevel(if that is the document's own file it counts as saved; otherwise the user's file stays untouched) → enable the level mod next to the active mods (turns off other mods writing the same WAD, incl. Test World on chapter 1), TestLevel chapter option = project chapter →ModManager.apply→ save slot (Preferences, default 3; backup first) unlocked up to the level if needed and map cursor on it → start the game. - Boot to level (phase 3,
editor.tools.playtest,notes/editor-playtest.md): Play now also applies the exe modboot-to-level, so the game loads the level directly (no menus) and Escape quits; Shift+F5 = play from the camera position (holding Shift or hovering the action shows a green/red crosshair at that point). While the game runs: "Game running" badge + Stop game button (see Game state). The old menu-based run is still available (Level → Play boots straight into the level off).-Dllamamod.editor.noLaunch=trueskips the launch (tests start the game through apptest).
Tests
EditorShellTest(CD needed,-Dllamamod.cd): drag = one undo step + world-grid snapping, rotate about a pivot, duplicate with waypoint-chain copy + delete/undo, brush move/scale, renderer live updates (tile move, marker move, delete/undo, waypoints of the selection), templates build brush tiles + thumbnails render, plugin discovery, number-field arithmetic.EditorTour(test classes, not a unit test): scripted run on an X display, writes the screenshots below.java -cp all.jar:build/classes/java/test -Dllamamod.data=DIR -Dllamamod.editor.msaa=0 com.eliasmueller.llamamod.editor.ui.EditorTour OUTDIR village,select,move,…(steps: empty, recover, village, select, move, rotate, marquee, panels, layers, build, drag, newlevel, place, save, play, crash, dark, shortcuts, dialogs).- End-to-end (2026-11-06, Xvfb :8 editor, :7 game): new level "Editor Island" (Island template,
chapter 7) → 2 coins + checkpoint placed, checkpoint duplicated → saved → Build OK (1830 brush
tiles, 6 objects, 5.6 MB) → Play (noLaunch: mod applied, slot 3 unlocked, cursor on TestLevel 7) →
apptest start/tolevel --world 9: slot shows "TestLevel Kapitel 7", the island loads with trees, coins and checkpoints, the llama walks (img/editor/ui_e2e_game.jpg). Drag & drop from the asset browser into the viewport placed an object (Robot drag). Crash recovery verified.
Screenshots (img/editor/ui_*.jpg): empty state, village, select+move gizmo, moved, rotate, marquee
(+ result), assets (tile models, objects, textures), level settings, layers (collision), build, drag &
drop, new island, placed objects, play-test dialog, dark theme, shortcuts, new-level and
open-original dialogs, LlamaMod header button, game.
Known limits / next steps
- Rotation only about the vertical axis (rx/rz via the inspector); scale only for brushes.
- Brush preview recompiles all brushes on every brush change (fine for dozens; a large level may need per-brush compilation). Generated brush tiles are picked by bounds heuristics.
- Tile models without tiles have no "place" preview; dropping places in the cell under the cursor with grey vertex colours (no lighting bake for original tile models).
- Clipboard is in-memory (same window), paste offsets by one step per paste.
- The LlamaMod mod list shows a newly installed play-test level only after restarting LlamaMod
(
AppState.registryis created once). - Not tested on Windows hardware (the viewport was tested under Wine in phase 1).
