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, + ); + }, + }; +}