diff --git a/.gitignore b/.gitignore index 60c177b..74bb995 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,11 @@ node_modules/ dist/ /main +/main.exe +/main.pdb /bloom-editor +/bloom-editor.exe +/bloom-editor.pdb .DS_Store *.log .claude/ diff --git a/PLAN.md b/PLAN.md index 67bf898..70267c3 100644 --- a/PLAN.md +++ b/PLAN.md @@ -59,6 +59,38 @@ Everything below was verified by reading current source, not from stale docs. Co 4. **Environment edits bypass the undo stack** — `environment-panel.ts` mutates `state.world.environment` directly and only sets dirty flags. 5. **The brush silently creates terrain on flat worlds.** `src/tools/brush-tool.ts:66-68` assigns `defaultTerrain()` when `world.terrain` is null. Both shooter arenas have `terrain: null`; one stray B-keypress + click adds a 128×128 heightmap to a world that shouldn't have one, and the terrain creation itself is not undoable (only the stroke's height deltas are). +### 1.2b Corrections & additional bugs (second audit, 2026-07-11, Windows/Perry 0.5.1208) + +Corrections to the audit above, verified against the actual files: + +- `arena_02` has **1** water volume (id `river`, a box volume standing in for the river), not 6; `rivers[]` is empty. +- `enemy_spawner` entities carry only `userData.kind` — there are no `enemyType`/`waveBits`/`maxAlive`/`cooldown` keys (§1.5 overstates); the whole wave plan lives in `wave_config.userData.waves`. F1 stands, its acceptance example doesn't. +- `loadRecentProjects` is not dead code (called from `addRecentProject`); the missing piece is UI that reads the list. +- Autosave/Ctrl+S live in `main.ts`, not `world-io.ts`. + +Additional bugs found (fixed in the same pass as B1-B4): + +6. **Environment sky & fog edits were silent no-ops** — `syncEnvironment` never applied `skyColor`/`fogStart`/`fogEnd`. Fixed: clear color reads the world's sky each frame; fog maps to `setFog` with a density approximation. +7. **Directional light accumulated** — `addDirectionalLight` per env sync stacked lights. Fixed: `setDirectionalLight` re-applied every frame (begin_frame resets the lighting block anyway — same reason the shooter re-sets sun/ambient per frame). +8. **Water/river id counters reset per launch** → duplicate ids on reopened worlds made `AddWaterCommand.undo` remove the wrong volume. Fixed: counters persist in `world.metadata` (like `nextEntityId`) with a collision guard. +9. **Backspace deleted the selected entity** — latent footgun for text fields. Fixed: Delete only, and only while no widget is active. +10. **Entity with both modelRef and prefabRef double-bound and leaked a node.** Fixed: prefab branch takes precedence, single bind. +11. **Entity tints rendered near-black in the editor** — `sync.ts` passed 0-1 world tints straight into `setSceneNodeColor`, which expects 0-255 (see `applyTint` in the engine loader). Fixed. + +✅ **Why the editor never started on Windows: it was written against an engine API that doesn't exist.** Three separate mismatches, each producing the same `TypeError: Expected number for native f64 parameter` on the first frame — an `undefined` reaching a native `f64` parameter: + +1. **Key constants.** The editor used `Key.LeftControl` / `LeftSuper` / `Escape` / `Delete` / `Space` / `LeftShift` / `RightShift`; the engine (`engine/src/core/keys.ts`) spells them `LEFT_CONTROL`, `LEFT_SUPER`, `ESCAPE`, `DELETE`, `SPACE`, `LEFT_SHIFT`, `RIGHT_SHIFT`. Letters (`Key.Z`) were correct, which hid the pattern. +2. **Mouse buttons.** `MouseButton.Left/Right/Middle` → engine has `LEFT/RIGHT/MIDDLE`. +3. **`drawLine` arity.** `widgets.ts:separator()` called `drawLine(x1,y1,x2,y2, 1, color.r, color.g, color.b, color.a)`, but the engine signature is `(x1, y1, x2, y2, thickness, Color)` — so it read `.r` off the number `1` and got `undefined`. This one only fires on the first panel that draws a separator, which is why it surfaced last. + +4. **`setSceneNodeTransform` / `updateSceneNodeGeometry` were unreachable from TypeScript.** Both take their arrays through `i64` pointer params, and Perry 0.5.x refuses to pass a `number[]` into an `i64` ("Expected safe integer for native i64 parameter"). The engine had already worked around this for meshes (the `bloom_mesh_scratch_*` buffers) but never migrated the scene-graph pair — the shooter uses `setSceneNodeTrs` (all-scalar) so nobody hit it, while the editor needs full matrices (non-uniform scale: a boundary wall is 40×4×0.5). **Fixed in the engine** (additive, nothing else changes): new `bloom_scene_set_transform16` (17 scalars, stateless) and `bloom_scene_update_geometry_scratch` (re-uses the mesh scratch); the TS wrappers now route through them, and the stale "prefer setSceneNodeTrs until the scratch migration lands" note in `engine/src/scene/index.ts` is now true history. + +All fixed. Perry does not type-check missing members on these `const` objects, so none of this failed at compile time. **Perry's reported stack line was flat wrong** (it blamed `sync.ts:383` for a fault in `main.ts`'s input block) — trust `console.error` breadcrumbs, not the line attribution. An engine-API audit of the remaining call sites (`drawRay`/`drawCube`/`drawText`/scene calls) found no further mismatches. + +⚠️ **Separately: Perry 0.5.1208 miscompiles `Map` fields on an interface** — a real bug, found while chasing the above, though it was *not* what broke the editor. Reading `.size` on a `Map` field of an interface access-violates once the program declares more than one such field; `Set.size` and class fields are fine. `AssetCatalog` and `HandleMap` are now classes as a precaution, and no code reads `Map.size` through a property chain. Full repro table + rules: **`docs/perry-map-size-av.md`** — read it before adding a `Map` to editor state, and report upstream. + +Traps recorded so nobody re-pays for them: Perry's **stdout is block-buffered and lost on a native crash** (use `console.error`); a debug line printing `someMap.size` *introduces* an access violation of its own (the instrumentation became the bug for hours); and `loadAssetCatalog` takes ~20 s here for the shooter's 26 GLBs (the "<1 s" comment in that file is macOS-calibrated) — the black window at startup is loading, not a hang. Async/lazy catalog loading deserves a follow-up. + ### 1.3 Dead code — written but unreachable (all verified by grep: zero call sites) | Code | What's missing to reach it | @@ -131,7 +163,21 @@ Tasks are grouped, not phased — everything ships. Order within reason: **A → - **B3.** Make terrain creation explicit and undoable: replace the silent `defaultTerrain()` in `brush-tool.ts:66-68` with either (a) a "Create terrain" button in the brush panel issuing a `CreateTerrainCommand`, or (b) folding terrain creation into the stroke command's undo state so Ctrl+Z after a first-stroke removes the terrain entirely. Either way, `world.terrain` must return to `null` on undo. - **B4.** Cache the prefab registry in `syncRebuilds` (currently rebuilt per entity per frame, `sync.ts:83-87`); invalidate on catalog changes. Remove the dead `label` imports. -### C. Water & rivers, end-to-end +### C. Water & rivers, end-to-end — ✅ DONE (2026-07-11) + +Landed. Notes that differ from the plan as written: + +- **`genMeshSplineRibbon` was also unreachable** from TypeScript — same i64-pointer problem as the scene transform (the plan assumed it was ready to use). Added `bloom_gen_mesh_spline_ribbon_scratch`; the wrapper now pushes points then widths through the mesh scratch. +- Shared helpers live in **`engine/src/world/render.ts`** (`spawnWaterVolume`, `spawnRiver`) and are called by both `instantiateWorld` and the editor's sync layer, so a river cannot look different in-game than in the editor. The 0-1 → 0-255 colour conversion happens there, once. The stale "pending Q8/Q9" warnings are gone. +- `InstantiateResult` reports `waterHandles` / `riverHandles` as **arrays, not Maps**, index-aligned with `world.water` / `world.rivers` — it already had one `Map`, and a second would have tripped the Perry interface-Map miscompile documented in `docs/perry-map-size-av.md`. +- Selection is now `{ primary, kind: 'entity' | 'water' | 'river' }`. Entity-only paths (gizmos, entity inspector, duplicate, frame-on-selection, outline) go through `selectedEntityId()`, which returns null for a water/river selection, so a selected river can never be handed to code that assumes `world.entities`. +- Outliner lists **Water and Rivers above Entities** — the panel does not scroll, and a world with 66 entities would otherwise bury them below the fold permanently. +- Delete removes the selected water/river (undo restores it at its original index, so ordering round-trips). Edits coalesce per drag like entity transforms. +- Creation defaults (`WATER_DEFAULTS`, `RIVER_DEFAULTS`) replace the previously hardcoded constants. + +Still open from C: dragging a **gizmo** on a water volume (move/scale writes `center`/`size`) and per-control-point river handles. Both are editable numerically in the inspector today. + +### C. Water & rivers, end-to-end (original plan) - **C1. Engine: real spawning in `instantiateWorld`** (`../engine/src/world/loader.ts:152-160`). Create a shared helper module (e.g. `../engine/src/world/render.ts`) with `spawnWaterVolume(v)` — scene node + box mesh sized to `v.size` at `v.center` (top face at `surfaceHeight`), `setSceneNodeWaterMaterial` with `waveAmplitude`/`waveSpeed` and **color converted 0-1 → 0-255** — and `spawnRiver(r)` — sample the Catmull-Rom spline through `controlPoints` (interpolate per-point `widths`), feed `genMeshSplineRibbon`, offset down by `depth`, water material. Replace the two TODO warnings with actual spawns; return handles in the instantiate result. Delete the stale "Q8/Q9" comments. Acceptance: garden (or a test scene) instantiating a world with water/rivers shows them. - **C2. Editor: render through the same helpers** in `sync.ts` (new `syncWater`/`syncRivers` driven by pending-flags, mirroring entities), replacing the water tool's translucent debug cubes and the river tool's debug lines. Keep an editor-only selected-highlight overlay. @@ -139,6 +185,19 @@ Tasks are grouped, not phased — everything ships. Order within reason: **A → - **C4. Inspector sections**: water (`surfaceHeight`, color, `waveAmplitude`, `waveSpeed`) and river (`depth`, `flowSpeed`, color, per-point width), editable, undoable (coalesced drag commands like `TransformEntityCommand`). - **C5. Toolbar buttons** for water and river next to the existing four (`toolbar.ts:46-51`), plus creation-defaults fields (e.g. in the brush-panel pattern) so new volumes/splines aren't hardcoded. +### Schema v2 — first-class point lights — ✅ DONE (2026-07-11) + +Not in the original plan; added because the editor could not light its own preview. + +In v1, a light was an *entity* carrying `userData.kind = "point_light"` plus `range` / `color` / `intensity` strings — a private convention between one game and its baker. The editor saw an entity with no model (an invisible, unlit marker), and every new game would have re-invented the same convention. Sun, ambient, and fog were already first-class in `environment`; point lights now sit beside them in a top-level `lights: LightData[]`. + +The dividing line, for future schema questions: **lights are engine-universal — every renderer knows what a point light is — so they are schema. A spawner or a wave plan means nothing without the game, so it stays `userData`.** The editor stays game-agnostic either way. + +- `WORLD_SCHEMA_VERSION = 2`; `migrateWorldData` lifts v1 `point_light` entities into `world.lights` on load, so old worlds keep working untouched. Covered by self-tests (id/position/colour/range/intensity carried over, non-light entities untouched, v2 worlds left alone, result validates). +- `applyWorldLights(world)` in `world/render.ts` must be called **every frame** — the renderer clears its lighting block in `begin_frame`, the same reason games re-apply sun and ambient. Calling it once at load lights the world for exactly one frame. +- Editor: Light tool (click to place), Lights section in the outliner, inspector (position / colour / intensity / range), delete with index-preserving undo, and a wire marker at each light plus a range sphere when selected — a light has no mesh, so without markers you cannot see or click one. +- Shooter: `arena_02` migrated to v2 (5 lights lifted out of `entities`); its baker reads `world.lights` and still falls back to the v1 entity form. **The generated runtime data is byte-identical apart from one comment** — the migration is semantically a no-op for the game. + ### D. Terrain paint & layers - **D1. UI:** layer list in the brush panel — add/remove `TerrainLayer` (pick `textureRef` via the asset catalog; ⚠ the catalog currently only scans models/prefabs — extend it to a textures dir, adding a `texturesDir` key to `editor.project.json` with a sensible default), set `tileScale`, select active layer (`state.brush.activeLayerIdx` already exists), and expose the `'paint'` kind button (`brush-panel.ts:22`). diff --git a/README.md b/README.md index e11e5f2..5e5063a 100644 --- a/README.md +++ b/README.md @@ -8,19 +8,33 @@ Like everything in the Bloom ecosystem, the editor is written in TypeScript and Core editing works; several planned features are unfinished. **[`PLAN.md`](PLAN.md)** contains a full verified audit (what works, what's broken, with file/line references) and the completion plan, including a definition of done. In short: -- **Working today:** entity placement and selection, move/rotate/scale gizmos, terrain sculpting (raise/lower/smooth/flatten), undo/redo, save/load with autosave, environment panel, fly-camera playtest mode. -- **Not finished yet:** prefab authoring UI, terrain texture painting, water/river editing beyond initial placement, `userData` editing, asset thumbnails, and a handful of bugs listed in the plan. +- **Working today:** opens the shooter project and renders `arena_02` (entities with no model — spawners, pickups, colliders — draw as colored, pickable placeholder boxes); entity placement and selection, move/rotate/scale gizmos, terrain sculpting, undo/redo, save/load with autosave, `userData` key/value editing, environment panel, fly-camera playtest mode. +- **Not finished yet:** prefab authoring UI, terrain texture painting, water/river editing beyond initial placement, asset thumbnails, recent-projects UI. + +Startup blocks for ~20 s while every GLB in the project's models dir is loaded synchronously (26 for the shooter). The window is black until that finishes — it is loading, not hung. + +### Platform notes + +Bringing this up on Windows required fixing three API mismatches in the editor (`Key.*` and `MouseButton.*` are `SCREAMING_SNAKE` in the engine, and `drawLine` takes a `Color`, not four channels) plus one engine gap (`setSceneNodeTransform` / `updateSceneNodeGeometry` couldn't cross the FFI — see `PLAN.md`). Also read [`docs/perry-map-size-av.md`](docs/perry-map-size-av.md) before putting a `Map` in editor state: Perry 0.5.x miscompiles `Map` fields declared on an interface. ## Building Requires [Perry](https://github.com/andrewtdiz/perry) ≥ 0.5 on your `PATH`, and the [engine](https://github.com/Bloom-Engine/engine) checked out as a sibling directory named `../engine` (the `bloom` dependency resolves via `file:../engine/`). ```sh -PERRY_ALLOW_PERRY_FEATURES=1 perry compile src/main.ts +perry compile src/main.ts ./main ``` -The env var is a temporary escape hatch until the `perry.allow.nativeLibrary` grant lands in `package.json` (task A1 in `PLAN.md`). `perry compile` emits the binary as `./main`. +`perry compile` emits the binary as `./main` (`main.exe` on Windows; pass `-o ` to change it). The native-library grant lives in `package.json` under `perry.allow.nativeLibrary`, so no environment variables are needed. + +Run the self-test suite headless with: + +```sh +./main --test +``` + +It prints failing assertions plus a summary and exits nonzero on any failure. ## Usage diff --git a/docs/perry-map-size-av.md b/docs/perry-map-size-av.md new file mode 100644 index 0000000..22ab7d5 --- /dev/null +++ b/docs/perry-map-size-av.md @@ -0,0 +1,95 @@ +# Perry bug: `Map` fields on an interface are miscompiled + +**Found:** 2026-07-11, while bringing the editor up on Windows. +**Toolchain:** Perry 0.5.1208, Windows 11, x86_64 (LLVM backend). +**Status:** open upstream; the editor works around it by never reading +`Map.prototype.size` through an interface field. + +## Symptom + +The process dies with an access violation (`0xC0000005`, exit +`-1073741819`). With `--debug-symbols`, `llvm-symbolizer` attributes the +faulting address to `perry_runtime::buffer::dataview::js_data_view_set`, +which is a red herring — the fault address drifts between builds and the +nearest-symbol attribution is not meaningful. The real story is that +`.size` lowers to `js_map_size()` (see +`perry-codegen/src/expr/property_get.rs`), and the pointer it unboxes out +of the field is not a Map. + +## Minimal repro + +```ts +interface Two { a: Map; b: Map; } +const t: Two = { a: new Map(), b: new Map() }; +console.error('size=' + t.a.size); // ← access violation +``` + +No engine, no FFI, no window. `perry compile two.ts -o two && ./two`. + +## What does and doesn't trigger it + +| Shape | Result | +|---|---| +| One interface, one `Map` field, read `.size` | **PASS** | +| One interface, **two** `Map` fields, read `.size` | **CRASH** | +| One interface, three `Map` fields | **CRASH** | +| **Two** interfaces, one `Map` field each | **CRASH** | +| Same one-`Map` interface used for two fields | **PASS** | +| Two `Set` fields, read `.size` | **PASS** | +| One `Map` + one `Set` field | **PASS** | +| **Class** with two `Map` fields, read `.size` | **PASS** | +| Two `Map` fields, only `get`/`set`/`has` (no `.size`) | **PASS** | +| `Record` instead of `Map` | **PASS** | + +So the trigger is: **reading `.size` on a `Map`-typed field of an +interface-typed object, in a program that declares more than one `Map` +field across its interfaces.** `Map` methods are fine; `Set.size` is fine; +class fields are fine (`is_map_expr`'s `PropertyGet` arm resolves class +fields explicitly — interfaces evidently take a different path that +mis-resolves the receiver once more than one candidate field exists). + +Reproduce the whole table with the bisect scripts kept alongside this note +in the scratchpad of the session that found it, or re-derive from the table +above — each row is ~10 lines. + +## It is not only `.size` + +`.size` is the loudest symptom (instant access violation), but the corruption +is in the **field read** itself. The editor never called `Map.size`, yet it +still died on the first frame: + +``` +TypeError: Expected number for native f64 parameter + at src/world-sync/sync.ts:383 +``` + +`HandleMap` was `interface { byEntity: Map; byHandle: Map }` — two `Map` +fields — and `Array.from(state.handles.byEntity.values())` on an *empty* map +returned bogus entries, which were then handed to `destroySceneNode()` and +rejected by the native ABI's number check. So any read of a `Map` field on a +multi-`Map` interface can yield garbage; `.size` merely dereferences it +immediately. + +## Workaround (what the editor does) + +1. **Hold `Map`s in a `class`, never a multi-`Map` interface.** `AssetCatalog` + and `HandleMap` in `src/state/editor-state.ts` are classes for exactly this + reason. With classes, `get` / `set` / `has` / `values()` / `keys()` all + behave correctly (verified against the editor's exact shapes). +2. **Never read `.size` on a `Map` through a property chain** — that still + crashes even when the holder is a class (`state.catalog.models.size` dies; + the class-local `this.models.size` form is fine). Count via + `Array.from(m.keys()).length`, or off a parallel array — the catalog keeps + `modelOrder: string[]` beside `models: Map<...>`, so + `catalog.modelOrder.length` is the count. +3. `Set.size` is safe through chains and is used freely (`pendingRebuild`, + `pendingDestroy`). + +## Debugging notes for the next person + +- Perry's stdout is block-buffered and is **lost** when the process dies on a + native fault. Print breadcrumbs with `console.error`. +- `llvm-symbolizer` attributed the fault to `js_data_view_set`, which is + nearest-symbol noise — don't chase it. +- Instrumenting a crash with `console.log('n=' + someMap.size)` *introduces* + this bug. That is how a debug line became the crash under investigation. diff --git a/package.json b/package.json index 17e187b..a1c0686 100644 --- a/package.json +++ b/package.json @@ -4,5 +4,15 @@ "main": "src/main.ts", "dependencies": { "bloom": "file:../engine/" + }, + "perry": { + "allow": { + "nativeLibrary": [ + "bloom", + "bloom/core", + "bloom/world", + "bloom/scene" + ] + } } } diff --git a/src/gizmos/move-gizmo.ts b/src/gizmos/move-gizmo.ts index cea2693..ecde7d5 100644 --- a/src/gizmos/move-gizmo.ts +++ b/src/gizmos/move-gizmo.ts @@ -13,7 +13,7 @@ import { updateSceneNodeGeometry, } from 'bloom/scene'; import { mat4Identity, mat4Translate, mat4Scale } from 'bloom'; -import { EditorState, handleOfEntity } from '../state/editor-state'; +import { EditorState, handleOfEntity, selectedEntityId } from '../state/editor-state'; import { TransformEntityCommand } from '../state/commands/transform-entity'; import { runCommand } from '../state/commands'; import { mouseToWorldRay, raySegmentDistance, Ray3 } from '../viewport/ray'; @@ -60,13 +60,13 @@ export function updateMoveGizmo(state: EditorState, gizmo: MoveGizmoState): void } // Position the gizmo at the selected entity. - if (state.selection.primary === null || state.activeTool !== 'transform' || state.transformMode !== 'move') { + if (selectedEntityId(state) === null || state.activeTool !== 'transform' || state.transformMode !== 'move') { gizmo.visible = false; gizmo.dragging = false; return; } - const entity = state.world.entities.find(e => e.id === state.selection.primary); + const entity = state.world.entities.find(e => e.id === selectedEntityId(state)); if (!entity) { gizmo.visible = false; gizmo.dragging = false; @@ -88,7 +88,7 @@ export function updateMoveGizmo(state: EditorState, gizmo: MoveGizmoState): void // ---- hit test gizmo axes ------------------------------------------------- - if (!gizmo.dragging && inViewport && isMouseButtonPressed(MouseButton.Left)) { + if (!gizmo.dragging && inViewport && isMouseButtonPressed(MouseButton.LEFT)) { const ray = mouseToWorldRay(state.camera, mx, my, sw, sh, state.viewportLeft, state.viewportTop, vw, vh); const pos = gizmo.anchor; @@ -119,7 +119,7 @@ export function updateMoveGizmo(state: EditorState, gizmo: MoveGizmoState): void // ---- drag in progress ---------------------------------------------------- if (gizmo.dragging) { - if (isMouseButtonDown(MouseButton.Left)) { + if (isMouseButtonDown(MouseButton.LEFT)) { const ray = mouseToWorldRay(state.camera, mx, my, sw, sh, state.viewportLeft, state.viewportTop, vw, vh); const axis = gizmo.dragAxis; const startPos = gizmo.dragStartPos; diff --git a/src/gizmos/rotate-gizmo.ts b/src/gizmos/rotate-gizmo.ts index 32b125e..23c7e37 100644 --- a/src/gizmos/rotate-gizmo.ts +++ b/src/gizmos/rotate-gizmo.ts @@ -3,7 +3,7 @@ import { drawRay, getMouseX, getMouseY, isMouseButtonDown, isMouseButtonPressed, isMouseButtonReleased, getScreenWidth, getScreenHeight, MouseButton } from 'bloom'; import { TransformData, Vec3Lit } from 'bloom/world'; -import { EditorState } from '../state/editor-state'; +import { EditorState, selectedEntityId } from '../state/editor-state'; import { TransformEntityCommand } from '../state/commands/transform-entity'; import { runCommand } from '../state/commands'; import { mouseToWorldRay, raySegmentDistance } from '../viewport/ray'; @@ -28,14 +28,14 @@ export function createRotateGizmoState(): RotateGizmoState { } export function updateRotateGizmo(state: EditorState, gizmo: RotateGizmoState): void { - if (state.playtesting || state.selection.primary === null || + if (state.playtesting || selectedEntityId(state) === null || state.activeTool !== 'transform' || state.transformMode !== 'rotate') { gizmo.visible = false; gizmo.dragging = false; return; } - const entity = state.world.entities.find(e => e.id === state.selection.primary); + const entity = state.world.entities.find(e => e.id === selectedEntityId(state)); if (!entity) { gizmo.visible = false; return; } gizmo.visible = true; @@ -51,7 +51,7 @@ export function updateRotateGizmo(state: EditorState, gizmo: RotateGizmoState): const sh = getScreenHeight(); // Hit test: approximate each circle as 16 line segments, check ray distance. - if (!gizmo.dragging && inViewport && isMouseButtonPressed(MouseButton.Left)) { + if (!gizmo.dragging && inViewport && isMouseButtonPressed(MouseButton.LEFT)) { const ray = mouseToWorldRay(state.camera, mx, my, sw, sh, state.viewportLeft, state.viewportTop, vw, vh); const pos = gizmo.anchor; const r = GIZMO_LENGTH; @@ -83,7 +83,7 @@ export function updateRotateGizmo(state: EditorState, gizmo: RotateGizmoState): } if (gizmo.dragging) { - if (isMouseButtonDown(MouseButton.Left)) { + if (isMouseButtonDown(MouseButton.LEFT)) { const angle = Math.atan2(my - sh / 2, mx - sw / 2); let delta = angle - gizmo.dragStartAngle; diff --git a/src/gizmos/scale-gizmo.ts b/src/gizmos/scale-gizmo.ts index 044044f..e4c2ab9 100644 --- a/src/gizmos/scale-gizmo.ts +++ b/src/gizmos/scale-gizmo.ts @@ -4,7 +4,7 @@ import { drawRay, drawCube, getMouseX, getMouseY, isMouseButtonDown, isMouseButtonPressed, getScreenWidth, getScreenHeight, MouseButton } from 'bloom'; import { TransformData, Vec3Lit } from 'bloom/world'; -import { EditorState } from '../state/editor-state'; +import { EditorState, selectedEntityId } from '../state/editor-state'; import { TransformEntityCommand } from '../state/commands/transform-entity'; import { runCommand } from '../state/commands'; import { mouseToWorldRay, raySegmentDistance } from '../viewport/ray'; @@ -29,14 +29,14 @@ export function createScaleGizmoState(): ScaleGizmoState { } export function updateScaleGizmo(state: EditorState, gizmo: ScaleGizmoState): void { - if (state.playtesting || state.selection.primary === null || + if (state.playtesting || selectedEntityId(state) === null || state.activeTool !== 'transform' || state.transformMode !== 'scale') { gizmo.visible = false; gizmo.dragging = false; return; } - const entity = state.world.entities.find(e => e.id === state.selection.primary); + const entity = state.world.entities.find(e => e.id === selectedEntityId(state)); if (!entity) { gizmo.visible = false; return; } gizmo.visible = true; @@ -51,7 +51,7 @@ export function updateScaleGizmo(state: EditorState, gizmo: ScaleGizmoState): vo const sw = getScreenWidth(); const sh = getScreenHeight(); - if (!gizmo.dragging && inViewport && isMouseButtonPressed(MouseButton.Left)) { + if (!gizmo.dragging && inViewport && isMouseButtonPressed(MouseButton.LEFT)) { const ray = mouseToWorldRay(state.camera, mx, my, sw, sh, state.viewportLeft, state.viewportTop, vw, vh); const pos = gizmo.anchor; const len = GIZMO_LENGTH; @@ -81,7 +81,7 @@ export function updateScaleGizmo(state: EditorState, gizmo: ScaleGizmoState): vo } if (gizmo.dragging) { - if (isMouseButtonDown(MouseButton.Left)) { + if (isMouseButtonDown(MouseButton.LEFT)) { const delta = (mx - gizmo.dragStartX) * 0.01; const startScl = (gizmo.dragStartTransform as TransformData).scale; const scl: Vec3Lit = [startScl[0], startScl[1], startScl[2]]; diff --git a/src/io/asset-catalog.ts b/src/io/asset-catalog.ts index 2be8572..bbf0c1a 100644 --- a/src/io/asset-catalog.ts +++ b/src/io/asset-catalog.ts @@ -12,6 +12,7 @@ import { readFile } from 'bloom'; import { PrefabData } from 'bloom/world'; import { EditorState, ModelEntry, Project } from '../state/editor-state'; import { basenameNoExt, categoryFromName, joinPath } from './paths'; +import { invalidatePrefabRegistry } from '../world-sync/sync'; export function loadAssetCatalog(state: EditorState): void { if (!state.project) return; @@ -19,6 +20,7 @@ export function loadAssetCatalog(state: EditorState): void { loadModels(state, project); loadPrefabs(state, project); + invalidatePrefabRegistry(); } function loadModels(state: EditorState, project: Project): void { diff --git a/src/main.ts b/src/main.ts index 0d8328c..9579b24 100644 --- a/src/main.ts +++ b/src/main.ts @@ -20,11 +20,12 @@ import { createEntity, Vec3Lit } from 'bloom/world'; import { handleSelectClick } from './tools/select-tool'; import { handlePlaceClick } from './tools/place-tool'; -import { EditorState, createEditorState, nextEntityId } from './state/editor-state'; +import { EditorState, createEditorState, nextEntityId, selectedEntityId } from './state/editor-state'; import { runCommand, undo, redo } from './state/commands'; import { CreateEntityCommand } from './state/commands/create-entity'; import { DestroyEntityCommand } from './state/commands/destroy-entity'; import { DuplicateEntityCommand } from './state/commands/duplicate-entity'; +import { RemoveWaterCommand, RemoveRiverCommand } from './state/commands/edit-water'; import { loadProject } from './io/project'; import { loadAssetCatalog } from './io/asset-catalog'; @@ -44,6 +45,7 @@ import { createScaleGizmoState, updateScaleGizmo, drawScaleGizmo } from './gizmo import { updateBrushTool } from './tools/brush-tool'; import { updateWaterTool, drawWaterVolumes } from './tools/water-tool'; import { updateRiverTool, drawRiverSplines } from './tools/river-tool'; +import { updateLightTool, drawLightMarkers, RemoveLightCommand } from './tools/light-tool'; import { updatePrefabTool, drawPrefabBreadcrumb } from './tools/prefab-tool'; import { drawEnvironmentPanel } from './ui/layouts/environment-panel'; import { drawBrushPanel } from './ui/layouts/brush-panel'; @@ -55,6 +57,20 @@ import { drawAssetPanel } from './ui/layouts/asset-panel'; import { drawInspector } from './ui/layouts/inspector'; import { drawOutliner } from './ui/layouts/outliner'; import { drawStatusBar } from './ui/layouts/status-bar'; +import { runSelfTests } from './tests/self-tests'; + +// ---- self-tests (headless) --------------------------------------------------- + +// `bloom-editor --test` runs the suite without opening a window and exits +// nonzero on any failure, so CI and pre-commit hooks can gate on it. +let selfTestMode = false; +for (let i = 0; i < process.argv.length; i++) { + if (process.argv[i] === '--test') selfTestMode = true; +} +if (selfTestMode) { + const failures = runSelfTests(); + process.exit(failures > 0 ? 1 : 0); +} // ---- init ------------------------------------------------------------------ @@ -73,6 +89,8 @@ const scaleGizmo = createScaleGizmoState(); // Load project + assets. loadProject(state); +// Blocking: loads every GLB in the project's models dir (~20 s for the +// shooter's 26 on a mid Windows box). Worth making async — see PLAN.md. loadAssetCatalog(state); // Open default world if available. @@ -80,6 +98,9 @@ if (state.project && state.project.defaultWorld.length > 0) { const worldPath = state.project.worldsDir + '/' + state.project.defaultWorld; openWorld(state, worldPath); addRecentProject(state.project.name, state.project.filePath); + // Start looking at the level rather than at the default orbit target, which + // on arena_02 puts the camera inside the building. + frameCameraOnWorld(state); } // Mark environment for initial sync. @@ -108,42 +129,59 @@ while (!windowShouldClose()) { // ---- input shortcuts ----------------------------------------------------- // Ctrl+Z / Ctrl+Y (undo/redo). - if (isKeyDown(Key.LeftControl) || isKeyDown(Key.LeftSuper)) { + if (isKeyDown(Key.LEFT_CONTROL) || isKeyDown(Key.LEFT_SUPER)) { if (isKeyPressed(Key.Z)) undo(state); if (isKeyPressed(Key.Y)) redo(state); if (isKeyPressed(Key.S)) saveCurrentWorld(state); } - // Delete selected entity. - if (isKeyPressed(Key.Delete) || isKeyPressed(Key.Backspace)) { - if (state.selection.primary !== null) { + // Delete the selection — entity, water volume, or river. Delete only + // (Backspace must stay free for text widgets), and never while a widget + // (text field, drag) is active. + if (isKeyPressed(Key.DELETE) && ui.activeId === null && state.selection.primary !== null) { + if (state.selection.kind === 'entity') { const entity = state.world.entities.find(e => e.id === state.selection.primary); if (entity) { const idx = state.world.entities.indexOf(entity); runCommand(state, new DestroyEntityCommand(entity, idx)); } + } else if (state.selection.kind === 'water') { + const idx = state.world.water.findIndex(w => w.id === state.selection.primary); + if (idx >= 0) runCommand(state, new RemoveWaterCommand(state.world.water[idx], idx)); + } else if (state.selection.kind === 'river') { + const idx = state.world.rivers.findIndex(r => r.id === state.selection.primary); + if (idx >= 0) runCommand(state, new RemoveRiverCommand(state.world.rivers[idx], idx)); + } else if (state.selection.kind === 'light') { + const idx = state.world.lights.findIndex(l => l.id === state.selection.primary); + if (idx >= 0) runCommand(state, new RemoveLightCommand(state.world.lights[idx], idx)); } } - // Duplicate (Ctrl+D). - if ((isKeyDown(Key.LeftControl) || isKeyDown(Key.LeftSuper)) && isKeyPressed(Key.D)) { - if (state.selection.primary !== null) { - const entity = state.world.entities.find(e => e.id === state.selection.primary); + // Duplicate (Ctrl+D). Entities only. + if ((isKeyDown(Key.LEFT_CONTROL) || isKeyDown(Key.LEFT_SUPER)) && isKeyPressed(Key.D)) { + const dupId = selectedEntityId(state); + if (dupId !== null) { + const entity = state.world.entities.find(e => e.id === dupId); if (entity) { runCommand(state, new DuplicateEntityCommand(entity)); } } } - // Tool hotkeys. - if (isKeyPressed(Key.Q)) state.activeTool = 'select'; - if (isKeyPressed(Key.W)) state.activeTool = 'place'; - if (isKeyPressed(Key.G)) { state.activeTool = 'transform'; state.transformMode = 'move'; } - if (isKeyPressed(Key.R)) { state.activeTool = 'transform'; state.transformMode = 'rotate'; } - if (isKeyPressed(Key.E)) { state.activeTool = 'transform'; state.transformMode = 'scale'; } - if (isKeyPressed(Key.B)) state.activeTool = 'brush'; - if (isKeyPressed(Key.T)) state.activeTool = 'water'; - if (isKeyPressed(Key.Y)) state.activeTool = 'river'; + // Tool hotkeys — only when no Ctrl/Cmd chord is held (so Ctrl+Y never + // doubles as a tool switch) and no widget is active (so typing "q" into a + // text field doesn't switch tools). + const chordHeld = isKeyDown(Key.LEFT_CONTROL) || isKeyDown(Key.LEFT_SUPER); + if (!chordHeld && ui.activeId === null) { + if (isKeyPressed(Key.Q)) state.activeTool = 'select'; + if (isKeyPressed(Key.W)) state.activeTool = 'place'; + if (isKeyPressed(Key.G)) { state.activeTool = 'transform'; state.transformMode = 'move'; } + if (isKeyPressed(Key.R)) { state.activeTool = 'transform'; state.transformMode = 'rotate'; } + if (isKeyPressed(Key.E)) { state.activeTool = 'transform'; state.transformMode = 'scale'; } + if (isKeyPressed(Key.B)) state.activeTool = 'brush'; + if (isKeyPressed(Key.T)) state.activeTool = 'water'; + if (isKeyPressed(Key.Y)) state.activeTool = 'river'; + } // F key: frame camera on selection (or world bounds if nothing selected). if (isKeyPressed(Key.F)) { @@ -155,7 +193,7 @@ while (!windowShouldClose()) { } // Escape: deselect. - if (isKeyPressed(Key.Escape)) { + if (isKeyPressed(Key.ESCAPE)) { state.selection.ids.clear(); state.selection.primary = null; syncSelectionOutline(state); @@ -176,7 +214,15 @@ while (!windowShouldClose()) { // ---- begin drawing ------------------------------------------------------- beginDrawing(); - clearBackground({ r: 42, g: 46, b: 56, a: 255 }); + // Clear to the world's sky color so the environment panel's sky edits are + // actually visible (the panel used to be a silent no-op here). + const envSky = state.world.environment.skyColor; + clearBackground({ + r: Math.floor(envSky[0] * 255), + g: Math.floor(envSky[1] * 255), + b: Math.floor(envSky[2] * 255), + a: 255, + }); // ---- 3D viewport --------------------------------------------------------- @@ -194,9 +240,11 @@ while (!windowShouldClose()) { drawRotateGizmo(rotateGizmo); drawScaleGizmo(scaleGizmo); - // Draw water volumes and river splines as immediate-mode overlays. + // In-progress previews for the water/river tools, plus light markers (a light + // has no mesh, so without these you cannot see or click one). drawWaterVolumes(state); drawRiverSplines(state); + drawLightMarkers(state); // Note: scene graph nodes are drawn automatically by the engine's retained- // mode renderer. We don't need to call drawModel for scene-graph entities. @@ -217,7 +265,7 @@ while (!windowShouldClose()) { // We draw UI panels next and check mouseCaptured afterwards. To avoid // a one-frame delay, we store the click intent here and process it after UI. - const viewportClicked = inViewport && isMouseButtonPressed(MouseButton.Left); + const viewportClicked = inViewport && isMouseButtonPressed(MouseButton.LEFT); // ---- 2D UI (drawn on top of the viewport) -------------------------------- @@ -257,6 +305,7 @@ while (!windowShouldClose()) { updateBrushTool(state); updateWaterTool(state); updateRiverTool(state); + updateLightTool(state); // ---- world sync (at the end of the frame) -------------------------------- diff --git a/src/playtest/playtest.ts b/src/playtest/playtest.ts index fc22e63..9286795 100644 --- a/src/playtest/playtest.ts +++ b/src/playtest/playtest.ts @@ -16,7 +16,7 @@ const LOOK_SPEED = 0.003; export function updatePlaytest(state: EditorState): void { // Toggle with Ctrl+P. - if ((isKeyDown(Key.LeftControl) || isKeyDown(Key.LeftSuper)) && isKeyPressed(Key.P)) { + if ((isKeyDown(Key.LEFT_CONTROL) || isKeyDown(Key.LEFT_SUPER)) && isKeyPressed(Key.P)) { state.playtesting = !state.playtesting; if (state.playtesting) { // Enter: set camera to current orbit eye position. @@ -56,8 +56,8 @@ export function updatePlaytest(state: EditorState): void { if (isKeyDown(Key.S)) { mx -= sinYaw; mz -= cosYaw; } if (isKeyDown(Key.A)) { mx += cosYaw; mz -= sinYaw; } if (isKeyDown(Key.D)) { mx -= cosYaw; mz += sinYaw; } - if (isKeyDown(Key.Space)) my += 1; - if (isKeyDown(Key.LeftShift)) my -= 1; + if (isKeyDown(Key.SPACE)) my += 1; + if (isKeyDown(Key.LEFT_SHIFT)) my -= 1; const speed = FLY_SPEED * dt; state.camera.target[0] += mx * speed; diff --git a/src/state/commands/create-terrain.ts b/src/state/commands/create-terrain.ts new file mode 100644 index 0000000..fd650fd --- /dev/null +++ b/src/state/commands/create-terrain.ts @@ -0,0 +1,21 @@ +// Explicit terrain creation. A world without terrain stays without terrain +// until the user asks for one — a stray brush stroke must never add a +// heightmap silently (both shooter arenas ship with terrain: null on purpose). +// Undo returns world.terrain to null; the sync layer then removes the node. + +import { defaultTerrain } from 'bloom/world'; +import { EditorState, Command } from '../editor-state'; + +export class CreateTerrainCommand implements Command { + readonly label = 'Create terrain'; + + do(state: EditorState): void { + state.world.terrain = defaultTerrain(); + state.pendingTerrainRebuild = true; + } + + undo(state: EditorState): void { + state.world.terrain = null; + state.pendingTerrainRebuild = true; + } +} diff --git a/src/state/commands/duplicate-entity.ts b/src/state/commands/duplicate-entity.ts index 699cf27..3836505 100644 --- a/src/state/commands/duplicate-entity.ts +++ b/src/state/commands/duplicate-entity.ts @@ -42,6 +42,7 @@ export class DuplicateEntityCommand implements Command { state.selection.ids.clear(); state.selection.ids.add(this.clone.id); state.selection.primary = this.clone.id; + state.selection.kind = 'entity'; } undo(state: EditorState): void { diff --git a/src/state/commands/edit-water.ts b/src/state/commands/edit-water.ts new file mode 100644 index 0000000..1339e07 --- /dev/null +++ b/src/state/commands/edit-water.ts @@ -0,0 +1,163 @@ +// Commands for water volumes and rivers: edit a property, or remove one. +// +// Water/river edits are drag-driven (the same dragFloat widgets as the entity +// inspector), so the edit commands coalesce like TransformEntityCommand — one +// undo entry per drag, not one per frame. + +import { WaterVolume, RiverSpline } from 'bloom/world'; +import { EditorState, Command } from '../editor-state'; + +// Snapshot/restore the whole record. Water and river records are small (a +// handful of scalars plus a colour), so storing before/after copies is simpler +// and safer than a per-field command, and it merges trivially. +function cloneWater(w: WaterVolume): WaterVolume { + return { + id: w.id, + kind: w.kind, + center: [w.center[0], w.center[1], w.center[2]], + size: [w.size[0], w.size[1], w.size[2]], + surfaceHeight: w.surfaceHeight, + color: [w.color[0], w.color[1], w.color[2], w.color[3]], + waveAmplitude: w.waveAmplitude, + waveSpeed: w.waveSpeed, + }; +} + +function cloneRiver(r: RiverSpline): RiverSpline { + const points: [number, number, number][] = []; + for (let i = 0; i < r.controlPoints.length; i++) { + const p = r.controlPoints[i]; + points.push([p[0], p[1], p[2]]); + } + return { + id: r.id, + controlPoints: points, + widths: r.widths.slice(), + depth: r.depth, + flowSpeed: r.flowSpeed, + color: [r.color[0], r.color[1], r.color[2], r.color[3]], + }; +} + +export class EditWaterCommand implements Command { + readonly label: string; + readonly waterId: string; + private before: WaterVolume; + private after: WaterVolume; + + constructor(waterId: string, before: WaterVolume, after: WaterVolume) { + this.waterId = waterId; + this.before = cloneWater(before); + this.after = cloneWater(after); + this.label = 'Edit water ' + waterId; + } + + do(state: EditorState): void { this.apply(state, this.after); } + undo(state: EditorState): void { this.apply(state, this.before); } + + private apply(state: EditorState, value: WaterVolume): void { + for (let i = 0; i < state.world.water.length; i++) { + if (state.world.water[i].id !== this.waterId) continue; + state.world.water[i] = cloneWater(value); + state.pendingWaterRebuild = true; + return; + } + } + + mergeWith(next: Command): boolean { + if (!(next instanceof EditWaterCommand)) return false; + const n = next as EditWaterCommand; + if (n.waterId !== this.waterId) return false; + this.after = cloneWater(n.after); + return true; + } +} + +export class EditRiverCommand implements Command { + readonly label: string; + readonly riverId: string; + private before: RiverSpline; + private after: RiverSpline; + + constructor(riverId: string, before: RiverSpline, after: RiverSpline) { + this.riverId = riverId; + this.before = cloneRiver(before); + this.after = cloneRiver(after); + this.label = 'Edit river ' + riverId; + } + + do(state: EditorState): void { this.apply(state, this.after); } + undo(state: EditorState): void { this.apply(state, this.before); } + + private apply(state: EditorState, value: RiverSpline): void { + for (let i = 0; i < state.world.rivers.length; i++) { + if (state.world.rivers[i].id !== this.riverId) continue; + state.world.rivers[i] = cloneRiver(value); + state.pendingWaterRebuild = true; + return; + } + } + + mergeWith(next: Command): boolean { + if (!(next instanceof EditRiverCommand)) return false; + const n = next as EditRiverCommand; + if (n.riverId !== this.riverId) return false; + this.after = cloneRiver(n.after); + return true; + } +} + +export class RemoveWaterCommand implements Command { + readonly label: string; + private volume: WaterVolume; + private index: number; + + constructor(volume: WaterVolume, index: number) { + this.volume = cloneWater(volume); + this.index = index; + this.label = 'Remove water ' + volume.id; + } + + do(state: EditorState): void { + const idx = state.world.water.findIndex(w => w.id === this.volume.id); + if (idx >= 0) state.world.water.splice(idx, 1); + if (state.selection.kind === 'water' && state.selection.primary === this.volume.id) { + state.selection.primary = null; + state.selection.kind = 'entity'; + } + state.pendingWaterRebuild = true; + } + + undo(state: EditorState): void { + // Restore at the original index so ids and ordering round-trip unchanged. + state.world.water.splice(this.index, 0, cloneWater(this.volume)); + state.pendingWaterRebuild = true; + } +} + +export class RemoveRiverCommand implements Command { + readonly label: string; + private river: RiverSpline; + private index: number; + + constructor(river: RiverSpline, index: number) { + this.river = cloneRiver(river); + this.index = index; + this.label = 'Remove river ' + river.id; + } + + do(state: EditorState): void { + const idx = state.world.rivers.findIndex(r => r.id === this.river.id); + if (idx >= 0) state.world.rivers.splice(idx, 1); + if (state.selection.kind === 'river' && state.selection.primary === this.river.id) { + state.selection.primary = null; + state.selection.kind = 'entity'; + } + state.pendingWaterRebuild = true; + } + + undo(state: EditorState): void { + state.world.rivers.splice(this.index, 0, cloneRiver(this.river)); + state.pendingWaterRebuild = true; + } +} diff --git a/src/state/commands/set-userdata.ts b/src/state/commands/set-userdata.ts new file mode 100644 index 0000000..d915e0e --- /dev/null +++ b/src/state/commands/set-userdata.ts @@ -0,0 +1,56 @@ +// Command: set, add, or remove a single userData key on an entity. userData is +// the game-defined side channel (spawner params, wave plans, collider extents), +// so the editor treats keys and values as opaque strings. Supports mergeWith so +// per-keystroke edits of the same key coalesce into one undo entry. + +import { EditorState, Command, EntityId } from '../editor-state'; + +export class SetUserDataCommand implements Command { + readonly label: string; + readonly entityId: EntityId; + readonly key: string; + private before: string | null; // null = key absent before. + private after: string | null; // null = key removed. + + constructor(entityId: EntityId, key: string, before: string | null, after: string | null) { + this.entityId = entityId; + this.key = key; + this.before = before; + this.after = after; + this.label = 'Edit userData ' + key; + } + + do(state: EditorState): void { + this.apply(state, this.after); + } + + undo(state: EditorState): void { + this.apply(state, this.before); + } + + private apply(state: EditorState, value: string | null): void { + for (let i = 0; i < state.world.entities.length; i++) { + const entity = state.world.entities[i]; + if (entity.id !== this.entityId) continue; + if (value === null) { + delete entity.userData[this.key]; + } else { + entity.userData[this.key] = value; + } + // Placeholder color/size can depend on userData (kind, halfExtents). + state.pendingRebuild.add(this.entityId); + return; + } + } + + // Coalesce consecutive keystroke edits of the same key on the same entity. + // Removals and additions never merge — they should stay separate undo steps. + mergeWith(next: Command): boolean { + if (!(next instanceof SetUserDataCommand)) return false; + const n = next as SetUserDataCommand; + if (n.entityId !== this.entityId || n.key !== this.key) return false; + if (this.after === null || n.after === null || this.before === null) return false; + this.after = n.after; + return true; + } +} diff --git a/src/state/editor-state.ts b/src/state/editor-state.ts index f82f0b1..b6e1224 100644 --- a/src/state/editor-state.ts +++ b/src/state/editor-state.ts @@ -11,7 +11,7 @@ import { // ---- sub-state types ------------------------------------------------------- export type EntityId = string; -export type ToolId = 'select' | 'place' | 'transform' | 'brush' | 'prefab' | 'water' | 'river'; +export type ToolId = 'select' | 'place' | 'transform' | 'brush' | 'prefab' | 'water' | 'river' | 'light'; export type TransformMode = 'move' | 'rotate' | 'scale'; export interface Project { @@ -34,7 +34,16 @@ export interface ModelEntry { loaded: boolean; } -export interface AssetCatalog { +// NB: AssetCatalog and HandleMap are CLASSES, not interfaces, and that is +// load-bearing. Perry 0.5.1208 miscompiles field access on an *interface* +// that declares more than one `Map` field: the field reads back as garbage, +// so `Array.from(handles.byEntity.values())` yielded bogus entries that then +// blew up as `TypeError: Expected number for native f64 parameter` on the +// first frame. Class fields resolve correctly. Full write-up + repro table: +// docs/perry-map-size-av.md. Also: never read `.size` on a Map through a +// property chain (still miscompiled even on classes) — count via keys()/a +// parallel array. `Set.size` is safe. +export class AssetCatalog { models: Map; // Key = relPath. prefabs: Map; // Key = prefab id. modelOrder: string[]; // Stable iteration order for the panel. @@ -42,11 +51,28 @@ export interface AssetCatalog { filter: string; // Substring filter for the asset panel. activeCategory: string; // "all" or a category slug. activeTab: number; // 0 = Models, 1 = Prefabs. + + constructor() { + this.models = new Map(); + this.prefabs = new Map(); + this.modelOrder = []; + this.prefabOrder = []; + this.filter = ''; + this.activeCategory = 'all'; + this.activeTab = 0; + } } +// What kind of thing the selection refers to. Entities, water volumes, and +// rivers are stored in separate arrays in the world file and are not +// interchangeable, so the selection has to say which array `primary` indexes +// into — an id alone is ambiguous. +export type SelectionKind = 'entity' | 'water' | 'river' | 'light'; + export interface Selection { - ids: Set; - primary: EntityId | null; // The one showing the gizmo + inspector. + ids: Set; // Multi-select; entities only. + primary: string | null; // The one showing the gizmo + inspector. + kind: SelectionKind; // What `primary` is. } export interface OrbitCamera { @@ -72,9 +98,15 @@ export interface BrushSettings { activeLayerIdx: number; // Used by paint brush. } -export interface HandleMap { +// Class, not an interface — see the note on AssetCatalog above. +export class HandleMap { byEntity: Map; // SceneNodeHandle. byHandle: Map; + + constructor() { + this.byEntity = new Map(); + this.byHandle = new Map(); + } } // ---- main state object ----------------------------------------------------- @@ -117,9 +149,13 @@ export interface EditorState { // Scene sync handles: HandleMap; terrainHandle: number; // 0 if no terrain node exists. + // Water/river scene nodes, index-aligned with world.water / world.rivers. + waterHandles: number[]; + riverHandles: number[]; pendingRebuild: Set; pendingDestroy: Set; // SceneNodeHandles to destroy this frame. pendingTerrainRebuild: boolean; + pendingWaterRebuild: boolean; // Any water/river add, edit, or removal. pendingEnvironmentSync: boolean; // Viewport @@ -148,20 +184,12 @@ import { createEmptyWorld } from 'bloom/world'; export function createEditorState(): EditorState { return { project: null, - catalog: { - models: new Map(), - prefabs: new Map(), - modelOrder: [], - prefabOrder: [], - filter: '', - activeCategory: 'all', - activeTab: 0, - }, + catalog: new AssetCatalog(), worldPath: null, world: createEmptyWorld('untitled', 'Untitled World'), editingPrefab: null, modified: false, - selection: { ids: new Set(), primary: null }, + selection: { ids: new Set(), primary: null, kind: 'entity' }, activeTool: 'select', transformMode: 'move', placeAssetRef: null, @@ -184,14 +212,14 @@ export function createEditorState(): EditorState { undoStack: [], redoStack: [], maxHistory: 200, - handles: { - byEntity: new Map(), - byHandle: new Map(), - }, + handles: new HandleMap(), terrainHandle: 0, + waterHandles: [], + riverHandles: [], pendingRebuild: new Set(), pendingDestroy: new Set(), pendingTerrainRebuild: false, + pendingWaterRebuild: false, pendingEnvironmentSync: false, viewportLeft: 240, viewportRight: 1000, @@ -201,6 +229,40 @@ export function createEditorState(): EditorState { }; } +// ---- selection helpers ----------------------------------------------------- + +// The selected entity id, or null when nothing is selected *or* the selection +// is a water volume / river. Every entity-only path (gizmos, entity inspector, +// duplicate, delete) goes through this so a selected river can never be fed to +// code that assumes `world.entities`. +export function selectedEntityId(state: EditorState): EntityId | null { + if (state.selection.kind !== 'entity') return null; + return state.selection.primary; +} + +export function selectEntity(state: EditorState, id: EntityId | null): void { + state.selection.primary = id; + state.selection.kind = 'entity'; +} + +export function selectWater(state: EditorState, id: string): void { + state.selection.ids.clear(); + state.selection.primary = id; + state.selection.kind = 'water'; +} + +export function selectRiver(state: EditorState, id: string): void { + state.selection.ids.clear(); + state.selection.primary = id; + state.selection.kind = 'river'; +} + +export function selectLight(state: EditorState, id: string): void { + state.selection.ids.clear(); + state.selection.primary = id; + state.selection.kind = 'light'; +} + // ---- handle map helpers ---------------------------------------------------- export function bindEntity(map: HandleMap, id: EntityId, handle: number): void { @@ -226,7 +288,22 @@ export function handleOfEntity(map: HandleMap, id: EntityId): number { return h !== undefined ? h : 0; } -// ---- next entity id -------------------------------------------------------- +// ---- id counters ------------------------------------------------------------- + +// All id counters persist in world.metadata so they survive editor restarts. +// A fresh in-memory counter would mint duplicate ids on a reopened world, +// and commands find their targets by id — a duplicate makes undo remove the +// wrong object. +export function nextCounterId(state: EditorState, counterKey: string, prefix: string): string { + const current = state.world.metadata[counterKey]; + let n = 1; + if (current !== undefined) { + n = parseInt(current); + if (n !== n) n = 1; // NaN guard + } + state.world.metadata[counterKey] = (n + 1).toString(); + return prefix + n.toString(); +} export function nextEntityId(state: EditorState): string { const key = 'nextEntityId'; diff --git a/src/tests/self-tests.ts b/src/tests/self-tests.ts index f1c0d23..dea7416 100644 --- a/src/tests/self-tests.ts +++ b/src/tests/self-tests.ts @@ -1,14 +1,22 @@ -// Self-tests — run via bloom-editor --test (check for arg in main.ts). -// Prints PASS/FAIL for each test, exits with nonzero on any failure. +// Self-tests — run via `bloom-editor --test` (wired in main.ts, which exits +// nonzero on any failure). Prints each failing assertion by name plus a +// pass/fail summary; runSelfTests returns the failure count. -import { WorldData, PrefabData, createEmptyWorld, createEntity } from 'bloom/world'; +import { WorldData, PrefabData, WaterVolume, createEmptyWorld, createEntity } from 'bloom/world'; import { validateWorld, validatePrefab } from 'bloom/world'; +import { migrateWorldData } from 'bloom/world'; import { buildHeightmapMesh, sampleHeight, defaultTerrain } from 'bloom/world'; import { expandPrefab, createPrefabRegistry, registerPrefab, PrefabLeaf } from 'bloom/world'; -import { createEditorState } from '../state/editor-state'; +import { + createEditorState, nextCounterId, + selectedEntityId, selectEntity, selectRiver, +} from '../state/editor-state'; +import { EditWaterCommand, RemoveWaterCommand } from '../state/commands/edit-water'; import { runCommand, undo, redo } from '../state/commands'; import { CreateEntityCommand } from '../state/commands/create-entity'; import { TransformEntityCommand } from '../state/commands/transform-entity'; +import { CreateTerrainCommand } from '../state/commands/create-terrain'; +import { SetUserDataCommand } from '../state/commands/set-userdata'; let passed = 0; let failed = 0; @@ -18,17 +26,28 @@ function assert(condition: boolean, name: string): void { passed++; } else { failed++; + console.log('FAIL: ' + name); } } export function runSelfTests(): number { + passed = 0; + failed = 0; + testWorldRoundTrip(); testValidation(); testTerrainBilinearSample(); testPrefabCycleDetection(); testPrefabExpansion(); testCommandUndoRedo(); + testMapSize(); + testCreateTerrainUndo(); + testCounterIds(); + testUserDataCommand(); + testWaterCommands(); + testLightMigration(); + console.log('self-tests: ' + passed + ' passed, ' + failed + ' failed'); return failed; } @@ -124,6 +143,159 @@ function testPrefabExpansion(): void { assert(leaves[2].modelRef === 'roof.glb', 'expand: third leaf is roof'); } +function testMapSize(): void { + // Regression probe: Map.size at editor startup coincided with a native + // access violation (0xc0000005) on Perry 0.5.1208 — keep this canary. + const m = new Map(); + assert(m.size === 0, 'map: empty size'); + m.set('a', 1); + m.set('b', 2); + assert(m.size === 2, 'map: size after set'); + m.delete('a'); + assert(m.size === 1, 'map: size after delete'); + // The editor AV'd specifically on string + Map.size concatenation. + const viaLocal = m.size; + assert(('n=' + viaLocal) === 'n=1', 'map: size concat via local'); + assert(('n=' + m.size) === 'n=1', 'map: size concat direct'); + console.log('map-size concat survived: n=' + m.size); +} + +function testCreateTerrainUndo(): void { + const state = createEditorState(); + assert(state.world.terrain === null, 'terrain cmd: starts null'); + + runCommand(state, new CreateTerrainCommand()); + assert(state.world.terrain !== null, 'terrain cmd: created'); + + undo(state); + assert(state.world.terrain === null, 'terrain cmd: undo returns terrain to null'); + + redo(state); + assert(state.world.terrain !== null, 'terrain cmd: redo re-creates'); +} + +function testCounterIds(): void { + const state = createEditorState(); + const a = nextCounterId(state, 'nextWaterId', 'water_'); + const b = nextCounterId(state, 'nextWaterId', 'water_'); + assert(a === 'water_1', 'counter: first id'); + assert(b === 'water_2', 'counter: second id'); + assert(state.world.metadata['nextWaterId'] === '3', 'counter: persists in world metadata'); +} + +function testUserDataCommand(): void { + const state = createEditorState(); + runCommand(state, new CreateEntityCommand(createEntity('ud_ent', 'x.glb', [0, 0, 0]))); + + runCommand(state, new SetUserDataCommand('ud_ent', 'cooldown', null, '5')); + assert(state.world.entities[0].userData['cooldown'] === '5', 'userdata: set'); + + runCommand(state, new SetUserDataCommand('ud_ent', 'cooldown', '5', '8')); + assert(state.world.entities[0].userData['cooldown'] === '8', 'userdata: edit'); + + undo(state); + assert(state.world.entities[0].userData['cooldown'] === '5', 'userdata: undo restores previous value'); + + runCommand(state, new SetUserDataCommand('ud_ent', 'cooldown', '5', null)); + assert(state.world.entities[0].userData['cooldown'] === undefined, 'userdata: remove'); + + undo(state); + assert(state.world.entities[0].userData['cooldown'] === '5', 'userdata: undo restores removed key'); +} + +function testWaterCommands(): void { + const state = createEditorState(); + const volume: WaterVolume = { + id: 'water_1', + kind: 'box', + center: [0, -1, 0], + size: [10, 2, 10], + surfaceHeight: 0.5, + color: [0.2, 0.5, 0.8, 0.6], + waveAmplitude: 0.1, + waveSpeed: 1.0, + }; + state.world.water.push(volume); + + // Edit coalescing: two drags on the same volume are one undo entry. + const before = { ...volume, center: [0, -1, 0] as [number, number, number], size: [10, 2, 10] as [number, number, number], color: [0.2, 0.5, 0.8, 0.6] as [number, number, number, number] }; + const mid = { ...before, waveSpeed: 2.0 }; + runCommand(state, new EditWaterCommand('water_1', before, mid)); + const after = { ...before, waveSpeed: 3.0 }; + runCommand(state, new EditWaterCommand('water_1', mid, after)); + assert(state.world.water[0].waveSpeed === 3.0, 'water: edit applied'); + assert(state.undoStack.length === 1, 'water: consecutive edits coalesce into one undo entry'); + + undo(state); + assert(state.world.water[0].waveSpeed === 1.0, 'water: undo restores the pre-drag value'); + + // Removal restores at the original index, so ordering round-trips. + state.world.water.push({ ...volume, id: 'water_2' }); + runCommand(state, new RemoveWaterCommand(state.world.water[0], 0)); + assert(state.world.water.length === 1, 'water: removed'); + assert(state.world.water[0].id === 'water_2', 'water: the right one was removed'); + + undo(state); + assert(state.world.water.length === 2, 'water: remove undone'); + assert(state.world.water[0].id === 'water_1', 'water: restored at its original index'); + + // Selecting a river must not let entity-only paths act on it. + selectRiver(state, 'river_1'); + assert(selectedEntityId(state) === null, 'selection: a river is not an entity selection'); + selectEntity(state, 'ent_1'); + assert(selectedEntityId(state) === 'ent_1', 'selection: entity selection reads back'); +} + +// Schema v1 carried point lights as entities with userData.kind='point_light'. +// migrateWorldData must lift them into world.lights and drop them from +// entities, without touching anything else — this runs on every load of an old +// world, so a bug here silently mangles worlds. +function testLightMigration(): void { + const world = createEmptyWorld('t', 'T') as any; + world.schemaVersion = 1; + world.lights = undefined; + + const lightEnt = createEntity('light_a', '', [3, 4, 5]); + lightEnt.userData = { + kind: 'point_light', + range: '18', + color: '1.0, 0.5, 0.25', + intensity: '2.5', + }; + const propEnt = createEntity('prop_a', 'models/crate.glb', [1, 0, 1]); + + world.entities.push(lightEnt); + world.entities.push(propEnt); + + const migrated = migrateWorldData(world as WorldData); + + assert(migrated.schemaVersion === 2, 'migration: schemaVersion bumped to 2'); + assert(migrated.lights.length === 1, 'migration: one light lifted'); + assert(migrated.entities.length === 1, 'migration: light removed from entities'); + assert(migrated.entities[0].id === 'prop_a', 'migration: non-light entity untouched'); + + const l = migrated.lights[0]; + assert(l.id === 'light_a', 'migration: light id preserved'); + assert(l.kind === 'point', 'migration: light kind'); + assert(l.position[0] === 3 && l.position[1] === 4 && l.position[2] === 5, 'migration: position carried over'); + assert(l.range === 18, 'migration: range parsed from userData'); + assert(l.intensity === 2.5, 'migration: intensity parsed from userData'); + assert(Math.abs(l.color[0] - 1.0) < 0.001 && Math.abs(l.color[1] - 0.5) < 0.001, + 'migration: color parsed from "r, g, b" string'); + + // A v2 world must pass through untouched (migration is not re-run). + const already = createEmptyWorld('t2', 'T2'); + already.lights.push({ + id: 'l1', name: 'l1', kind: 'point', + position: [0, 1, 0], color: [1, 1, 1], intensity: 1, range: 5, + }); + const again = migrateWorldData(already); + assert(again.lights.length === 1, 'migration: v2 world is left alone'); + + const v = validateWorld(again); + assert(v.ok === true, 'migration: migrated world validates'); +} + function testCommandUndoRedo(): void { const state = createEditorState(); diff --git a/src/tools/brush-tool.ts b/src/tools/brush-tool.ts index 80f4ede..4503a4a 100644 --- a/src/tools/brush-tool.ts +++ b/src/tools/brush-tool.ts @@ -9,7 +9,7 @@ // both the before and after snapshots. Undo restores the before snapshot. import { isMouseButtonDown, isMouseButtonPressed, isMouseButtonReleased, MouseButton, getMouseX, getMouseY, getScreenWidth, getScreenHeight, getDeltaTime } from 'bloom'; -import { raycastTerrain, defaultTerrain, TerrainData } from 'bloom/world'; +import { raycastTerrain, TerrainData } from 'bloom/world'; import { EditorState } from '../state/editor-state'; import { runCommand } from '../state/commands'; import { mouseToWorldRay } from '../viewport/ray'; @@ -63,11 +63,10 @@ export function updateBrushTool(state: EditorState): void { return; } - // Ensure the world has terrain. - if (!state.world.terrain) { - state.world.terrain = defaultTerrain(); - state.pendingTerrainRebuild = true; - } + // No terrain — sculpting requires explicit creation first (the brush + // panel's "Create terrain" button, an undoable command). Silently creating + // one here would corrupt terrain-less worlds on a stray click. + if (!state.world.terrain) return; const terrain = state.world.terrain as TerrainData; const mx = getMouseX(); @@ -88,19 +87,19 @@ export function updateBrushTool(state: EditorState): void { if (!hit.hit) return; // Start stroke. - if (isMouseButtonPressed(MouseButton.Left)) { + if (isMouseButtonPressed(MouseButton.LEFT)) { brushState.stroking = true; brushState.heightsSnapshot = terrain.heights.slice(); } // Apply brush while mouse is held. - if (brushState.stroking && isMouseButtonDown(MouseButton.Left)) { + if (brushState.stroking && isMouseButtonDown(MouseButton.LEFT)) { applyBrush(state, terrain, hit.cellX, hit.cellZ); state.pendingTerrainRebuild = true; } // End stroke. - if (brushState.stroking && isMouseButtonReleased(MouseButton.Left)) { + if (brushState.stroking && isMouseButtonReleased(MouseButton.LEFT)) { endStroke(state); } } diff --git a/src/tools/light-tool.ts b/src/tools/light-tool.ts new file mode 100644 index 0000000..5bd6845 --- /dev/null +++ b/src/tools/light-tool.ts @@ -0,0 +1,186 @@ +// Light tool — click on the ground to drop a point light. +// +// Lights are schema, not entities (world.lights), so they get their own tool +// rather than riding on the place tool's model catalog. Placed lights are lit +// for real in the viewport (see sync.ts → applyWorldLights); the wire marker +// drawn here is just so you can find and click one, since a light has no mesh. + +import { + isMouseButtonPressed, getMouseX, getMouseY, getScreenWidth, getScreenHeight, + MouseButton, drawSphereWires, +} from 'bloom'; +import { LightData, Vec3Lit } from 'bloom/world'; +import { EditorState, Command, nextCounterId, selectLight } from '../state/editor-state'; +import { runCommand } from '../state/commands'; +import { mouseToWorldRay, rayPlaneIntersect } from '../viewport/ray'; + +// Defaults for a freshly placed light. A light at ground level is useless, so +// new ones start at head height. +export interface LightDefaults { + height: number; + color: Vec3Lit; + intensity: number; + range: number; +} + +export const LIGHT_DEFAULTS: LightDefaults = { + height: 3, + color: [1.0, 0.9, 0.75], + intensity: 1.0, + range: 12, +}; + +export class AddLightCommand implements Command { + readonly label = 'Add light'; + private light: LightData; + + constructor(light: LightData) { this.light = light; } + + do(state: EditorState): void { + state.world.lights.push(this.light); + selectLight(state, this.light.id); + } + + undo(state: EditorState): void { + const idx = state.world.lights.findIndex(l => l.id === this.light.id); + if (idx >= 0) state.world.lights.splice(idx, 1); + if (state.selection.kind === 'light' && state.selection.primary === this.light.id) { + state.selection.primary = null; + state.selection.kind = 'entity'; + } + } +} + +export class EditLightCommand implements Command { + readonly label: string; + readonly lightId: string; + private before: LightData; + private after: LightData; + + constructor(lightId: string, before: LightData, after: LightData) { + this.lightId = lightId; + this.before = cloneLight(before); + this.after = cloneLight(after); + this.label = 'Edit light ' + lightId; + } + + do(state: EditorState): void { this.apply(state, this.after); } + undo(state: EditorState): void { this.apply(state, this.before); } + + private apply(state: EditorState, value: LightData): void { + for (let i = 0; i < state.world.lights.length; i++) { + if (state.world.lights[i].id !== this.lightId) continue; + state.world.lights[i] = cloneLight(value); + return; + } + } + + // Coalesce a drag on the same light into one undo entry. + mergeWith(next: Command): boolean { + if (!(next instanceof EditLightCommand)) return false; + const n = next as EditLightCommand; + if (n.lightId !== this.lightId) return false; + this.after = cloneLight(n.after); + return true; + } +} + +export class RemoveLightCommand implements Command { + readonly label: string; + private light: LightData; + private index: number; + + constructor(light: LightData, index: number) { + this.light = cloneLight(light); + this.index = index; + this.label = 'Remove light ' + light.id; + } + + do(state: EditorState): void { + const idx = state.world.lights.findIndex(l => l.id === this.light.id); + if (idx >= 0) state.world.lights.splice(idx, 1); + if (state.selection.kind === 'light' && state.selection.primary === this.light.id) { + state.selection.primary = null; + state.selection.kind = 'entity'; + } + } + + undo(state: EditorState): void { + // Restore at the original index so the file's ordering round-trips. + state.world.lights.splice(this.index, 0, cloneLight(this.light)); + } +} + +export function cloneLight(l: LightData): LightData { + return { + id: l.id, + name: l.name, + kind: l.kind, + position: [l.position[0], l.position[1], l.position[2]], + color: [l.color[0], l.color[1], l.color[2]], + intensity: l.intensity, + range: l.range, + }; +} + +export function updateLightTool(state: EditorState): void { + if (state.activeTool !== 'light') return; + + const mx = getMouseX(); + const my = getMouseY(); + const inViewport = mx > state.viewportLeft && mx < state.viewportRight && + my > state.viewportTop && my < state.viewportBottom; + if (!inViewport) return; + if (!isMouseButtonPressed(MouseButton.LEFT)) return; + + const vw = state.viewportRight - state.viewportLeft; + const vh = state.viewportBottom - state.viewportTop; + const ray = mouseToWorldRay(state.camera, mx, my, getScreenWidth(), getScreenHeight(), state.viewportLeft, state.viewportTop, vw, vh); + const ground = rayPlaneIntersect(ray, [0, 0, 0], [0, 1, 0]); + if (!ground) return; + + const d = LIGHT_DEFAULTS; + const id = nextLightId(state); + const light: LightData = { + id: id, + name: id, + kind: 'point', + position: [ground[0], ground[1] + d.height, ground[2]], + color: [d.color[0], d.color[1], d.color[2]], + intensity: d.intensity, + range: d.range, + }; + + runCommand(state, new AddLightCommand(light)); + state.modified = true; +} + +function nextLightId(state: EditorState): string { + let id = nextCounterId(state, 'nextLightId', 'light_'); + while (state.world.lights.some(l => l.id === id)) { + id = nextCounterId(state, 'nextLightId', 'light_'); + } + return id; +} + +// A light has no geometry, so draw a marker: a small sphere at the light, and a +// wire sphere at its range when selected, so "how far does this reach" is +// answerable without doing arithmetic on the inspector numbers. +export function drawLightMarkers(state: EditorState): void { + for (let i = 0; i < state.world.lights.length; i++) { + const l = state.world.lights[i]; + const selected = state.selection.kind === 'light' && state.selection.primary === l.id; + const pos = { x: l.position[0], y: l.position[1], z: l.position[2] }; + const tint = { + r: Math.floor(l.color[0] * 255), + g: Math.floor(l.color[1] * 255), + b: Math.floor(l.color[2] * 255), + a: 255, + }; + + drawSphereWires(pos, 0.25, tint); + if (selected) { + drawSphereWires(pos, l.range, { r: tint.r, g: tint.g, b: tint.b, a: 90 }); + } + } +} diff --git a/src/tools/prefab-tool.ts b/src/tools/prefab-tool.ts index a5c9c2f..1550fd2 100644 --- a/src/tools/prefab-tool.ts +++ b/src/tools/prefab-tool.ts @@ -92,7 +92,7 @@ export function drawPrefabBreadcrumb(state: EditorState, screenW: number): void export function updatePrefabTool(state: EditorState): void { if (!state.editingPrefab) return; - if (isKeyPressed(Key.Escape)) { + if (isKeyPressed(Key.ESCAPE)) { exitPrefabMode(state); } } diff --git a/src/tools/river-tool.ts b/src/tools/river-tool.ts index bb9aad0..f934376 100644 --- a/src/tools/river-tool.ts +++ b/src/tools/river-tool.ts @@ -1,10 +1,11 @@ // River tool — click to add control points along the ground, double-click or -// ESC to finish. Creates a RiverSpline entry. Rendered as a debug line strip -// until the Q9 spline ribbon mesh is wired up for visual rendering. +// ESC to finish. Creates a RiverSpline entry, which the sync layer turns into a +// ribbon mesh with the animated water material (the engine's shared spawnRiver, +// also used by games at load time). Only the in-progress spline is drawn here. import { isMouseButtonPressed, isKeyPressed, getMouseX, getMouseY, getScreenWidth, getScreenHeight, MouseButton, Key, drawRay } from 'bloom'; -import { RiverSpline, Vec3Lit } from 'bloom/world'; -import { EditorState, Command } from '../state/editor-state'; +import { RiverSpline, Vec3Lit, Vec4Lit } from 'bloom/world'; +import { EditorState, Command, nextCounterId } from '../state/editor-state'; import { runCommand } from '../state/commands'; import { mouseToWorldRay, rayPlaneIntersect } from '../viewport/ray'; @@ -14,13 +15,32 @@ class AddRiverCommand implements Command { constructor(river: RiverSpline) { this.river = river; } - do(state: EditorState): void { state.world.rivers.push(this.river); } + do(state: EditorState): void { + state.world.rivers.push(this.river); + state.pendingWaterRebuild = true; + } undo(state: EditorState): void { const idx = state.world.rivers.findIndex(r => r.id === this.river.id); if (idx >= 0) state.world.rivers.splice(idx, 1); + state.pendingWaterRebuild = true; } } +// Defaults for a newly drawn river; editable in the water/river panel. +export interface RiverDefaults { + width: number; + depth: number; + flowSpeed: number; + color: Vec4Lit; +} + +export const RIVER_DEFAULTS: RiverDefaults = { + width: 2.0, + depth: 1.0, + flowSpeed: 1.0, + color: [0.2, 0.4, 0.7, 0.7], +}; + interface RiverToolState { placing: boolean; points: Vec3Lit[]; @@ -28,7 +48,16 @@ interface RiverToolState { } const toolState: RiverToolState = { placing: false, points: [], lastClickTime: 0 }; -let riverIdCounter = 1; + +// Ids come from a world-metadata counter (survives restarts) with a +// collision guard against hand-authored ids. +function nextRiverId(state: EditorState): string { + let id = nextCounterId(state, 'nextRiverId', 'river_'); + while (state.world.rivers.some(r => r.id === id)) { + id = nextCounterId(state, 'nextRiverId', 'river_'); + } + return id; +} export function updateRiverTool(state: EditorState): void { if (state.activeTool !== 'river') { @@ -47,14 +76,14 @@ export function updateRiverTool(state: EditorState): void { if (!inViewport) return; // ESC finishes the river. - if (isKeyPressed(Key.Escape) && toolState.placing && toolState.points.length >= 2) { + if (isKeyPressed(Key.ESCAPE) && toolState.placing && toolState.points.length >= 2) { finishRiver(state); toolState.placing = false; toolState.points = []; return; } - if (isMouseButtonPressed(MouseButton.Left)) { + if (isMouseButtonPressed(MouseButton.LEFT)) { const vw = state.viewportRight - state.viewportLeft; const vh = state.viewportBottom - state.viewportTop; const ray = mouseToWorldRay(state.camera, mx, my, getScreenWidth(), getScreenHeight(), state.viewportLeft, state.viewportTop, vw, vh); @@ -77,42 +106,31 @@ export function updateRiverTool(state: EditorState): void { } function finishRiver(state: EditorState): void { + const d = RIVER_DEFAULTS; const river: RiverSpline = { - id: 'river_' + riverIdCounter++, + id: nextRiverId(state), controlPoints: toolState.points.slice(), - widths: toolState.points.map(() => 2.0), - depth: 1.0, - flowSpeed: 1.0, - color: [0.2, 0.4, 0.7, 0.7], + widths: toolState.points.map(() => d.width), + depth: d.depth, + flowSpeed: d.flowSpeed, + color: [d.color[0], d.color[1], d.color[2], d.color[3]], }; runCommand(state, new AddRiverCommand(river)); state.modified = true; } +// Finished rivers are ribbon meshes in the scene graph (see sync.ts). This only +// draws the spline being laid down, plus a handle at each committed point. export function drawRiverSplines(state: EditorState): void { - for (let i = 0; i < state.world.rivers.length; i++) { - const r = state.world.rivers[i]; - const pts = r.controlPoints; - const c = { r: r.color[0] * 255, g: r.color[1] * 255, b: r.color[2] * 255, a: r.color[3] * 255 }; - for (let j = 0; j < pts.length - 1; j++) { - drawRay( - { x: pts[j][0], y: pts[j][1] + 0.1, z: pts[j][2] }, - { x: pts[j + 1][0] - pts[j][0], y: pts[j + 1][1] - pts[j][1], z: pts[j + 1][2] - pts[j][2] }, - c, - ); - } - } + if (!toolState.placing || toolState.points.length === 0) return; - // Draw in-progress spline. - if (toolState.placing && toolState.points.length >= 2) { - for (let j = 0; j < toolState.points.length - 1; j++) { - const a = toolState.points[j]; - const b = toolState.points[j + 1]; - drawRay( - { x: a[0], y: a[1] + 0.1, z: a[2] }, - { x: b[0] - a[0], y: b[1] - a[1], z: b[2] - a[2] }, - { r: 100, g: 200, b: 255, a: 255 }, - ); - } + for (let j = 0; j < toolState.points.length - 1; j++) { + const a = toolState.points[j]; + const b = toolState.points[j + 1]; + drawRay( + { x: a[0], y: a[1] + 0.1, z: a[2] }, + { x: b[0] - a[0], y: b[1] - a[1], z: b[2] - a[2] }, + { r: 100, g: 200, b: 255, a: 255 }, + ); } } diff --git a/src/tools/select-tool.ts b/src/tools/select-tool.ts index 69e8790..ad46066 100644 --- a/src/tools/select-tool.ts +++ b/src/tools/select-tool.ts @@ -2,7 +2,7 @@ // Supports Shift-click for multi-select. import { getMouseX, getMouseY, isMouseButtonPressed, isKeyDown, MouseButton, Key } from 'bloom'; -import { EditorState } from '../state/editor-state'; +import { EditorState, selectEntity } from '../state/editor-state'; import { pickEntityAtMouse, syncSelectionOutline } from '../viewport/picking'; export function handleSelectClick(state: EditorState): void { @@ -11,7 +11,7 @@ export function handleSelectClick(state: EditorState): void { const mx = getMouseX(); const my = getMouseY(); const pickedId = pickEntityAtMouse(state, mx, my); - const shift = isKeyDown(Key.LeftShift) || isKeyDown(Key.RightShift); + const shift = isKeyDown(Key.LEFT_SHIFT) || isKeyDown(Key.RIGHT_SHIFT); if (pickedId !== null) { if (shift) { @@ -21,21 +21,21 @@ export function handleSelectClick(state: EditorState): void { if (state.selection.primary === pickedId) { // Promote another selected entity or clear. const remaining = Array.from(state.selection.ids); - state.selection.primary = remaining.length > 0 ? remaining[0] : null; + selectEntity(state, remaining.length > 0 ? remaining[0] : null); } } else { state.selection.ids.add(pickedId); - state.selection.primary = pickedId; + selectEntity(state, pickedId); } } else { state.selection.ids.clear(); state.selection.ids.add(pickedId); - state.selection.primary = pickedId; + selectEntity(state, pickedId); } } else { if (!shift) { state.selection.ids.clear(); - state.selection.primary = null; + selectEntity(state, null); } } syncSelectionOutline(state); diff --git a/src/tools/water-tool.ts b/src/tools/water-tool.ts index 85fe3d4..5958497 100644 --- a/src/tools/water-tool.ts +++ b/src/tools/water-tool.ts @@ -5,7 +5,7 @@ import { isMouseButtonPressed, isMouseButtonDown, isMouseButtonReleased, getMouseX, getMouseY, getScreenWidth, getScreenHeight, MouseButton, drawCube } from 'bloom'; import { WaterVolume, Vec3Lit, Vec4Lit } from 'bloom/world'; -import { EditorState, Command } from '../state/editor-state'; +import { EditorState, Command, nextCounterId } from '../state/editor-state'; import { runCommand } from '../state/commands'; import { mouseToWorldRay, rayPlaneIntersect } from '../viewport/ray'; @@ -15,10 +15,14 @@ class AddWaterCommand implements Command { constructor(volume: WaterVolume) { this.volume = volume; } - do(state: EditorState): void { state.world.water.push(this.volume); } + do(state: EditorState): void { + state.world.water.push(this.volume); + state.pendingWaterRebuild = true; + } undo(state: EditorState): void { const idx = state.world.water.findIndex(w => w.id === this.volume.id); if (idx >= 0) state.world.water.splice(idx, 1); + state.pendingWaterRebuild = true; } } @@ -28,7 +32,35 @@ interface WaterToolState { } const toolState: WaterToolState = { placing: false, startPoint: null }; -let waterIdCounter = 1; + +// Defaults for a newly dragged-out volume. Exported so the water panel can +// edit them before placement — previously these were frozen constants inline, +// so every volume came out the same shade of blue at the same height. +export interface WaterDefaults { + surfaceHeight: number; + depth: number; // Box height below the surface. + color: Vec4Lit; + waveAmplitude: number; + waveSpeed: number; +} + +export const WATER_DEFAULTS: WaterDefaults = { + surfaceHeight: 0.5, + depth: 2, + color: [0.2, 0.5, 0.8, 0.6], + waveAmplitude: 0.1, + waveSpeed: 1.0, +}; + +// Ids come from a world-metadata counter (survives restarts), with a guard +// against collisions with hand-authored ids like arena_02's "river". +function nextWaterId(state: EditorState): string { + let id = nextCounterId(state, 'nextWaterId', 'water_'); + while (state.world.water.some(w => w.id === id)) { + id = nextCounterId(state, 'nextWaterId', 'water_'); + } + return id; +} export function updateWaterTool(state: EditorState): void { if (state.activeTool !== 'water') { @@ -48,27 +80,28 @@ export function updateWaterTool(state: EditorState): void { const ground = rayPlaneIntersect(ray, [0, 0, 0], [0, 1, 0]); if (!ground) return; - if (isMouseButtonPressed(MouseButton.Left)) { + if (isMouseButtonPressed(MouseButton.LEFT)) { toolState.placing = true; toolState.startPoint = [ground[0], ground[1], ground[2]]; } - if (toolState.placing && isMouseButtonReleased(MouseButton.Left) && toolState.startPoint) { + if (toolState.placing && isMouseButtonReleased(MouseButton.LEFT) && toolState.startPoint) { const sp = toolState.startPoint; const cx = (sp[0] + ground[0]) / 2; const cz = (sp[2] + ground[2]) / 2; const sx = Math.abs(ground[0] - sp[0]); const sz = Math.abs(ground[2] - sp[2]); if (sx > 0.5 && sz > 0.5) { + const d = WATER_DEFAULTS; const volume: WaterVolume = { - id: 'water_' + waterIdCounter++, + id: nextWaterId(state), kind: 'box', - center: [cx, 0, cz], - size: [sx, 2, sz], - surfaceHeight: 0.5, - color: [0.2, 0.5, 0.8, 0.6], - waveAmplitude: 0.1, - waveSpeed: 1.0, + center: [cx, d.surfaceHeight - d.depth / 2, cz], + size: [sx, d.depth, sz], + surfaceHeight: d.surfaceHeight, + color: [d.color[0], d.color[1], d.color[2], d.color[3]], + waveAmplitude: d.waveAmplitude, + waveSpeed: d.waveSpeed, }; runCommand(state, new AddWaterCommand(volume)); state.modified = true; @@ -78,14 +111,30 @@ export function updateWaterTool(state: EditorState): void { } } +// Placed volumes are real scene nodes with the animated water material (see +// world-sync/sync.ts → the engine's shared spawnWaterVolume). All this draws is +// the rubber-band preview while the user is dragging one out. export function drawWaterVolumes(state: EditorState): void { - for (let i = 0; i < state.world.water.length; i++) { - const w = state.world.water[i]; - const c = w.color; - drawCube( - { x: w.center[0], y: w.surfaceHeight, z: w.center[2] }, - w.size[0], w.size[1], w.size[2], - { r: c[0] * 255, g: c[1] * 255, b: c[2] * 255, a: c[3] * 255 }, - ); - } + if (!toolState.placing || !toolState.startPoint) return; + + const mx = getMouseX(); + const my = getMouseY(); + const vw = state.viewportRight - state.viewportLeft; + const vh = state.viewportBottom - state.viewportTop; + const ray = mouseToWorldRay(state.camera, mx, my, getScreenWidth(), getScreenHeight(), state.viewportLeft, state.viewportTop, vw, vh); + const ground = rayPlaneIntersect(ray, [0, 0, 0], [0, 1, 0]); + if (!ground) return; + + const sp = toolState.startPoint; + const cx = (sp[0] + ground[0]) / 2; + const cz = (sp[2] + ground[2]) / 2; + const sx = Math.abs(ground[0] - sp[0]); + const sz = Math.abs(ground[2] - sp[2]); + if (sx < 0.01 || sz < 0.01) return; + + drawCube( + { x: cx, y: WATER_DEFAULTS.surfaceHeight, z: cz }, + sx, 0.05, sz, + { r: 90, g: 170, b: 230, a: 140 }, + ); } diff --git a/src/ui/layouts/brush-panel.ts b/src/ui/layouts/brush-panel.ts index 6355d94..ceb700f 100644 --- a/src/ui/layouts/brush-panel.ts +++ b/src/ui/layouts/brush-panel.ts @@ -2,9 +2,11 @@ // Controls: brush kind radio, radius slider, strength slider, flatten target. import { UiContext } from '../ui-context'; -import { beginPanel, endPanel, label, separator, dragFloat, toggleButton, Ref } from '../widgets'; +import { beginPanel, endPanel, labelSmall, separator, dragFloat, toggleButton, button, Ref } from '../widgets'; import { Theme } from '../theme'; import { EditorState, BrushSettings } from '../../state/editor-state'; +import { runCommand } from '../../state/commands'; +import { CreateTerrainCommand } from '../../state/commands/create-terrain'; export function drawBrushPanel(ui: UiContext, state: EditorState): void { if (state.activeTool !== 'brush') return; @@ -14,6 +16,19 @@ export function drawBrushPanel(ui: UiContext, state: EditorState): void { const pw = 220; const ph = 240; + // Terrain-less world: offer explicit creation instead of sculpting into a + // silently materialized heightmap. + if (!state.world.terrain) { + beginPanel(ui, 'brush_panel', px, py, pw, 110, 'Brush Settings'); + labelSmall(ui, 'This world has no terrain.'); + labelSmall(ui, 'Create one to start sculpting:'); + if (button(ui, 'brush_create_terrain', 'Create terrain')) { + runCommand(state, new CreateTerrainCommand()); + } + endPanel(ui); + return; + } + beginPanel(ui, 'brush_panel', px, py, pw, ph, 'Brush Settings'); const brush = state.brush; diff --git a/src/ui/layouts/environment-panel.ts b/src/ui/layouts/environment-panel.ts index 5e16687..43ef0e5 100644 --- a/src/ui/layouts/environment-panel.ts +++ b/src/ui/layouts/environment-panel.ts @@ -2,7 +2,7 @@ // Changes apply live via the pendingEnvironmentSync flag. import { UiContext } from '../ui-context'; -import { beginPanel, endPanel, label, separator, dragFloat, vec3Field, toggleButton, Ref } from '../widgets'; +import { beginPanel, endPanel, separator, dragFloat, vec3Field, toggleButton, Ref } from '../widgets'; import { Theme } from '../theme'; import { EditorState } from '../../state/editor-state'; import { Vec3Lit } from 'bloom/world'; diff --git a/src/ui/layouts/inspector.ts b/src/ui/layouts/inspector.ts index eedbfc0..3b0c396 100644 --- a/src/ui/layouts/inspector.ts +++ b/src/ui/layouts/inspector.ts @@ -1,35 +1,68 @@ -// Right-bottom property inspector: shows transform, name, and tags for the -// selected entity. Transform fields use dragFloat (Blender-style click-drag). +// Right-bottom property inspector: shows transform, name, tags, and the +// userData key/value table for the selected entity. Transform fields use +// dragFloat (Blender-style click-drag); userData values are free-form strings +// edited in place (the editor treats them as opaque — semantics belong to the +// game). All edits go through the undo stack. -import { getScreenWidth, getScreenHeight } from 'bloom'; +import { getScreenWidth, getScreenHeight, drawText } from 'bloom'; import { UiContext } from '../ui-context'; -import { beginPanel, endPanel, label, labelSmall, separator, dragFloat, vec3Field, Ref } from '../widgets'; +import { + beginPanel, endPanel, label, labelSmall, separator, vec3Field, dragFloat, + toolButton, button, Ref, +} from '../widgets'; +import { textInput } from '../text-input'; import { Theme } from '../theme'; -import { EditorState } from '../../state/editor-state'; +import { EditorState, selectedEntityId } from '../../state/editor-state'; import { TransformEntityCommand } from '../../state/commands/transform-entity'; +import { SetUserDataCommand } from '../../state/commands/set-userdata'; +import { + EditWaterCommand, EditRiverCommand, RemoveWaterCommand, RemoveRiverCommand, +} from '../../state/commands/edit-water'; +import { EditLightCommand, RemoveLightCommand, cloneLight } from '../../tools/light-tool'; import { runCommand } from '../../state/commands'; -import { Vec3Lit, TransformData } from 'bloom/world'; +import { Vec3Lit, TransformData, EntityData, WaterVolume, RiverSpline } from 'bloom/world'; + +// Add-row scratch state — survives across frames until '+' commits it. +const newKeyRef: Ref = { value: '' }; +const newValRef: Ref = { value: '' }; export function drawInspector(ui: UiContext, state: EditorState): void { const screenW = getScreenWidth(); const screenH = getScreenHeight(); const pw = Theme.assetPanelWidth; const px = screenW - pw; - // Inspector fills the bottom half of the asset panel column. - const panelH = 260; - const py = screenH - Theme.statusBarHeight - panelH; - beginPanel(ui, 'inspector', px, py, pw, panelH, 'Inspector'); - - if (state.selection.primary === null) { - labelSmall(ui, 'No selection'); - endPanel(ui); + // Water and rivers get their own property panels — they are not entities and + // have no transform, model, tags, or userData. + if (state.selection.kind === 'water' && state.selection.primary !== null) { + drawWaterInspector(ui, state, px, screenH, pw); + return; + } + if (state.selection.kind === 'river' && state.selection.primary !== null) { + drawRiverInspector(ui, state, px, screenH, pw); + return; + } + if (state.selection.kind === 'light' && state.selection.primary !== null) { + drawLightInspector(ui, state, px, screenH, pw); return; } - const entity = state.world.entities.find(e => e.id === state.selection.primary); + const entity = selectedEntityId(state) !== null + ? state.world.entities.find(e => e.id === selectedEntityId(state)) + : undefined; + + // Panel grows upward with the userData row count (no scrolling yet). + const rowAdvance = Theme.rowHeight + Theme.spacing; + const udRows = entity ? Object.keys(entity.userData).length : 0; + let panelH = 300 + udRows * rowAdvance; + const maxH = Math.floor(screenH * 0.65); + if (panelH > maxH) panelH = maxH; + const py = screenH - Theme.statusBarHeight - panelH; + + beginPanel(ui, 'inspector', px, py, pw, panelH, 'Inspector'); + if (!entity) { - labelSmall(ui, 'Entity not found'); + labelSmall(ui, state.selection.primary === null ? 'No selection' : 'Entity not found'); endPanel(ui); return; } @@ -68,9 +101,59 @@ export function drawInspector(ui: UiContext, state: EditorState): void { labelSmall(ui, 'Tags: ' + entity.tags.join(', ')); } + separator(ui); + drawUserDataSection(ui, state, entity); + endPanel(ui); } +// ---- userData table ---------------------------------------------------------- + +function drawUserDataSection(ui: UiContext, state: EditorState, entity: EntityData): void { + labelSmall(ui, 'userData'); + + const keyColW = 86; + const delW = 18; + const innerW = ui.panelW - Theme.padding * 2; + const fieldX = ui.cursorX + keyColW; + const fieldW = innerW - keyColW - delW - 6; + + const keys = Object.keys(entity.userData); + for (let k = 0; k < keys.length; k++) { + const key = keys[k]; + const rowY = ui.cursorY; + const oldValue = entity.userData[key]; + + drawText(key, ui.cursorX, rowY + 4, Theme.fontSizeSmall, Theme.textDim); + + const valRef: Ref = { value: oldValue }; + if (textInput(ui, 'ud_' + entity.id + '_' + key, valRef, fieldX, rowY, fieldW)) { + runCommand(state, new SetUserDataCommand(entity.id, key, oldValue, valRef.value)); + } + + if (toolButton(ui, 'ud_del_' + entity.id + '_' + key, 'x', fieldX + fieldW + 4, rowY, delW, false)) { + runCommand(state, new SetUserDataCommand(entity.id, key, oldValue, null)); + } + + ui.cursorY = rowY + Theme.rowHeight + Theme.spacing; + } + + // Add-row: key field, value field, '+' commits (key must be new and non-empty). + const addY = ui.cursorY; + const halfW = Math.floor((innerW - delW - 8) / 2); + textInput(ui, 'ud_newkey_' + entity.id, newKeyRef, ui.cursorX, addY, halfW); + textInput(ui, 'ud_newval_' + entity.id, newValRef, ui.cursorX + halfW + 4, addY, halfW); + if (toolButton(ui, 'ud_add_' + entity.id, '+', ui.cursorX + halfW * 2 + 8, addY, delW, false)) { + const nk = newKeyRef.value.trim(); + if (nk.length > 0 && entity.userData[nk] === undefined) { + runCommand(state, new SetUserDataCommand(entity.id, nk, null, newValRef.value)); + newKeyRef.value = ''; + newValRef.value = ''; + } + } + ui.cursorY = addY + Theme.rowHeight + Theme.spacing; +} + function cloneTransform(t: TransformData): TransformData { return { position: [t.position[0], t.position[1], t.position[2]], @@ -78,3 +161,247 @@ function cloneTransform(t: TransformData): TransformData { scale: [t.scale[0], t.scale[1], t.scale[2]], }; } + +// ---- water inspector --------------------------------------------------------- + +function cloneWater(w: WaterVolume): WaterVolume { + return { + id: w.id, kind: w.kind, + center: [w.center[0], w.center[1], w.center[2]], + size: [w.size[0], w.size[1], w.size[2]], + surfaceHeight: w.surfaceHeight, + color: [w.color[0], w.color[1], w.color[2], w.color[3]], + waveAmplitude: w.waveAmplitude, + waveSpeed: w.waveSpeed, + }; +} + +function drawWaterInspector( + ui: UiContext, state: EditorState, + px: number, screenH: number, pw: number, +): void { + const panelH = 330; + const py = screenH - Theme.statusBarHeight - panelH; + beginPanel(ui, 'inspector', px, py, pw, panelH, 'Water'); + + const idx = state.world.water.findIndex(w => w.id === state.selection.primary); + if (idx < 0) { + labelSmall(ui, 'Water volume not found'); + endPanel(ui); + return; + } + + const w = state.world.water[idx]; + const before = cloneWater(w); + let changed = false; + + label(ui, w.id); + separator(ui); + + const centerRef: Ref = { value: [w.center[0], w.center[1], w.center[2]] }; + if (vec3Field(ui, 'wat_center', 'Center', centerRef)) { + w.center = centerRef.value; + changed = true; + } + + const sizeRef: Ref = { value: [w.size[0], w.size[1], w.size[2]] }; + if (vec3Field(ui, 'wat_size', 'Size', sizeRef)) { + w.size = sizeRef.value; + changed = true; + } + + const surfRef: Ref = { value: w.surfaceHeight }; + if (dragFloat(ui, 'wat_surf', 'Surface Y', surfRef, 0.05, -100, 100)) { + w.surfaceHeight = surfRef.value; + changed = true; + } + + const ampRef: Ref = { value: w.waveAmplitude }; + if (dragFloat(ui, 'wat_amp', 'Wave Amp', ampRef, 0.01, 0, 2)) { + w.waveAmplitude = ampRef.value; + changed = true; + } + + const spdRef: Ref = { value: w.waveSpeed }; + if (dragFloat(ui, 'wat_spd', 'Wave Spd', spdRef, 0.01, 0, 10)) { + w.waveSpeed = spdRef.value; + changed = true; + } + + separator(ui); + changed = drawColorFields(ui, 'wat', w.color) || changed; + + if (changed) { + runCommand(state, new EditWaterCommand(w.id, before, w)); + } + + separator(ui); + if (button(ui, 'wat_delete', 'Delete volume')) { + runCommand(state, new RemoveWaterCommand(state.world.water[idx], idx)); + } + + endPanel(ui); +} + +// ---- river inspector --------------------------------------------------------- + +function cloneRiver(r: RiverSpline): RiverSpline { + const pts: [number, number, number][] = []; + for (let i = 0; i < r.controlPoints.length; i++) { + const p = r.controlPoints[i]; + pts.push([p[0], p[1], p[2]]); + } + return { + id: r.id, + controlPoints: pts, + widths: r.widths.slice(), + depth: r.depth, + flowSpeed: r.flowSpeed, + color: [r.color[0], r.color[1], r.color[2], r.color[3]], + }; +} + +function drawRiverInspector( + ui: UiContext, state: EditorState, + px: number, screenH: number, pw: number, +): void { + const panelH = 300; + const py = screenH - Theme.statusBarHeight - panelH; + beginPanel(ui, 'inspector', px, py, pw, panelH, 'River'); + + const idx = state.world.rivers.findIndex(r => r.id === state.selection.primary); + if (idx < 0) { + labelSmall(ui, 'River not found'); + endPanel(ui); + return; + } + + const r = state.world.rivers[idx]; + const before = cloneRiver(r); + let changed = false; + + label(ui, r.id); + labelSmall(ui, r.controlPoints.length + ' control points'); + separator(ui); + + const depthRef: Ref = { value: r.depth }; + if (dragFloat(ui, 'riv_depth', 'Depth', depthRef, 0.05, 0, 20)) { + r.depth = depthRef.value; + changed = true; + } + + const flowRef: Ref = { value: r.flowSpeed }; + if (dragFloat(ui, 'riv_flow', 'Flow Spd', flowRef, 0.01, 0, 10)) { + r.flowSpeed = flowRef.value; + changed = true; + } + + // One width for the whole river: per-point widths are in the format, but a + // single slider covers the common case without a per-point UI. Editing it + // sets every point's width; the file keeps the array. + const widthRef: Ref = { value: r.widths.length > 0 ? r.widths[0] : 1 }; + if (dragFloat(ui, 'riv_width', 'Width', widthRef, 0.05, 0.1, 50)) { + for (let i = 0; i < r.widths.length; i++) r.widths[i] = widthRef.value; + changed = true; + } + + separator(ui); + changed = drawColorFields(ui, 'riv', r.color) || changed; + + if (changed) { + runCommand(state, new EditRiverCommand(r.id, before, r)); + } + + separator(ui); + if (button(ui, 'riv_delete', 'Delete river')) { + runCommand(state, new RemoveRiverCommand(state.world.rivers[idx], idx)); + } + + endPanel(ui); +} + +// ---- light inspector --------------------------------------------------------- + +function drawLightInspector( + ui: UiContext, state: EditorState, + px: number, screenH: number, pw: number, +): void { + const panelH = 300; + const py = screenH - Theme.statusBarHeight - panelH; + beginPanel(ui, 'inspector', px, py, pw, panelH, 'Light'); + + const idx = state.world.lights.findIndex(l => l.id === state.selection.primary); + if (idx < 0) { + labelSmall(ui, 'Light not found'); + endPanel(ui); + return; + } + + const l = state.world.lights[idx]; + const before = cloneLight(l); + let changed = false; + + label(ui, l.name); + labelSmall(ui, l.id); + separator(ui); + + const posRef: Ref = { value: [l.position[0], l.position[1], l.position[2]] }; + if (vec3Field(ui, 'lit_pos', 'Position', posRef)) { + l.position = posRef.value; + changed = true; + } + + const colorRef: Ref = { value: [l.color[0], l.color[1], l.color[2]] }; + if (vec3Field(ui, 'lit_col', 'Color RGB', colorRef)) { + l.color = [clamp01(colorRef.value[0]), clamp01(colorRef.value[1]), clamp01(colorRef.value[2])]; + changed = true; + } + + const intRef: Ref = { value: l.intensity }; + if (dragFloat(ui, 'lit_int', 'Intensity', intRef, 0.02, 0, 20)) { + l.intensity = intRef.value; + changed = true; + } + + const rangeRef: Ref = { value: l.range }; + if (dragFloat(ui, 'lit_range', 'Range', rangeRef, 0.1, 0.1, 200)) { + l.range = rangeRef.value; + changed = true; + } + + if (changed) { + runCommand(state, new EditLightCommand(l.id, before, l)); + } + + separator(ui); + if (button(ui, 'lit_delete', 'Delete light')) { + runCommand(state, new RemoveLightCommand(state.world.lights[idx], idx)); + } + + endPanel(ui); +} + +// RGBA in 0-1, edited in place. Shared by both panels. +function drawColorFields(ui: UiContext, idPrefix: string, color: number[]): boolean { + let changed = false; + + const rgbRef: Ref = { value: [color[0], color[1], color[2]] }; + if (vec3Field(ui, idPrefix + '_rgb', 'Color RGB', rgbRef)) { + color[0] = clamp01(rgbRef.value[0]); + color[1] = clamp01(rgbRef.value[1]); + color[2] = clamp01(rgbRef.value[2]); + changed = true; + } + + const alphaRef: Ref = { value: color[3] }; + if (dragFloat(ui, idPrefix + '_a', 'Opacity', alphaRef, 0.01, 0, 1)) { + color[3] = clamp01(alphaRef.value); + changed = true; + } + + return changed; +} + +function clamp01(v: number): number { + return v < 0 ? 0 : (v > 1 ? 1 : v); +} diff --git a/src/ui/layouts/outliner.ts b/src/ui/layouts/outliner.ts index d678ee1..ce0abc1 100644 --- a/src/ui/layouts/outliner.ts +++ b/src/ui/layouts/outliner.ts @@ -1,10 +1,14 @@ -// Left-side outliner: flat list of all entities in the world. Clicking selects. +// Left-side outliner: everything in the world, in three sections — entities, +// water volumes, rivers. Clicking selects; the selection carries which section +// it came from, because ids are only unique within their own array. import { getScreenHeight } from 'bloom'; import { UiContext } from '../ui-context'; import { beginPanel, endPanel, labelSmall, listRow } from '../widgets'; import { Theme } from '../theme'; -import { EditorState } from '../../state/editor-state'; +import { + EditorState, selectEntity, selectWater, selectRiver, selectLight, +} from '../../state/editor-state'; import { syncSelectionOutline } from '../../viewport/picking'; export function drawOutliner(ui: UiContext, state: EditorState): void { @@ -16,22 +20,69 @@ export function drawOutliner(ui: UiContext, state: EditorState): void { beginPanel(ui, 'outliner', 0, py, pw, ph, 'Outliner'); const entities = state.world.entities; - if (entities.length === 0) { - labelSmall(ui, 'No entities'); + const water = state.world.water; + const rivers = state.world.rivers; + const lights = state.world.lights; + + if (entities.length === 0 && water.length === 0 && rivers.length === 0 && lights.length === 0) { + labelSmall(ui, 'Empty world'); endPanel(ui); state.viewportLeft = pw; return; } + // Water and rivers first: there are only ever a handful, while a world can + // have hundreds of entities, and this panel does not scroll yet. Listing them + // last would put them permanently below the fold and make them unselectable. + if (water.length > 0) { + labelSmall(ui, 'Water'); + for (let i = 0; i < water.length; i++) { + const w = water[i]; + const selected = state.selection.kind === 'water' && state.selection.primary === w.id; + if (listRow(ui, 'out_water_' + i, w.id, selected, 1)) { + selectWater(state, w.id); + syncSelectionOutline(state); + } + } + } + + if (rivers.length > 0) { + labelSmall(ui, 'Rivers'); + for (let i = 0; i < rivers.length; i++) { + const r = rivers[i]; + const selected = state.selection.kind === 'river' && state.selection.primary === r.id; + if (listRow(ui, 'out_river_' + i, r.id, selected, 1)) { + selectRiver(state, r.id); + syncSelectionOutline(state); + } + } + } + + if (lights.length > 0) { + labelSmall(ui, 'Lights'); + for (let i = 0; i < lights.length; i++) { + const l = lights[i]; + const selected = state.selection.kind === 'light' && state.selection.primary === l.id; + if (listRow(ui, 'out_light_' + i, l.name, selected, 1)) { + selectLight(state, l.id); + syncSelectionOutline(state); + } + } + } + + if (water.length > 0 || rivers.length > 0 || lights.length > 0) { + labelSmall(ui, 'Entities'); + } + for (let i = 0; i < entities.length; i++) { const entity = entities[i]; - const selected = state.selection.primary === entity.id; + const selected = state.selection.kind === 'entity' && state.selection.primary === entity.id; const indent = entity.prefabRef !== null ? 1 : 0; - if (listRow(ui, 'out_' + i, entity.name, selected, indent)) { + if (listRow(ui, 'out_ent_' + i, entity.name, selected, indent)) { state.selection.ids.clear(); state.selection.ids.add(entity.id); - state.selection.primary = entity.id; + selectEntity(state, entity.id); syncSelectionOutline(state); } } diff --git a/src/ui/layouts/toolbar.ts b/src/ui/layouts/toolbar.ts index 335e930..b03afd4 100644 --- a/src/ui/layouts/toolbar.ts +++ b/src/ui/layouts/toolbar.ts @@ -42,12 +42,15 @@ export function drawToolbar(ui: UiContext, state: EditorState): void { drawRect(x, y + 2, 1, Theme.buttonHeight - 4, Theme.border); x += 8; - // Tool buttons. + // Tool buttons. Water/river were reachable only by hotkey (T/Y) before. const tools: [ToolId, string][] = [ ['select', 'Sel'], ['place', 'Place'], ['transform', 'Move'], ['brush', 'Brush'], + ['water', 'Water'], + ['river', 'River'], + ['light', 'Light'], ]; for (let i = 0; i < tools.length; i++) { const [tid, lbl] = tools[i]; diff --git a/src/ui/text-input.ts b/src/ui/text-input.ts index a12d7ee..3a54e1f 100644 --- a/src/ui/text-input.ts +++ b/src/ui/text-input.ts @@ -51,7 +51,7 @@ export function textInput( } // ESC cancels focus. - if (isKeyPressed(Key.Escape)) { + if (isKeyPressed(Key.ESCAPE)) { ui.activeId = null; } } diff --git a/src/ui/ui-context.ts b/src/ui/ui-context.ts index ea0ebb9..4bdefd0 100644 --- a/src/ui/ui-context.ts +++ b/src/ui/ui-context.ts @@ -67,10 +67,10 @@ export function createUiContext(): UiContext { export function uiBeginFrame(ui: UiContext): void { ui.mouseX = getMouseX(); ui.mouseY = getMouseY(); - ui.mouseDownLeft = isMouseButtonDown(MouseButton.Left); - ui.mousePressedLeft = isMouseButtonPressed(MouseButton.Left); - ui.mouseReleasedLeft = isMouseButtonReleased(MouseButton.Left); - ui.mouseDownRight = isMouseButtonDown(MouseButton.Right); + ui.mouseDownLeft = isMouseButtonDown(MouseButton.LEFT); + ui.mousePressedLeft = isMouseButtonPressed(MouseButton.LEFT); + ui.mouseReleasedLeft = isMouseButtonReleased(MouseButton.LEFT); + ui.mouseDownRight = isMouseButtonDown(MouseButton.RIGHT); ui.mouseDeltaX = getMouseDeltaX(); ui.mouseDeltaY = getMouseDeltaY(); ui.mouseWheel = getMouseWheel(); diff --git a/src/ui/widgets.ts b/src/ui/widgets.ts index e9e1425..4e766e5 100644 --- a/src/ui/widgets.ts +++ b/src/ui/widgets.ts @@ -66,10 +66,13 @@ export function labelSmall(ui: UiContext, text: string, color?: UiColor): void { export function separator(ui: UiContext): void { const y = ui.cursorY + Theme.spacing; + // drawLine takes (x1, y1, x2, y2, thickness, Color) — pass the Color object, + // not its four channels. Splatting the channels made the engine read `.r` + // off a number, yielding undefined and a native-ABI TypeError. drawLine( ui.panelX + Theme.padding, y, ui.panelX + ui.panelW - Theme.padding, y, - 1, Theme.border.r, Theme.border.g, Theme.border.b, Theme.border.a, + 1, Theme.border, ); ui.cursorY = y + Theme.spacing * 2; } @@ -269,8 +272,8 @@ export function vec3Field( ui: UiContext, id: string, labelText: string, ref: Ref, ): boolean { - drawText(labelText, ui.cursorX, ui.cursorY + 4, Theme.fontSizeSmall, Theme.textDim); - ui.cursorY += Theme.fontSizeSmall + 2; + drawText(labelText, ui.cursorX, ui.cursorY, Theme.fontSizeSmall, Theme.textDim); + ui.cursorY += Theme.fontSizeSmall + Theme.spacing; const fw = (ui.panelW - Theme.padding * 2 - Theme.spacing * 2) / 3; const baseX = ui.cursorX; diff --git a/src/viewport/frame.ts b/src/viewport/frame.ts index 9b4374c..dd25e1b 100644 --- a/src/viewport/frame.ts +++ b/src/viewport/frame.ts @@ -2,7 +2,7 @@ // selected entity fills the viewport. Triggered by pressing F with an entity // selected, or automatically after opening a world. -import { EditorState, handleOfEntity } from '../state/editor-state'; +import { EditorState, handleOfEntity, selectedEntityId } from '../state/editor-state'; import { Vec3Lit } from 'bloom/world'; // Frame the camera on a world-space bounding box. Sets the orbit target to @@ -34,9 +34,9 @@ export function frameCameraOnBounds( // Frame the camera on the currently selected entity's model bounds. export function frameCameraOnSelection(state: EditorState): void { - if (state.selection.primary === null) return; + if (selectedEntityId(state) === null) return; - const entity = state.world.entities.find(e => e.id === state.selection.primary); + const entity = state.world.entities.find(e => e.id === selectedEntityId(state)); if (!entity) return; // Look up model bounds from the catalog. diff --git a/src/viewport/orbit-camera.ts b/src/viewport/orbit-camera.ts index afe78c7..12f2bfc 100644 --- a/src/viewport/orbit-camera.ts +++ b/src/viewport/orbit-camera.ts @@ -24,7 +24,7 @@ export function updateOrbitCamera(state: EditorState): void { const wheel = getMouseWheel(); // Rotate (right-mouse drag). - if (isMouseButtonDown(MouseButton.Right)) { + if (isMouseButtonDown(MouseButton.RIGHT)) { cam.yaw -= dx * ROTATE_SPEED; cam.pitch -= dy * ROTATE_SPEED; if (cam.pitch < MIN_PITCH) cam.pitch = MIN_PITCH; @@ -33,7 +33,7 @@ export function updateOrbitCamera(state: EditorState): void { } // Pan (middle-mouse drag). Move the target in the camera's local XY plane. - if (isMouseButtonDown(MouseButton.Middle)) { + if (isMouseButtonDown(MouseButton.MIDDLE)) { const speed = cam.distance * PAN_SPEED; const cosYaw = Math.cos(cam.yaw); const sinYaw = Math.sin(cam.yaw); diff --git a/src/viewport/picking.ts b/src/viewport/picking.ts index d049a51..47a6e37 100644 --- a/src/viewport/picking.ts +++ b/src/viewport/picking.ts @@ -6,7 +6,7 @@ import { pickScene, setPostFxSelected, setPostFxHovered, enablePostFx, } from 'bloom/scene'; import { - EditorState, entityOfHandle, handleOfEntity, + EditorState, entityOfHandle, handleOfEntity, selectedEntityId, } from '../state/editor-state'; // Call once at startup to enable the outline post-FX pipeline. @@ -41,8 +41,9 @@ export function updateHover( // Update the outline to match the current selection. export function syncSelectionOutline(state: EditorState): void { - if (state.selection.primary !== null) { - const h = handleOfEntity(state.handles, state.selection.primary); + const entityId = selectedEntityId(state); + if (entityId !== null) { + const h = handleOfEntity(state.handles, entityId); setPostFxSelected(h); } else { setPostFxSelected(0); diff --git a/src/world-sync/sync.ts b/src/world-sync/sync.ts index ddceeb1..7fb174d 100644 --- a/src/world-sync/sync.ts +++ b/src/world-sync/sync.ts @@ -5,8 +5,9 @@ // ids into pendingRebuild. Each frame, this module: // 1. Processes pendingRebuild: creates or updates scene nodes. // 2. Processes pendingDestroy: destroys scene nodes and unbinds handles. -// 3. If pendingTerrainRebuild: re-uploads the heightmap mesh. -// 4. If pendingEnvironmentSync: re-applies lighting/shadows. +// 3. If pendingTerrainRebuild: re-uploads (or removes) the heightmap mesh. +// 4. Re-applies ambient/sun/fog every frame (the renderer's begin_frame +// resets the lighting block); shadow toggles stay behind the dirty flag. // // This keeps the scene graph always consistent with the data model, while // letting tools batch their mutations without worrying about GPU side-effects. @@ -17,13 +18,16 @@ import { setSceneNodeColor, setSceneNodeVisible, setSceneNodeParent, updateSceneNodeGeometry, enableShadows, disableShadows, - addDirectionalLight, - setAmbientLight, + setAmbientLight, setDirectionalLight, + genMeshCube, vec3, mat4Identity, } from 'bloom'; +import { setFog } from 'bloom/core'; import { trsToMat4 } from 'bloom/world'; import { buildHeightmapMesh } from 'bloom/world'; -import { expandPrefab, PrefabLeaf, createPrefabRegistry, registerPrefab } from 'bloom/world'; +import { expandPrefab, PrefabLeaf, createPrefabRegistry, registerPrefab, PrefabRegistry } from 'bloom/world'; +import { spawnWaterVolume, spawnRiver, applyWorldLights } from 'bloom/world'; +import { EntityData, Vec3Lit, Mat4Lit } from 'bloom/world'; import { EditorState, bindEntity, unbindEntity, handleOfEntity, } from '../state/editor-state'; @@ -33,11 +37,174 @@ export function syncWorldToScene(state: EditorState): void { syncDestroys(state); syncRebuilds(state); syncTerrain(state); + syncWaterAndRivers(state); syncEnvironment(state); } +// ---- water & rivers ---------------------------------------------------------- +// +// Rendered through the engine's shared spawn helpers (bloom/world's render.ts), +// the same ones `instantiateWorld` uses, so what you see here is what the game +// shows. Tools set `pendingWaterRebuild` after any add/edit/remove; we tear the +// nodes down and respawn them, which is cheap at authoring-time volumes and +// avoids a per-property update API for every water knob. +function syncWaterAndRivers(state: EditorState): void { + if (!state.pendingWaterRebuild) return; + state.pendingWaterRebuild = false; + + for (let i = 0; i < state.waterHandles.length; i++) { + if (state.waterHandles[i] !== 0) destroySceneNode(state.waterHandles[i]); + } + for (let i = 0; i < state.riverHandles.length; i++) { + if (state.riverHandles[i] !== 0) destroySceneNode(state.riverHandles[i]); + } + state.waterHandles = []; + state.riverHandles = []; + + for (let i = 0; i < state.world.water.length; i++) { + state.waterHandles.push(spawnWaterVolume(state.world.water[i])); + } + for (let i = 0; i < state.world.rivers.length; i++) { + state.riverHandles.push(spawnRiver(state.world.rivers[i])); + } +} + +// ---- placeholder rendering --------------------------------------------------- +// +// Entities whose model is missing — including sentinel refs like +// `_gizmo_box.glb` that some games use for pure-data marker entities — must +// still be visible and pickable, or they can never be selected or edited. +// They render as a colored cube: a shared unit-cube model, sized by the +// `userData.halfExtents` convention (full extents = 2 × halfExtents, matching +// how games draw these boxes) and then by the entity transform. + +let placeholderCube = 0; + +function getPlaceholderCube(): number { + if (placeholderCube === 0) { + placeholderCube = genMeshCube(1, 1, 1).handle; + } + return placeholderCube; +} + +// Stable display colors for well-known userData.kind values. Unknown kinds +// hash to a stable hue (two kinds never silently share a color); entities with +// no kind at all get "missing model" magenta. +const KIND_COLORS = new Map([ + ['player_spawn', [90, 220, 120]], + ['collider_box', [150, 150, 158]], + ['point_light', [255, 216, 96]], + ['enemy_spawner', [230, 84, 84]], + ['weapon_pickup', [84, 180, 255]], + ['wave_config', [200, 105, 230]], +]); + +// static_mesh placeholders take their color from the first tag — the same +// vocabulary the shooter's baker maps to paint categories. +const MESH_TAG_COLORS = new Map([ + ['building', [208, 178, 140]], + ['terrain', [110, 162, 92]], + ['prop', [165, 122, 82]], +]); + +function hueToRgb(hue: number): Vec3Lit { + // HSV with s=0.6, v=0.85, folded into 0-255. + const h = hue / 60; + const c = 0.85 * 0.6; + const x = c * (1 - Math.abs((h % 2) - 1)); + const m = 0.85 - c; + let r = 0, g = 0, b = 0; + if (h < 1) { r = c; g = x; } + else if (h < 2) { r = x; g = c; } + else if (h < 3) { g = c; b = x; } + else if (h < 4) { g = x; b = c; } + else if (h < 5) { r = x; b = c; } + else { r = c; b = x; } + return [Math.floor((r + m) * 255), Math.floor((g + m) * 255), Math.floor((b + m) * 255)]; +} + +function placeholderColor(entity: EntityData): Vec3Lit { + const kind = entity.userData['kind']; + if (kind === undefined || kind === '') return [255, 0, 255]; + if (kind === 'static_mesh') { + const tag = entity.tags.length > 0 ? entity.tags[0] : ''; + const byTag = MESH_TAG_COLORS.get(tag); + if (byTag) return byTag; + return [172, 172, 172]; + } + const known = KIND_COLORS.get(kind); + if (known) return known; + let h = 0; + for (let i = 0; i < kind.length; i++) h = ((h * 31) + kind.charCodeAt(i)) | 0; + return hueToRgb(((h % 360) + 360) % 360); +} + +// Parse the "x, y, z" userData.halfExtents convention. Null when absent or +// malformed (the placeholder then stays a unit cube). +function parseHalfExtents(entity: EntityData): Vec3Lit | null { + const s = entity.userData['halfExtents']; + if (s === undefined || s === '') return null; + const parts = s.split(','); + if (parts.length !== 3) return null; + const x = parseFloat(parts[0]); + const y = parseFloat(parts[1]); + const z = parseFloat(parts[2]); + if (x !== x || y !== y || z !== z) return null; + return [x, y, z]; +} + +// Entity transform with the box extents folded in as a post-multiplied local +// scale (column-major: scale each basis column). +function placeholderMatrix(entity: EntityData): Mat4Lit { + const m = trsToMat4(entity.transform); + const he = parseHalfExtents(entity); + if (he !== null) { + const ex = he[0] * 2, ey = he[1] * 2, ez = he[2] * 2; + for (let i = 0; i < 4; i++) { + m[i] *= ex; + m[4 + i] *= ey; + m[8 + i] *= ez; + } + } + return m; +} + +// True when this entity renders as a placeholder cube rather than a real model. +function isPlaceholder(state: EditorState, entity: EntityData): boolean { + if (entity.prefabRef !== null && entity.prefabRef.length > 0) return false; + if (entity.modelRef === null || entity.modelRef.length === 0) return true; + const me = state.catalog.models.get(entity.modelRef); + return !(me && me.loaded); +} + +// ---- prefab registry cache --------------------------------------------------- + +// Built lazily from the catalog and reused across frames — rebuilding it per +// prefab entity was pure waste. Invalidate whenever the catalog changes +// (loadAssetCatalog calls this; prefab saves must too). +let prefabRegistryCache: PrefabRegistry | null = null; + +export function invalidatePrefabRegistry(): void { + prefabRegistryCache = null; +} + +function getPrefabRegistry(state: EditorState): PrefabRegistry { + if (prefabRegistryCache === null) { + prefabRegistryCache = createPrefabRegistry(); + for (const [, prefab] of state.catalog.prefabs) { + registerPrefab(prefabRegistryCache, prefab); + } + } + return prefabRegistryCache; +} + // ---- entity creates & updates ---------------------------------------------- +function applyTint(node: number, tint: number[]): void { + // World-format tints are 0-1 floats; the scene API takes 0-255. + setSceneNodeColor(node, tint[0] * 255, tint[1] * 255, tint[2] * 255, tint[3] * 255); +} + function syncRebuilds(state: EditorState): void { if (state.pendingRebuild.size === 0) return; @@ -52,63 +219,73 @@ function syncRebuilds(state: EditorState): void { const existingHandle = handleOfEntity(state.handles, entityId); if (existingHandle !== 0) { - // Update transform (and tint) on an existing scene node. - setSceneNodeTransform(existingHandle, trsToMat4(entity.transform)); + // Update transform (and tint) on an existing scene node. Placeholder + // nodes keep their halfExtents scale folded into the matrix. + const m = isPlaceholder(state, entity) ? placeholderMatrix(entity) : trsToMat4(entity.transform); + setSceneNodeTransform(existingHandle, m); if (entity.tint !== null) { - setSceneNodeColor( - existingHandle, - entity.tint[0], entity.tint[1], entity.tint[2], entity.tint[3], - ); + applyTint(existingHandle, entity.tint); } - } else { - // Create a new scene node. Look up the model handle from the catalog. - if (entity.modelRef !== null) { - const modelEntry = state.catalog.models.get(entity.modelRef); - if (modelEntry && modelEntry.loaded) { - const node = createSceneNode(); - attachModelToNode(node, modelEntry.modelHandle, 0); - setSceneNodeTransform(node, trsToMat4(entity.transform)); - if (entity.tint !== null) { - setSceneNodeColor( - node, - entity.tint[0], entity.tint[1], entity.tint[2], entity.tint[3], - ); - } - setSceneNodeVisible(node, true); - bindEntity(state.handles, entityId, node); + continue; + } + + // Exactly one of modelRef / prefabRef should be set; if both are, the + // prefab wins and the modelRef is ignored (a second node here would leak + // and clobber the pick binding). + if (entity.prefabRef !== null && entity.prefabRef.length > 0) { + const registry = getPrefabRegistry(state); + const root = createSceneNode(); + setSceneNodeTransform(root, trsToMat4(entity.transform)); + setSceneNodeVisible(root, true); + + const leaves: PrefabLeaf[] = []; + const errors: string[] = []; + const visited = new Set(); + expandPrefab(registry, entity.prefabRef, mat4Identity(), entity.tint, entity.tags, leaves, errors, visited, entity.id); + + for (let li = 0; li < leaves.length; li++) { + const leaf = leaves[li]; + const me = state.catalog.models.get(leaf.modelRef); + const leafNode = createSceneNode(); + if (me && me.loaded) { + attachModelToNode(leafNode, me.modelHandle, 0); + if (leaf.tint) applyTint(leafNode, leaf.tint); + } else { + // Missing leaf model — same placeholder treatment as entities. + attachModelToNode(leafNode, getPlaceholderCube(), 0); + if (leaf.tint) applyTint(leafNode, leaf.tint); + else setSceneNodeColor(leafNode, 255, 0, 255, 255); } + setSceneNodeTransform(leafNode, leaf.worldMatrix); + setSceneNodeParent(leafNode, root); + setSceneNodeVisible(leafNode, true); } - // Prefab entities. - if (entity.prefabRef !== null && entity.prefabRef.length > 0) { - const registry = createPrefabRegistry(); - // Populate the registry from the catalog. - for (const [id, prefab] of state.catalog.prefabs) { - registerPrefab(registry, prefab); - } - const root = createSceneNode(); - setSceneNodeTransform(root, trsToMat4(entity.transform)); - setSceneNodeVisible(root, true); - - const leaves: PrefabLeaf[] = []; - const errors: string[] = []; - const visited = new Set(); - expandPrefab(registry, entity.prefabRef, mat4Identity(), entity.tint, entity.tags, leaves, errors, visited, entity.id); - - for (let li = 0; li < leaves.length; li++) { - const leaf = leaves[li]; - const me = state.catalog.models.get(leaf.modelRef); - if (me && me.loaded) { - const leafNode = createSceneNode(); - attachModelToNode(leafNode, me.modelHandle, 0); - setSceneNodeTransform(leafNode, leaf.worldMatrix); - if (leaf.tint) setSceneNodeColor(leafNode, leaf.tint[0], leaf.tint[1], leaf.tint[2], leaf.tint[3]); - setSceneNodeParent(leafNode, root); - setSceneNodeVisible(leafNode, true); - } - } - bindEntity(state.handles, entityId, root); + bindEntity(state.handles, entityId, root); + continue; + } + + // Model entity — real mesh when loaded, colored placeholder cube when the + // ref is missing/sentinel, so every entity is visible and pickable. + const node = createSceneNode(); + const modelEntry = entity.modelRef !== null ? state.catalog.models.get(entity.modelRef) : undefined; + if (modelEntry && modelEntry.loaded) { + attachModelToNode(node, modelEntry.modelHandle, 0); + setSceneNodeTransform(node, trsToMat4(entity.transform)); + if (entity.tint !== null) { + applyTint(node, entity.tint); + } + } else { + attachModelToNode(node, getPlaceholderCube(), 0); + setSceneNodeTransform(node, placeholderMatrix(entity)); + if (entity.tint !== null) { + applyTint(node, entity.tint); + } else { + const c = placeholderColor(entity); + setSceneNodeColor(node, c[0], c[1], c[2], 255); } } + setSceneNodeVisible(node, true); + bindEntity(state.handles, entityId, node); } } @@ -137,7 +314,14 @@ function syncTerrain(state: EditorState): void { if (!state.pendingTerrainRebuild) return; state.pendingTerrainRebuild = false; - if (!state.world.terrain) return; + if (!state.world.terrain) { + // Terrain was removed (e.g. CreateTerrainCommand undo) — drop the node. + if (state.terrainHandle !== 0) { + destroySceneNode(state.terrainHandle); + state.terrainHandle = 0; + } + return; + } const mesh = buildHeightmapMesh(state.world.terrain); @@ -157,22 +341,52 @@ function syncTerrain(state: EditorState): void { // ---- environment ----------------------------------------------------------- function syncEnvironment(state: EditorState): void { - if (!state.pendingEnvironmentSync) return; - state.pendingEnvironmentSync = false; - + // The renderer's begin_frame resets the lighting block every frame + // (immediate-mode convention — the same reason the shooter re-sets + // sun/ambient per frame), so ambient, sun, and fog are re-applied + // unconditionally. setDirectionalLight replaces the sun in place; + // addDirectionalLight here would accumulate one extra light per call. const env = state.world.environment; setAmbientLight( - { r: env.ambientColor[0] * 255, g: env.ambientColor[1] * 255, b: env.ambientColor[2] * 255, a: 255 }, + { + r: Math.floor(env.ambientColor[0] * 255), + g: Math.floor(env.ambientColor[1] * 255), + b: Math.floor(env.ambientColor[2] * 255), + a: 255, + }, env.ambientIntensity, ); - addDirectionalLight( - env.sunDirection[0], env.sunDirection[1], env.sunDirection[2], - env.sunColor[0], env.sunColor[1], env.sunColor[2], + setDirectionalLight( + vec3(env.sunDirection[0], env.sunDirection[1], env.sunDirection[2]), + { + r: Math.floor(env.sunColor[0] * 255), + g: Math.floor(env.sunColor[1] * 255), + b: Math.floor(env.sunColor[2] * 255), + a: 255, + }, env.sunIntensity, ); + // The world's point lights, re-submitted for the same reason as the sun: the + // renderer clears its lighting block every frame. This is what lets the editor + // preview a world's lighting rather than guessing at it. + applyWorldLights(state.world); + + // The engine's fog is exponential height fog while the schema stores a + // linear start/end pair — approximate with a density that reaches ~95% + // extinction at fogEnd, near-uniform over height. + if (env.fogEnd > 0.0001) { + setFog(env.fogColor[0], env.fogColor[1], env.fogColor[2], 3.0 / env.fogEnd, 0, 0.02); + } else { + setFog(env.fogColor[0], env.fogColor[1], env.fogColor[2], 0, 0, 0.02); + } + + // Shadow toggling swaps render passes — only on explicit change. + if (!state.pendingEnvironmentSync) return; + state.pendingEnvironmentSync = false; + if (env.shadowsEnabled) { enableShadows(); } else { @@ -206,5 +420,6 @@ export function rebuildAllSceneNodes(state: EditorState): void { if (state.world.terrain !== null) { state.pendingTerrainRebuild = true; } + state.pendingWaterRebuild = true; state.pendingEnvironmentSync = true; } diff --git a/tools/.testout/dumps/crash_main_21568_6a51d0e7.dmp b/tools/.testout/dumps/crash_main_21568_6a51d0e7.dmp new file mode 100644 index 0000000..79b71fa Binary files /dev/null and b/tools/.testout/dumps/crash_main_21568_6a51d0e7.dmp differ diff --git a/tools/.testout/dumps/crash_main_24380_6a51cfa4.dmp b/tools/.testout/dumps/crash_main_24380_6a51cfa4.dmp new file mode 100644 index 0000000..d7ea4d6 Binary files /dev/null and b/tools/.testout/dumps/crash_main_24380_6a51cfa4.dmp differ diff --git a/tools/.testout/dumps/crash_main_25908_6a51cf9f.dmp b/tools/.testout/dumps/crash_main_25908_6a51cf9f.dmp new file mode 100644 index 0000000..d18bf8e Binary files /dev/null and b/tools/.testout/dumps/crash_main_25908_6a51cf9f.dmp differ diff --git a/tools/.testout/dumps/crash_main_27960_6a51d309.dmp b/tools/.testout/dumps/crash_main_27960_6a51d309.dmp new file mode 100644 index 0000000..0d4572e Binary files /dev/null and b/tools/.testout/dumps/crash_main_27960_6a51d309.dmp differ diff --git a/tools/.testout/dumps/crash_main_28228_6a51cf98.dmp b/tools/.testout/dumps/crash_main_28228_6a51cf98.dmp new file mode 100644 index 0000000..e570548 Binary files /dev/null and b/tools/.testout/dumps/crash_main_28228_6a51cf98.dmp differ diff --git a/tools/.testout/dumps/crash_main_28608_6a51d089.dmp b/tools/.testout/dumps/crash_main_28608_6a51d089.dmp new file mode 100644 index 0000000..1bec6d2 Binary files /dev/null and b/tools/.testout/dumps/crash_main_28608_6a51d089.dmp differ diff --git a/tools/.testout/dumps/crash_main_29408_6a51d2cc.dmp b/tools/.testout/dumps/crash_main_29408_6a51d2cc.dmp new file mode 100644 index 0000000..8f8f834 Binary files /dev/null and b/tools/.testout/dumps/crash_main_29408_6a51d2cc.dmp differ diff --git a/tools/.testout/dumps/crash_main_8268_6a51d170.dmp b/tools/.testout/dumps/crash_main_8268_6a51d170.dmp new file mode 100644 index 0000000..24178d0 Binary files /dev/null and b/tools/.testout/dumps/crash_main_8268_6a51d170.dmp differ diff --git a/tools/.testout/dumps/crash_main_8880_6a524cba.dmp b/tools/.testout/dumps/crash_main_8880_6a524cba.dmp new file mode 100644 index 0000000..006881d Binary files /dev/null and b/tools/.testout/dumps/crash_main_8880_6a524cba.dmp differ