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