HardYards/web/world/js/world.js
m3ultra a5ed4fcdb6 Place the shed and spare table; expose world.shedTable (unblocks Lane D)
SPRINT3 decision 9 / §Lane A.1. Lane D's §7 loop — rig, carry a spare, repair
mid-storm — had nowhere to pick a spare up, so wireYardActions self-skipped the
pickup and nothing in the game could put one in your hands.

world.shedTable.pos is published synchronously from constants even though the
meshes arrive later: createWorld() has to stay sync (a.test.js and the selftest
build a yard with no server), and wireYardActions reads the position at wiring
time. dress() then refines it onto Lane E's baked pickup_anchor — which moved it
5 cm off my guess at where a table top is, so it was worth reading.

dress() is async and separate from createWorld for the same reason: a fetch in
the constructor would make the selftest slow and flaky, or break it. Each load is
guarded individually, so a missing GLB leaves its graybox standing instead of
taking the boot down.

Verified by hand rather than by registration check: walk up, hold E, spare in
hand; a second hold says "hands full"; leaning on the table with E held for 6 s
deals exactly one spare (D's latch works); nothing fires from across the yard.
Selftest 169/0/0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 01:20:35 +10:00

481 lines
18 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 { 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 };
// Shed on the east side, table out in front of it. Lane D tested reachability
// against (9, 6), so the table stays there and the shed tucks in behind.
const SHED = { x: 11.8, z: 6.2, rotY: -Math.PI / 2 };
const SHED_TABLE = { x: 9, z: 6, rotY: -Math.PI / 2 };
// 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);
// --- 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),
};
/**
* 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();
const load = async (name) => {
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; }
});
return gltf.scene;
} catch (err) {
console.warn(`[world] ${name} unavailable, keeping graybox:`, err.message);
return null;
}
};
const [shed, table] = await Promise.all([load('shed_01_v1'), load('shed_table_v1')]);
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);
}
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,
// 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;
},
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,
);
},
};
}