Files
llamamod/notes/editor-ui.md
T

230 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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](img/editor/ui_select_move.jpg)
## 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`:
```java
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).