SPRINT4 §Lane A 1-5. Everything the game already simulated was invisible to a stranger; you can now play a whole round with eyes and a mouse and never open the console. hud.js only ever READS — it takes no decisions and owns no state worth keeping, so it can be this chatty without anything drifting. Corner load bars are world-anchored rather than screen-anchored because a number in the corner of the screen can't tell you WHICH shackle is about to go, and that is the entire "...the shackle, I knew about the shackle" moment. They hold constant screen size with distance: a house corner is 18 m away from the posts, where a world-sized bar is a few unreadable pixels. Prep is Lane B's createRiggingUI wired to the phase machine — their picking was already complete, this is the hands. Forecast reads the three authored storms, so the difficulty select came free: Sea Breeze / Southerly Buster / Wild Night with real peak wind, gust character and change time. Two bugs the wiring surfaced, both found by playing rather than reading: - The rigging session survived "play again": a second round started at $35 with three corners already hung, and the four anchors you clicked were silently ignored because the session was full. resetRig() walks it back through the session's own public moves, so the economy stays the one source of truth. - The forecast card's lifetime was owned by its own button, so any other route into prep left a card floating over a live game eating input. The phase owns it now. Verified by playing it: good rig on the wild night wins at 53%, the same yard's cheap drum-tight span loses 4 corners, the gentle night with a good rig ends at 93% "not a leaf out of place". Selftest 184/0/0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
646 lines
25 KiB
JavaScript
646 lines
25 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 };
|
||
|
||
// Lane E's house_yardside GLB is a 9.2 x 2.9 x 1.05 m façade whose fascia
|
||
// anchors sit at local z = +0.55, so placing it here lands them on z = -9.95 —
|
||
// the same line the graybox taught everyone to expect.
|
||
const HOUSE = { x: 0, z: -10.5 };
|
||
|
||
// The gnome stands just off the bed's south-east corner — inside the footprint
|
||
// of most rigs, so a sail that lets go has something of the client's to land on.
|
||
// DESIGN.md: your own failure is the worst debris of all.
|
||
const GNOME = { x: 4.3, z: 4.4, rotY: -2.2, collateralValue: 25 };
|
||
|
||
// 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 = [];
|
||
/** 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;
|
||
|
||
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);
|
||
graybox.trees.set(spec.id, 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").
|
||
// 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").
|
||
const postSpecs = [
|
||
{ id: 'p1', x: -4.5, z: 5.5, h: 4.0 },
|
||
{ id: 'p2', x: 4.0, z: 6.0, h: 4.0 },
|
||
{ id: 'p3', x: 0, z: 7.0, 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);
|
||
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 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),
|
||
};
|
||
|
||
/**
|
||
* 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) => {
|
||
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, houseGlb, tree1, tree2, bedGlb, gnome] = await Promise.all([
|
||
load('shed_01_v1'), load('shed_table_v1'), load('house_yardside_v1'),
|
||
load('tree_gum_01_v1'), load('tree_gum_02_v1'), load('garden_bed_v1'),
|
||
load('garden_gnome_01_v1'),
|
||
]);
|
||
|
||
// --- 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 [i, id] of ['h1', 'h2', 'h3'].entries()) {
|
||
adoptAnchor(houseGlb, `fascia_anchor_0${i + 1}`, 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 [glb, spec] of [[tree1, treeSpecs[0]], [tree2, treeSpecs[1]]]) {
|
||
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() });
|
||
}
|
||
|
||
glb.updateWorldMatrix(true, true);
|
||
const suffix = ['', 'b', 'c'];
|
||
for (let i = 1; i <= 3; i++) {
|
||
const node = glb.getObjectByName(`branch_anchor_0${i}`);
|
||
if (!node) continue;
|
||
const id = spec.id + suffix[i - 1];
|
||
if (i === 1) adoptAnchor(glb, `branch_anchor_01`, id);
|
||
else {
|
||
const p = new THREE.Vector3().setFromMatrixPosition(node.matrixWorld);
|
||
const a = makeSwayAnchor(id, p, spec.phase, wind);
|
||
a.ratingHint = node.userData?.rating_hint ?? 1;
|
||
anchors.push(a);
|
||
}
|
||
}
|
||
}
|
||
|
||
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,
|
||
/** @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;
|
||
},
|
||
|
||
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,
|
||
);
|
||
},
|
||
};
|
||
}
|