diff --git a/web/world/index.html b/web/world/index.html
new file mode 100644
index 0000000..9310e65
--- /dev/null
+++ b/web/world/index.html
@@ -0,0 +1,38 @@
+
+
+
+
+
+SHADES — yard (M0)
+
+
+
+
+
+ booting…
+ WASD move · shift run · RMB drag orbit · wheel zoom · Enter next phase
+
+
+
+
diff --git a/web/world/js/camera.js b/web/world/js/camera.js
new file mode 100644
index 0000000..4701751
--- /dev/null
+++ b/web/world/js/camera.js
@@ -0,0 +1,176 @@
+/**
+ * SHADES — third-person camera rig. Lane A owns this file.
+ *
+ * Shoulder-follow by default, orbit while the right mouse button is held.
+ * Two things here are load-bearing for other lanes:
+ * - `yaw` is what Lane D's WASD is relative to.
+ * - the camera collides against world.solids, because the house sits on the
+ * north edge of a 20 m yard and a naive follow cam walks straight through
+ * the wall the moment the player stands with their back to it.
+ */
+
+import * as THREE from '../vendor/three.module.js';
+
+/** Stand-off kept between the camera and whatever it collided with. */
+const WALL_MARGIN = 0.25;
+/** Absolute floor on camera-to-head distance. Above the 0.1 near plane. */
+const NEAR_FLOOR = 0.15;
+/** How far the camera stays above the terrain. */
+const GROUND_CLEARANCE = 0.35;
+
+const DEFAULTS = {
+ distance: 4.5, // meters behind the target
+ height: 1.55, // look-at height on the player (roughly the head)
+ shoulder: 0.55, // lateral offset — the "third person" of it
+ pitch: -0.18, // radians, slightly down
+ minPitch: -1.15,
+ maxPitch: 0.55,
+ yaw: 0.0,
+ sensitivity: 0.0042, // radians per pixel
+ followLambda: 9, // position smoothing rate (higher = stiffer)
+ fov: 62,
+};
+
+/**
+ * @param {HTMLElement} domElement Element that receives the orbit drag.
+ * @param {object} [opts]
+ * @returns {import('./contracts.js').CameraRig}
+ */
+export function createCameraRig(domElement, opts = {}) {
+ const cfg = { ...DEFAULTS, ...opts };
+
+ const object = new THREE.PerspectiveCamera(cfg.fov, 1, 0.1, 400);
+ object.position.set(0, 3, 8);
+
+ let yaw = cfg.yaw;
+ let pitch = cfg.pitch;
+ let dragging = false;
+ let lastX = 0, lastY = 0;
+
+ // Smoothed camera position. Seeded on the first update so the camera doesn't
+ // fly in from the origin on frame one.
+ const smoothed = new THREE.Vector3();
+ let seeded = false;
+
+ const onContextMenu = (e) => e.preventDefault();
+ const onPointerDown = (e) => {
+ if (e.button !== 2) return; // RMB only
+ dragging = true;
+ lastX = e.clientX; lastY = e.clientY;
+ domElement.setPointerCapture?.(e.pointerId);
+ };
+ const onPointerMove = (e) => {
+ if (!dragging) return;
+ yaw -= (e.clientX - lastX) * cfg.sensitivity;
+ pitch -= (e.clientY - lastY) * cfg.sensitivity;
+ pitch = Math.max(cfg.minPitch, Math.min(cfg.maxPitch, pitch));
+ lastX = e.clientX; lastY = e.clientY;
+ };
+ const onPointerUp = (e) => {
+ if (e.button !== 2) return;
+ dragging = false;
+ domElement.releasePointerCapture?.(e.pointerId);
+ };
+ const onWheel = (e) => {
+ cfg.distance = Math.max(2, Math.min(9, cfg.distance + Math.sign(e.deltaY) * 0.4));
+ };
+
+ domElement.addEventListener('contextmenu', onContextMenu);
+ domElement.addEventListener('pointerdown', onPointerDown);
+ domElement.addEventListener('pointermove', onPointerMove);
+ domElement.addEventListener('pointerup', onPointerUp);
+ domElement.addEventListener('wheel', onWheel, { passive: true });
+
+ const target = new THREE.Vector3();
+ const desired = new THREE.Vector3();
+ const dir = new THREE.Vector3();
+ const ray = new THREE.Raycaster();
+ ray.far = 20;
+
+ /** @type {THREE.Object3D[]} */
+ let solids = [];
+ /** @type {(x:number, z:number) => number} */
+ let groundAt = () => -Infinity;
+
+ return {
+ object,
+
+ get yaw() { return yaw; },
+ set yaw(v) { yaw = v; },
+
+ get pitch() { return pitch; },
+
+ /**
+ * Obstacle meshes the camera must not pass through. NOT the ground — see
+ * setGround(). Raycasting a 4800-triangle terrain every frame to learn
+ * something world.heightAt() answers in closed form is the kind of waste
+ * that only shows up once four other systems are also running.
+ */
+ setSolids(list) { solids = list ?? []; },
+
+ /** @param {(x:number, z:number) => number} fn Pass world.heightAt. */
+ setGround(fn) { groundAt = fn ?? (() => -Infinity); },
+
+ /**
+ * @param {number} dt
+ * @param {THREE.Vector3} targetPos Player feet position.
+ */
+ update(dt, targetPos) {
+ target.set(targetPos.x, targetPos.y + cfg.height, targetPos.z);
+
+ // Orbit offset, then slide sideways for the over-the-shoulder framing.
+ const cp = Math.cos(pitch);
+ dir.set(Math.sin(yaw) * cp, -Math.sin(pitch), Math.cos(yaw) * cp).normalize();
+ desired.copy(target).addScaledVector(dir, cfg.distance);
+ desired.x += Math.cos(yaw) * cfg.shoulder;
+ desired.z -= Math.sin(yaw) * cfg.shoulder;
+
+ // Collision: cast from the head toward where the camera wants to be and
+ // pull in to the first thing in the way.
+ if (solids.length) {
+ const toCam = desired.clone().sub(target);
+ const dist = toCam.length();
+ ray.set(target, toCam.divideScalar(dist || 1));
+ ray.far = dist;
+ const hit = ray.intersectObjects(solids, true)[0];
+ if (hit) {
+ // The wall wins. A comfortable stand-off distance is a preference; a
+ // camera inside the house is a bug, so when the two disagree the
+ // geometry gets the last word and the player eats a shoulder-filling
+ // close-up instead. (Getting this backwards — clamping UP to a
+ // minimum distance after the raycast — is what put the camera 17 cm
+ // inside the north wall the first time round.)
+ const safe = Math.min(dist, hit.distance - WALL_MARGIN);
+ desired.copy(target).addScaledVector(ray.ray.direction, Math.max(NEAR_FLOOR, safe));
+ }
+ }
+
+ // Ground is handled analytically rather than by raycast: exact, can't be
+ // tunnelled through, and free.
+ desired.y = Math.max(desired.y, groundAt(desired.x, desired.z) + GROUND_CLEARANCE);
+
+ if (!seeded) { smoothed.copy(desired); seeded = true; }
+ // Exponential smoothing that is correct at any dt (not the usual
+ // frame-rate-dependent lerp).
+ const a = 1 - Math.exp(-cfg.followLambda * dt);
+ smoothed.lerp(desired, a);
+
+ object.position.copy(smoothed);
+ object.lookAt(target);
+ },
+
+ /** Keep the projection square with the canvas. */
+ resize(width, height) {
+ object.aspect = width / Math.max(1, height);
+ object.updateProjectionMatrix();
+ },
+
+ dispose() {
+ domElement.removeEventListener('contextmenu', onContextMenu);
+ domElement.removeEventListener('pointerdown', onPointerDown);
+ domElement.removeEventListener('pointermove', onPointerMove);
+ domElement.removeEventListener('pointerup', onPointerUp);
+ domElement.removeEventListener('wheel', onWheel);
+ },
+ };
+}
diff --git a/web/world/js/main.js b/web/world/js/main.js
new file mode 100644
index 0000000..698a077
--- /dev/null
+++ b/web/world/js/main.js
@@ -0,0 +1,239 @@
+/**
+ * SHADES — boot, game loop, phase machine. Lane A owns this file.
+ *
+ * Nothing is auto-run on import: index.html calls boot(). That keeps createGame()
+ * importable from selftest.html, which must never construct a WebGLRenderer.
+ *
+ * The loop is a fixed-dt accumulator. Sim modules only ever see FIXED_DT, never
+ * a real frame delta — that is the whole reason selftest can fast-forward a 90 s
+ * storm in a few milliseconds and get the same numbers the player got.
+ */
+
+import * as THREE from '../vendor/three.module.js';
+import { FIXED_DT, PHASES, STORM_LEN, YARD, Emitter, createStubWind } from './contracts.js';
+import { createWorld, heightAt } from './world.js';
+import { createCameraRig } from './camera.js';
+
+// ---------------------------------------------------------------------------
+// Phase machine
+// ---------------------------------------------------------------------------
+
+/**
+ * forecast → prep → storm → aftermath → forecast.
+ * @returns {import('./contracts.js').Game}
+ */
+export function createGame() {
+ const emitter = new Emitter();
+ let phase = 'forecast';
+ let phaseT = 0;
+
+ return {
+ get phase() { return phase; },
+ /** Seconds since this phase began. The storm clock. */
+ get phaseT() { return phaseT; },
+
+ on(type, fn) { return emitter.on(type, fn); },
+
+ /** @param {'forecast'|'prep'|'storm'|'aftermath'} next */
+ setPhase(next) {
+ if (!PHASES.includes(next)) throw new Error(`unknown phase '${next}'`);
+ if (next === phase) return;
+ const from = phase;
+ phase = next;
+ phaseT = 0;
+ emitter.emit('phaseChange', { from, to: next });
+ },
+
+ /** Advance to the next phase in loop order. */
+ advance() {
+ this.setPhase(PHASES[(PHASES.indexOf(phase) + 1) % PHASES.length]);
+ },
+
+ tick(dt) {
+ phaseT += dt;
+ // The storm is on a timer; every other phase waits for the player.
+ if (phase === 'storm' && phaseT >= STORM_LEN) this.setPhase('aftermath');
+ },
+ };
+}
+
+// ---------------------------------------------------------------------------
+// M0 placeholder player
+// ---------------------------------------------------------------------------
+
+/**
+ * A 1.7 m capsule that walks. This exists ONLY so the camera has something to
+ * follow and the yard scale is legible before Lane D lands.
+ *
+ * Lane D: replace the call site in boot() with your player.js factory — the
+ * shape you need to satisfy is `Player` in contracts.js ({pos, carrying, busy,
+ * update}) — then delete this function. Everything else in this file already
+ * talks to you through that contract, so nothing else should need to change.
+ */
+function createPlaceholderPlayer(scene, world, cameraRig) {
+ const WALK = 2.2, RUN = 4.5; // m/s
+
+ const mesh = new THREE.Mesh(
+ new THREE.CapsuleGeometry(0.28, 1.14, 4, 12),
+ new THREE.MeshStandardMaterial({ color: 0xffd27a, roughness: 0.7 }),
+ );
+ mesh.name = 'player_placeholder';
+ mesh.castShadow = true;
+ scene.add(mesh);
+
+ const keys = new Set();
+ const onDown = (e) => {
+ keys.add(e.key.toLowerCase());
+ if ([' ', 'arrowup', 'arrowdown', 'arrowleft', 'arrowright'].includes(e.key.toLowerCase())) e.preventDefault();
+ };
+ const onUp = (e) => keys.delete(e.key.toLowerCase());
+ addEventListener('keydown', onDown);
+ addEventListener('keyup', onUp);
+
+ const pos = new THREE.Vector3(0, heightAt(0, 6), 6);
+ const move = new THREE.Vector3();
+ const fwd = new THREE.Vector3();
+ const right = new THREE.Vector3();
+ let facing = 0;
+
+ const hx = YARD.width / 2 - 0.5, hz = YARD.depth / 2 - 0.5;
+
+ return {
+ pos,
+ carrying: null,
+ busy: false,
+ mesh,
+
+ update(dt) {
+ const yaw = cameraRig.yaw;
+ // Camera-relative: forward is where the camera is looking, flattened.
+ fwd.set(-Math.sin(yaw), 0, -Math.cos(yaw));
+ right.set(Math.cos(yaw), 0, -Math.sin(yaw));
+
+ move.set(0, 0, 0);
+ if (keys.has('w') || keys.has('arrowup')) move.add(fwd);
+ if (keys.has('s') || keys.has('arrowdown')) move.sub(fwd);
+ if (keys.has('d') || keys.has('arrowright')) move.add(right);
+ if (keys.has('a') || keys.has('arrowleft')) move.sub(right);
+
+ if (move.lengthSq() > 0) {
+ move.normalize().multiplyScalar((keys.has('shift') ? RUN : WALK) * dt);
+ pos.x = Math.max(-hx, Math.min(hx, pos.x + move.x));
+ pos.z = Math.max(-hz, Math.min(hz, pos.z + move.z));
+ facing = Math.atan2(move.x, move.z);
+ }
+ pos.y = world.heightAt(pos.x, pos.z);
+
+ mesh.position.set(pos.x, pos.y + 0.85, pos.z); // capsule centre
+ mesh.rotation.y = facing;
+ },
+
+ dispose() {
+ removeEventListener('keydown', onDown);
+ removeEventListener('keyup', onUp);
+ },
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Boot
+// ---------------------------------------------------------------------------
+
+/**
+ * @param {object} [opts]
+ * @param {HTMLCanvasElement} [opts.canvas]
+ */
+export function boot(opts = {}) {
+ const canvas = opts.canvas ?? document.getElementById('c');
+
+ 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;
+ renderer.toneMappingExposure = 1.0;
+
+ const scene = new THREE.Scene();
+
+ // Lane C: swap createStubWind() for your createWeather(). Everything that
+ // moves reads this one object, so that swap is the whole integration.
+ const wind = createStubWind({ calm: true });
+
+ const world = createWorld(scene, { wind });
+ const cameraRig = createCameraRig(canvas);
+ cameraRig.setSolids(world.solids);
+ cameraRig.setGround(world.heightAt);
+
+ const player = createPlaceholderPlayer(scene, world, cameraRig);
+ const game = createGame();
+
+ // --- dev overlay (temporary — Lane A's hud.js replaces it after M0) ----
+ const hud = document.getElementById('dev');
+ const banner = document.getElementById('banner');
+ game.on('phaseChange', ({ to }) => {
+ if (banner) {
+ banner.textContent = to.toUpperCase();
+ banner.style.opacity = '1';
+ setTimeout(() => { banner.style.opacity = '0'; }, 1400);
+ }
+ });
+ addEventListener('keydown', (e) => {
+ if (e.key === 'Enter') game.advance();
+ });
+
+ // --- resize ------------------------------------------------------------
+ function resize() {
+ const w = canvas.clientWidth || innerWidth;
+ const h = canvas.clientHeight || innerHeight;
+ renderer.setSize(w, h, false);
+ cameraRig.resize(w, h);
+ }
+ addEventListener('resize', resize);
+ resize();
+
+ // --- loop --------------------------------------------------------------
+ const clock = new THREE.Clock();
+ let acc = 0;
+ let simT = 0;
+ let frames = 0, fpsT = 0, fps = 0;
+
+ function step(dt, t) {
+ game.tick(dt);
+ world.update(dt, t);
+ player.update(dt, t);
+ // Lane B: sailRig.step(dt, wind, t) goes here.
+ // Lane C: debris.step(dt, wind, t) goes here.
+ }
+
+ function frame() {
+ // Clamped so a background tab or a breakpoint doesn't make the sim try to
+ // catch up over thousands of steps and lock the page.
+ const raw = Math.min(0.25, clock.getDelta());
+ acc += raw;
+ let guard = 0;
+ while (acc >= FIXED_DT && guard++ < 60) {
+ step(FIXED_DT, simT);
+ simT += FIXED_DT;
+ acc -= FIXED_DT;
+ }
+
+ cameraRig.update(raw, player.pos);
+ renderer.render(scene, cameraRig.object);
+
+ frames++; fpsT += raw;
+ if (fpsT >= 0.5) { fps = frames / fpsT; frames = 0; fpsT = 0; }
+ if (hud) {
+ const w = wind.sample(player.pos, simT);
+ hud.textContent =
+ `${fps.toFixed(0)} fps | phase ${game.phase} ${game.phaseT.toFixed(1)}s | ` +
+ `wind ${w.length().toFixed(1)} m/s | t ${simT.toFixed(1)}s`;
+ }
+ requestAnimationFrame(frame);
+ }
+ requestAnimationFrame(frame);
+
+ // Handy for poking at the world from the console.
+ const api = { renderer, scene, world, cameraRig, player, game, wind, get simT() { return simT; } };
+ globalThis.SHADES = api;
+ return api;
+}
diff --git a/web/world/js/world.js b/web/world/js/world.js
new file mode 100644
index 0000000..6abb07f
--- /dev/null
+++ b/web/world/js/world.js
@@ -0,0 +1,401 @@
+/**
+ * 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 { YARD, createStubWind } from './contracts.js';
+
+/**
+ * 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 GARDEN_BED = { x: 1, z: 2, w: 6, d: 4 };
+
+// Sun: mid-afternoon, high and off the north-west shoulder. Elevation 55°.
+// Stored as the direction from the GROUND toward the SUN (see contracts.js).
+const SUN_ELEV = (55 * Math.PI) / 180;
+const SUN_AZIM = (-125 * Math.PI) / 180;
+const SUN_DIR = new THREE.Vector3(
+ Math.cos(SUN_ELEV) * Math.sin(SUN_AZIM),
+ Math.sin(SUN_ELEV),
+ Math.cos(SUN_ELEV) * Math.cos(SUN_AZIM),
+).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 });
+
+ 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 = [];
+
+ // --- 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);
+
+ for (const [i, x] of [-5, 0, 5].entries()) {
+ anchors.push(makeStaticAnchor(`h${i + 1}`, 'house', new THREE.Vector3(x, 2.6, -9.9)));
+ }
+
+ // --- 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_*`.
+ const treeSpecs = [
+ { id: 't1', x: -9, z: 2, phase: 0.7, trunkH: 4.2, anchorY: 3.4 },
+ { id: 't2', x: 8, z: -2, phase: 2.9, trunkH: 3.8, anchorY: 3.1 },
+ ];
+ 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);
+
+ // The anchor is at a branch fork, not the canopy centre.
+ anchors.push(makeSwayAnchor(
+ spec.id,
+ new THREE.Vector3(spec.x, y0 + spec.anchorY, spec.z),
+ spec.phase,
+ wind,
+ ));
+ }
+
+ // --- 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").
+ const postSpecs = [
+ { id: 'p1', x: -6, z: 7, h: 4.0 },
+ { id: 'p2', x: 5, z: 7.5, h: 4.0 },
+ ];
+ 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(makeStaticAnchor(spec.id, 'post', top));
+ }
+
+ // --- 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);
+
+ // --- 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 runs = [
+ { from: [-hx, hz], to: [hx, hz] }, // south
+ { from: [-hx, -hz], to: [-hx, hz] }, // west
+ { from: [hx, -hz], to: [hx, hz] }, // east
+ ];
+ 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);
+
+ // --- the world object --------------------------------------------------
+ return {
+ anchors,
+ heightAt,
+ gardenBed: GARDEN_BED,
+ sunDir: SUN_DIR.clone(),
+ solids,
+ root,
+ sun,
+
+ /** @param {string} id */
+ anchor(id) {
+ return anchors.find((a) => a.id === id) ?? null;
+ },
+
+ 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,
+ );
+ },
+ };
+}