Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 78 additions & 7 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path>`, 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 "<name>"`
> 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`.
Expand Down
21 changes: 21 additions & 0 deletions src/io/asset-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
15 changes: 14 additions & 1 deletion src/io/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions src/io/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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;
Expand Down
19 changes: 17 additions & 2 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down
54 changes: 54 additions & 0 deletions src/playtest/launch.ts
Original file line number Diff line number Diff line change
@@ -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 <path>`), 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 + ')');
}
Loading