HardYards/web/world/js/world.js
m3ultra 2af4662cd7 Add p4, the one close anchor — the wild night is now winnable (gate 1)
SPRINT6 gate 1's anchor lever, placed by measurement rather than by guess.

The wild night had no winnable line for a geometric reason: every anchor near
the bed (p1/p2/p3) is SOUTH of it, and nothing stands north short of the house
10 m away. So covering rigs had to span the yard and died, while rigs small
enough to survive sat beside the bed rather than over it. p4 supplies the
missing north-west corner.

Its position is swept, not chosen. Against the smallest quad covering 90% of the
bed: (-2.2,-1.2) → 44.4 m², (-3.2,-1.2) → 48.6, (-3.2,-2.0) → 51.7, beyond
z=-4 → no effect at all. Pulling it inward to 44.4 would put full coverage
INSIDE the survivable band and collapse DESIGN.md's central tension — covering
the bed has to cost risk. a.test.js's >45 m² floor is what shouts if anyone
moves it in. With the 8° rake the top anchor lands at (-3.72, -1.40), and full
coverage costs 51.6 m² against 63.5 before: a real new option, 19% cheaper,
still bigger than the 23-38 m² rigs that survive unaided.

Measured through the real $80 shop: t2+p3+p4+t2b, four shackles and a spare
($75), ends hp 58 with 1 corner lost — a WIN, where before every covering rig
lost 2-3 corners and ended at 36%. Hail damage falls 51 → 34 HP versus an
uncovered rig, so decision 13 is visibly doing its job.

Selftest 244/0/0; both yard tensions still green.

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

668 lines
26 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 };
// 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 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").
// SPRINT6 gate 1: p4 is the ONE close anchor the balance pass allows, and its
// position is measured rather than guessed.
//
// The wild night had no winnable line because every anchor near the bed —
// p1/p2/p3 — is SOUTH of it, and nothing stands north short of the house 10 m
// away. So covering rigs had to span the yard and died, while rigs small
// enough to survive sat beside the bed instead of over it.
//
// p4 supplies the missing north-west corner. Swept its position against the
// smallest quad that covers 90% of the bed:
// (-2.2, -1.2) → 44.4 m² ← breaks the tradeoff (see below)
// (-3.2, -1.2) → 48.6 m² ← here
// (-3.2, -2.0) → 51.7 m²
// (-4.2, -3.0) → 60.0 m²
// beyond ~z=-4 → 63.5 m², i.e. too far away to matter at all
// Full coverage costs 63.5 m² without it. At (-3.2,-1.2) that drops to 48.6 —
// a real new option, 23% cheaper — while still costing you a bigger sail than
// the 23-38 m² rigs that survive on their own. Pulling it in to (-2.2,-1.2)
// would put full coverage at 44.4 m², INSIDE the survivable band, which
// collapses DESIGN.md's central tension: covering the bed has to cost risk.
// a.test.js asserts that >45 m² floor, so moving this post inward goes red.
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 },
{ id: 'p4', x: -3.2, z: -1.2, 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,
);
},
};
}