HardYards/web/world/js/world.js
m3ultra 0cabb19dc7 Anchor rework + house/tree GLBs: give the yard a real choice (decisions 2 & 6)
Posts pulled in to (-4.5,5.5)/(4.0,6.0) with p3 at (0,7), and dress() now swaps
Lane E's house_yardside and both gum trees over the graybox, adopting their baked
anchors rather than my constants (decision 6). E's fascia sits at x=-3..3, not my
guessed -5..5 — narrowing the house span by 4 m is a real part of why small quads
exist at all.

Every anchor now carries E's rating_hint: fascia 0.35 with collateral "gutter"
(they encoded DESIGN.md's "the fascia board is a lie" straight into the asset),
tree branches descending 1.0/0.88/0.76 from fork to thin limb. branch_anchor_01
keeps the t1/t2 ids so nothing referencing them breaks; the rest are added.

The yard went from 7 anchors offering nothing under 110 m² to 11 offering 34
quads in the 18-45 m² band, 8 of which shade a quarter of the bed or more.
Measured through the same storm_02: the big house-to-post span loses its
carabiner at t=3.7 s and cascades to 2/4, while a 37.7 m² tree-to-post rig at
0.85 tension survives all 90 s intact and shades 58% of the bed. That is
DESIGN.md's thesis finally standing up in the yard rather than in a doc.

Lane B's "cascade at t=0.4 s from pre-tension alone" is gone: calm peaks are now
634 N (big) and 200 N (small) against a 1200 N carabiner, and no rig breaks
before the storm starts.

Two asserts pin it, because both halves are easy to lose by accident: at least 3
riggable quads in 18-45 m² must shade the bed, AND full bed coverage must stay
above 45 m² — if a small quad ever covers the whole bed, the rigging puzzle has
no wrong answers left. Selftest 172/0/0.

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

595 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 };
// 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() };
// --- 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 70192 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 1845 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);
// --- 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();
/** 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] = 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'),
]);
// --- 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,
// 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,
);
},
};
}