161 lines
11 KiB
Markdown
161 lines
11 KiB
Markdown
# Level editor core – API (`editor.model`, `editor.model.cmd`, `editor.io`)
|
||
|
||
Status: phase 1 done (core, no UI): `src/main/java/com/eliasmueller/llamamod/editor/{model,model/cmd,io}`,
|
||
LlamaMod integration in `mods/levels` (`notes/mod-levels.md`). Tests: `src/test/java/.../editor/io`.
|
||
Project file format: `notes/editor-format.md`. Design brief: `notes/editor-design.md`.
|
||
|
||
## Quick start
|
||
|
||
```java
|
||
byte[] wad = Files.readAllBytes(cd.resolve("wads/t1l1m001.wad"));
|
||
Scene s = WadImporter.importWad(wad, "t1l1m001", rel -> read(cd.resolve(rel))); // original level
|
||
History h = new History(s); // undo/redo
|
||
s.addListener(e -> viewport.repaint()); // change events
|
||
h.execute(Edits.translate(List.of(strat), 1.0, 0, 0, null)); // move 1 cell east
|
||
h.undo();
|
||
byte[] out = WadBuilder.build(s); // Scene → WAD (byte-identical if unedited)
|
||
|
||
Bases bases = Bases.fromCd(cd); // original CD folder
|
||
Project p = ProjectIO.load(path, bases); // .llamalevel (p.scene(), p.warnings())
|
||
ProjectIO.save(p, path, bases); // stores only the changes
|
||
Project n = Project.create(bases, "new", "t1l1m001"); // empty level on the village base
|
||
Project e = Project.create(bases, "edit", "t1l3m001"); // edit an original level
|
||
```
|
||
|
||
## Coordinate spaces (important for rendering)
|
||
|
||
| Space | Used by | Axes |
|
||
|---|---|---|
|
||
| **export space** | `TileModel` vertices (local), `Tile.x/y/z` (cells, z ≤ 0), `Light` x/y/z | right-handed, y up; world vertex = `tile.transform().point(vx, vy, vz)` |
|
||
| **game space**, 20.12 fixed point (4096 = 1 cell) | `Strat`, `PathNode`, `EnvZone` target, collision records | x east, y up, z = grid row (positive) = −export z |
|
||
| **game space, cells (double)** | `Brush` parameters, `MeshData` | same, unscaled |
|
||
|
||
Angles: ints in 1/4096 turns (`PropType.ANGLE`). Strat yaw 0 = facing +z (game), 1024 = facing +x.
|
||
Tile transform (export space): `X = x + vx·cos a − vz·sin a; Y = y + vy; Z = z + vx·sin a + vz·cos a`,
|
||
a = rot/4096·2π (`format.Geometry.TileXform`). Grid cell of a tile = (floor(x), floor(−z)).
|
||
Collision records (`TileModel.collision0..2`, 32 bytes) are stored in game space; decode with
|
||
`TileModel.collisionPolygon(g, k)` (→ `format.Collision.Decoded`, already converted to export
|
||
space) and `Collision.polygon(decoded)` for the corners (local to the tile; apply the tile transform).
|
||
Placed in the world: `tile.worldCollisionPolygons()` / `model.collisionPolygons(xf)` (corners, normal,
|
||
material; polygons with fewer than 3 corners skipped).
|
||
|
||
## Scene (`editor.model.Scene`)
|
||
|
||
Singletons: `settings()` (`LevelSettings`), `regions()` (`Regions`). Ordered lists (`NodeList<T>`,
|
||
read-only `List` with O(1) `indexOf`; order = WAD order):
|
||
|
||
| Accessor | Node | WAD source |
|
||
|---|---|---|
|
||
| `tileModels()` | `TileModel` | TRAK models (vertices, faces, planes, 3 collision groups, bbox header) |
|
||
| `tiles()` | `Tile` | MAP tiles: x, y, z, rot, `model` (ref), swap (alt model, door/bridge), vertex colours |
|
||
| `strats()` | `Strat` | MAP objects: `type` (name), `code`, position/rotation (fixed), `params`, type fields, `waypoint` (ref), flags |
|
||
| `pathNodes()` | `PathNode` | MAP waypoints/camera/respawn nodes; `next` (ref) chains |
|
||
| `zones()` | `EnvZone` | MAP 90-byte env/teleport/respawn zones incl. fog, colours, music |
|
||
| `lights()` | `Light` | LGHT (directional / point) |
|
||
| `pages()` | `TexturePage` | TEXT pages (256×256 ABGR1555, `argb()` for images) |
|
||
| `textures()` | `TextureEntry` | TEXT texture table (faces use the **index** in this list) |
|
||
| `textureAnims()` | `TextureAnim` | TEXT texture animations |
|
||
| `brushes()` | `Brush` | editor-only primitives → tiles at build time |
|
||
| `meshAssets()` | `MeshAsset` | imported OBJ/glTF (`mesh()` = parsed `MeshData`) |
|
||
| `chunks()` | `RawChunk` | WAD chunk order; unmanaged chunks (STPC, sound, …) verbatim |
|
||
|
||
Other: `node(id)`, `listOf(kind)`, `catalog()` (`StratCatalog`: placeable object types of the base
|
||
level, variants with type fields, default params and relative waypoint chains), `assets()`
|
||
(project files), `chain(pathNode)`, `playerStart()`, `referencesTo(node)`, `create(kind)` /
|
||
`createBrush(kind)` (detached node with a fresh id), `add/remove/set` (with events, no undo).
|
||
|
||
**Stable ids**: imported nodes `"<kind>:<index in the WAD>"` (`tile:12`, `strat:3`, `wp:40`, `zone:0`,
|
||
`model:7`, `tex:120`, `page:2`, `light:0`, `anim:1`, `chunk:5`), new nodes `"<kind>:n<N>"`, singletons
|
||
`settings:0`, `regions:0`. Ids never change, also not when other nodes are deleted.
|
||
|
||
## Nodes and properties
|
||
|
||
Every node: `id()`, `kind()` (`NodeKind`), `label()`, `props()` → `List<Prop>`. A `Prop` has
|
||
`name()`, `label()`, `group()` (inspector section), `type()` (`PropType`), `readOnly()`,
|
||
`nullable()`, `refKind()`, `choices()`, `get(node)`. Value classes by `PropType`:
|
||
INT/FIXED/ANGLE/FLAGS/COLOR → `Integer`, DOUBLE → `Double`, BOOL, STRING/CHOICE → `String`,
|
||
REF → `Node`, INT_ARRAY `int[]`, DOUBLE_ARRAY `double[]`, BYTES `byte[]`. Arrays are shared: never
|
||
mutate a returned array, set a new one. Typed getters exist on every node class (`tile.x()`,
|
||
`strat.gx()` (cells), `model.vertices()` …).
|
||
|
||
Key render data:
|
||
* `TileModel.vertices()` 6 doubles/vertex (x y z nx ny nz), `faces()` 6 ints/face (flags, v0, v1,
|
||
v2, tex, pad), `planes()` 4/face; helpers `vertexCount()`, `faceCount()`, `faceFlags(f)`,
|
||
`faceVertex(f,k)`, `faceTexture(f)`, `collisionCount(g)`, `collisionMaterial(g,k)`.
|
||
* UVs: `format.Geometry.faceUvs(flags, Geometry.texUv(entry.toFormat(), page.width(), page.height()))`;
|
||
flat colours: `TextureEntry.isFlat()`, `flatRgb()`. Face flags: 0x400 double-sided, 0x4000
|
||
ignore vertex colours, 0x8 batch start.
|
||
* `Tile.colors()`: frames × nv RGBA words (0x80 = 1.0, engine ×2), first frame = `colors[0..nv)`.
|
||
* `Regions.visibleFrom(camCellX, camCellZ, cellX, cellZ, width)` = engine visibility rule.
|
||
* Collision materials: 0 normal, 16 exit floor (LevelDoor), 17/18 selective, 30 no floor/death.
|
||
* `StratCatalog.category(type)` → player, exit, checkpoint, camera, trigger, collectible, force, other.
|
||
|
||
## Commands (`editor.model.cmd`)
|
||
|
||
`History(scene)`: `execute(cmd)`, `undo()`, `redo()`, `canUndo/Redo`, `undoLabel/redoLabel`,
|
||
`begin(label)`/`end()` (group into one step), `markSaved()`/`isDirty()`, `addListener(Runnable)`.
|
||
Commands: `SetProp.of(node, "prop", value)` / `SetProp.of(node, prop, value, mergeKey)`,
|
||
`AddNode(node[, index])`, `RemoveNode(scene, node)` (clears optional references, refuses if a
|
||
tile still uses a model), `Batch(label, cmds[, mergeKey])`. **Drags**: give every step of one
|
||
gesture the same `mergeKey` object → consecutive steps merge into one undo entry.
|
||
`Edits` helpers: `translate(nodes, dx, dy, dz, mergeKey)` (cells, game space; strats, waypoints,
|
||
tiles, brushes, zone targets, point lights), `rotateTo(node, degrees, mergeKey)`,
|
||
`placeStrat(scene, type, variant, x, y, z, yawDeg)` (copies type fields/params/waypoint chain from
|
||
the catalog), `delete(scene, nodes)` (also removes waypoint chains used only by deleted strats),
|
||
`addBrush(scene, kind, params)`, `set(label, Map<Node, Map<prop, value>>, mergeKey)`.
|
||
|
||
Events (`SceneListener`, `SceneEvent`): `ADDED(node, index)`, `REMOVED(node, index)`,
|
||
`CHANGED(node, prop, oldValue)`, `RELOADED` (bulk change, rebuild everything). Fired synchronously
|
||
on the changing thread (use the EDT).
|
||
|
||
## Brushes (`Brush`, `BrushKind`)
|
||
|
||
Kinds FLOOR, BOX, PLATFORM, PILLAR, RAMP, STAIRS, FENCE, TREE, SEA, KILL_PLANE, QUAD, COLLIDER, MESH
|
||
– the `levelbuild.LevelBuilder` primitives; parameters = props named as in `BrushKind.params()`
|
||
(`Param(name, label, type, default, role, choices)`; role X/Y/Z/XZ_LIST/XYZ_LIST/XZ_RECT marks
|
||
coordinates). `brush.bounds()` gives an approximate box. Texture parameters: village names
|
||
(`LevelBuilder.TEX`, resolved per base by `BaseTextures`), `"#index"`, flat colour names (`LevelBuilder.FLAT`) or `"rgbRRGGBB"`.
|
||
For a preview of the generated geometry: `BrushCompiler.compile(scene, warnings)` → generated
|
||
`TileModel`s/`Tile`s/textures (same as the build).
|
||
|
||
## IO (`editor.io`)
|
||
|
||
| Class | Purpose |
|
||
|---|---|
|
||
| `WadImporter.importWad(bytes, base, cd)` | original WAD → Scene (cd: loader for other CD files, may be null) |
|
||
| `WadBuilder.build(scene)` / `buildWithReport(scene)` | Scene → WAD (+ `Stats`, warnings); validates first, throws `IllegalStateException` with all errors |
|
||
| `Validator.validate(scene, warnings)` | errors (unbuildable) + warnings, for a problems panel |
|
||
| `BrushCompiler.compile(scene, warnings)` | brushes/meshes → generated `TileModel`s/`Tile`s/pages/entries (ids "gen:…", not in the scene) – usable as a preview of the build |
|
||
| `Project` | `scene()`, `thumbnail()`/`setThumbnail(png)`, `warnings()`; `Project.create(bases, "edit"/"new", base)` |
|
||
| `ProjectIO` | `load/read(bytes)`, `save(p, path, bases)`/`write`, `info(bytes)` (header only), `sha256` |
|
||
| `Templates` | `original(bases, base)`, `newLevel(bases, base)` (reference), `clearBaseLeftovers(scene)`, `DEFAULT_BASE` |
|
||
| `Bases` | original files: `Bases.fromCd(dir)` (case-insensitive), `wad(base)`, `file(rel)` |
|
||
| `MeshImport.importFile(scene, path)` | OBJ(+MTL, map_Kd), glTF (.gltf + .bin/data URIs, .glb) → detached `MeshAsset` (add with `AddNode`); files copied into `scene.assets()`; `load(scene, asset)` re-parses |
|
||
| `TextureImport.importImages(scene, images, size)` | images → `Result(command, entries, pages)`: execute the command, then use `"#" + entries[i]` as a texture |
|
||
| `Images` | page ↔ PNG/`BufferedImage` (`pageImage`, `pagePng`, `toAbgr1555`) |
|
||
|
||
Meshes at build time: transformed (scale, yaw, position), then every triangle goes through
|
||
`LevelBuilder.quad` (split into 1×1 cells / 10-unit height bands, collision polygon per triangle
|
||
with the brush's material, lit vertex colours). The engine has no free UVs – a triangle always
|
||
shows one half of a texture rectangle – so each triangle gets the rectangle around its UVs
|
||
(rounded to `uvSteps` per texture) on a copy of its material texture (flipped in U and/or V when
|
||
that fits the corner order better). Rectangular quads map exactly; arbitrary UV layouts are
|
||
approximated; tiling UVs (> 1) are clamped. Material textures are packed into new 256×256 pages
|
||
at `texSize` (power of two). Untextured materials use flat colours (Kd / baseColorFactor);
|
||
vertex colours tint the lighting.
|
||
|
||
## Limits / unknown (phase 1)
|
||
|
||
* Strat code (STPC) and object models are kept verbatim: only types present in the base level are
|
||
placeable (`StratCatalog`), plus RubyIdol borrowed from t1l1m002 for the village (as levelbuild).
|
||
* Meaning of many raw fields is unknown (`unk*` props of zones/waypoints, texture entry flags,
|
||
collision groups B/C, region value) – they are editable but not interpreted.
|
||
* Tile swaps (WFPC 0x10000) can only exist in bases with that flag.
|
||
* Texture names are the village's levelbuild names; on other bases they resolve to the same picture
|
||
(`levelbuild.BaseTextures`, pixel fingerprint); names the base lacks are not offered (GeoWidgets) and a
|
||
brush using one is skipped with a warning (the editor has no CD access to copy them over).
|
||
* Lights: types other than 1/2/4 carry no data and are ignored by the engine.
|
||
* Removing original tiles shifts tile indices used by strat scripts (see editor-format.md).
|
||
* The engine limits: ≤ 8000 vertices per tile model, collision within ±8 of the tile origin
|
||
(handled by the brush compiler), texture pages ≤ 127.
|