SPRINT10 gate 1b. A night is now {storm, site}; the week hands over a yard per
night and night three is the corner block. Nights 1/2/4/5 are the backyard; the
plain-string night form still works and defaults to backyard_01.
site_02_corner_block.json: a smaller 24x16 yard with E's carport as the trap it
was built to be — beam anchors rating_hint 0.22, posts 0.30, both below the
house fascia's 0.35, collateral "carport". The site IS winnable off the honest
anchors (one tree east, four ground posts): I enumerated the honest quads at
authoring time and there are four in-band ones covering the bed, so the carport
is temptation not necessity. Full load audit is Lane B's tools/site_audit.
The carport's beams are keyed work:"bracket" — D's field, the MECHANISM not the
type. A bracket up a bare beam is the one anchor you can't fake from the ground,
so it needs the ladder; keyed on type it would fail open (a carport isn't a
house) and the ladder mechanic would silently vanish, which is exactly the audit
D ran. C's venturi list is in the JSON with two gap coordinates they asked for.
The world is rebuilt on a site change, not re-pointed: createPlayer captures the
world into its ground clamp, collider, ladder and broom (Lane D's modules, no
setters), so a clean rebuild is honest — and a site change only happens at a
forecast, never mid-storm. world.dispose() frees the old yard so nothing leaks.
The re-point of rig + session lives inside loadSiteInto, keyed on the rebuild
itself, after a caller-side version proved fragile.
Verified end to end through real phase transitions: play night 1, hit the
aftermath, and the forecast into night 3 rebuilds into the corner block —
carport present, 10 anchors (cb/cp/q/tr, not h/t/p), smaller bed, player rebuilt,
no scene leak. Then back to the backyard, 12 anchors restored. Selftest 289/0/0.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
795 lines
33 KiB
JavaScript
795 lines
33 KiB
JavaScript
/**
|
||
* SHADES — the yard. Lane A owns this file.
|
||
*
|
||
* M0 is deliberately graybox: every mesh here is a stand-in with the right
|
||
* dimensions and the right NAME, so Lane E's real GLBs drop into the same slots
|
||
* without anyone re-deriving positions. What is NOT placeholder, and what other
|
||
* lanes should build against, is the layout: anchor positions, ground heights,
|
||
* the garden rect and the sun direction.
|
||
*
|
||
* Geometry conventions: meters, +Y up, origin at yard centre on the ground.
|
||
* North (-Z) is the house edge. The yard is 30 (x) by 20 (z).
|
||
*/
|
||
|
||
import * as THREE from '../vendor/three.module.js';
|
||
import { createStubWind } from './contracts.js';
|
||
|
||
/** Degrees → radians. Site JSON is authored in degrees; nobody writes π/2 by hand. */
|
||
const rad = (deg) => ((deg ?? 0) * Math.PI) / 180;
|
||
|
||
/**
|
||
* Ground height in meters at a world XZ. Pure and cheap — this is called by the
|
||
* player every frame and by every piece of debris, so it stays closed-form
|
||
* rather than sampling a texture. Gentle by design (about ±0.3 m): enough that
|
||
* water has somewhere to go and the yard doesn't read as a table, not so much
|
||
* that it fights the rigging puzzle.
|
||
*
|
||
* @param {number} x
|
||
* @param {number} z
|
||
* @returns {number}
|
||
*/
|
||
export function heightAt(x, z) {
|
||
return (
|
||
0.18 * Math.sin(x * 0.21 + 1.3) * Math.cos(z * 0.27 - 0.4) +
|
||
0.09 * Math.sin(x * 0.53 - 2.1) * Math.sin(z * 0.41 + 0.8) -
|
||
0.06 * Math.cos(x * 0.11)
|
||
);
|
||
}
|
||
|
||
const SITE_DIR = new URL('../data/sites', import.meta.url).href;
|
||
|
||
/**
|
||
* Load a site. SPRINT10 gate 1 — the yard stopped being code today.
|
||
*
|
||
* Everything that used to be a constant in this file (bed rect, house line,
|
||
* tree and post placements, shed, gnome, sun angle) is now `data/sites/*.json`,
|
||
* and world.js is a builder. The proof of a faithful extraction is that nothing
|
||
* moved: Lane E's anchor tripwires pin five branch positions to 6 dp and
|
||
* a.test's quad-band asserts pin the whole rigging geometry, so a byte-identical
|
||
* backyard_01 is checkable rather than claimable.
|
||
*
|
||
* Async and separate from createWorld() for the same reason dress() is: the
|
||
* selftest builds yards with no server, and a fetch in the constructor is either
|
||
* a break or a flake. Callers load the site, then build.
|
||
*
|
||
* @param {string} name e.g. 'backyard_01'
|
||
*/
|
||
export async function loadSite(name, dir = SITE_DIR) {
|
||
const url = `${dir}/${name}.json`;
|
||
const res = await fetch(url);
|
||
if (!res.ok) throw new Error(`world: cannot load site ${url} (${res.status})`);
|
||
return validateSite(await res.json(), name);
|
||
}
|
||
|
||
/**
|
||
* Sites are content, so they fail loud rather than half-build. Every field this
|
||
* checks is one that would otherwise produce a yard that looks fine and is
|
||
* subtly wrong — an absent bed silently becomes NaN coverage, a missing anchor
|
||
* list silently becomes an unriggable site.
|
||
*/
|
||
export function validateSite(site, name = site?.id ?? '?') {
|
||
const bad = [];
|
||
if (!site || typeof site !== 'object') bad.push('not an object');
|
||
else {
|
||
if (!site.yard?.width || !site.yard?.depth) bad.push('yard.width/depth');
|
||
if (!site.gardenBed) bad.push('gardenBed');
|
||
if (!Number.isFinite(site.sun?.elevationDeg)) bad.push('sun.elevationDeg');
|
||
if (!Number.isFinite(site.sun?.azimuthDeg)) bad.push('sun.azimuthDeg');
|
||
const ids = [
|
||
...(site.house?.anchors ?? []).map((a) => a.id),
|
||
...(site.trees ?? []).flatMap((t) => (t.anchors ?? []).map((a) => a.id)),
|
||
...(site.posts ?? []).map((p) => p.id),
|
||
...(site.structures ?? []).flatMap((s) => (s.anchors ?? []).map((a) => a.id)),
|
||
];
|
||
if (!ids.length) bad.push('no anchors at all — nothing to rig to');
|
||
const dupes = ids.filter((id, i) => ids.indexOf(id) !== i);
|
||
if (dupes.length) bad.push(`duplicate anchor ids: ${[...new Set(dupes)].join(', ')}`);
|
||
// Lane D's field. `work` is the MECHANISM, not the anchor's type: a post is
|
||
// tensioned from a cleat at its base and a tree strop is thrown, but a
|
||
// bracket bolted up a bare wall is the one you cannot fake — so that, and
|
||
// only that, wants the ladder. Keyed on mechanism because `type` is a closed
|
||
// enum ('house'|'tree'|'post') that site_02's carport doesn't fit, and
|
||
// needsLadder keyed on type FAILS OPEN there: the ladder mechanic silently
|
||
// vanishes and you re-rig a 2.6 m bracket standing on the grass (D's audit).
|
||
const works = [
|
||
...(site.house?.anchors ?? []),
|
||
...(site.trees ?? []).flatMap((t) => t.anchors ?? []),
|
||
...(site.posts ?? []),
|
||
...(site.structures ?? []).flatMap((s) => s.anchors ?? []),
|
||
];
|
||
for (const a of works) {
|
||
if (a.work !== 'cloth' && a.work !== 'bracket') {
|
||
bad.push(`anchor ${a.id}: work must be "cloth" or "bracket", got ${JSON.stringify(a.work)}`);
|
||
}
|
||
}
|
||
}
|
||
if (bad.length) throw new Error(`world: site '${name}' is invalid:\n ${bad.join('\n ')}`);
|
||
return site;
|
||
}
|
||
|
||
/** Direction from the GROUND toward the SUN (see contracts.js), off site data. */
|
||
function sunDirOf(site) {
|
||
const el = (site.sun.elevationDeg * Math.PI) / 180;
|
||
const az = (site.sun.azimuthDeg * Math.PI) / 180;
|
||
return new THREE.Vector3(
|
||
Math.cos(el) * Math.sin(az),
|
||
Math.sin(el),
|
||
Math.cos(el) * Math.cos(az),
|
||
).normalize();
|
||
}
|
||
|
||
const COLORS = {
|
||
sky: 0x9fc4dd,
|
||
grass: 0x4a7c3f,
|
||
soil: 0x6b4a2f,
|
||
plant: 0x7fce6a,
|
||
house: 0x8a8f96,
|
||
trim: 0x6e737a,
|
||
bark: 0x5a3d24,
|
||
leaf: 0x2f6b28,
|
||
steel: 0x9aa4ad,
|
||
timber: 0x7a6a4f,
|
||
};
|
||
// (kept explicit rather than clever — Lane E will replace these with materials
|
||
// baked into the GLBs, at which point this table shrinks to nothing.)
|
||
|
||
/**
|
||
* Build the yard.
|
||
*
|
||
* @param {THREE.Scene} scene
|
||
* @param {object} [opts]
|
||
* @param {import('./contracts.js').Wind} [opts.wind]
|
||
* Wind is injected because tree anchors sway with it, and sway is dynamic
|
||
* load — the whole reason a tree anchor is scarier than a post. Defaults to
|
||
* the calm stub so world.js is usable headless before Lane C lands.
|
||
* @returns {import('./contracts.js').World}
|
||
*/
|
||
export function createWorld(scene, opts = {}) {
|
||
const wind = opts.wind ?? createStubWind({ calm: true });
|
||
|
||
// SPRINT10 gate 1. Every one of these was a module constant in this file an
|
||
// hour ago; they're site data now. They keep their old local names on purpose
|
||
// — the builder below is ~500 lines of geometry that was already correct, and
|
||
// rebinding the names is a change of SOURCE, not of behaviour. That's what
|
||
// makes "backyard_01 is byte-identical" a structural claim rather than a hope.
|
||
const site = validateSite(
|
||
opts.site ?? (() => { throw new Error(
|
||
'world: createWorld now needs a site — `createWorld(scene, { wind, site: await loadSite("backyard_01") })`. '
|
||
+ 'The yard stopped being code in SPRINT10; data/sites/*.json is the source of truth.'); })(),
|
||
);
|
||
const YARD = site.yard;
|
||
const GARDEN_BED = site.gardenBed;
|
||
const SUN_DIR = sunDirOf(site);
|
||
const HOUSE = site.house;
|
||
const SHED = site.shed;
|
||
const SHED_TABLE = site.shedTable ? { ...site.shedTable, rotY: rad(site.shedTable.rotYDeg) } : null;
|
||
const GNOME = site.gnome ? { ...site.gnome, rotY: rad(site.gnome.rotYDeg) } : null;
|
||
const treeSpecs = site.trees ?? [];
|
||
const postSpecs = site.posts ?? [];
|
||
|
||
const root = new THREE.Group();
|
||
root.name = 'yard';
|
||
scene.add(root);
|
||
|
||
/** @type {THREE.Object3D[]} */
|
||
const solids = [];
|
||
/** @type {import('./contracts.js').Anchor[]} */
|
||
const anchors = [];
|
||
/** @type {{group: THREE.Object3D, phase: number, base: THREE.Euler}[]} */
|
||
const canopies = [];
|
||
/** Graybox stand-ins, kept so dress() can retire them once E's GLBs load. */
|
||
const graybox = { house: null, trees: new Map(), bed: null };
|
||
/**
|
||
* E's three wilt states, siblings in one GLB — toggled by visibility rather
|
||
* than reloaded, which is why they all ship together.
|
||
* @type {{full: THREE.Object3D, tattered: THREE.Object3D, dead: THREE.Object3D}|null}
|
||
*/
|
||
let plants = null;
|
||
|
||
// --- sky & light -------------------------------------------------------
|
||
// Calm-day only. Lane C's skyfx.js takes over the sky and this becomes the
|
||
// "before" state the storm darkens away from.
|
||
scene.background = new THREE.Color(COLORS.sky);
|
||
scene.fog = new THREE.Fog(COLORS.sky, 34, 95);
|
||
|
||
// Sky fill carries more here than it would in most scenes. The sun sits in
|
||
// the NORTH (this is an Australian yard — "southerly change", gum trees), and
|
||
// the house is the yard's north edge, so the wall the player spends the whole
|
||
// game looking at is permanently backlit. That's correct, and it's also how
|
||
// a real south-facing rear wall looks — but the fascia line carries three of
|
||
// the seven anchors, so it has to stay readable in shadow rather than going
|
||
// to a void. Sky bounce is what does that in the real yard too.
|
||
const hemi = new THREE.HemisphereLight(0xbfd8ea, COLORS.grass, 1.8);
|
||
scene.add(hemi);
|
||
|
||
const sun = new THREE.DirectionalLight(0xfff2dc, 2.0);
|
||
sun.position.copy(SUN_DIR).multiplyScalar(40);
|
||
sun.target.position.set(0, 0, 0);
|
||
sun.castShadow = true;
|
||
sun.shadow.mapSize.set(2048, 2048);
|
||
// Frame the yard tightly — a loose shadow frustum is why sail shadows go
|
||
// soft and blocky, and the sail's shadow IS the product here.
|
||
const sc = sun.shadow.camera;
|
||
sc.left = -19; sc.right = 19; sc.top = 15; sc.bottom = -15;
|
||
sc.near = 1; sc.far = 90;
|
||
sun.shadow.bias = -0.0006;
|
||
sun.shadow.normalBias = 0.02;
|
||
scene.add(sun);
|
||
scene.add(sun.target);
|
||
|
||
// --- terrain -----------------------------------------------------------
|
||
const groundGeo = new THREE.PlaneGeometry(YARD.width, YARD.depth, 60, 40);
|
||
groundGeo.rotateX(-Math.PI / 2);
|
||
const gpos = groundGeo.attributes.position;
|
||
for (let i = 0; i < gpos.count; i++) {
|
||
gpos.setY(i, heightAt(gpos.getX(i), gpos.getZ(i)));
|
||
}
|
||
gpos.needsUpdate = true;
|
||
groundGeo.computeVertexNormals();
|
||
const ground = new THREE.Mesh(
|
||
groundGeo,
|
||
new THREE.MeshStandardMaterial({ color: COLORS.grass, roughness: 0.95 }),
|
||
);
|
||
ground.name = 'ground';
|
||
ground.receiveShadow = true;
|
||
root.add(ground);
|
||
// Deliberately NOT in `solids`: the ground is answered by heightAt(), which
|
||
// is exact and free. Raycasting it would be 4800 triangles a frame to learn
|
||
// something we already know in closed form.
|
||
|
||
// --- house (north edge) ------------------------------------------------
|
||
// Rear wall sits exactly on z = -10 so the fascia anchors have a round
|
||
// number to live on. Lane E's house_yardside.glb replaces this group and
|
||
// should keep fascia_anchor_* at these positions.
|
||
const house = new THREE.Group();
|
||
house.name = 'house_yardside';
|
||
const wall = new THREE.Mesh(
|
||
new THREE.BoxGeometry(16, 3.0, 6),
|
||
new THREE.MeshStandardMaterial({ color: COLORS.house, roughness: 0.85 }),
|
||
);
|
||
wall.position.set(0, 1.5, -13);
|
||
wall.castShadow = true;
|
||
wall.receiveShadow = true;
|
||
house.add(wall);
|
||
|
||
const roof = new THREE.Mesh(
|
||
new THREE.BoxGeometry(16.8, 0.22, 6.8),
|
||
new THREE.MeshStandardMaterial({ color: COLORS.trim, roughness: 0.7 }),
|
||
);
|
||
roof.position.set(0, 3.1, -13);
|
||
roof.castShadow = true;
|
||
house.add(roof);
|
||
|
||
// The fascia line — the lie the player will be tempted by (DESIGN.md).
|
||
const fascia = new THREE.Mesh(
|
||
new THREE.BoxGeometry(16, 0.24, 0.12),
|
||
new THREE.MeshStandardMaterial({ color: COLORS.trim, roughness: 0.6 }),
|
||
);
|
||
fascia.position.set(0, 2.72, -9.98);
|
||
house.add(fascia);
|
||
|
||
root.add(house);
|
||
solids.push(wall, roof);
|
||
graybox.house = house;
|
||
|
||
// Graybox stand-ins at the graybox's own spacing. dress() moves every one of
|
||
// these onto the position Lane E baked, which is why the numbers here don't
|
||
// matter and the IDS do — the site names them, and everything downstream
|
||
// (Lane B's quads, D's prompts, the balance suite) refers to them by name.
|
||
for (const [i, a] of (HOUSE?.anchors ?? []).entries()) {
|
||
anchors.push(Object.assign(
|
||
makeStaticAnchor(a.id, a.type ?? 'house', new THREE.Vector3(-5 + i * 5, 2.6, -9.9)),
|
||
{ work: a.work },
|
||
));
|
||
}
|
||
|
||
// --- trees -------------------------------------------------------------
|
||
// Two, on opposite shoulders, mirroring the prototype's tree placement.
|
||
// Canopies are separate named nodes so they can be swayed independently —
|
||
// Lane E's tree_gum_01.glb must keep `trunk` / `canopy_*` / `branch_anchor_*`.
|
||
// (treeSpecs is site.trees — bound at the top of createWorld.)
|
||
for (const spec of treeSpecs) {
|
||
const y0 = heightAt(spec.x, spec.z);
|
||
const tree = new THREE.Group();
|
||
tree.name = `tree_${spec.id}`;
|
||
tree.position.set(spec.x, y0, spec.z);
|
||
|
||
const trunk = new THREE.Mesh(
|
||
new THREE.CylinderGeometry(0.18, 0.28, spec.trunkH, 8),
|
||
new THREE.MeshStandardMaterial({ color: COLORS.bark, roughness: 1 }),
|
||
);
|
||
trunk.name = 'trunk';
|
||
trunk.position.y = spec.trunkH / 2;
|
||
trunk.castShadow = true;
|
||
tree.add(trunk);
|
||
solids.push(trunk);
|
||
|
||
const canopy = new THREE.Group();
|
||
canopy.name = 'canopy';
|
||
canopy.position.y = spec.trunkH;
|
||
const blobMat = new THREE.MeshStandardMaterial({ color: COLORS.leaf, roughness: 1 });
|
||
for (const [j, b] of [
|
||
{ x: 0, y: 0.7, z: 0, r: 2.1 },
|
||
{ x: 1.1, y: 0.1, z: 0.5, r: 1.4 },
|
||
{ x: -0.9, y: 0.3, z: -0.6, r: 1.5 },
|
||
].entries()) {
|
||
const blob = new THREE.Mesh(new THREE.SphereGeometry(b.r, 12, 8), blobMat);
|
||
blob.name = `canopy_${j}`;
|
||
blob.position.set(b.x, b.y, b.z);
|
||
blob.castShadow = true;
|
||
canopy.add(blob);
|
||
}
|
||
tree.add(canopy);
|
||
canopies.push({ group: canopy, phase: spec.phase, base: canopy.rotation.clone() });
|
||
|
||
root.add(tree);
|
||
graybox.trees.set(spec.id, tree);
|
||
|
||
// The anchor is at a branch fork, not the canopy centre. One per entry in
|
||
// the site's list; dress() moves each onto its baked branch_anchor_* node.
|
||
for (const a of spec.anchors ?? []) {
|
||
anchors.push(Object.assign(
|
||
makeSwayAnchor(a.id, new THREE.Vector3(spec.x, y0 + spec.anchorY, spec.z), spec.phase, wind),
|
||
{ work: a.work },
|
||
));
|
||
}
|
||
}
|
||
|
||
// --- posts -------------------------------------------------------------
|
||
// Raked away from the yard centre, because that is the correct practice and
|
||
// the shape should teach it before any text does (DESIGN.md: "rake the post
|
||
// away from the load").
|
||
// SPRINT3 decision 2: posts pulled in off the fence and a third added.
|
||
//
|
||
// The old pair sat at (-6, 7) and (5, 7.5), which put every rigging option in
|
||
// the 70–192 m² range Lane B flagged — a sail that big pre-tensions itself
|
||
// into a cascade at t=0.4 s before the wind has done anything, so the yard was
|
||
// teaching the wrong lesson. Pulled in, plus p3, the same yard now offers 31
|
||
// quads in the 18–45 m² band (8 of which shade a quarter of the bed or more).
|
||
//
|
||
// Worth knowing before anyone "fixes" it: the smallest quad that covers the
|
||
// bed COMPLETELY is 59 m², and that is not a bug to tune away. The bed sits
|
||
// 10 m off the house, so any house-to-post sail is ~16 m long, and covering a
|
||
// 6 m bed with it costs you a sail the storm will take. Full shade is meant to
|
||
// be the expensive answer; the small quads buy survival and pay in patchy
|
||
// shade. That IS the design (DESIGN.md, "big flat low vs small twisted steep").
|
||
// SPRINT6 gate 1: p4 is the ONE close anchor the balance pass allows, and its
|
||
// position is measured rather than guessed — and the measurement moved with
|
||
// the data. p4's swept position table now lives in backyard_01.json's `_why`,
|
||
// beside the coordinate it justifies, because that is the number someone will
|
||
// be tempted to nudge. (postSpecs is site.posts, bound at the top.)
|
||
const RAKE = (8 * Math.PI) / 180;
|
||
for (const spec of postSpecs) {
|
||
const y0 = heightAt(spec.x, spec.z);
|
||
// Lean away from the centre of the yard, in the XZ plane.
|
||
const away = new THREE.Vector2(spec.x, spec.z).normalize();
|
||
const top = new THREE.Vector3(
|
||
spec.x + Math.sin(RAKE) * spec.h * away.x,
|
||
y0 + Math.cos(RAKE) * spec.h,
|
||
spec.z + Math.sin(RAKE) * spec.h * away.y,
|
||
);
|
||
|
||
const post = new THREE.Mesh(
|
||
new THREE.CylinderGeometry(0.06, 0.08, spec.h, 8),
|
||
new THREE.MeshStandardMaterial({ color: COLORS.steel, roughness: 0.5, metalness: 0.6 }),
|
||
);
|
||
post.name = `sail_post_${spec.id}`;
|
||
post.position.set((spec.x + top.x) / 2, (y0 + top.y) / 2, (spec.z + top.z) / 2);
|
||
post.quaternion.setFromUnitVectors(
|
||
new THREE.Vector3(0, 1, 0),
|
||
top.clone().sub(new THREE.Vector3(spec.x, y0, spec.z)).normalize(),
|
||
);
|
||
post.castShadow = true;
|
||
root.add(post);
|
||
solids.push(post);
|
||
|
||
const footing = new THREE.Mesh(
|
||
new THREE.CylinderGeometry(0.22, 0.26, 0.18, 10),
|
||
new THREE.MeshStandardMaterial({ color: 0x9c9c96, roughness: 0.95 }),
|
||
);
|
||
footing.position.set(spec.x, y0 + 0.06, spec.z);
|
||
footing.receiveShadow = true;
|
||
root.add(footing);
|
||
|
||
anchors.push(Object.assign(
|
||
makeStaticAnchor(spec.id, spec.type ?? 'post', top),
|
||
{ work: spec.work },
|
||
));
|
||
}
|
||
|
||
// --- structures (the carport, and whatever else a site tacks on) -------
|
||
// These carry ANCHORS, which is the whole point: site_02's carport looks like
|
||
// four free tie-offs and is the worst steel in the game. Graybox positions are
|
||
// approximate; dress() moves each anchor onto the node Lane E baked, with the
|
||
// rating_hint / collateral that make it a trap. The site's own asserts (E's
|
||
// e.test) pin those ratings below the fascia's — do not "fix" them upward.
|
||
const structures = new Map();
|
||
for (const st of site.structures ?? []) {
|
||
const marker = new THREE.Group();
|
||
marker.name = st.id;
|
||
marker.position.set(st.x, heightAt(st.x, st.z), st.z);
|
||
marker.rotation.y = rad(st.rotYDeg);
|
||
root.add(marker);
|
||
structures.set(st.id, { spec: st, marker });
|
||
for (const a of st.anchors ?? []) {
|
||
// Graybox guess: near the structure origin, above head height. dress()
|
||
// corrects it; until then the site is at least riggable in the selftest.
|
||
anchors.push(Object.assign(
|
||
makeStaticAnchor(a.id, a.type ?? 'post', new THREE.Vector3(st.x, heightAt(st.x, st.z) + 2.4, st.z)),
|
||
{ work: a.work },
|
||
));
|
||
}
|
||
}
|
||
|
||
// --- garden bed (the thing you are protecting) -------------------------
|
||
const bed = new THREE.Group();
|
||
bed.name = 'garden_bed';
|
||
const bedY = heightAt(GARDEN_BED.x, GARDEN_BED.z);
|
||
bed.position.set(GARDEN_BED.x, bedY, GARDEN_BED.z);
|
||
|
||
const soil = new THREE.Mesh(
|
||
new THREE.BoxGeometry(GARDEN_BED.w, 0.3, GARDEN_BED.d),
|
||
new THREE.MeshStandardMaterial({ color: COLORS.soil, roughness: 1 }),
|
||
);
|
||
soil.position.y = 0.15;
|
||
soil.receiveShadow = true;
|
||
bed.add(soil);
|
||
|
||
const plantMat = new THREE.MeshStandardMaterial({ color: COLORS.plant, roughness: 0.9 });
|
||
for (let i = 0; i < 6; i++) {
|
||
for (let j = 0; j < 4; j++) {
|
||
const plant = new THREE.Mesh(new THREE.SphereGeometry(0.28, 8, 6), plantMat);
|
||
plant.position.set(
|
||
(-0.5 + (i + 0.5) / 6) * GARDEN_BED.w,
|
||
0.42,
|
||
(-0.5 + (j + 0.5) / 4) * GARDEN_BED.d,
|
||
);
|
||
plant.castShadow = true;
|
||
plant.receiveShadow = true;
|
||
bed.add(plant);
|
||
}
|
||
}
|
||
root.add(bed);
|
||
graybox.bed = bed;
|
||
|
||
// --- boundary fence ----------------------------------------------------
|
||
// East, south and west only: the house is the north boundary.
|
||
const fence = new THREE.Group();
|
||
fence.name = 'fence';
|
||
const railMat = new THREE.MeshStandardMaterial({ color: 0x7a6a4f, roughness: 1 });
|
||
const hx = YARD.width / 2, hz = YARD.depth / 2;
|
||
const SIDES = {
|
||
south: { from: [-hx, hz], to: [hx, hz] },
|
||
west: { from: [-hx, -hz], to: [-hx, hz] },
|
||
east: { from: [hx, -hz], to: [hx, hz] },
|
||
north: { from: [-hx, -hz], to: [hx, -hz] },
|
||
};
|
||
// backyard_01 fences south/west/east — the house IS its north boundary. The
|
||
// corner block has a street on two sides and fences the other two, which is
|
||
// the whole reason this is a site field rather than three hardcoded runs.
|
||
const runs = (site.fence?.sides ?? ['south', 'west', 'east']).map((s) => SIDES[s]).filter(Boolean);
|
||
for (const run of runs) {
|
||
const [x0, z0] = run.from, [x1, z1] = run.to;
|
||
const len = Math.hypot(x1 - x0, z1 - z0);
|
||
const n = Math.round(len / 2.5);
|
||
for (let i = 0; i <= n; i++) {
|
||
const f = i / n;
|
||
const x = x0 + (x1 - x0) * f, z = z0 + (z1 - z0) * f;
|
||
const p = new THREE.Mesh(new THREE.BoxGeometry(0.1, 1.6, 0.1), railMat);
|
||
p.position.set(x, heightAt(x, z) + 0.8, z);
|
||
p.castShadow = true;
|
||
fence.add(p);
|
||
}
|
||
for (const ry of [0.6, 1.35]) {
|
||
const rail = new THREE.Mesh(new THREE.BoxGeometry(len, 0.12, 0.04), railMat);
|
||
rail.position.set((x0 + x1) / 2, heightAt((x0 + x1) / 2, (z0 + z1) / 2) + ry, (z0 + z1) / 2);
|
||
rail.rotation.y = Math.atan2(-(z1 - z0), x1 - x0);
|
||
rail.castShadow = true;
|
||
fence.add(rail);
|
||
}
|
||
}
|
||
root.add(fence);
|
||
solids.push(fence);
|
||
|
||
// --- shed & spare table ------------------------------------------------
|
||
// Where the spare hardware lives, which makes it where the §7 scenario
|
||
// starts: rig → carry a spare → repair mid-storm. Lane D's pickup radius is
|
||
// 1.5 m off this point and everything downstream of it is already wired, so
|
||
// this small thing gates the whole hand-played loop.
|
||
//
|
||
// The position is published SYNCHRONOUSLY, from constants, even though the
|
||
// meshes arrive later in dress(). createWorld() has to stay sync — a.test.js
|
||
// and the selftest build a yard without a server — and Lane D's
|
||
// wireYardActions reads world.shedTable at wiring time. dress() refines the
|
||
// point to Lane E's baked `pickup_anchor` if it's there.
|
||
const shedTable = {
|
||
pos: new THREE.Vector3(SHED_TABLE.x, heightAt(SHED_TABLE.x, SHED_TABLE.z) + 0.9, SHED_TABLE.z),
|
||
};
|
||
|
||
/**
|
||
* Show one of E's three wilt states. No-op against the graybox bed, so the
|
||
* HUD can call it unconditionally.
|
||
* @param {'full'|'tattered'|'dead'} which
|
||
*/
|
||
function setPlants(which) {
|
||
if (!plants) return;
|
||
for (const [k, node] of Object.entries(plants)) {
|
||
if (node) node.visible = k === which;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Swap Lane E's GLBs in over the graybox. Async and separate from
|
||
* createWorld() on purpose: the selftest builds a yard with no server, and a
|
||
* fetch in the constructor would either break it or make it slow and flaky.
|
||
* Every load is individually guarded — a missing GLB leaves its graybox
|
||
* standing rather than taking the boot down with it.
|
||
*/
|
||
async function dress() {
|
||
const { GLTFLoader } = await import('../vendor/addons/loaders/GLTFLoader.js');
|
||
const loader = new GLTFLoader();
|
||
|
||
/** Take a graybox stand-in out of the scene AND out of `solids`. */
|
||
const retire = (obj) => {
|
||
if (!obj) return;
|
||
obj.traverse((o) => {
|
||
const i = solids.indexOf(o);
|
||
if (i >= 0) solids.splice(i, 1);
|
||
o.geometry?.dispose();
|
||
});
|
||
const i = solids.indexOf(obj);
|
||
if (i >= 0) solids.splice(i, 1);
|
||
obj.parent?.remove(obj);
|
||
};
|
||
|
||
/**
|
||
* Move an existing anchor onto the position Lane E baked, and take their
|
||
* rating_hint with it. Mutates `pos` in place rather than reassigning it:
|
||
* `interact.register` and Lane B's corners capture these vectors by
|
||
* reference, and a reassign would leave them holding a stale one.
|
||
*/
|
||
const adoptAnchor = (glb, nodeName, anchorId) => {
|
||
const node = glb.getObjectByName(nodeName);
|
||
const anchor = anchors.find((a) => a.id === anchorId);
|
||
if (!node || !anchor) return false;
|
||
anchor.pos.setFromMatrixPosition(node.matrixWorld);
|
||
anchor.ratingHint = node.userData?.rating_hint ?? 1;
|
||
anchor.collateral = node.userData?.collateral ?? null;
|
||
return true;
|
||
};
|
||
|
||
const load = async (name) => {
|
||
if (!name) return null; // the site simply doesn't have one
|
||
try {
|
||
const gltf = await loader.loadAsync(new URL(`../models/${name}.glb`, import.meta.url).href);
|
||
gltf.scene.traverse((o) => {
|
||
if (o.isMesh) { o.castShadow = true; o.receiveShadow = true; }
|
||
// Lane E's optional-node flag: glTF has no visibility bit and Blender's
|
||
// hide_render does NOT survive export (THREADS [E] 2026-07-17 — the
|
||
// garden bed drew all three wilt states superimposed for five sprints).
|
||
if (o.userData?.hidden_by_default) o.visible = false;
|
||
});
|
||
return gltf.scene;
|
||
} catch (err) {
|
||
console.warn(`[world] ${name} unavailable, keeping graybox:`, err.message);
|
||
return null;
|
||
}
|
||
};
|
||
|
||
// Model names come off the site now. `load(null)` resolves null, so a site
|
||
// that simply has no shed (site_02 has a carport) skips it without a branch.
|
||
const [shed, table, houseGlb, bedGlb, gnome] = await Promise.all([
|
||
load(SHED?.model), load(SHED_TABLE?.model), load(HOUSE?.model),
|
||
load(site.gardenBedModel ?? 'garden_bed_v1'), load(GNOME?.model),
|
||
]);
|
||
const treeGlbs = await Promise.all(treeSpecs.map((s) => load(s.model)));
|
||
|
||
// --- garden bed ------------------------------------------------------
|
||
if (bedGlb) {
|
||
retire(graybox.bed);
|
||
bedGlb.name = 'garden_bed';
|
||
bedGlb.position.set(GARDEN_BED.x, heightAt(GARDEN_BED.x, GARDEN_BED.z), GARDEN_BED.z);
|
||
root.add(bedGlb);
|
||
const pick = (n) => bedGlb.getObjectByName(n) ?? null;
|
||
plants = { full: pick('plants_full'), tattered: pick('plants_tattered'), dead: pick('plants_dead') };
|
||
setPlants('full');
|
||
}
|
||
|
||
// --- gnome (aftermath collateral bait) -------------------------------
|
||
// Placed under the sail's likely footprint on purpose: DESIGN.md wants your
|
||
// own failures to be the worst debris, and something breakable has to be
|
||
// standing there for that to land.
|
||
if (gnome) {
|
||
gnome.name = 'garden_gnome_01';
|
||
gnome.position.set(GNOME.x, heightAt(GNOME.x, GNOME.z), GNOME.z);
|
||
gnome.rotation.y = GNOME.rotY;
|
||
root.add(gnome);
|
||
}
|
||
|
||
// --- house (decision 6: no re-cut, the GLB's data wins) ---------------
|
||
// E's fascia sits at 2.80 m and their anchors span x=-3..3, where my
|
||
// graybox guessed 2.6 m and -5..5. Reading them narrows the house span by
|
||
// 4 m, which is a real part of why the yard now offers small quads at all.
|
||
// Every fascia anchor carries rating_hint 0.35 — E encoded DESIGN.md's
|
||
// "the fascia board is a lie" straight into the asset, and `collateral:
|
||
// "gutter"` says what it takes with it when it goes.
|
||
if (houseGlb) {
|
||
retire(graybox.house);
|
||
houseGlb.name = 'house_yardside';
|
||
houseGlb.position.set(HOUSE.x, heightAt(HOUSE.x, HOUSE.z), HOUSE.z);
|
||
root.add(houseGlb);
|
||
solids.push(houseGlb);
|
||
houseGlb.updateWorldMatrix(true, true);
|
||
for (const a of HOUSE.anchors ?? []) adoptAnchor(houseGlb, a.node, a.id);
|
||
}
|
||
|
||
// --- trees -----------------------------------------------------------
|
||
// Each tree ships 2-3 branch anchors with descending rating_hint (1.0 at
|
||
// the fork, 0.76 out where the limb is thin) — the intel DESIGN.md wants
|
||
// inspection to buy. branch_anchor_01 keeps the original t1/t2 id so
|
||
// nothing that already references them breaks; the rest are added.
|
||
for (const [i, spec] of treeSpecs.entries()) {
|
||
const glb = treeGlbs[i];
|
||
if (!glb) continue;
|
||
const old = graybox.trees.get(spec.id);
|
||
retire(old);
|
||
// The graybox canopy was what world.update() swayed — hand that job over.
|
||
const idx = canopies.findIndex((c) => old && old.getObjectByName('canopy') === c.group);
|
||
if (idx >= 0) canopies.splice(idx, 1);
|
||
|
||
glb.name = `tree_${spec.id}`;
|
||
glb.position.set(spec.x, heightAt(spec.x, spec.z), spec.z);
|
||
root.add(glb);
|
||
const trunk = glb.getObjectByName('trunk');
|
||
if (trunk) solids.push(trunk);
|
||
const canopy = glb.getObjectByName('canopy_01') || glb.getObjectByName('canopy');
|
||
if (canopy?.parent) {
|
||
canopies.push({ group: canopy.parent, phase: spec.phase, base: canopy.parent.rotation.clone() });
|
||
}
|
||
|
||
// Every branch anchor the site names now EXISTS before dress() runs (they
|
||
// were created off spec.anchors above), so this only has to move them onto
|
||
// the nodes Lane E baked. That deleted the old suffix-guessing loop, which
|
||
// built t1/t1b/t1c out of an array index and could only ever describe the
|
||
// two trees it was written for.
|
||
glb.updateWorldMatrix(true, true);
|
||
for (const a of spec.anchors ?? []) adoptAnchor(glb, a.node, a.id);
|
||
}
|
||
|
||
if (shed) {
|
||
shed.name = 'shed_01';
|
||
shed.position.set(SHED.x, heightAt(SHED.x, SHED.z), SHED.z);
|
||
shed.rotation.y = SHED.rotY;
|
||
root.add(shed);
|
||
solids.push(shed);
|
||
}
|
||
if (table) {
|
||
table.name = 'shed_table';
|
||
table.position.set(SHED_TABLE.x, heightAt(SHED_TABLE.x, SHED_TABLE.z), SHED_TABLE.z);
|
||
table.rotation.y = SHED_TABLE.rotY;
|
||
root.add(table);
|
||
// NOT in solids: you want to walk up to the table, not be fenced off it.
|
||
|
||
// Prefer Lane E's baked anchor over my guess at where a table top is.
|
||
table.updateWorldMatrix(true, true);
|
||
const anchor = table.getObjectByName('pickup_anchor');
|
||
if (anchor) shedTable.pos.setFromMatrixPosition(anchor.matrixWorld);
|
||
}
|
||
|
||
// --- structures ------------------------------------------------------
|
||
// The carport, and its trap. Load the GLB, drop it on its graybox marker,
|
||
// and move each anchor onto the node E baked — carrying the rating_hint and
|
||
// collateral that make it the worst tie-off in the game. adoptAnchor reads
|
||
// both off userData, so the "0.22 beam" trap arrives with the geometry.
|
||
for (const st of site.structures ?? []) {
|
||
const entry = structures.get(st.id);
|
||
const glb = await load(st.model);
|
||
if (!glb) continue;
|
||
retire(entry.marker);
|
||
glb.name = st.id;
|
||
glb.position.set(st.x, heightAt(st.x, st.z), st.z);
|
||
glb.rotation.y = rad(st.rotYDeg);
|
||
root.add(glb);
|
||
if (st.solid) glb.traverse((o) => { if (o.isMesh) solids.push(o); });
|
||
glb.updateWorldMatrix(true, true);
|
||
for (const a of st.anchors ?? []) adoptAnchor(glb, a.node, a.id);
|
||
}
|
||
|
||
return { shed, table };
|
||
}
|
||
|
||
// --- the world object --------------------------------------------------
|
||
return {
|
||
anchors,
|
||
heightAt,
|
||
gardenBed: GARDEN_BED,
|
||
sunDir: SUN_DIR.clone(),
|
||
solids,
|
||
root,
|
||
/**
|
||
* Where a spare gets picked up. `{pos}` — Lane D registers a 1.5 m hold-E
|
||
* off this point. Present from construction; dress() may nudge it onto
|
||
* Lane E's `pickup_anchor`.
|
||
*/
|
||
shedTable,
|
||
dress,
|
||
/** @param {'full'|'tattered'|'dead'} which */
|
||
setPlants,
|
||
/** Where the breakable client property stands, and what breaking it costs. */
|
||
gnome: GNOME,
|
||
// Lane C's skyfx MODULATES these as the storm builds and hands them back
|
||
// untouched on dispose() — it doesn't own them. That's why the yard exposes
|
||
// its lights rather than keeping them private.
|
||
sun,
|
||
hemi,
|
||
|
||
/** @param {string} id */
|
||
anchor(id) {
|
||
return anchors.find((a) => a.id === id) ?? null;
|
||
},
|
||
|
||
/**
|
||
* Take the whole yard out of the scene and free its GPU memory. SPRINT10:
|
||
* the week rebuilds the world when the site changes between nights, and
|
||
* without this each new yard leaks the last one's geometry and its lights
|
||
* pile up in the scene. Removes the root and both lights (skyfx borrows the
|
||
* lights and hands them back on its own dispose, so by here they're ours).
|
||
*/
|
||
dispose() {
|
||
root.traverse((o) => { o.geometry?.dispose?.(); o.material?.dispose?.(); });
|
||
root.parent?.remove(root);
|
||
sun.parent?.remove(sun);
|
||
sun.target.parent?.remove(sun.target);
|
||
hemi.parent?.remove(hemi);
|
||
},
|
||
|
||
update(dt, t) {
|
||
// Canopies lean with the wind they actually stand in — this is the tell
|
||
// the player reads a gust front from, a beat before it reaches the sail.
|
||
for (const c of canopies) {
|
||
const w = wind.sample(c.group.getWorldPosition(_worldPos), t);
|
||
const speed = w.length();
|
||
const lean = Math.min(0.22, speed * 0.007);
|
||
const flutter = 0.55 + 0.45 * Math.sin(t * 2.3 + c.phase);
|
||
c.group.rotation.z = c.base.z - Math.cos(Math.atan2(w.z, w.x)) * lean * flutter;
|
||
c.group.rotation.x = c.base.x + Math.sin(Math.atan2(w.z, w.x)) * lean * flutter;
|
||
}
|
||
},
|
||
};
|
||
}
|
||
|
||
const _worldPos = new THREE.Vector3();
|
||
|
||
/** @returns {import('./contracts.js').Anchor} */
|
||
function makeStaticAnchor(id, type, pos) {
|
||
const p = pos.clone();
|
||
return { id, type, pos: p, sway: () => p };
|
||
}
|
||
|
||
/**
|
||
* A tree anchor. Wanders with the wind it stands in, which is exactly why it is
|
||
* the dangerous choice: the sway is dynamic load the rig has to eat, and a
|
||
* drum-tight rig on a swaying tree snaps turnbuckles (DESIGN.md).
|
||
*
|
||
* @returns {import('./contracts.js').Anchor}
|
||
*/
|
||
function makeSwayAnchor(id, pos, phase, wind) {
|
||
const p = pos.clone();
|
||
const scratch = new THREE.Vector3(); // per-anchor, so two anchors never alias
|
||
return {
|
||
id,
|
||
type: 'tree',
|
||
pos: p,
|
||
sway(t) {
|
||
const speed = wind.sample(p, t).length();
|
||
const amp = Math.min(0.35, speed * 0.012);
|
||
const s = Math.sin(t * 1.9 + phase);
|
||
return scratch.set(
|
||
p.x + s * amp,
|
||
p.y - Math.abs(s) * amp * 0.25,
|
||
p.z + Math.cos(t * 1.3 + phase) * amp * 0.5,
|
||
);
|
||
},
|
||
};
|
||
}
|