HardYards/web/world/js/editor.js

1382 lines
55 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* SHADES / HARD YARDS — the yard editor. Lane A owns this file (SPRINT14 gate 1).
*
* docs/MANUAL.md's "authoring a site" cookbook, with the typing removed. It
* renders the REAL dressed world through the SAME `loadSite`/`createWorld`
* path the game boots, so what you place is what you play — this is not a
* diagram and there is deliberately no second renderer to keep in sync.
*
* Two rules it exists to enforce, both of them from THREADS:
*
* 1. **No parallel schema.** Placement writes the same named-key site-JSON
* shapes `world.js` already reads. There is no generic `props[]` in this
* repo on purpose (the gnome pattern) and the editor does not invent one.
* If you can't express a thing as a named key, the answer is a loader
* change plus a palette entry — not an editor-private format.
* 2. **The editor cannot make a yard the game can't boot.** `validateSite`
* runs on every edit into a panel (not the console), an invalid yard
* exports under a loud `_INVALID` key, and a.test round-trips a placed
* yard through export → `loadSite` → `createWorld` → `dress` every run.
*
* ── THE SEAM API (Lane B's SCORE IT, Lane C's wind authoring) ───────────────
* `globalThis.EDITOR`, also the resolved value of `createEditor()`:
*
* scene, renderer, camera three.js handles (camera is a PerspectiveCamera)
* world the live world from createWorld (null before first build)
* site the LIVE in-memory site object — mutate it, then markDirty()
* siteClone() deep clone, canonically ordered (what export would write)
* setSite(obj) → Promise; replace the whole site and rebuild
* rebuild() → Promise; re-run createWorld + dress against `site`
* markDirty() re-validate + re-render panels + fire 'change'
* validation() → { ok, errors:string[], warnings:string[] }
* exportJSON() → canonical JSON string (stable key order, 2-space)
* on(evt, fn) / off(evt, fn) 'change' | 'rebuild' | 'select' | 'siteload'
* mountPanel({id,title,order}) → { root, body } — idempotent per id; see editor.html
* overlay THREE.Group, CLEARED ON EVERY REBUILD — put gizmos here
* raycastGround(ev) → {x,y,z} | null (y = heightAt(x,z))
* registerTool(spec)/setTool(id) pointer tools; see `registerTool` below
* select(ref)/selection ref = {kind, id} — see REFS
* HEIGHT_AT world.js's heightAt, re-exported for gizmo placement
*
* Events carry `{ site }` (change/siteload), `{ world, site }` (rebuild) or
* `{ ref }` (select). C: mutate `EDITOR.site.wind` then `markDirty()` — the
* wind block round-trips through the same canonical export as everything else,
* and `validateSiteWind` is already wired into `validateSite`, so a bad gain
* lands in the validation panel for free.
*
* ── WHY REBUILD-ON-DROP ────────────────────────────────────────────────────
* Dragging translates the entry's scene nodes live (cheap, one object). The
* WORLD is rebuilt on drop, because anchors are the thing downstream cares
* about and only `createWorld` + `dress` produce them honestly: a GLB-backed
* anchor's position and its `rating_hint` come out of the asset, not out of
* arithmetic the editor could do itself. Re-deriving them here would be a
* fourth harness rebuilding site truth by hand — the exact mistake
* docs/MANUAL.md's `windForSite()` gotcha is a monument to. Measured on this
* machine: a warm rebuild of backyard_01 is ~40-90 ms, which is a drop, not a
* drag. If that ever stops being true, cache by model name — don't re-derive.
*/
import * as THREE from '../vendor/three.module.js';
import { ANCHOR_TYPE, createStubWind } from './contracts.js';
import { createWorld, heightAt, loadSite, validateSite } from './world.js';
// ---------------------------------------------------------------------------
// Canonical serialisation — "same yard in, byte-identical JSON out"
// ---------------------------------------------------------------------------
/**
* Key order per shape. Site diffs are reviewed by humans, so a key that moves
* because an editor session happened to set it second is noise that hides the
* one number that actually changed.
*
* The rule is total and therefore deterministic: keys named here come first in
* THIS order, then every remaining key sorted alphabetically. Insertion order
* is never consulted — it isn't stable across a load/edit/export cycle, which
* is precisely what "byte-identical" has to survive.
*
* `_`-prefixed authoring notes are listed in place rather than swept to the
* end: this repo expects levels to carry their design notes, and a `_design`
* block that reads BEFORE the geometry it explains is the reason the corner
* block's carport is still legible three sprints later.
*/
const KEY_ORDER = {
$root: [
'id', 'name', 'blurb', '_blurb', '_design', '_why',
'yard', 'sun', 'gardenBed', 'gardenBedModel',
'house', 'structures', 'trees', 'posts',
'fence', 'shed', 'shedTable', 'gnome', 'bike',
'separation', 'wind',
],
yard: ['width', 'depth'],
sun: ['elevationDeg', 'azimuthDeg'],
gardenBed: ['x', 'z', 'w', 'd'],
house: [
'model', 'wreckedModel', 'collateralKey', 'collateralValue', 'collateralLabel',
'_collateral', 'x', 'z', 'rotYDeg', 'anchors',
],
structure: [
'id', 'model', 'wreckedModel', 'x', 'z', 'rotYDeg', 'solid',
'collateralKey', 'collateralValue', 'collateralLabel', '_collateral', '_types', 'anchors',
],
tree: ['id', 'model', 'x', 'z', 'rotYDeg', 'phase', 'trunkH', 'anchorY', '_why', 'anchors'],
anchor: ['id', 'node', 'type', 'work'],
post: ['id', 'x', 'z', 'h', 'type', 'work'],
fence: ['sides'],
shed: ['model', 'x', 'z', 'rotYDeg'],
shedTable: ['model', 'x', 'z', 'rotYDeg', 'pickupNode'],
gnome: ['model', 'x', 'z', 'rotYDeg', 'collateralValue', '_why'],
bike: ['model', 'x', 'z', 'rotYDeg', '_why'],
wind: ['_venturi', 'venturi'],
venturi: ['x', 'z', 'axis', 'gain', 'radius', 'sharp'],
separation: ['_why', '_playedReference', 'stormKey', 'tension', 'line',
'heldMustExceed', 'bareMustLoseBelow', 'separationAtLeast'],
line: ['anchor', 'hw'],
};
/** Which KEY_ORDER table applies to the objects inside a given key. */
const CHILD_SHAPE = {
structures: 'structure', trees: 'tree', posts: 'post', anchors: 'anchor',
venturi: 'venturi', line: 'line',
};
/**
* Deep-clone `value` with every object's keys in canonical order.
* Pure: it reads, it never rounds. Rounding happens at EDIT time (see
* `round3`) so that export stays a plain serialisation and the in-memory site
* and the exported file can never disagree about a coordinate.
*/
function canonical(value, shape = '$root') {
if (Array.isArray(value)) {
const child = CHILD_SHAPE[shape] ?? shape;
return value.map((v) => canonical(v, child));
}
if (value === null || typeof value !== 'object') return value;
const order = KEY_ORDER[shape] ?? [];
const known = order.filter((k) => Object.hasOwn(value, k));
const rest = Object.keys(value).filter((k) => !order.includes(k)).sort();
const out = {};
// A child's shape name is its KEY. That single rule covers both cases:
// `yard` finds KEY_ORDER.yard, and `posts` reaches the array branch which
// maps it through CHILD_SHAPE to `post`. An unknown key finds no table and
// sorts alphabetically, which is still deterministic.
for (const k of [...known, ...rest]) out[k] = canonical(value[k], k);
return out;
}
/**
* The export. `_INVALID` first and unmissable when the yard doesn't validate —
* an invalid site must still be exportable (you save your work mid-build) but
* it must not be able to land in `data/sites/` looking clean. `loadSite` would
* throw on it anyway; the key is there so a HUMAN sees it in the diff first.
*
* @param {object} site
* @param {{ok:boolean, errors:string[]}} [validation]
* @returns {string}
*/
export function exportSiteJSON(site, validation) {
const body = canonical(site, '$root');
const out = (validation && !validation.ok)
? { _INVALID: [
'THIS YARD DOES NOT VALIDATE — the game cannot boot it. Exported so you',
'do not lose the work. Fix these and re-export before it goes anywhere',
'near web/world/data/sites/:',
...validation.errors,
], ...body }
: body;
return `${JSON.stringify(out, null, 2)}\n`;
}
/** Coordinates are rounded where they're WRITTEN, so export stays pure. */
const round3 = (n) => Math.round(n * 1000) / 1000;
// ---------------------------------------------------------------------------
// The palette — every placeable, and the exact named-key shape it writes
// ---------------------------------------------------------------------------
/**
* Node names are the ones actually baked in the committed GLBs (read out of
* the files, not remembered). They are checked after every rebuild by
* `adoptionWarnings()`: a palette entry that names a node the asset doesn't
* have would otherwise leave the anchor sitting at its graybox position with
* `ratingHint` 1.0 — a trap that silently becomes honest steel, which is the
* invincible-house gotcha wearing a different hat.
*/
export const PALETTE = [
{
kind: 'post', label: 'Post', hint: 'an honest ground post — the cheap, safe anchor',
make: (id, x, z) => ({ id, x, z, h: 4.0, type: 'post', work: 'cloth' }),
},
{
kind: 'tree', label: 'Gum (big, 3 anchors)', model: 'tree_gum_01_v1',
hint: 'sways — dynamic load, which is why a tree is scarier than a post',
make: (id, x, z) => ({
id, model: 'tree_gum_01_v1', x, z, phase: 0.7, trunkH: 4.2, anchorY: 3.4,
anchors: [1, 2, 3].map((i) => ({
id: i === 1 ? id : `${id}${'abc'[i - 1]}`,
node: `branch_anchor_0${i}`, type: 'tree', work: 'cloth',
})),
}),
},
{
kind: 'tree', label: 'Gum (smaller, 2 anchors)', model: 'tree_gum_02_v1',
hint: 'shorter reach; the corner block\'s honest anchor',
make: (id, x, z) => ({
id, model: 'tree_gum_02_v1', x, z, phase: 1.7, trunkH: 3.8, anchorY: 3.1,
anchors: [1, 2].map((i) => ({
id: i === 1 ? id : `${id}b`,
node: `branch_anchor_0${i}`, type: 'tree', work: 'cloth',
})),
}),
},
{
kind: 'structure', label: 'Carport (the trap)', model: 'carport_01_v1',
hint: 'four anchors that look free; rating_hint 0.22/0.30 and it takes $180 with it',
make: (id, x, z) => ({
id, model: 'carport_01_v1', wreckedModel: 'carport_01_wrecked_v1',
x, z, rotYDeg: 0, solid: true,
// `collateralKey` is written EXPLICITLY and is not optional here. The
// anchors say `collateral: "carport"`; the structure's id is whatever
// the editor generated (s1, s2, …). Without this line the $180 prices to
// null and the trap becomes a free failure — E caught it, and it is the
// gutter bug wearing the editor's hat. site JSON canonical, GLB extra as
// the fallback: the same contract as the price beside it.
collateralKey: 'carport',
collateralValue: 180, collateralLabel: 'the carport',
anchors: [
{ id: `${id}_b1`, node: 'beam_anchor_01', type: 'carport', work: 'bracket' },
{ id: `${id}_b2`, node: 'beam_anchor_02', type: 'carport', work: 'bracket' },
{ id: `${id}_p1`, node: 'post_anchor_01', type: 'carport_post', work: 'cloth' },
{ id: `${id}_p2`, node: 'post_anchor_02', type: 'carport_post', work: 'cloth' },
],
}),
},
{
// SPRINT14 gate 3.1, E's "honest middle". Two apex anchors at 0.45 —
// better steel than the house fascia (0.35), worse than a gum fork — and a
// crossbar that is the most anchor-looking object in the game and carries
// `tie_off: false`. $140 is E's number, ADOPTED unchanged; the ruling and
// the one condition for revisiting it are in THREADS.
// ⚠️ The `_v1` suffix is LOAD-BEARING and its absence is silent. These two
// entries shipped without it (SPRINT14, D's cold pass): the GLB 404s, the
// prop never draws, and because `adoptAnchor` does `rating_hint ?? 1` a
// MISSING MODEL BECOMES THE BEST STEEL IN THE GAME — the swing apexes read
// 1.00 instead of E's 0.45, the jacaranda 1.00/1.00/1.00 instead of
// 0.95/0.52/0.40 — and the yard still validates VALID. Same species as E's
// `tie_off` audit: silence is not neutral where a default is generous.
kind: 'structure', label: 'Swing set (the honest middle)', model: 'swing_set_01_v1',
requires: ['swing_frame'],
hint: 'apex anchors 0.45; the crossbar LOOKS like the answer and is not an anchor',
make: (id, x, z) => ({
id, model: 'swing_set_01_v1', wreckedModel: 'swing_set_01_wrecked_v1',
x, z, rotYDeg: 0, solid: false,
collateralKey: 'swing_set',
collateralValue: 140, collateralLabel: 'the swing set',
anchors: [1, 2].map((i) => ({
id: `${id}_f${i}`, node: `frame_anchor_0${i}`, type: 'swing_frame', work: 'cloth',
})),
}),
},
{
// The ladder IS the feature: 0.95 low, then 0.52 / 0.40 — reach for height
// on a jacaranda and you pay 58% for it, against the gum's 24%. Unpriced by
// E's own ruling (no limb-failure event to watch; billing an unseen event
// is the lie the invoice exists to kill). Same node names as the gums.
kind: 'tree', label: 'Jacaranda (height costs)', model: 'tree_jacaranda_01_v1',
hint: 'forks low and strong, then falls off a cliff — the "which tree" question',
make: (id, x, z) => ({
id, model: 'tree_jacaranda_01_v1', x, z, phase: 1.1, trunkH: 3.4, anchorY: 2.4,
anchors: [1, 2, 3].map((i) => ({
id: i === 1 ? id : `${id}${'abc'[i - 1]}`,
node: `branch_anchor_0${i}`, type: 'tree', work: 'cloth',
})),
}),
},
{
kind: 'house', label: 'House (north edge)', model: 'house_yardside_v1', singleton: true,
hint: 'three fascia anchors at rating_hint 0.35 — "the fascia board is a lie"',
make: (_id, x, z) => ({
model: 'house_yardside_v1', wreckedModel: 'house_yardside_wrecked_v1',
collateralKey: 'gutter', collateralValue: 90, collateralLabel: 'the gutter',
x, z,
anchors: [1, 2, 3].map((i) => ({
id: `h${i}`, node: `fascia_anchor_0${i}`, type: 'house', work: 'bracket',
})),
}),
},
{
kind: 'shed', label: 'Shed', model: 'shed_01_v1', singleton: true,
hint: 'scenery — no rig anchors (its only node is door_anchor)',
make: (_id, x, z) => ({ model: 'shed_01_v1', x, z, rotYDeg: -90 }),
},
{
kind: 'shedTable', label: 'Spare table', model: 'shed_table_v1', singleton: true,
hint: 'where a spare gets picked up — no table, no mid-storm repair',
make: (_id, x, z) => ({ model: 'shed_table_v1', x, z, rotYDeg: -90, pickupNode: 'pickup_anchor' }),
},
{
kind: 'gnome', label: 'Gnome ($25)', model: 'garden_gnome_01_v1', singleton: true,
hint: 'breakable client property — the gnome pattern, priced in the SITE',
make: (_id, x, z) => ({ model: 'garden_gnome_01_v1', x, z, rotYDeg: 0, collateralValue: 25 }),
},
{
kind: 'bike', label: "Kid's bike", model: 'bike_kid_01_v1', singleton: true,
hint: 'deliberately unpriced until the sim can knock it over (a tripwire pins this)',
make: (_id, x, z) => ({ model: 'bike_kid_01_v1', x, z, rotYDeg: 180 }),
},
];
/** Singletons the template always carries and you may move but not delete. */
const UNDELETABLE = new Set(['gardenBed']);
/**
* The empty template — SPRINT14 gate 1.1's "flat yard + fence ring + garden bed",
* PLUS one post, and the post is not padding.
*
* `validateSite` rejects a yard with no anchors ("nothing to rig to"), and
* `createWorld` validates before it builds anything — so a literally
* anchor-less template cannot be rendered AT ALL. Shipped as spec'd, the
* editor's front door was a black viewport with an error in the corner, which
* I only found by opening it. One post is the smallest thing that is a yard
* rather than a rectangle, and it makes the first frame a place you can stand.
*
* The lesson the anchor-less template was meant to teach survives, in a better
* place: delete this post and the validation panel says why, in those words,
* while the last valid yard stays on screen so you can put it back.
*/
export function emptyTemplate() {
return {
id: 'site_new',
name: 'Untitled yard',
blurb: '',
_design: [
'Why this yard exists, what the trap is, and what you measured. Sites are',
'data and this block is the level\'s design doc — docs/MANUAL.md step 1.',
],
yard: { width: 24, depth: 16 },
sun: { elevationDeg: 55, azimuthDeg: -125 },
gardenBed: { x: 0, z: 0, w: 5, d: 3.5 },
posts: [{ id: 'p1', x: -4, z: -3, h: 4.0, type: 'post', work: 'cloth' }],
fence: { sides: ['north', 'south', 'east', 'west'] },
wind: {},
};
}
// ---------------------------------------------------------------------------
// REFS — how the editor addresses a placed thing
// ---------------------------------------------------------------------------
// { kind: 'post'|'tree'|'structure', id } an entry in that array
// { kind: 'house'|'shed'|'shedTable'|'gnome'|'bike'|'gardenBed' } the singleton
// A ref is plain data so it survives a rebuild; nothing holds a mesh.
const ARRAY_KIND = { post: 'posts', tree: 'trees', structure: 'structures' };
/**
* Unique id in the namespace this kind occupies, e.g. p1, p2, t1, s1.
* @param {object} site @param {string} kind
*/
export function nextId(site, kind) {
const key = ARRAY_KIND[kind];
const taken = new Set((site[key] ?? []).map((e) => e.id));
const stem = kind === 'post' ? 'p' : kind === 'tree' ? 't' : 's';
for (let i = 1; i < 999; i++) if (!taken.has(`${stem}${i}`)) return `${stem}${i}`;
return `${stem}${Date.now()}`;
}
/**
* Write one palette item into a site at (x, z). Pure-ish: mutates `site` and
* returns the ref, touching nothing else.
*
* Extracted from the click handler on purpose — a.test's round-trip places its
* yard through THIS function, so the test exercises the code the mouse runs
* rather than a test-shaped imitation of it. A round-trip assert against a
* separate placement path would prove nothing about the editor.
*
* @returns {{kind:string, id?:string}}
*/
export function placeEntry(site, item, x, z) {
const key = ARRAY_KIND[item.kind];
const id = key ? nextId(site, item.kind) : null;
const entry = item.make(id, round3(x), round3(z));
if (key) {
site[key] ??= [];
site[key].push(entry);
} else {
site[item.kind] = entry;
}
return key ? { kind: item.kind, id } : { kind: item.kind };
}
const refEq = (a, b) => !!a && !!b && a.kind === b.kind && (a.id ?? null) === (b.id ?? null);
/** Every scene node world.js builds for this ref (graybox AND dressed AND wreck). */
function nodeNamesFor(ref) {
switch (ref.kind) {
case 'post': return [`sail_post_${ref.id}`];
case 'tree': return [`tree_${ref.id}`];
case 'structure': return [ref.id, `${ref.id}_wrecked`];
case 'house': return ['house_yardside', 'house_yardside_wrecked'];
case 'shed': return ['shed_01'];
case 'shedTable': return ['shed_table'];
case 'gnome': return ['garden_gnome_01'];
case 'bike': return ['bike_kid_01'];
case 'gardenBed': return ['garden_bed'];
default: return [];
}
}
// ---------------------------------------------------------------------------
/**
* Build the editor.
* @param {{canvas:HTMLCanvasElement, side:HTMLElement, toolbar:HTMLElement,
* readout:HTMLElement, hint:HTMLElement, site?:string}} opts
*/
export async function createEditor(opts) {
const { canvas, side, toolbar, readout, hint } = opts;
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(52, 1, 0.1, 400);
/** C's gizmos live here. Cleared on every rebuild — see `rebuild()`. */
const overlay = new THREE.Group();
overlay.name = 'editor_overlay';
scene.add(overlay);
/** The editor's OWN pick handles + selection ring. Never exported, never dressed. */
const handles = new THREE.Group();
handles.name = 'editor_handles';
scene.add(handles);
// The wind the editor renders with is the calm stub, deliberately: gate 1 is
// geometry, and a yard that only looks right in a gale is a yard nobody can
// place a post in. C's overlay samples the REAL site wind for the storm read
// (gate 2.2) — that's their seam, and it does not become this one.
const wind = createStubWind({ calm: true });
let site = emptyTemplate();
let world = null;
let selection = null;
let validationCache = { ok: true, errors: [], warnings: [] };
let rebuilding = false;
let rebuildMs = 0;
let lastStatus = 'template';
const listeners = new Map();
const emit = (evt, payload) => {
for (const fn of listeners.get(evt) ?? []) {
try { fn(payload); } catch (err) { console.error(`[editor] listener for '${evt}' threw:`, err); }
}
};
// --- panels ------------------------------------------------------------
const panels = new Map();
/**
* Idempotent per id — see editor.html's seam contract. `order` is what keeps
* B's card and C's wind block in a fixed place in the rail no matter which
* lane's script happens to run first.
*/
function mountPanel({ id, title, order = 100, collapsed = false }) {
if (panels.has(id)) {
const p = panels.get(id);
if (title) p.titleEl.firstChild.textContent = title;
return p;
}
const root = document.createElement('section');
root.className = 'ed-panel';
root.dataset.panel = id;
root.dataset.order = String(order);
if (collapsed) root.classList.add('collapsed');
const titleEl = document.createElement('h2');
titleEl.className = 'ed-panel-title';
titleEl.append(document.createTextNode(title ?? id));
titleEl.addEventListener('click', () => root.classList.toggle('collapsed'));
const body = document.createElement('div');
body.className = 'ed-panel-body';
root.append(titleEl, body);
side.append(root);
// Re-sort by declared order so a late mount lands in its reserved slot.
[...side.children]
.sort((a, b) => Number(a.dataset.order) - Number(b.dataset.order))
.forEach((el) => side.append(el));
const panel = { root, body, titleEl, id };
panels.set(id, panel);
return panel;
}
// --- validation --------------------------------------------------------
/**
* Anchors that name a GLB node the asset doesn't actually have.
*
* `validateSite` can't catch this — it never opens the model. But an anchor
* whose node is missing is silently NOT adopted: it keeps its graybox
* position and `ratingHint` defaults to 1, so a carport beam rated 0.22
* becomes honest steel and the site's whole trap evaporates while every test
* stays green. The tell is `collateral`: `adoptAnchor` always sets it (to
* null when the node carries none), and nothing else ever does.
*/
function adoptionWarnings() {
if (!world) return [];
const out = [];
const declared = [
...(site.house?.anchors ?? []).map((a) => [a, site.house.model]),
...(site.trees ?? []).flatMap((t) => (t.anchors ?? []).map((a) => [a, t.model])),
...(site.structures ?? []).flatMap((s) => (s.anchors ?? []).map((a) => [a, s.model])),
];
for (const [a, model] of declared) {
if (!a.node) continue;
const live = world.anchors.find((x) => x.id === a.id);
if (!live) { out.push(`anchor ${a.id}: declared but not built`); continue; }
if (!Object.hasOwn(live, 'collateral')) {
out.push(`anchor ${a.id}: node "${a.node}" not found in ${model} — it is sitting at its `
+ 'graybox position with rating_hint 1.0, so any trap on it is not real');
}
// E's `tie_off: false` (SPRINT14 gate 3.1). A door step, a bench top, a
// broom grip and a lighting hint are not steel, but `adoptAnchor` reads
// `rating_hint ?? 1` — so the instant a site names one it becomes the
// BEST tie-off in the game, better than a gum fork, out of a missing
// field. The palette doesn't offer them, but a hand-edited site or an
// imported yard can still name one, and this is the only place that can
// see it: `validateSite` never opens the GLB.
if (live.tieOff === false) {
out.push(`anchor ${a.id}: node "${a.node}" in ${model} is NOT a tie-off `
+ '(tie_off: false — it is a step/benchtop/grip, not steel). Rigging to it would '
+ 'hand you the strongest anchor in the yard by accident. Remove it.');
}
}
return out;
}
/** Runs on EVERY edit. Errors render in the panel, never the console. */
function revalidate() {
const errors = [];
try {
validateSite(structuredClone(site), site.id ?? '?');
} catch (err) {
// validateSite throws one message with the reasons on indented lines.
const lines = String(err.message).split('\n').map((s) => s.trim()).filter(Boolean);
errors.push(...lines.slice(1));
if (!errors.length) errors.push(String(err.message));
}
validationCache = { ok: errors.length === 0, errors, warnings: adoptionWarnings() };
return validationCache;
}
// --- world rebuild -----------------------------------------------------
/**
* Rebuild the yard from the live site object, through the game's own path.
*
* Guarded per-call: an invalid site (which the editor produces constantly and
* on purpose while you're mid-build) must leave the LAST GOOD world standing
* rather than blanking the viewport — you cannot fix a yard you can't see.
*/
async function rebuild() {
revalidate();
if (!validationCache.ok && world) {
// Keep the standing yard; the panel already says why.
lastStatus = 'invalid — showing last valid yard';
rebuildHandles();
renderPanels();
emit('change', { site });
return world;
}
const t0 = performance.now();
rebuilding = true;
try {
const next = createWorld(scene, { wind, site: structuredClone(site) });
if (world) world.dispose();
world = next;
await world.dress();
lastStatus = 'ok';
} catch (err) {
lastStatus = `build failed: ${err.message}`;
validationCache.errors.push(`createWorld: ${err.message}`);
validationCache.ok = false;
} finally {
rebuilding = false;
rebuildMs = performance.now() - t0;
}
// C's gizmos are rebuilt from the site, so the editor owns clearing them —
// a stale arrow over a moved venturi is the wind-authoring cousin of the
// phantom sail, and one lane clearing is better than two lanes remembering.
overlay.clear();
revalidate();
rebuildHandles();
renderPanels();
emit('rebuild', { world, site });
emit('change', { site });
return world;
}
// --- pick handles ------------------------------------------------------
const HANDLE_MAT = new THREE.MeshBasicMaterial({ visible: false });
const RING_MAT = new THREE.MeshBasicMaterial({ color: 0x7ee0ff, transparent: true, opacity: 0.85 });
const RING_GEO = new THREE.RingGeometry(0.72, 0.92, 28).rotateX(-Math.PI / 2);
const DOT_GEO = new THREE.RingGeometry(0.34, 0.44, 20).rotateX(-Math.PI / 2);
const DOT_MAT = new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.28 });
/**
* The editor picks its OWN invisible handles rather than the dressed meshes.
*
* Two reasons, both learned elsewhere in this repo: raycasting a GLB gives
* you a leaf mesh and no way back to the site entry that spawned it, and
* `world.js`'s node naming is world.js's business — coupling the editor's
* picking to it would make a rename in the loader a silent break in the
* editor. This is the same trick rigging.js uses for anchor markers
* (`userData.anchorId`), which is precedent, not novelty.
*/
function rebuildHandles() {
handles.clear();
for (const ref of allRefs()) {
const e = entryFor(ref);
if (!e) continue;
const y = heightAt(e.x, e.z);
const box = new THREE.Mesh(new THREE.CylinderGeometry(0.85, 0.85, 3.4, 10), HANDLE_MAT);
box.position.set(e.x, y + 1.7, e.z);
box.userData.ref = ref;
handles.add(box);
const dot = new THREE.Mesh(DOT_GEO, DOT_MAT);
dot.position.set(e.x, y + 0.04, e.z);
dot.renderOrder = 2;
handles.add(dot);
if (refEq(ref, selection)) {
const ring = new THREE.Mesh(RING_GEO, RING_MAT);
ring.position.set(e.x, y + 0.05, e.z);
ring.renderOrder = 3;
handles.add(ring);
}
}
}
function allRefs() {
const out = [];
for (const [kind, key] of Object.entries(ARRAY_KIND)) {
for (const e of site[key] ?? []) out.push({ kind, id: e.id });
}
for (const kind of ['house', 'shed', 'shedTable', 'gnome', 'bike', 'gardenBed']) {
if (site[kind]) out.push({ kind });
}
return out;
}
function entryFor(ref) {
if (!ref) return null;
const key = ARRAY_KIND[ref.kind];
if (key) return (site[key] ?? []).find((e) => e.id === ref.id) ?? null;
return site[ref.kind] ?? null;
}
function labelFor(ref) {
const e = entryFor(ref);
if (!e) return ref.kind;
if (ARRAY_KIND[ref.kind]) return `${ref.kind} ${ref.id}`;
return ref.kind;
}
// --- placement / move / delete ----------------------------------------
function place(paletteItem, x, z) {
selection = placeEntry(site, paletteItem, x, z);
emit('select', { ref: selection });
return rebuild();
}
/**
* Move without a rebuild — drag feedback. The site entry is written and the
* entry's scene nodes TRANSLATE by the delta.
*
* Translation, not assignment, on all three axes: world.js places a dressed
* GLB at `(x, heightAt(x,z), z)` but leaves the graybox house group at the
* origin with its offsets in the children, so assigning y would teleport the
* graybox and only the graybox. A delta is correct for both, which is the
* point of never assuming another module's local frame.
*/
function moveTo(ref, x, z) {
const e = entryFor(ref);
if (!e) return;
const nx = round3(x), nz = round3(z);
const dx = nx - e.x;
const dz = nz - e.z;
const dy = heightAt(nx, nz) - heightAt(e.x, e.z);
e.x = nx;
e.z = nz;
for (const name of nodeNamesFor(ref)) {
const node = world?.root?.getObjectByName(name);
if (node) node.position.set(node.position.x + dx, node.position.y + dy, node.position.z + dz);
}
rebuildHandles();
}
function remove(ref) {
if (!ref || UNDELETABLE.has(ref.kind)) return false;
const key = ARRAY_KIND[ref.kind];
if (key) {
const i = (site[key] ?? []).findIndex((e) => e.id === ref.id);
if (i < 0) return false;
site[key].splice(i, 1);
if (!site[key].length) delete site[key];
} else {
if (!site[ref.kind]) return false;
delete site[ref.kind];
}
selection = null;
emit('select', { ref: null });
rebuild();
return true;
}
// --- camera: orbit / pan / dolly --------------------------------------
const cam = { target: new THREE.Vector3(0, 1, 0), dist: 26, yaw: 0.6, pitch: 0.62 };
function applyCamera() {
cam.pitch = Math.max(0.06, Math.min(1.45, cam.pitch));
cam.dist = Math.max(4, Math.min(120, cam.dist));
const r = cam.dist * Math.cos(cam.pitch);
camera.position.set(
cam.target.x + r * Math.sin(cam.yaw),
cam.target.y + cam.dist * Math.sin(cam.pitch),
cam.target.z + r * Math.cos(cam.yaw),
);
camera.lookAt(cam.target);
}
// --- pointer -----------------------------------------------------------
const ray = new THREE.Raycaster();
const ndc = new THREE.Vector2();
const GROUND_PLANE = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
const _hit = new THREE.Vector3();
function setNdc(ev) {
const r = canvas.getBoundingClientRect();
ndc.set(((ev.clientX - r.left) / r.width) * 2 - 1, -((ev.clientY - r.top) / r.height) * 2 + 1);
ray.setFromCamera(ndc, camera);
}
/**
* Where on the ground the cursor is. Two steps on purpose: the flat y=0 plane
* gives the xz, then `heightAt` gives the true ground y there. Raycasting the
* terrain mesh instead would be 4800 triangles to learn something world.js
* already answers in closed form (its own comment says so).
*/
function raycastGround(ev) {
setNdc(ev);
if (!ray.ray.intersectPlane(GROUND_PLANE, _hit)) return null;
return { x: _hit.x, y: heightAt(_hit.x, _hit.z), z: _hit.z };
}
/**
* How near the ground point counts as "you meant that one", in metres. A
* shade post is ~0.1 m of actual geometry, so picking has to be generous or
* authoring becomes a game of hunting pixels.
*/
const PICK_GROUND_RADIUS = 1.3;
/**
* What did the user click on?
*
* TWO passes, and the second is not a nicety — it is the fix for a bug this
* file shipped with for an afternoon. Pass one raycasts the invisible body
* handles, which is right when you click an object's MIDDLE. But a handle is
* a cylinder centred 1.7 m up, and from a raised editor camera the ray
* through the pixel at an object's BASE has already travelled ~2 m
* horizontally by the time it climbs to that height — so it misses the
* cylinder completely. Clicking the foot of the carport, which is where the
* eye goes and is the very pixel you clicked to PLACE it, selected nothing.
*
* Pass two asks the question the user is actually asking: of everything
* placed, which is nearest to the patch of ground I clicked? That is
* camera-angle independent, so it cannot rot the way the raycast did the
* moment the pitch changed.
*
* Found by driving real pointer events at the page rather than by reading
* the code: the handles were provably in the scene, at provably the right
* positions, and the raycast still returned nothing.
*/
function pickRef(ev) {
setNdc(ev);
const hits = ray.intersectObjects(handles.children, false);
for (const h of hits) if (h.object.userData.ref) return h.object.userData.ref;
const g = raycastGround(ev);
if (!g) return null;
let best = null;
let bestD = PICK_GROUND_RADIUS;
for (const ref of allRefs()) {
const e = entryFor(ref);
if (!e) continue;
const d = Math.hypot(e.x - g.x, e.z - g.z);
if (d < bestD) { bestD = d; best = ref; }
}
return best;
}
/**
* Pointer tools. C's venturi placement is one of these: register it, call
* `setTool('venturi')`, and the editor's own select/drag stops competing for
* the same clicks instead of two lanes both listening on the canvas.
*
* spec: { id, label, cursor?, onPointerDown(ev, ground), onPointerMove, onPointerUp }
* Return `true` from a handler to consume the event.
*/
const tools = new Map();
let activeTool = null;
function registerTool(spec) {
tools.set(spec.id, spec);
renderToolbar();
return spec;
}
function setTool(id) {
activeTool = id ? (tools.get(id) ?? null) : null;
armed = null;
canvas.style.cursor = activeTool?.cursor ?? 'default';
renderToolbar();
renderPanels();
}
/** The palette item waiting for a click, if any. */
let armed = null;
let drag = null;
canvas.addEventListener('pointerdown', (ev) => {
canvas.setPointerCapture(ev.pointerId);
const ground = raycastGround(ev);
if (activeTool?.onPointerDown?.(ev, ground)) return;
if (ev.button === 2 || ev.shiftKey) { drag = { mode: 'pan', x: ev.clientX, y: ev.clientY }; return; }
if (ev.button !== 0) return;
if (armed && ground) {
const item = armed;
armed = null;
renderPanels();
place(item, ground.x, ground.z);
return;
}
const ref = pickRef(ev);
if (ref) {
selection = ref;
emit('select', { ref });
rebuildHandles();
renderPanels();
drag = { mode: 'move', ref, moved: false };
return;
}
selection = null;
emit('select', { ref: null });
rebuildHandles();
renderPanels();
drag = { mode: 'orbit', x: ev.clientX, y: ev.clientY };
});
canvas.addEventListener('pointermove', (ev) => {
const ground = raycastGround(ev);
if (activeTool?.onPointerMove?.(ev, ground)) return;
if (!drag) return;
if (drag.mode === 'orbit') {
cam.yaw -= (ev.clientX - drag.x) * 0.006;
cam.pitch += (ev.clientY - drag.y) * 0.005;
drag.x = ev.clientX; drag.y = ev.clientY;
applyCamera();
} else if (drag.mode === 'pan') {
const k = cam.dist * 0.0016;
const dx = (ev.clientX - drag.x) * k, dy = (ev.clientY - drag.y) * k;
cam.target.x += -Math.cos(cam.yaw) * dx + Math.sin(cam.yaw) * dy;
cam.target.z += Math.sin(cam.yaw) * dx + Math.cos(cam.yaw) * dy;
drag.x = ev.clientX; drag.y = ev.clientY;
applyCamera();
} else if (drag.mode === 'move' && ground) {
drag.moved = true;
moveTo(drag.ref, ground.x, ground.z);
renderPanels();
}
});
canvas.addEventListener('pointerup', (ev) => {
canvas.releasePointerCapture?.(ev.pointerId);
const ground = raycastGround(ev);
if (activeTool?.onPointerUp?.(ev, ground)) { drag = null; return; }
const wasMove = drag?.mode === 'move' && drag.moved;
drag = null;
// The rebuild is on DROP, not on every move: anchors have to come out of
// createWorld+dress to be honest, and that is a drop-cost, not a drag-cost.
if (wasMove) rebuild();
});
canvas.addEventListener('contextmenu', (ev) => ev.preventDefault());
canvas.addEventListener('wheel', (ev) => {
ev.preventDefault();
cam.dist *= Math.exp(ev.deltaY * 0.0012);
applyCamera();
}, { passive: false });
addEventListener('keydown', (ev) => {
if (/^(INPUT|TEXTAREA|SELECT)$/.test(ev.target?.tagName ?? '')) return;
if ((ev.key === 'Delete' || ev.key === 'Backspace') && selection) {
ev.preventDefault();
remove(selection);
}
if (ev.key === 'Escape') { armed = null; setTool(null); }
});
// =======================================================================
// Panels
// =======================================================================
const pSite = mountPanel({ id: 'site', title: 'SITE', order: 10 });
const pPalette = mountPanel({ id: 'palette', title: 'PLACE', order: 20 });
const pInspector = mountPanel({ id: 'inspector', title: 'SELECTED', order: 30 });
const pValidate = mountPanel({ id: 'validate', title: 'VALIDATION', order: 80 });
const pExport = mountPanel({ id: 'export', title: 'EXPORT', order: 90 });
const el = (tag, cls, text) => {
const n = document.createElement(tag);
if (cls) n.className = cls;
if (text != null) n.textContent = text;
return n;
};
function row(label, control) {
const r = el('div', 'ed-row');
r.append(el('span', 'ed-label', label), control);
return r;
}
function numInput(get, set, step = 0.1) {
const i = el('input', 'ed-num');
i.type = 'number'; i.step = String(step); i.value = String(get() ?? 0);
i.addEventListener('change', () => {
const v = Number(i.value);
if (Number.isFinite(v)) { set(round3(v)); rebuild(); }
});
return i;
}
function textInput(get, set) {
const i = el('input', 'ed-text');
i.type = 'text'; i.value = get() ?? '';
i.addEventListener('change', () => { set(i.value); markDirty(); });
return i;
}
/**
* `_design` / `_why` are edited as text and STORED as a string array, because
* that is how every shipped site stores them — JSON has no comments, so the
* repo's convention is one array element per line. Making the field visible
* is gate 1.3's actual requirement: the levels carry their design notes, and
* a note you have to remember to add is a note that doesn't get added.
*/
function notesInput(key) {
const t = el('textarea', 'ed-text');
t.rows = 5;
t.value = Array.isArray(site[key]) ? site[key].join('\n') : (site[key] ?? '');
t.addEventListener('change', () => {
const lines = t.value.replace(/\s+$/, '').split('\n');
if (lines.length === 1 && !lines[0]) delete site[key];
else site[key] = lines;
markDirty();
});
return t;
}
function renderSitePanel() {
const b = pSite.body;
b.replaceChildren();
b.append(row('id', textInput(() => site.id, (v) => { site.id = v; })));
b.append(row('name', textInput(() => site.name, (v) => { site.name = v; })));
const blurb = el('textarea', 'ed-text');
blurb.rows = 3;
blurb.value = site.blurb ?? '';
blurb.addEventListener('change', () => { site.blurb = blurb.value; markDirty(); });
b.append(row('blurb', blurb));
b.append(el('p', 'ed-note',
'The blurb carries real design load — night 3\'s "plenty to tie off to" is the '
+ 'carport trap\'s cover story.'));
b.append(row('yard w', numInput(() => site.yard.width, (v) => { site.yard.width = v; }, 0.5)));
b.append(row('yard d', numInput(() => site.yard.depth, (v) => { site.yard.depth = v; }, 0.5)));
b.append(row('sun elev', numInput(() => site.sun.elevationDeg, (v) => { site.sun.elevationDeg = v; }, 1)));
b.append(row('sun azim', numInput(() => site.sun.azimuthDeg, (v) => { site.sun.azimuthDeg = v; }, 1)));
const sides = el('div', 'ed-row');
sides.append(el('span', 'ed-label', 'fence'));
for (const s of ['north', 'south', 'east', 'west']) {
const btn = el('button', 'ed-btn', s[0].toUpperCase());
const on = (site.fence?.sides ?? []).includes(s);
if (on) btn.classList.add('on');
btn.title = s;
btn.addEventListener('click', () => {
site.fence ??= { sides: [] };
const list = new Set(site.fence.sides ?? []);
if (list.has(s)) list.delete(s); else list.add(s);
// Fixed order, not click order: a fence list that reorders itself is a
// diff that looks like a change and isn't.
site.fence.sides = ['north', 'south', 'east', 'west'].filter((k) => list.has(k));
rebuild();
});
sides.append(btn);
}
b.append(sides);
b.append(el('p', 'ed-label', '_design'), notesInput('_design'));
b.append(el('p', 'ed-note', 'Why this yard exists and what you measured. Ships in the JSON.'));
b.append(el('p', 'ed-label', '_why'), notesInput('_why'));
const load = el('div', 'ed-row');
load.append(el('span', 'ed-label', 'load'));
for (const s of ['backyard_01', 'site_02_corner_block']) {
const btn = el('button', 'ed-btn', s === 'backyard_01' ? 'backyard' : 'corner');
btn.addEventListener('click', async () => {
try {
const def = await loadSite(s);
await setSite(structuredClone(def));
} catch (err) {
validationCache.errors.push(`load ${s}: ${err.message}`);
renderPanels();
}
});
load.append(btn);
}
const blank = el('button', 'ed-btn', 'template');
blank.addEventListener('click', () => setSite(emptyTemplate()));
load.append(blank);
b.append(load);
}
/**
* The palette can only offer what the CHECKED enum will accept.
*
* `requires` names the anchor types an item writes. If contracts.js doesn't
* carry one yet, the item is hidden rather than offered — placing it would
* produce a site `validateSite` rejects, i.e. the editor handing you a yard
* the game can't boot, which is the one thing gate 1 exists to prevent.
*
* It also makes the cross-lane seam self-healing: E's swing set types
* `swing_frame`, and it appears in this palette the moment their widening of
* ANCHOR_TYPE lands in the merge — no follow-up commit, and no window where
* the button exists and the enum doesn't.
*/
const availablePalette = () =>
PALETTE.filter((item) => (item.requires ?? []).every((ty) => ANCHOR_TYPE.includes(ty)));
function renderPalettePanel() {
const b = pPalette.body;
b.replaceChildren();
for (const item of availablePalette()) {
const exists = item.singleton && site[item.kind];
const btn = el('button', 'ed-btn', item.label);
btn.style.width = '100%';
btn.style.textAlign = 'left';
btn.style.marginBottom = '4px';
btn.title = item.hint;
if (armed === item) btn.classList.add('on');
if (exists) {
btn.disabled = true;
btn.textContent = `${item.label} (placed)`;
}
btn.addEventListener('click', () => {
armed = armed === item ? null : item;
setToolNone();
renderPanels();
});
b.append(btn);
}
b.append(el('p', 'ed-note', armed
? `Click the ground to place: ${armed.label}. ${armed.hint}. ESC cancels.`
: 'Pick one, then click the ground. Drag a placed thing to move it; '
+ 'Delete removes it. Right-drag or shift-drag pans, wheel zooms.'));
}
function setToolNone() { if (activeTool) setTool(null); }
function renderInspectorPanel() {
const b = pInspector.body;
b.replaceChildren();
const e = entryFor(selection);
if (!e) {
b.append(el('p', 'ed-note', 'Nothing selected. Click a marker in the yard.'));
const list = el('ul', 'ed-list');
for (const ref of allRefs()) {
const li = el('li');
li.append(el('span', null, labelFor(ref)));
const ent = entryFor(ref);
li.append(el('span', 'ed-kv', `${ent.x?.toFixed?.(1) ?? '—'}, ${ent.z?.toFixed?.(1) ?? '—'}`));
li.addEventListener('click', () => {
selection = ref;
emit('select', { ref });
rebuildHandles();
renderPanels();
});
list.append(li);
}
b.append(list);
return;
}
const head = el('div', 'ed-card-head');
head.append(el('span', null, labelFor(selection)));
if (e.model) head.append(el('span', 'ed-tag', e.model));
b.append(head);
b.append(row('x', numInput(() => e.x, (v) => { e.x = v; })));
b.append(row('z', numInput(() => e.z, (v) => { e.z = v; })));
if (Object.hasOwn(e, 'rotYDeg')) b.append(row('rotY°', numInput(() => e.rotYDeg, (v) => { e.rotYDeg = v; }, 5)));
if (Object.hasOwn(e, 'h')) b.append(row('height', numInput(() => e.h, (v) => { e.h = v; }, 0.25)));
if (Object.hasOwn(e, 'trunkH')) b.append(row('trunkH', numInput(() => e.trunkH, (v) => { e.trunkH = v; }, 0.1)));
if (Object.hasOwn(e, 'anchorY')) b.append(row('anchorY', numInput(() => e.anchorY, (v) => { e.anchorY = v; }, 0.1)));
if (Object.hasOwn(e, 'phase')) b.append(row('phase', numInput(() => e.phase, (v) => { e.phase = v; }, 0.1)));
if (selection.kind === 'gardenBed') {
b.append(row('bed w', numInput(() => e.w, (v) => { e.w = v; }, 0.25)));
b.append(row('bed d', numInput(() => e.d, (v) => { e.d = v; }, 0.25)));
}
if (Object.hasOwn(e, 'collateralValue')) {
b.append(row('collateral $', numInput(() => e.collateralValue, (v) => { e.collateralValue = v; }, 5)));
}
if (e.anchors?.length) {
b.append(el('p', 'ed-note', 'anchors (live, from the dressed world):'));
const list = el('ul', 'ed-list');
for (const a of e.anchors) {
const live = world?.anchors.find((x) => x.id === a.id);
const li = el('li');
li.append(el('span', null, `${a.id} · ${a.type} · ${a.work}`));
const rh = live?.ratingHint;
const tag = el('span', 'ed-tag', live ? `hint ${(rh ?? 1).toFixed(2)}` : 'not built');
if (!live || !Object.hasOwn(live, 'collateral')) tag.classList.add('ed-err');
else if ((rh ?? 1) < 0.5) tag.classList.add('ed-warn');
li.append(tag);
list.append(li);
}
b.append(list);
}
if (!UNDELETABLE.has(selection.kind)) {
const del = el('button', 'ed-btn danger', `delete ${labelFor(selection)}`);
del.style.marginTop = '8px';
del.addEventListener('click', () => remove(selection));
b.append(del);
}
}
function renderValidationPanel() {
const b = pValidate.body;
b.replaceChildren();
const v = validationCache;
const head = el('div', 'ed-card-head');
head.append(el('span', v.ok ? 'ed-ok' : 'ed-err', v.ok ? 'VALID — the game can boot this' : `INVALID — ${v.errors.length} problem(s)`));
b.append(head);
if (!v.ok) {
const list = el('ul', 'ed-list');
for (const e of v.errors) list.append(el('li', 'ed-err', e));
b.append(list);
if (v.errors.some((e) => e.includes('no anchors at all'))) {
b.append(el('p', 'ed-note',
'A yard with nothing to tie off to is not a level. Place a Post (or a tree, '
+ 'or the house) and this goes green.'));
}
}
if (v.warnings.length) {
b.append(el('p', 'ed-warn', `${v.warnings.length} asset warning(s) — validateSite cannot see these:`));
const list = el('ul', 'ed-list');
for (const w of v.warnings) list.append(el('li', 'ed-warn', w));
b.append(list);
}
if (v.ok && !v.warnings.length) {
b.append(el('p', 'ed-note',
'Validation is structural only: it says the game can BOOT this yard, not that '
+ 'the yard is a game. That question is SCORE IT.'));
}
}
function renderExportPanel() {
const b = pExport.body;
b.replaceChildren();
const bar = el('div', 'ed-row');
const copy = el('button', 'ed-btn primary', 'copy JSON');
copy.addEventListener('click', async () => {
const text = exportJSON();
try {
await navigator.clipboard.writeText(text);
copy.textContent = 'copied ✓';
} catch {
copy.textContent = 'clipboard blocked — use download';
}
setTimeout(() => { copy.textContent = 'copy JSON'; }, 1800);
});
const dl = el('button', 'ed-btn', 'download');
dl.addEventListener('click', () => {
const blob = new Blob([exportJSON()], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `${site.id || 'site_new'}.json`;
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
});
bar.append(copy, dl);
b.append(bar);
if (!validationCache.ok) {
b.append(el('p', 'ed-err',
'This yard does NOT validate. It will still export — you should not lose work — '
+ 'but under a loud "_INVALID" key listing every problem, so it cannot land in '
+ 'data/sites/ looking clean.'));
}
// Gate 1.6 — export is not shipping, and the editor has to say so, because
// the one thing you cannot do from this page is the thing that makes a yard
// playable. docs/MANUAL.md, "authoring a site", step 6.
const card = el('div', 'ed-card');
card.append(el('div', 'ed-card-head', 'EXPORT ≠ SHIPPED'));
const ol = document.createElement('ol');
ol.className = 'ed-note';
ol.style.paddingLeft = '18px';
ol.style.margin = '0';
for (const step of [
`Save the JSON as web/world/data/sites/${site.id || 'site_new'}.json.`,
'A site becomes PLAYABLE only when a week.js NIGHTS entry names it — client, '
+ 'addr, brief and storm live there, not here. Until then the yard exists and '
+ 'nobody can reach it.',
'boot({ site: \'' + (site.id || 'site_new') + '\' }) seeks the week to that night.',
'Then run the gauntlet before calling it a level: SCORE IT here, then '
+ 'tools/site_audit, gardenfly, storm_envelope — and a cold playthrough by '
+ 'someone who did not author it.',
]) {
const li = document.createElement('li');
li.textContent = step;
ol.append(li);
}
card.append(ol);
b.append(card);
const pre = el('pre', 'ed-note');
pre.style.cssText = 'max-height:220px;overflow:auto;background:#0e1418;padding:8px;border-radius:4px;margin:8px 0 0;white-space:pre;';
pre.textContent = exportJSON();
b.append(pre);
}
function renderToolbar() {
toolbar.replaceChildren();
for (const spec of tools.values()) {
const btn = el('button', 'ed-btn', spec.label ?? spec.id);
if (activeTool === spec) btn.classList.add('on');
btn.addEventListener('click', () => setTool(activeTool === spec ? null : spec.id));
toolbar.append(btn);
}
}
let renderQueued = false;
function renderPanels() {
if (renderQueued) return;
renderQueued = true;
queueMicrotask(() => {
renderQueued = false;
renderSitePanel();
renderPalettePanel();
renderInspectorPanel();
renderValidationPanel();
renderExportPanel();
});
}
function markDirty() {
revalidate();
rebuildHandles();
renderPanels();
emit('change', { site });
}
function exportJSON() { return exportSiteJSON(site, validationCache); }
async function setSite(next) {
site = next;
selection = null;
emit('siteload', { site });
await rebuild();
// The camera frames whatever yard just arrived rather than whatever yard
// the last one was — a 24x16 corner block viewed from a 30x20 framing reads
// as a mistake you then chase in the data.
cam.target.set(0, 1, 0);
cam.dist = Math.max(site.yard.width, site.yard.depth) * 1.15;
applyCamera();
return world;
}
// --- the loop ----------------------------------------------------------
function resize() {
const w = canvas.clientWidth || 1;
const h = canvas.clientHeight || 1;
renderer.setSize(w, h, false);
camera.aspect = w / Math.max(1, h);
camera.updateProjectionMatrix();
}
addEventListener('resize', resize);
let t = 0;
renderer.setAnimationLoop(() => {
t += 1 / 60;
// The editor is a TOOL: this loop is for looking, not for simulating, so it
// does not pretend to be the game's fixed-dt chain. Nothing here feeds a
// number anyone scores.
if (world && !rebuilding) world.update(1 / 60, t);
readout.textContent =
`${site.id} ${site.yard.width}×${site.yard.depth} m\n`
+ `anchors ${world?.anchors.length ?? 0} ${validationCache.ok ? 'valid' : 'INVALID'}\n`
+ `rebuild ${rebuildMs.toFixed(0)} ms ${lastStatus}`;
renderer.render(scene, camera);
});
hint.textContent =
'drag empty ground = orbit · shift/right-drag = pan · wheel = zoom\n'
+ 'click a marker = select · drag it = move · Delete = remove';
// --- the object ---------------------------------------------------------
const api = {
// three.js handles
scene, renderer, camera, overlay,
get world() { return world; },
// site
get site() { return site; },
siteClone: () => canonical(structuredClone(site), '$root'),
setSite,
rebuild,
markDirty,
validation: () => ({ ...validationCache }),
exportJSON,
// selection
get selection() { return selection; },
select(ref) {
selection = ref;
emit('select', { ref });
rebuildHandles();
renderPanels();
},
// seams
mountPanel,
raycastGround,
registerTool,
setTool,
get tool() { return activeTool?.id ?? null; },
on(evt, fn) { listeners.set(evt, [...(listeners.get(evt) ?? []), fn]); return () => api.off(evt, fn); },
off(evt, fn) { listeners.set(evt, (listeners.get(evt) ?? []).filter((f) => f !== fn)); },
// constants other lanes need
HEIGHT_AT: heightAt,
PALETTE,
ANCHOR_TYPE,
};
globalThis.EDITOR = api;
resize();
applyCamera();
const startSite = new URLSearchParams(location.search).get('site');
if (startSite) {
try {
await setSite(structuredClone(await loadSite(startSite)));
} catch (err) {
await setSite(emptyTemplate());
validationCache.errors.push(`?site=${startSite}: ${err.message}`);
renderPanels();
}
} else {
await setSite(emptyTemplate());
}
return api;
}