diff --git a/PLAN.md b/PLAN.md index 70267c3..d14e924 100644 --- a/PLAN.md +++ b/PLAN.md @@ -198,13 +198,84 @@ The dividing line, for future schema questions: **lights are engine-universal - 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`). -- **D2. Kernel:** implement the `'paint'` branch in `applyBrush` (`brush-tool.ts:121-150`): add weight to the active layer's per-cell `weights[]` with the same radial falloff as sculpt kernels, renormalizing across layers per cell. Undo via a weights-snapshot command (mirror `TerrainStrokeCommand`). -- **D3. Splat rendering.** ⚠ Investigate `bloom_compile_material` (`../engine/native/.../ffi_core/models.rs`, TS wrapper in the engine) — determine whether compiled materials support texture bindings. Target design: pack up to 4 layer weights into an RGBA weights texture regenerated on paint, sample each layer's texture by terrain UV × `tileScale`, blend by weights. Implement in the engine (shared by editor and `instantiateWorld`). If `compile_material` cannot bind textures, the required engine extension (a material-with-texture-slots FFI) is **in scope** — enumerate and build it rather than shipping a vertex-color approximation. Acceptance: paint two layers in the editor, save, load in a game via `instantiateWorld`, blended texturing visible in both. - -### E. Prefab authoring — wire the existing logic +### D. Terrain paint & layers — ✅ DONE (2026-07-13) + +> Paint the ground. The layer list lives in the brush panel; layers are the world's +> own `terrain.layers`, and the swatch beside each is the **mask colour** the +> viewport tints it with — the viewport shows you *coverage*, the game shows you the +> material. +> +> - **D1 UI** — `+ Add layer` picks a texture from the project's textures dir (new +> `texturesDir` key, default `assets/textures`; the catalog lists them without +> loading them — a splat layer only ever stores a path). Select the active layer, +> delete a layer, `paint` is a fifth brush kind. +> - **D2 Kernel** — `paintCell` in `brush-tool.ts`. LMB paints, **Shift+LMB erases**. +> A splat is a *partition*: painting grass in must push everything else out, or the +> weights sum past 1 and a cell that is 90% grass AND 90% rock renders as a washed-out +> average of every texture at once. Erase drives the active layer to zero and does NOT +> push the others up, so coverage falls and the ground fades back to the game's +> procedural blend rather than to a bald patch. Undo snapshots **every** layer, not +> just the one you were holding. +> - **D3 Rendering** — no new material system was needed. The shooter already had a +> 4-layer triplanar splat shader whose weights were computed procedurally (slope, +> moisture noise, distance to the river) and authored *nowhere*. The painted weights +> now arrive as one RGBA8 texel per terrain cell and are mixed **over** that +> procedural blend by coverage. So a cell nobody painted keeps the procedural look +> exactly — which is why turning this on changed neither shipped arena by a pixel. +> +> Transport needed one engine addition: **EN-049 `createTextureArrayFromTexels`**. The +> existing byte-array FFI takes a raw pointer, and Perry cannot pass an array to one +> ("Expected safe integer for native i64 parameter") — so it was uncallable, and every +> caller had ended up on `createTextureArrayFromFiles`, which is right for art and +> useless for data computed at load. +> +> **Verified end-to-end in the game**, not just in the editor: `tools/paint-test-world.ts` +> writes the same data the brush does, and the shooter renders it. Editor preview is the +> heightmap mesh's vertex colour (`buildHeightmapMesh` blends the mask palette by weight). +> +> Cost along the way: **EN-050** — Perry miscompiled the engine's `clamp`, so every splat +> weight quantised to `0` and painted terrain loaded unpainted. See shooter +> `docs/perry-quirks.md` #8; it is pinned by the `testSplatPaintPartition` self-test. + +### Play-in-editor — ✅ DONE (2026-07-12) + +> A **Play** button in the toolbar (and Ctrl+R): saves the level currently on screen +> — not the one on disk — to a scratch world, and launches the real game on it +> (`--world `, new in the shooter). The fly-cam shows you geometry; only the +> game shows you whether the spawners spawn and whether the arena has a shape. +> +> Needed two engine additions: **EN-048 `launchProcess`** (Perry's +> `child_process.spawn` compiles and then does nothing — undefined pid, no process), +> and the shooter's `--world` override. `playCommand` in `editor.project.json` opts a +> project in; no key, no button. + +### E. Prefab authoring — ✅ DONE (2026-07-12) + +> **Shipped.** `prefab-tool.ts` had existed for weeks with **zero call sites**, +> because the UI it appeared to need — a parallel render path for children, a +> parallel selection model, parallel gizmo handling — was too big a job to start. +> +> **It didn't need any of that.** A `PrefabChild` is an `EntityData` minus `name` +> and `userData`, so while you are editing a prefab its children simply **ARE** +> `state.world.entities`: the real world is parked in a stash and the children are +> handed to the editor as if they were the world. Rendering, picking, the gizmos, +> delete, duplicate, snapping, undo/redo — every one of those was already written +> against entities, and not one of them needs to know it is looking at a prefab. +> +> - **E1/E2 (UI):** name field + `+ New Prefab` in the Prefabs tab; `Edit ""` +> for the selected prefab. No double-click — the UI context has no notion of one, +> and inventing a hidden gesture is worse than a visible button. +> - **E3:** placement uses the ordinary `CreateEntityCommand`. Ctrl+S saves the +> *prefab* (not the world — that would write the neutral authoring stage over the +> real level). ESC exits and restores the world **and its undo history** exactly. +> - **E4:** the catalog refreshes on save, so a new prefab is immediately placeable. +> - **Cycle rejection** is transitive (A→B→C→A), refused at the one place a cycle can +> be created — the place tool — and it now *says so*, via a new transient status +> line. A click that silently does nothing reads as a broken editor, not as a rule. +> - 6 new self-tests (mode round-trip incl. history restore; direct, one-hop and +> multi-hop cycles). + +### E. Prefab authoring — original plan - **E1.** "New Prefab" button in the asset panel's prefab tab → prompt for a name using the `textInput` widget (`src/ui/text-input.ts`) → `enterNewPrefabMode`. - **E2.** Double-click a prefab entry → `enterPrefabEditMode`. diff --git a/src/io/asset-catalog.ts b/src/io/asset-catalog.ts index bbf0c1a..fd32bde 100644 --- a/src/io/asset-catalog.ts +++ b/src/io/asset-catalog.ts @@ -20,9 +20,30 @@ export function loadAssetCatalog(state: EditorState): void { loadModels(state, project); loadPrefabs(state, project); + loadTextures(state, project); invalidatePrefabRegistry(); } +/// List the texture files, but do NOT load them. +/// +/// Splat layers only ever store a path — the game decodes the image, the editor +/// shows a mask colour. Loading forty 2K PNGs into VRAM at startup so the layer +/// panel can print their names would be the most expensive no-op in the program. +function loadTextures(state: EditorState, project: Project): void { + let files: string[]; + try { + files = readdirSync(project.texturesDir) as string[]; + } catch (e) { + return; // No textures dir — the layer picker just comes up empty. + } + + for (let i = 0; i < files.length; i++) { + const file = files[i]; + if (!file.endsWith('.png') && !file.endsWith('.jpg') && !file.endsWith('.jpeg')) continue; + state.catalog.textureOrder.push(joinPath(project.texturesDir, file)); + } +} + function loadModels(state: EditorState, project: Project): void { let files: string[]; try { diff --git a/src/io/paths.ts b/src/io/paths.ts index 4821eed..a73ac69 100644 --- a/src/io/paths.ts +++ b/src/io/paths.ts @@ -29,7 +29,20 @@ export function categoryFromName(relPath: string): string { } export function joinPath(a: string, b: string): string { - if (a.length === 0) return b; + // '.' is not a directory component — it is "here", and prefixing it produces a + // path that is EQUIVALENT to `b` but not EQUAL to it. That distinction cost the + // editor its entire model rendering: + // + // the project file lives in the CWD, so rootDir came out as '.' + // -> the asset catalog was keyed './assets/models/prop_tree.glb' + // -> world entities reference 'assets/models/prop_tree.glb' + // -> every lookup missed, and EVERY entity with a model rendered as a grey + // placeholder cube. The whole arena — 88 trees, the building, every prop. + // + // The models loaded fine. Nothing errored. The editor just quietly showed you a + // level made of boxes, which is indistinguishable from "this level is made of + // boxes" and is why it survived so long. + if (a.length === 0 || a === '.' || a === './') return b; if (b.length === 0) return a; if (a.charAt(a.length - 1) === '/') return a + b; return a + '/' + b; diff --git a/src/io/project.ts b/src/io/project.ts index 7076df8..595c43c 100644 --- a/src/io/project.ts +++ b/src/io/project.ts @@ -34,7 +34,9 @@ export function loadProject(state: EditorState): boolean { modelsDir?: string; prefabsDir?: string; worldsDir?: string; + texturesDir?: string; defaultWorld?: string; + playCommand?: string; }; // Determine the root directory from the project file path. @@ -48,7 +50,9 @@ export function loadProject(state: EditorState): boolean { modelsDir: joinPath(rootDir, raw.modelsDir || 'assets/models'), prefabsDir: joinPath(rootDir, raw.prefabsDir || 'assets/prefabs'), worldsDir: joinPath(rootDir, raw.worldsDir || 'assets/worlds'), + texturesDir: joinPath(rootDir, raw.texturesDir || 'assets/textures'), defaultWorld: raw.defaultWorld || '', + playCommand: raw.playCommand || '', }; return true; diff --git a/src/main.ts b/src/main.ts index 9579b24..8fecbeb 100644 --- a/src/main.ts +++ b/src/main.ts @@ -46,10 +46,13 @@ 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 { + updatePrefabTool, drawPrefabBreadcrumb, savePrefabToDisk, +} from './tools/prefab-tool'; import { drawEnvironmentPanel } from './ui/layouts/environment-panel'; import { drawBrushPanel } from './ui/layouts/brush-panel'; import { updatePlaytest, drawPlaytestOverlay } from './playtest/playtest'; +import { launchGame } from './playtest/launch'; import { frameCameraOnSelection, frameCameraOnWorld } from './viewport/frame'; import { addRecentProject } from './io/recent'; import { drawToolbar } from './ui/layouts/toolbar'; @@ -132,7 +135,14 @@ while (!windowShouldClose()) { 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); + // In prefab mode Ctrl+S means "save the prefab" — saving the world from inside + // a prefab would write the neutral authoring stage over the real level. + if (isKeyPressed(Key.S)) { + if (state.editingPrefab) savePrefabToDisk(state); + else saveCurrentWorld(state); + } + // Ctrl+R — run the game on this level. (Ctrl+P is already the fly-cam.) + if (isKeyPressed(Key.R) && !state.editingPrefab) launchGame(state); } // Delete the selection — entity, water volume, or river. Delete only @@ -207,6 +217,11 @@ while (!windowShouldClose()) { updatePrefabTool(state); + if (state.statusMessageT > 0) { + state.statusMessageT = state.statusMessageT - dt; + if (state.statusMessageT < 0) state.statusMessageT = 0; + } + // ---- camera update ------------------------------------------------------- updateOrbitCamera(state); diff --git a/src/playtest/launch.ts b/src/playtest/launch.ts new file mode 100644 index 0000000..8742981 --- /dev/null +++ b/src/playtest/launch.ts @@ -0,0 +1,54 @@ +// Play-in-editor: run the real game on the level you are looking at. +// +// The fly-cam (playtest.ts) shows you the geometry. It cannot show you whether the +// spawners spawn, whether the cover works, whether the arena has a shape — the +// things a level actually has to do. For that you need the game, and if testing a +// level means "add it to a manifest, restart the game, navigate the menu", it will +// not get tested often enough to matter. +// +// So: save the current world to a scratch file, launch the game pointed straight at +// it (`--world `), and get out of the way. + +import { launchProcess } from 'bloom/core'; +import { saveWorld } from 'bloom/world'; +import { EditorState, setStatus } from '../state/editor-state'; +import { joinPath } from '../io/paths'; + +/// The scratch level. Deliberately inside the project's worlds dir rather than a +/// system temp dir: the game resolves asset paths relative to its own working +/// directory, so a world parked in %TEMP% would load and then fail to find a single +/// model. Gitignored. +const SCRATCH = '__playtest.world.json'; + +export function launchGame(state: EditorState): void { + const project = state.project; + if (!project) { + setStatus(state, 'No project — cannot launch'); + return; + } + if (project.playCommand.length === 0) { + setStatus(state, 'No "playCommand" in editor.project.json'); + return; + } + + // Save what is on screen, NOT what is on disk. The whole point is to test the + // edit you just made, and making you save first would defeat it. + const worldPath = joinPath(project.worldsDir, SCRATCH); + const res = saveWorld(worldPath, state.world); + if (!res.ok) { + setStatus(state, 'Play: could not write ' + worldPath); + return; + } + // Fire and forget. The editor must not block on the game, and must not die with + // it either: close the game and you are back in the editor, undo history intact. + // + // launchProcess, not child_process.spawn — Perry's spawn compiles and then does + // nothing at all (undefined pid, no process). See engine EN-048. + const cwd = project.rootDir.length > 0 ? project.rootDir : '.'; + const pid = launchProcess(project.playCommand, ['--world', worldPath], cwd); + if (pid === 0) { + setStatus(state, 'Play: could not launch ' + project.playCommand); + return; + } + setStatus(state, 'Playing this level in ' + project.playCommand + ' (pid ' + pid + ')'); +} diff --git a/src/state/commands/terrain-paint.ts b/src/state/commands/terrain-paint.ts new file mode 100644 index 0000000..d8a6e7f --- /dev/null +++ b/src/state/commands/terrain-paint.ts @@ -0,0 +1,133 @@ +// Splat-layer commands: add a layer, remove a layer, and paint a stroke. +// +// A paint stroke snapshots EVERY layer's weights, not just the active one. +// Painting grass onto a cell necessarily takes weight away from the dirt that +// was there — a splat is a partition of unity, so one brush dab writes to all +// of them. Undoing a stroke that only restored the layer you were holding would +// leave the others carrying the erosion, and the terrain would drift a little +// further from correct with every Ctrl+Z. + +import { TerrainLayer, createTerrainLayer } from 'bloom/world'; +import { EditorState, Command } from '../editor-state'; + +/// Copy the weights of every layer. Used for both halves of a stroke. +export function snapshotWeights(layers: TerrainLayer[]): number[][] { + const out = new Array(layers.length); + for (let i = 0; i < layers.length; i++) out[i] = layers[i].weights.slice(); + return out; +} + +/// Rebuild `t.layers` with `add` appended, or with index `drop` removed. +/// +/// Explicit construction rather than `.push()` / `.splice()`: `t.layers` came +/// out of `JSON.parse`, and a JSON-parsed array grown with `.push()` is the +/// exact shape Perry's array bridge gets wrong (`.length` reports the literal +/// init size — perry-quirks #2). The undo stack can afford `.push()` because it +/// never crosses the FFI; a layer list that gets serialized back to disk cannot. +function rebuildLayers(layers: TerrainLayer[], add: TerrainLayer | null, drop: number, at: number): TerrainLayer[] { + const n = layers.length + (add !== null ? 1 : 0) - (drop >= 0 ? 1 : 0); + const out = new Array(n); + let w = 0; + for (let i = 0; i < layers.length; i++) { + if (i === drop) continue; + if (add !== null && w === at) { out[w] = add; w++; } + out[w] = layers[i]; + w++; + } + if (add !== null && w === at) { out[w] = add; w++; } + return out; +} + +function restoreWeights(state: EditorState, snap: number[][]): void { + const t = state.world.terrain; + if (!t) return; + // A stroke's snapshot is only valid against the layer set it was taken from. + // If layers have since been added or removed the stroke is unreplayable, and + // silently writing a stale snapshot over a different layer set would scramble + // the paint. Undo/redo interleaved with add/remove is the case; refuse it. + if (snap.length !== t.layers.length) return; + for (let i = 0; i < snap.length; i++) t.layers[i].weights = snap[i].slice(); + state.pendingTerrainRebuild = true; +} + +export class TerrainPaintCommand implements Command { + readonly label: string; + private before: number[][]; + private after: number[][]; + + constructor(before: number[][], after: number[][]) { + this.label = 'Paint terrain'; + this.before = before; + this.after = after; + } + + do(state: EditorState): void { restoreWeights(state, this.after); } + undo(state: EditorState): void { restoreWeights(state, this.before); } +} + +export class AddTerrainLayerCommand implements Command { + readonly label: string; + private id: string; + private textureRef: string; + private tileScale: number; + + constructor(id: string, textureRef: string, tileScale: number) { + this.label = 'Add terrain layer'; + this.id = id; + this.textureRef = textureRef; + this.tileScale = tileScale; + } + + do(state: EditorState): void { + const t = state.world.terrain; + if (!t) return; + const layer = createTerrainLayer(t, this.id, this.textureRef, this.tileScale); + t.layers = rebuildLayers(t.layers, layer, -1, t.layers.length); + // Select what you just made — otherwise the first thing every user does + // after adding a layer is paint with the previous one. + state.brush.activeLayerIdx = t.layers.length - 1; + state.pendingTerrainRebuild = true; + } + + undo(state: EditorState): void { + const t = state.world.terrain; + if (!t || t.layers.length === 0) return; + t.layers = rebuildLayers(t.layers, null, t.layers.length - 1, -1); + if (state.brush.activeLayerIdx >= t.layers.length) { + state.brush.activeLayerIdx = t.layers.length - 1; + } + state.pendingTerrainRebuild = true; + } +} + +export class RemoveTerrainLayerCommand implements Command { + readonly label: string; + private idx: number; + private removed: TerrainLayer | null; + + constructor(idx: number) { + this.label = 'Remove terrain layer'; + this.idx = idx; + this.removed = null; + } + + do(state: EditorState): void { + const t = state.world.terrain; + if (!t || this.idx < 0 || this.idx >= t.layers.length) return; + // Hold the whole layer, weights included: removing a painted layer and + // undoing must give the paint back, not an empty layer with the right name. + this.removed = t.layers[this.idx]; + t.layers = rebuildLayers(t.layers, null, this.idx, -1); + if (state.brush.activeLayerIdx >= t.layers.length) { + state.brush.activeLayerIdx = t.layers.length - 1; + } + state.pendingTerrainRebuild = true; + } + + undo(state: EditorState): void { + const t = state.world.terrain; + if (!t || !this.removed) return; + t.layers = rebuildLayers(t.layers, this.removed, -1, this.idx); + state.pendingTerrainRebuild = true; + } +} diff --git a/src/state/editor-state.ts b/src/state/editor-state.ts index b6e1224..5e8be85 100644 --- a/src/state/editor-state.ts +++ b/src/state/editor-state.ts @@ -21,7 +21,10 @@ export interface Project { modelsDir: string; // Resolved absolute. prefabsDir: string; worldsDir: string; + texturesDir: string; // Splat-layer source textures. defaultWorld: string; + /// Optional: the game binary to launch for play-in-editor. Empty = no Play button. + playCommand: string; } export interface ModelEntry { @@ -48,6 +51,7 @@ export class AssetCatalog { prefabs: Map; // Key = prefab id. modelOrder: string[]; // Stable iteration order for the panel. prefabOrder: string[]; + textureOrder: string[]; // relPaths under texturesDir; splat-layer sources. filter: string; // Substring filter for the asset panel. activeCategory: string; // "all" or a category slug. activeTab: number; // 0 = Models, 1 = Prefabs. @@ -57,6 +61,7 @@ export class AssetCatalog { this.prefabs = new Map(); this.modelOrder = []; this.prefabOrder = []; + this.textureOrder = []; this.filter = ''; this.activeCategory = 'all'; this.activeTab = 0; @@ -111,6 +116,19 @@ export class HandleMap { // ---- main state object ----------------------------------------------------- +/// The world (and its history) parked while a prefab is being edited. +export interface PrefabStash { + world: WorldData; + worldPath: string | null; + undoStack: Command[]; + redoStack: Command[]; + selectionIds: string[]; + selectionPrimary: string | null; + activeTool: ToolId; + placeAssetRef: string | null; + modified: boolean; +} + export interface EditorState { // Project project: Project | null; @@ -122,8 +140,19 @@ export interface EditorState { worldPath: string | null; world: WorldData; editingPrefab: PrefabData | null; // Non-null while in prefab edit mode. + // What the world looked like before we entered prefab mode. See prefab-tool.ts: + // while editing, the prefab's children ARE `state.world.entities`, so every tool, + // gizmo and undo command already written for entities works on them unchanged. + // This is what gets swapped back on the way out. + prefabStash: PrefabStash | null; modified: boolean; + // A transient line in the status bar. The editor had no way to tell the user it + // had REFUSED something — a click that silently does nothing reads as a bug in + // the editor, not as a rule being enforced. + statusMessage: string; + statusMessageT: number; // seconds remaining + // Selection selection: Selection; @@ -188,6 +217,9 @@ export function createEditorState(): EditorState { worldPath: null, world: createEmptyWorld('untitled', 'Untitled World'), editingPrefab: null, + prefabStash: null, + statusMessage: '', + statusMessageT: 0, modified: false, selection: { ids: new Set(), primary: null, kind: 'entity' }, activeTool: 'select', @@ -316,3 +348,10 @@ export function nextEntityId(state: EditorState): string { state.world.metadata[key] = (n + 1).toString(); return 'ent_' + n.toString().padStart(4, '0'); } + + +/// Post a transient message to the status bar. +export function setStatus(state: EditorState, msg: string): void { + state.statusMessage = msg; + state.statusMessageT = 4.0; +} diff --git a/src/tests/self-tests.ts b/src/tests/self-tests.ts index dea7416..c6fd9a1 100644 --- a/src/tests/self-tests.ts +++ b/src/tests/self-tests.ts @@ -6,6 +6,7 @@ import { WorldData, PrefabData, WaterVolume, createEmptyWorld, createEntity } fr import { validateWorld, validatePrefab } from 'bloom/world'; import { migrateWorldData } from 'bloom/world'; import { buildHeightmapMesh, sampleHeight, defaultTerrain } from 'bloom/world'; +import { createTerrainLayer, quantizeWeight, terrainLayerMaskColor } from 'bloom/world'; import { expandPrefab, createPrefabRegistry, registerPrefab, PrefabLeaf } from 'bloom/world'; import { createEditorState, nextCounterId, @@ -17,6 +18,14 @@ 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'; +import { + AddTerrainLayerCommand, RemoveTerrainLayerCommand, TerrainPaintCommand, snapshotWeights, +} from '../state/commands/terrain-paint'; +import { paintCell } from '../tools/brush-tool'; +import { + enterNewPrefabMode, enterPrefabEditMode, exitPrefabMode, wouldCycle, +} from '../tools/prefab-tool'; +import { joinPath } from '../io/paths'; let passed = 0; let failed = 0; @@ -43,9 +52,15 @@ export function runSelfTests(): number { testMapSize(); testCreateTerrainUndo(); testCounterIds(); + testPrefabAuthoringMode(); + testPrefabAuthoringCycles(); + testPathJoinIdentity(); testUserDataCommand(); testWaterCommands(); testLightMigration(); + testSplatLayerCommands(); + testSplatPaintPartition(); + testSplatMaskPreview(); console.log('self-tests: ' + passed + ' passed, ' + failed + ' failed'); return failed; @@ -322,3 +337,240 @@ function testCommandUndoRedo(): void { undo(state); // undo create assert(state.world.entities.length === 0, 'command: undo create'); } + + +// --- Prefab authoring mode (PLAN §E) ---------------------------------------- +// +// The mode works by swapping the prefab's children in AS the world's entities, so +// every existing tool operates on them unchanged. These tests pin the two things +// that swap could plausibly get wrong: losing the world on the way out, and letting +// a prefab contain itself. +function testPrefabAuthoringMode(): void { + const state = createEditorState(); + state.project = { + root: '.', modelsDir: 'models', prefabsDir: 'prefabs', + worldsDir: 'worlds', texturesDir: 'textures', + } as any; + + // A world with one entity, and a dirty flag we expect to survive the round trip. + runCommand(state, new CreateEntityCommand(createEntity('world_ent', 'a.glb', [1, 2, 3]))); + const worldRef = state.world; + const undoDepth = state.undoStack.length; + assert(state.world.entities.length === 1, 'prefab mode: world starts with 1 entity'); + + // Entering swaps the world out for a neutral authoring stage. + enterNewPrefabMode(state, 'camp', 'Camp'); + assert(state.editingPrefab !== null, 'prefab mode: entered'); + assert(state.world !== worldRef, 'prefab mode: world was swapped out'); + assert(state.world.entities.length === 0, 'prefab mode: new prefab starts empty'); + assert(state.world.terrain === null, 'prefab mode: authoring stage has no terrain'); + assert(state.undoStack.length === 0, 'prefab mode: history is separate'); + + // Placing a part uses the ordinary entity command — that is the whole point. + runCommand(state, new CreateEntityCommand(createEntity('part_0', 'tent.glb', [0, 0, 0]))); + runCommand(state, new CreateEntityCommand(createEntity('part_1', 'fire.glb', [2, 0, 0]))); + assert(state.world.entities.length === 2, 'prefab mode: parts placed as entities'); + + // ...and so does undo. + undo(state); + assert(state.world.entities.length === 1, 'prefab mode: undo works on parts'); + redo(state); + assert(state.world.entities.length === 2, 'prefab mode: redo works on parts'); + + // Leaving must restore the world EXACTLY, history included. + exitPrefabMode(state); + assert(state.editingPrefab === null, 'prefab mode: exited'); + assert(state.world === worldRef, 'prefab mode: original world restored'); + assert(state.world.entities.length === 1, 'prefab mode: world entity survived'); + assert(state.undoStack.length === undoDepth, 'prefab mode: world history restored'); + assert(state.prefabStash === null, 'prefab mode: stash cleared'); +} + +// --- Cycle rejection (PLAN §E acceptance) ------------------------------------ +// +// A prefab that contains itself expands forever at load and takes the game with it. +// Direct self-reference is the obvious case; the one that actually gets built by +// accident is transitive — A holds B, B holds A. +function testPrefabAuthoringCycles(): void { + const state = createEditorState(); + + // Catalog: b contains c, c contains a. + state.catalog.prefabs.set('b', { + id: 'b', name: 'B', children: [ + { id: 'x', modelRef: null, prefabRef: 'c', + transform: { position: [0, 0, 0], rotation: [0, 0, 0], scale: [1, 1, 1] }, + tint: null, tags: [] }, + ], + } as any); + state.catalog.prefabs.set('c', { + id: 'c', name: 'C', children: [ + { id: 'y', modelRef: null, prefabRef: 'a', + transform: { position: [0, 0, 0], rotation: [0, 0, 0], scale: [1, 1, 1] }, + tint: null, tags: [] }, + ], + } as any); + state.catalog.prefabs.set('d', { id: 'd', name: 'D', children: [] } as any); + + // Not editing anything: nothing to cycle into. + assert(!wouldCycle(state, 'b'), 'cycle: no-op outside prefab mode'); + + enterNewPrefabMode(state, 'a', 'A'); + assert(wouldCycle(state, 'a'), 'cycle: direct self-reference rejected'); + assert(wouldCycle(state, 'b'), 'cycle: TRANSITIVE reference rejected (a -> b -> c -> a)'); + assert(wouldCycle(state, 'c'), 'cycle: one-hop transitive rejected (a -> c -> a)'); + assert(!wouldCycle(state, 'd'), 'cycle: an unrelated prefab is allowed'); + exitPrefabMode(state); +} + + +// --- Asset-key identity ------------------------------------------------------ +// +// The catalog is keyed by the SAME string the world file stores in `modelRef`, and +// the two are built by different code paths. A path that is merely EQUIVALENT is +// not good enough — it has to be EQUAL, because the lookup is a Map.get. +// +// This is not hypothetical: rootDir came out as '.' whenever the project file sat +// in the CWD, so the catalog was keyed './assets/models/x.glb' while worlds said +// 'assets/models/x.glb'. Every lookup missed and the editor rendered the entire +// arena — trees, building, every prop — as grey placeholder cubes, silently. +function testPathJoinIdentity(): void { + assert(joinPath('.', 'assets/models') === 'assets/models', + 'paths: "." root does not poison the key'); + assert(joinPath('./', 'assets/models') === 'assets/models', + 'paths: "./" root does not poison the key'); + assert(joinPath('', 'assets/models') === 'assets/models', + 'paths: empty root is a no-op'); + assert(joinPath('proj', 'assets/models') === 'proj/assets/models', + 'paths: a real root still joins'); + assert(joinPath('proj/', 'assets/models') === 'proj/assets/models', + 'paths: trailing slash is not doubled'); + // The thing that actually broke: catalog key must equal the world file's ref. + const catalogKey = joinPath(joinPath('.', 'assets/models'), 'prop_tree.glb'); + assert(catalogKey === 'assets/models/prop_tree.glb', + 'paths: catalog key MATCHES the world modelRef'); +} + +// ---- Splat layers (PLAN §D) ------------------------------------------------- + +// Add / remove / paint, and what undo owes you afterwards. +// +// The one that matters: removing a PAINTED layer and undoing must give the paint +// back. A remove that only remembered the layer's name and texture would undo to +// a layer that is correctly named, correctly textured, and blank — and the paint +// would be gone with no error anywhere. +function testSplatLayerCommands(): void { + const state = createEditorState(); + runCommand(state, new CreateTerrainCommand()); + const t = state.world.terrain as any; + const cells = t.width * t.depth; + + runCommand(state, new AddTerrainLayerCommand('grass', 'assets/textures/g.png', 1.0)); + assert(t.layers.length === 1, 'splat: layer added'); + assert(t.layers[0].weights.length === cells, 'splat: weights sized to the grid'); + assert(t.layers[0].weights[0] === 0, 'splat: a new layer starts unpainted'); + assert(state.brush.activeLayerIdx === 0, 'splat: the new layer is selected'); + + runCommand(state, new AddTerrainLayerCommand('rock', 'assets/textures/r.png', 1.0)); + assert(t.layers.length === 2 && state.brush.activeLayerIdx === 1, 'splat: second layer selected'); + + // Paint layer 1 (rock) into cell 5, through a real command. + const before = snapshotWeights(t.layers); + paintCell(t.layers, 1, 5, 1.0, 1.0); + runCommand(state, new TerrainPaintCommand(before, snapshotWeights(t.layers))); + assert(t.layers[1].weights[5] === 1, 'splat: paint wrote the active layer'); + + undo(state); + assert(t.layers[1].weights[5] === 0, 'splat: undo reverts the stroke'); + redo(state); + assert(t.layers[1].weights[5] === 1, 'splat: redo replays the stroke'); + + // Remove the painted layer, then undo. The paint must come back. + runCommand(state, new RemoveTerrainLayerCommand(1)); + assert(t.layers.length === 1, 'splat: layer removed'); + assert(state.brush.activeLayerIdx === 0, 'splat: selection clamped after removal'); + undo(state); + assert(t.layers.length === 2, 'splat: undo restores the layer'); + assert(t.layers[1].weights[5] === 1, 'splat: undo restores the layer WITH its paint'); + + // Undoing an add must not leave the brush pointing past the end of the list. + runCommand(state, new AddTerrainLayerCommand('dirt', 'assets/textures/d.png', 1.0)); + assert(state.brush.activeLayerIdx === 2, 'splat: third layer selected'); + undo(state); + assert(t.layers.length === 2, 'splat: undo removes the added layer'); + assert(state.brush.activeLayerIdx < t.layers.length, 'splat: activeLayerIdx stays in range'); +} + +// A splat is a partition of unity. If the weights at a cell can sum past 1, the +// shader blends 90% grass with 90% rock and the terrain goes uniformly grey — +// which looks like a lighting bug and is not one. +function testSplatPaintPartition(): void { + const t = defaultTerrain(); + const layers = [ + createTerrainLayer(t, 'a', 'a.png', 1), + createTerrainLayer(t, 'b', 'b.png', 1), + createTerrainLayer(t, 'c', 'c.png', 1), + ]; + const idx = 42; + + // Fill the cell with b, then paint a over it at full strength. + paintCell(layers, 1, idx, 1.0, 1.0); + assert(layers[1].weights[idx] === 1, 'partition: b painted to 1'); + paintCell(layers, 0, idx, 1.0, 1.0); + assert(layers[0].weights[idx] === 1, 'partition: a painted to 1'); + assert(layers[1].weights[idx] === 0, 'partition: a fully displaced b'); + + // A partial dab must leave the total at exactly 1, not above it. + const l2 = [ + createTerrainLayer(t, 'a', 'a.png', 1), + createTerrainLayer(t, 'b', 'b.png', 1), + ]; + paintCell(l2, 1, idx, 1.0, 1.0); // b = 1 + paintCell(l2, 0, idx, 1.0, 0.25); // a = 0.25 -> b must fall to 0.75 + const sum = l2[0].weights[idx] + l2[1].weights[idx]; + assert(Math.abs(sum - 1.0) < 0.002, 'partition: weights sum to 1 after a partial dab (got ' + sum + ')'); + assert(Math.abs(l2[1].weights[idx] - 0.75) < 0.002, 'partition: b scaled proportionally'); + + // Erase drives the active layer to 0 and does NOT push the others back up — + // falling coverage is what lets the game blend back to its procedural base. + paintCell(l2, 0, idx, 0.0, 1.0); + assert(l2[0].weights[idx] === 0, 'partition: erase zeroes the active layer'); + assert(Math.abs(l2[1].weights[idx] - 0.75) < 0.002, 'partition: erase leaves the others alone'); + const cov = l2[0].weights[idx] + l2[1].weights[idx]; + assert(cov < 1.0, 'partition: erasing lowers total coverage'); + + // Quantization is what keeps the world file from becoming a megabyte of noise. + assert(quantizeWeight(0.5019607843137255) === 0.502, 'partition: weights quantize to 3dp'); + assert(quantizeWeight(-1) === 0 && quantizeWeight(2) === 1, 'partition: weights clamp to 0..1'); +} + +// The editor's paint preview is the heightmap mesh's vertex colour. If that +// stays grey no matter what you paint, the paint tool is invisible and therefore +// useless — so pin it. +function testSplatMaskPreview(): void { + const t = defaultTerrain(); + const bare = buildHeightmapMesh(t); + const STRIDE = 12; + const r0 = bare.vertices[6]; // vertex 0, colour R + + t.layers = [createTerrainLayer(t, 'rock', 'rock.png', 1)]; + const unpainted = buildHeightmapMesh(t); + assert(unpainted.vertices[6] === r0, + 'mask: adding an unpainted layer does not change the terrain'); + + // Paint layer 0 fully at cell 0. Vertex 0's colour must become the mask colour. + t.layers[0].weights[0] = 1.0; + const painted = buildHeightmapMesh(t); + const want = terrainLayerMaskColor(0); + assert(Math.abs(painted.vertices[6] - want[0]) < 0.001 && + Math.abs(painted.vertices[7] - want[1]) < 0.001 && + Math.abs(painted.vertices[8] - want[2]) < 0.001, + 'mask: a fully-painted cell takes the layer mask colour'); + // ...and its neighbour, which nobody painted, must not have moved. + assert(painted.vertices[STRIDE + 6] === r0, + 'mask: an unpainted cell keeps the bare colour'); + + // A short weights array (hand-edited file, resized grid) must not produce NaN. + t.layers[0].weights = [1.0]; + const ragged = buildHeightmapMesh(t); + assert(ragged.vertices[STRIDE + 6] === r0, 'mask: a short weights array degrades to bare, not NaN'); +} diff --git a/src/tools/brush-tool.ts b/src/tools/brush-tool.ts index 4503a4a..99c9a5d 100644 --- a/src/tools/brush-tool.ts +++ b/src/tools/brush-tool.ts @@ -8,10 +8,11 @@ // snapshotted. On stroke end, a TerrainStrokeCommand is emitted that holds // 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, TerrainData } from 'bloom/world'; +import { isMouseButtonDown, isMouseButtonPressed, isMouseButtonReleased, MouseButton, getMouseX, getMouseY, getScreenWidth, getScreenHeight, getDeltaTime, isKeyDown, Key } from 'bloom'; +import { raycastTerrain, TerrainData, TerrainLayer, quantizeWeight } from 'bloom/world'; import { EditorState } from '../state/editor-state'; import { runCommand } from '../state/commands'; +import { TerrainPaintCommand, snapshotWeights } from '../state/commands/terrain-paint'; import { mouseToWorldRay } from '../viewport/ray'; import { Command } from '../state/editor-state'; @@ -48,11 +49,13 @@ class TerrainStrokeCommand implements Command { interface BrushToolState { stroking: boolean; heightsSnapshot: number[] | null; + weightsSnapshot: number[][] | null; // Paint strokes: every layer, see terrain-paint.ts. } const brushState: BrushToolState = { stroking: false, heightsSnapshot: null, + weightsSnapshot: null, }; // ---- Update ---------------------------------------------------------------- @@ -86,15 +89,33 @@ export function updateBrushTool(state: EditorState): void { const hit = raycastTerrain(terrain, ray.origin, ray.direction, 200, 0.5); if (!hit.hit) return; + const painting = state.brush.kind === 'paint'; + + // Painting with no layer to paint into is a no-op, not a crash. The panel + // says so; do not let a click through to an out-of-range index. + if (painting && (terrain.layers.length === 0 || + state.brush.activeLayerIdx < 0 || + state.brush.activeLayerIdx >= terrain.layers.length)) { + return; + } + // Start stroke. if (isMouseButtonPressed(MouseButton.LEFT)) { brushState.stroking = true; - brushState.heightsSnapshot = terrain.heights.slice(); + if (painting) { + brushState.weightsSnapshot = snapshotWeights(terrain.layers); + } else { + brushState.heightsSnapshot = terrain.heights.slice(); + } } // Apply brush while mouse is held. if (brushState.stroking && isMouseButtonDown(MouseButton.LEFT)) { - applyBrush(state, terrain, hit.cellX, hit.cellZ); + if (painting) { + applyPaint(state, terrain, hit.cellX, hit.cellZ); + } else { + applyBrush(state, terrain, hit.cellX, hit.cellZ); + } state.pendingTerrainRebuild = true; } @@ -105,14 +126,23 @@ export function updateBrushTool(state: EditorState): void { } function endStroke(state: EditorState): void { - if (brushState.heightsSnapshot && state.world.terrain) { - runCommand(state, new TerrainStrokeCommand( - brushState.heightsSnapshot, - state.world.terrain.heights.slice(), - )); + const t = state.world.terrain; + if (t) { + if (brushState.weightsSnapshot) { + runCommand(state, new TerrainPaintCommand( + brushState.weightsSnapshot, + snapshotWeights(t.layers), + )); + } else if (brushState.heightsSnapshot) { + runCommand(state, new TerrainStrokeCommand( + brushState.heightsSnapshot, + t.heights.slice(), + )); + } } brushState.stroking = false; brushState.heightsSnapshot = null; + brushState.weightsSnapshot = null; } // ---- Brush kernels --------------------------------------------------------- @@ -148,6 +178,81 @@ function applyBrush(state: EditorState, t: TerrainData, cx: number, cz: number): } } +// ---- Paint ----------------------------------------------------------------- + +/// Paint the active splat layer under the brush. Hold Shift to erase. +/// +/// A splat is a partition: the four (or eight) weights at a cell say what +/// fraction of the ground is grass, dirt, rock. So painting grass IN must push +/// everything else OUT, or the weights sum past 1 and the shader renders a cell +/// that is 90% grass AND 90% rock — which reads as a washed-out average of every +/// texture at once, the classic "my terrain went grey" bug. +/// +/// Erasing does NOT push the others back up. It drives the active layer toward +/// zero and leaves the rest where they are, so the cell's total coverage falls — +/// and coverage is exactly what the shooter's shader uses to blend back to its +/// procedural slope/moisture blend. Erase everything and you get the untouched +/// terrain, not a bald patch. +function applyPaint(state: EditorState, t: TerrainData, cx: number, cz: number): void { + const brush = state.brush; + const active = brush.activeLayerIdx; + const layers = t.layers; + const n = layers.length; + const r = Math.ceil(brush.radius); + const dt = getDeltaTime(); + const erase = isKeyDown(Key.LeftShift) || isKeyDown(Key.RightShift); + const target = erase ? 0.0 : 1.0; + + for (let dz = -r; dz <= r; dz++) { + for (let dx = -r; dx <= r; dx++) { + const x = cx + dx; + const z = cz + dz; + if (x < 0 || x >= t.width || z < 0 || z >= t.depth) continue; + + const dist = Math.sqrt(dx * dx + dz * dz); + if (dist > brush.radius) continue; + + const falloff = 1.0 - dist / brush.radius; + const idx = z * t.width + x; + + // Rate is deliberately brisk (×4): a splat weight only has to travel 0..1, + // where a sculpt brush moves metres, so the same strength value would feel + // dead here. + const amount = Math.min(brush.strength * falloff * dt * 4.0, 1.0); + paintCell(layers, active, idx, target, amount); + } + } +} + +/// Move one cell's splat weights toward `target` for layer `active`, then keep +/// the cell a valid partition. Pure, and exported so the self-tests can exercise +/// the part that is actually easy to get wrong without a mouse and a frame clock. +export function paintCell( + layers: TerrainLayer[], active: number, idx: number, + target: number, amount: number, +): void { + const n = layers.length; + const prev = layers[active].weights[idx]; + const next = quantizeWeight(prev + (target - prev) * amount); + layers[active].weights[idx] = next; + + // Re-normalize the others so the cell's total never exceeds 1. Scaling them + // proportionally (rather than subtracting equally) is what keeps a 70/30 + // dirt/rock mix reading as 70/30 after grass is painted over the top of it. + let others = 0; + for (let l = 0; l < n; l++) { + if (l !== active) others = others + layers[l].weights[idx]; + } + const room = 1.0 - next; + if (others > room && others > 0.0) { + const k = room / others; + for (let l = 0; l < n; l++) { + if (l === active) continue; + layers[l].weights[idx] = quantizeWeight(layers[l].weights[idx] * k); + } + } +} + function avgNeighbors(t: TerrainData, x: number, z: number): number { let sum = 0; let count = 0; diff --git a/src/tools/place-tool.ts b/src/tools/place-tool.ts index 3620c3d..bae528c 100644 --- a/src/tools/place-tool.ts +++ b/src/tools/place-tool.ts @@ -4,10 +4,11 @@ import { getMouseX, getMouseY, getScreenWidth, getScreenHeight } from 'bloom'; import { createEntity, Vec3Lit } from 'bloom/world'; import { sampleHeight } from 'bloom/world'; -import { EditorState, nextEntityId } from '../state/editor-state'; +import { EditorState, nextEntityId, setStatus } from '../state/editor-state'; import { CreateEntityCommand } from '../state/commands/create-entity'; import { runCommand } from '../state/commands'; import { mouseToWorldRay, rayPlaneIntersect } from '../viewport/ray'; +import { wouldCycle } from './prefab-tool'; export function handlePlaceClick(state: EditorState): void { if (state.activeTool !== 'place' || state.placeAssetRef === null) return; @@ -38,6 +39,15 @@ export function handlePlaceClick(state: EditorState): void { const modelRef = isPrefab ? null : ref; const prefabRef = isPrefab ? ref.substring(7) : null; + // A prefab that contains itself — directly or through a chain — expands forever at + // load time and takes the game down with it. The one place that can be created is + // right here, so it is the one place that has to refuse. + if (isPrefab && prefabRef !== null && state.editingPrefab + && wouldCycle(state, prefabRef)) { + setStatus(state, 'Cannot place "' + prefabRef + '" inside itself — prefab cycle'); + return; + } + const entity = createEntity(id, modelRef || '', hitPoint as Vec3Lit); if (isPrefab) { entity.modelRef = null; diff --git a/src/tools/prefab-tool.ts b/src/tools/prefab-tool.ts index 1550fd2..d2c0792 100644 --- a/src/tools/prefab-tool.ts +++ b/src/tools/prefab-tool.ts @@ -1,98 +1,247 @@ // Prefab authoring mode. // -// When the user enters prefab edit mode (via New Prefab or double-clicking -// a prefab in the asset panel), the viewport swaps to a neutral scene. -// All placement tools mutate editingPrefab.children instead of world.entities. -// Save writes *.prefab.json to the project's prefabsDir. +// THE IDEA THAT MAKES THIS SMALL. A `PrefabChild` is an `EntityData` minus `name` +// and `userData`. So while you are editing a prefab, its children simply ARE +// `state.world.entities`: the real world is parked in a stash and the prefab's +// children are handed to the editor as if they were the world. +// +// Everything then works for free — rendering, picking, the move/rotate/scale +// gizmos, delete, duplicate, snapping, undo/redo — because every one of those was +// already written against entities and none of them needs to know it is looking at +// a prefab. The alternative (a parallel render path, a parallel selection model, +// parallel gizmo handling for children) is how a feature stays unwritten forever. +// Which is exactly what happened here: the logic in this file has existed for weeks +// with ZERO call sites, because the UI it seemed to need was too big a job. +// +// Save converts the entities back into children and writes `*.prefab.json`. import { isKeyPressed, Key, drawText } from 'bloom'; -import { createEmptyPrefab, PrefabData, PrefabChild, TransformData, Vec3Lit } from 'bloom/world'; -import { savePrefab } from 'bloom/world'; -import { EditorState } from '../state/editor-state'; +import { + createEmptyPrefab, createEmptyWorld, savePrefab, + PrefabData, PrefabChild, EntityData, WorldData, +} from 'bloom/world'; +import { EditorState, PrefabStash } from '../state/editor-state'; import { rebuildAllSceneNodes } from '../world-sync/sync'; +import { frameCameraOnBounds } from '../viewport/frame'; import { Theme } from '../ui/theme'; -// Enter prefab edit mode with a new empty prefab. -export function enterNewPrefabMode(state: EditorState, id: string, name: string): void { - state.editingPrefab = createEmptyPrefab(id, name); +// --- conversions ------------------------------------------------------------- + +function childToEntity(c: PrefabChild): EntityData { + const nm = c.modelRef !== null ? c.modelRef : (c.prefabRef !== null ? c.prefabRef : c.id); + return { + id: c.id, + name: nm, + modelRef: c.modelRef, + prefabRef: c.prefabRef, + transform: { + position: [c.transform.position[0], c.transform.position[1], c.transform.position[2]], + rotation: [c.transform.rotation[0], c.transform.rotation[1], c.transform.rotation[2]], + scale: [c.transform.scale[0], c.transform.scale[1], c.transform.scale[2]], + }, + tint: c.tint, + tags: c.tags, + userData: {}, + }; +} + +function entityToChild(e: EntityData): PrefabChild { + return { + id: e.id, + modelRef: e.modelRef, + prefabRef: e.prefabRef, + transform: { + position: [e.transform.position[0], e.transform.position[1], e.transform.position[2]], + rotation: [e.transform.rotation[0], e.transform.rotation[1], e.transform.rotation[2]], + scale: [e.transform.scale[0], e.transform.scale[1], e.transform.scale[2]], + }, + tint: e.tint, + tags: e.tags, + }; +} + +/// A neutral stage to author on: no terrain, no water, no rivers — just the parts +/// and the grid. A prefab that was authored against one world's hills would look +/// wrong everywhere else. +function prefabWorkspace(prefab: PrefabData): WorldData { + const w = createEmptyWorld('__prefab__', 'Prefab: ' + prefab.name); + const n = prefab.children.length; + const ents = new Array(n); + for (let i = 0; i < n; i++) ents[i] = childToEntity(prefab.children[i]); + w.entities = ents; + return w; +} + +// --- cycle rejection --------------------------------------------------------- + +/// Would placing prefab `candidateId` inside the one we are editing create a cycle? +/// +/// A prefab that contains itself — directly, or through a chain — expands forever at +/// load time and takes the game with it. The check has to be TRANSITIVE: A holding B +/// holding A is just as fatal as A holding A, and much easier to build by accident. +export function wouldCycle(state: EditorState, candidateId: string): boolean { + const editing = state.editingPrefab; + if (!editing) return false; + if (candidateId === editing.id) return true; + + // Walk everything the candidate transitively contains, looking for ourselves. + const seen = new Set(); + const stack: string[] = [candidateId]; + while (stack.length > 0) { + const id = stack.pop() as string; + if (seen.has(id)) continue; + seen.add(id); + const p = state.catalog.prefabs.get(id); + if (!p) continue; + for (let i = 0; i < p.children.length; i++) { + const ref = p.children[i].prefabRef; + if (ref === null) continue; + if (ref === editing.id) return true; + stack.push(ref); + } + } + return false; +} + +// --- mode transitions -------------------------------------------------------- + +function beginEditing(state: EditorState, prefab: PrefabData): void { + const ids: string[] = []; + state.selection.ids.forEach((v) => { ids.push(v); }); + const stash: PrefabStash = { + world: state.world, + worldPath: state.worldPath, + undoStack: state.undoStack, + redoStack: state.redoStack, + selectionIds: ids, + selectionPrimary: state.selection.primary, + activeTool: state.activeTool, + placeAssetRef: state.placeAssetRef, + modified: state.modified, + }; + state.prefabStash = stash; + + state.editingPrefab = prefab; + state.world = prefabWorkspace(prefab); + state.worldPath = null; + // A separate history. Undoing past the start of prefab mode and landing back + // inside the world's edits would be indefensible. + state.undoStack = []; + state.redoStack = []; state.selection.ids.clear(); state.selection.primary = null; - // Clear the viewport scene — we'll render a neutral background. + state.activeTool = 'select'; + state.modified = false; rebuildAllSceneNodes(state); + + // Frame the camera on the PARTS, not on whatever level we just came from. Without + // this the camera stays pointed at a 200 m arena and a three-prop prefab is three + // pixels of dust at the origin — which is exactly how it first came up. + framePrefab(state); +} + +/// Point the camera at the prefab's contents. An empty prefab gets a small default +/// box, so the first part you place lands in view instead of behind you. +function framePrefab(state: EditorState): void { + const ents = state.world.entities; + if (ents.length === 0) { + frameCameraOnBounds(state, [-2, 0, -2], [2, 2, 2]); + return; + } + let mnx = 1e9; let mny = 1e9; let mnz = 1e9; + let mxx = -1e9; let mxy = -1e9; let mxz = -1e9; + for (let i = 0; i < ents.length; i++) { + const p = ents[i].transform.position; + if (p[0] < mnx) mnx = p[0]; + if (p[1] < mny) mny = p[1]; + if (p[2] < mnz) mnz = p[2]; + if (p[0] > mxx) mxx = p[0]; + if (p[1] > mxy) mxy = p[1]; + if (p[2] > mxz) mxz = p[2]; + } + // Pad, so a single part (a degenerate box) still gets a sane orbit distance. + const pad = 1.5; + frameCameraOnBounds(state, + [mnx - pad, mny - pad, mnz - pad], + [mxx + pad, mxy + pad + 1.0, mxz + pad]); +} + +/// Enter prefab edit mode with a new, empty prefab. +export function enterNewPrefabMode(state: EditorState, id: string, name: string): void { + if (state.editingPrefab) return; + beginEditing(state, createEmptyPrefab(id, name)); } -// Enter prefab edit mode for an existing prefab (loaded from catalog). +/// Enter prefab edit mode for an existing prefab from the catalog. export function enterPrefabEditMode(state: EditorState, prefabId: string): void { + if (state.editingPrefab) return; const prefab = state.catalog.prefabs.get(prefabId); if (!prefab) return; - // Deep-clone the prefab so edits don't mutate the catalog copy. - state.editingPrefab = JSON.parse(JSON.stringify(prefab)) as PrefabData; - state.selection.ids.clear(); - state.selection.primary = null; - rebuildAllSceneNodes(state); + // Deep clone: edits must not touch the catalog copy until Save says so. + beginEditing(state, JSON.parse(JSON.stringify(prefab)) as PrefabData); } -// Exit prefab edit mode, returning to the world. +/// Leave prefab mode and put the world back exactly as it was. export function exitPrefabMode(state: EditorState): void { - state.editingPrefab = null; + const stash = state.prefabStash; + if (!stash) { state.editingPrefab = null; return; } + state.world = stash.world; + state.worldPath = stash.worldPath; + state.undoStack = stash.undoStack; + state.redoStack = stash.redoStack; state.selection.ids.clear(); - state.selection.primary = null; + for (let i = 0; i < stash.selectionIds.length; i++) { + state.selection.ids.add(stash.selectionIds[i]); + } + state.selection.primary = stash.selectionPrimary; + state.activeTool = stash.activeTool; + state.placeAssetRef = stash.placeAssetRef; + state.modified = stash.modified; + state.editingPrefab = null; + state.prefabStash = null; rebuildAllSceneNodes(state); } -// Save the current prefab to disk and update the catalog. +/// Write the prefab to disk and refresh the catalog, so it is immediately placeable +/// without a restart. export function savePrefabToDisk(state: EditorState): boolean { - if (!state.editingPrefab || !state.project) return false; const prefab = state.editingPrefab; + if (!prefab || !state.project) return false; + + // The children ARE the workspace entities. That is the whole design. + const n = state.world.entities.length; + const kids = new Array(n); + for (let i = 0; i < n; i++) kids[i] = entityToChild(state.world.entities[i]); + prefab.children = kids; + const path = state.project.prefabsDir + '/' + prefab.id + '.prefab.json'; const result = savePrefab(path, prefab); if (result.ok) { - // Update the catalog entry. state.catalog.prefabs.set(prefab.id, JSON.parse(JSON.stringify(prefab)) as PrefabData); if (!state.catalog.prefabOrder.includes(prefab.id)) { state.catalog.prefabOrder.push(prefab.id); } + state.modified = false; } return result.ok; } -// Add a child to the currently editing prefab. -export function addPrefabChild( - state: EditorState, - childId: string, - modelRef: string | null, - prefabRef: string | null, - position: Vec3Lit, -): void { - if (!state.editingPrefab) return; - const child: PrefabChild = { - id: childId, - modelRef: modelRef, - prefabRef: prefabRef, - transform: { - position: position, - rotation: [0, 0, 0], - scale: [1, 1, 1], - }, - tint: null, - tags: [], - }; - state.editingPrefab.children.push(child); -} +// --- per-frame --------------------------------------------------------------- -// Draw the prefab mode breadcrumb bar (called from main loop when editing). export function drawPrefabBreadcrumb(state: EditorState, screenW: number): void { if (!state.editingPrefab) return; - const y = Theme.toolbarHeight; - const text = 'Editing prefab: ' + state.editingPrefab.name + ' [ESC to exit, Ctrl+S to save]'; - drawText(text, 12, y + 4, Theme.fontSizeSmall, Theme.textAccent); + // Over the VIEWPORT, not at x=12 — which is the outliner, and the breadcrumb was + // landing straight on top of its heading. + const x = state.viewportLeft + 12; + const y = Theme.toolbarHeight + 6; + const n = state.world.entities.length; + const dirty = state.modified ? ' *' : ''; + const text = 'PREFAB: ' + state.editingPrefab.name + dirty + + ' (' + n + ' parts) [Ctrl+S save · ESC exit]'; + drawText(text, x, y, Theme.fontSizeSmall, Theme.textAccent); } -// Per-frame update. Handles ESC to exit and Ctrl+S to save. export function updatePrefabTool(state: EditorState): void { if (!state.editingPrefab) return; - - if (isKeyPressed(Key.ESCAPE)) { - exitPrefabMode(state); - } + if (isKeyPressed(Key.ESCAPE)) exitPrefabMode(state); } diff --git a/src/ui/layouts/asset-panel.ts b/src/ui/layouts/asset-panel.ts index 2bc60e6..e5b0e8b 100644 --- a/src/ui/layouts/asset-panel.ts +++ b/src/ui/layouts/asset-panel.ts @@ -3,9 +3,17 @@ import { getScreenWidth, getScreenHeight } from 'bloom'; import { UiContext } from '../ui-context'; -import { beginPanel, endPanel, label, labelSmall, listRow, separator, toolButton } from '../widgets'; +import { beginPanel, endPanel, label, labelSmall, listRow, separator, toolButton, button } from '../widgets'; +import { textInput, Ref } from '../text-input'; import { Theme } from '../theme'; import { EditorState } from '../../state/editor-state'; +import { + enterNewPrefabMode, enterPrefabEditMode, exitPrefabMode, savePrefabToDisk, +} from '../../tools/prefab-tool'; + +// The name field for "New Prefab". Module-scope because it must survive between +// frames — an immediate-mode text field with a per-frame Ref forgets what you typed. +const newPrefabName: Ref = { value: '' }; export function drawAssetPanel(ui: UiContext, state: EditorState): void { const screenW = getScreenWidth(); @@ -95,22 +103,80 @@ function drawPrefabList( ui: UiContext, state: EditorState, panelX: number, panelW: number, ): void { + // While a prefab is open, this tab is where you get OUT of it — offering to open a + // second one from inside the first is how you lose work. + if (state.editingPrefab) { + labelSmall(ui, 'Editing: ' + state.editingPrefab.name); + labelSmall(ui, 'Place parts from the Models tab.'); + ui.cursorY += Theme.spacing; + if (button(ui, 'prefab_save', 'Save Prefab (Ctrl+S)')) savePrefabToDisk(state); + if (button(ui, 'prefab_exit', 'Exit (ESC)')) exitPrefabMode(state); + return; + } + + // --- New prefab: name field + button. + const fieldW = panelW - Theme.padding * 2; + textInput(ui, 'new_prefab_name', newPrefabName, ui.cursorX, ui.cursorY, fieldW); + ui.cursorY += Theme.rowHeight + Theme.spacing; + + const nm = newPrefabName.value.trim(); + if (button(ui, 'new_prefab', '+ New Prefab')) { + // An unnamed prefab is a file you will never find again. Fall back to a + // sequential name rather than refusing the click and saying nothing. + const name = nm.length > 0 ? nm : ('prefab_' + (state.catalog.prefabOrder.length + 1)); + enterNewPrefabMode(state, slugify(name), name); + newPrefabName.value = ''; + } + + separator(ui); + const order = state.catalog.prefabOrder; if (order.length === 0) { - labelSmall(ui, 'No prefabs found'); + labelSmall(ui, 'No prefabs yet.'); + labelSmall(ui, 'Name one above and hit + New Prefab.'); return; } + + let selectedId: string | null = null; for (let i = 0; i < order.length; i++) { const prefabId = order[i]; const prefab = state.catalog.prefabs.get(prefabId); if (!prefab) continue; const selected = state.placeAssetRef === 'prefab:' + prefabId; - if (listRow(ui, 'prefab_' + i, prefab.name, selected, 0)) { + if (selected) selectedId = prefabId; + const n = prefab.children.length; + const rowText = prefab.name + ' (' + n + ')'; + if (listRow(ui, 'prefab_' + i, rowText, selected, 0)) { state.placeAssetRef = 'prefab:' + prefabId; state.activeTool = 'place'; + selectedId = prefabId; } } + + // Edit the selected one. (No double-click: the UI context has no notion of one, + // and inventing a hidden gesture is worse than a visible button.) + if (selectedId !== null) { + ui.cursorY += Theme.spacing; + const p = state.catalog.prefabs.get(selectedId); + const label2 = 'Edit "' + (p ? p.name : selectedId) + '"'; + if (button(ui, 'edit_prefab', label2)) enterPrefabEditMode(state, selectedId); + } +} + +/// Lowercase, non-alphanumerics to underscores — this becomes a filename and a +/// stable id that world files reference by string. +function slugify(name: string): string { + let out = ''; + const lower = name.toLowerCase(); + for (let i = 0; i < lower.length; i++) { + const c = lower.charAt(i); + const code = lower.charCodeAt(i); + const isNum = code >= 48 && code <= 57; + const isAlpha = code >= 97 && code <= 122; + out = out + (isNum || isAlpha ? c : '_'); + } + return out.length > 0 ? out : 'prefab'; } function collectCategories(state: EditorState): string[] { diff --git a/src/ui/layouts/brush-panel.ts b/src/ui/layouts/brush-panel.ts index ceb700f..85dab09 100644 --- a/src/ui/layouts/brush-panel.ts +++ b/src/ui/layouts/brush-panel.ts @@ -1,20 +1,45 @@ // Brush settings panel — visible when the brush tool is active. -// Controls: brush kind radio, radius slider, strength slider, flatten target. +// +// Two brushes share it: the sculpt brush (raise/lower/smooth/flatten), which +// moves heights, and the paint brush, which writes splat weights. Paint needs a +// layer to paint INTO, so when it is selected the panel grows a layer list: the +// layers are the world's (`terrain.layers`), the swatch beside each is the mask +// colour the viewport tints it with, and `+ Add layer` picks a texture out of +// the project's textures dir. +// +// The swatch is a MASK colour, not a thumbnail of the texture — see +// `terrainLayerMaskColor` in the engine. The viewport shows you *coverage*; the +// game is what shows you the material. +import { drawRect, drawText } from 'bloom'; +import { terrainLayerMaskColor } from 'bloom/world'; import { UiContext } from '../ui-context'; -import { beginPanel, endPanel, labelSmall, separator, dragFloat, toggleButton, button, Ref } from '../widgets'; -import { Theme } from '../theme'; +import { beginPanel, endPanel, label, labelSmall, separator, dragFloat, toggleButton, button, listRow, Ref } from '../widgets'; +import { Theme, UiColor } from '../theme'; import { EditorState, BrushSettings } from '../../state/editor-state'; import { runCommand } from '../../state/commands'; import { CreateTerrainCommand } from '../../state/commands/create-terrain'; +import { AddTerrainLayerCommand, RemoveTerrainLayerCommand } from '../../state/commands/terrain-paint'; +import { basenameNoExt } from '../../io/paths'; + +// Is the "+ Add layer" texture picker open? Panel-local: it is not world data, +// it must not be undoable, and it must not survive a tool switch. +let pickingTexture = false; + +function maskColor(i: number): UiColor { + const c = terrainLayerMaskColor(i); + return { r: c[0] * 255, g: c[1] * 255, b: c[2] * 255, a: 255 }; +} export function drawBrushPanel(ui: UiContext, state: EditorState): void { - if (state.activeTool !== 'brush') return; + if (state.activeTool !== 'brush') { + pickingTexture = false; + return; + } const px = Theme.outlinerWidth + 10; const py = Theme.toolbarHeight + 10; - const pw = 220; - const ph = 240; + const pw = 240; // Terrain-less world: offer explicit creation instead of sculpting into a // silently materialized heightmap. @@ -29,33 +54,40 @@ export function drawBrushPanel(ui: UiContext, state: EditorState): void { return; } - beginPanel(ui, 'brush_panel', px, py, pw, ph, 'Brush Settings'); - + const terrain = state.world.terrain; const brush = state.brush; + const painting = brush.kind === 'paint'; - // Kind toggles. - const kinds: BrushSettings['kind'][] = ['raise', 'lower', 'smooth', 'flatten']; + // The panel grows with the layer list, and again with the picker. A fixed 240 + // was fine when it held four toggles and two sliders. + let ph = 250; + if (painting) { + ph = ph + 60 + terrain.layers.length * Theme.rowHeight; + if (pickingTexture) ph = ph + 40 + state.catalog.textureOrder.length * Theme.rowHeight; + } + + beginPanel(ui, 'brush_panel', px, py, pw, ph, 'Brush Settings'); + + const kinds: BrushSettings['kind'][] = ['raise', 'lower', 'smooth', 'flatten', 'paint']; for (let i = 0; i < kinds.length; i++) { if (toggleButton(ui, 'brush_kind_' + kinds[i], kinds[i], brush.kind === kinds[i])) { brush.kind = kinds[i]; + if (brush.kind !== 'paint') pickingTexture = false; } } separator(ui); - // Radius. const radiusRef: Ref = { value: brush.radius }; if (dragFloat(ui, 'brush_radius', 'Radius', radiusRef, 0.1, 1, 30)) { brush.radius = radiusRef.value; } - // Strength. const strengthRef: Ref = { value: brush.strength }; if (dragFloat(ui, 'brush_strength', 'Strength', strengthRef, 0.01, 0.01, 2.0)) { brush.strength = strengthRef.value; } - // Flatten target height (only when kind === 'flatten'). if (brush.kind === 'flatten') { const targetRef: Ref = { value: brush.targetHeight }; if (dragFloat(ui, 'brush_target', 'Target H', targetRef, 0.1, -50, 50)) { @@ -63,5 +95,76 @@ export function drawBrushPanel(ui: UiContext, state: EditorState): void { } } + if (painting) drawLayerList(ui, state); + endPanel(ui); } + +function drawLayerList(ui: UiContext, state: EditorState): void { + const terrain = state.world.terrain; + if (!terrain) return; + const brush = state.brush; + + separator(ui); + label(ui, 'Splat layers'); + + if (terrain.layers.length === 0) { + labelSmall(ui, 'None yet — add one to paint.', Theme.textDim); + } + + for (let i = 0; i < terrain.layers.length; i++) { + const rowY = ui.cursorY; + + // Indent 1 leaves room for the swatch, which is overdrawn rather than laid + // out beside the row: listRow owns the row's hit-test and cursor advance. + if (listRow(ui, 'brush_layer_' + i, terrain.layers[i].id, i === brush.activeLayerIdx, 1)) { + brush.activeLayerIdx = i; + } + + const sw = 9; + drawRect(ui.panelX + Theme.padding, rowY + (Theme.rowHeight - sw) / 2, sw, sw, maskColor(i)); + + // Delete, right-aligned. Drawn after listRow so its hit-test wins the click. + if (miniButton(ui, 'brush_layer_del_' + i, 'x', ui.panelX + ui.panelW - 22, rowY + 4)) { + runCommand(state, new RemoveTerrainLayerCommand(i)); + return; // The list being iterated just changed length. + } + } + + if (!pickingTexture) { + if (button(ui, 'brush_add_layer', '+ Add layer')) pickingTexture = true; + if (terrain.layers.length > 0) { + labelSmall(ui, 'LMB paint - Shift+LMB erase', Theme.textDim); + } + return; + } + + // --- texture picker --- + separator(ui); + const textures = state.catalog.textureOrder; + if (textures.length === 0) { + labelSmall(ui, 'No textures in project.', Theme.textDim); + } + for (let i = 0; i < textures.length; i++) { + const relPath = textures[i]; + if (listRow(ui, 'brush_tex_' + i, basenameNoExt(relPath), false, 1)) { + // The layer id is the texture's basename: short, stable, and already what + // the list shows — so a level author never has to name anything. + runCommand(state, new AddTerrainLayerCommand(basenameNoExt(relPath), relPath, 1.0)); + pickingTexture = false; + return; + } + } + if (button(ui, 'brush_pick_cancel', 'Cancel')) pickingTexture = false; +} + +/// A one-glyph square button at an explicit position, for the per-row delete. +/// `button` is full-width and cursor-driven, so it cannot sit inside a row. +function miniButton(ui: UiContext, id: string, text: string, x: number, y: number): boolean { + const s = 16; + const hovered = ui.mouseX >= x && ui.mouseX < x + s && ui.mouseY >= y && ui.mouseY < y + s; + if (hovered) { ui.hotId = id; ui.mouseCaptured = true; } + drawRect(x, y, s, s, hovered ? Theme.textError : Theme.button); + drawText(text, x + 5, y + 2, Theme.fontSizeSmall, Theme.text); + return hovered && ui.mousePressedLeft; +} diff --git a/src/ui/layouts/inspector.ts b/src/ui/layouts/inspector.ts index 3b0c396..b15f119 100644 --- a/src/ui/layouts/inspector.ts +++ b/src/ui/layouts/inspector.ts @@ -403,5 +403,7 @@ function drawColorFields(ui: UiContext, idPrefix: string, color: number[]): bool } function clamp01(v: number): number { - return v < 0 ? 0 : (v > 1 ? 1 : v); + if (v < 0) return 0; + if (v > 1) return 1; + return v; } diff --git a/src/ui/layouts/status-bar.ts b/src/ui/layouts/status-bar.ts index 9fb91cd..6c79eb8 100644 --- a/src/ui/layouts/status-bar.ts +++ b/src/ui/layouts/status-bar.ts @@ -27,6 +27,11 @@ export function drawStatusBar(state: EditorState): void { drawText('selected: ' + state.selection.primary, 220, ty, Theme.fontSizeSmall, Theme.textAccent); } + // Transient message — a refused action has to SAY it was refused. + if (state.statusMessageT > 0 && state.statusMessage.length > 0) { + drawText(state.statusMessage, 440, ty, Theme.fontSizeSmall, Theme.textError); + } + // Modified indicator. if (state.modified) { drawText('* modified', screenW - 100, ty, Theme.fontSizeSmall, Theme.textError); diff --git a/src/ui/layouts/toolbar.ts b/src/ui/layouts/toolbar.ts index b03afd4..340eb56 100644 --- a/src/ui/layouts/toolbar.ts +++ b/src/ui/layouts/toolbar.ts @@ -1,6 +1,7 @@ // Top toolbar: File actions (New, Open, Save) + tool selection buttons. import { drawRect, getScreenWidth } from 'bloom'; +import { launchGame } from '../../playtest/launch'; import { UiContext } from '../ui-context'; import { toolButton } from '../widgets'; import { Theme } from '../theme'; @@ -42,6 +43,18 @@ export function drawToolbar(ui: UiContext, state: EditorState): void { drawRect(x, y + 2, 1, Theme.buttonHeight - 4, Theme.border); x += 8; + // Play-in-editor. Runs the REAL game on the level currently on screen — not on + // what is saved, on what you are looking at. Only shown when the project actually + // names a game to run; a dead button is worse than no button. + if (state.project && state.project.playCommand.length > 0) { + if (toolButton(ui, 'tb_play', 'Play', x, y, bw + 8, false)) { + launchGame(state); + } + x += bw + 8 + gap; + drawRect(x, y + 2, 1, Theme.buttonHeight - 4, Theme.border); + x += 8; + } + // Tool buttons. Water/river were reachable only by hotkey (T/Y) before. const tools: [ToolId, string][] = [ ['select', 'Sel'], diff --git a/src/viewport/ray.ts b/src/viewport/ray.ts index 913a017..476625a 100644 --- a/src/viewport/ray.ts +++ b/src/viewport/ray.ts @@ -143,5 +143,7 @@ function unprojectPoint( } function clamp01(v: number): number { - return v < 0 ? 0 : (v > 1 ? 1 : v); + if (v < 0) return 0; + if (v > 1) return 1; + return v; }