Files

20 KiB
Raw Permalink Blame History

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.

village

Launch

  • LlamaMod header: Level Editor button next to Play (ui/MainFrame.openEditor). Same JVM, same AppPaths/Config/ModManager objects; 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=DIR uses another CD folder, -Dllamamod.editor.msaa=0 disables 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 uses backgroundStatus, 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 use AxisLayout (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.
  • The naming logic lives in editor.model.ReadableNames (so editor.io can use it: brush compile warnings "Floor 2: … – skipped", Validator messages, "is still used by" errors); ui.NodeNames extends it. Also: waypoint added / pilot mode status, object check texts ("Coin 3: …", "Waypoint chain at Waypoint 4 (chain 2)"), missing texture page ("Texture #5 uses page 7, but the level has only 4 texture pages (0–3)"), Problems tooltip. Numbers in filter queries match whole numbers at both ends ("2" no longer finds "Floor 12"); the tile palette filter uses the same rule on model names/numbers.
  • Tool status and check texts use NodeNames too (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 via StratModels from the STPC raw chunk (same rules as LevelSceneAdapter), 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 but pickNode ignores 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 mod boot-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=true skips 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.registry is created once).
  • Not tested on Windows hardware (the viewport was tested under Wine in phase 1).