230 lines
20 KiB
Markdown
230 lines
20 KiB
Markdown
# 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, 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).
|