15 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).
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, play-test |
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) |
Widgets, util.SwingListeners |
Widgets.button(text, icon, tip, run), Widgets.listCell(c, list, selected); onTextChange, onMenuOpen |
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.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 (camera node 8 cells behind, as levelbuild) 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. 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).
