The yard's meshes are placeholders; its layout is not. Anchor positions, ground heights, the garden rect and the sun direction are what the other lanes build against, so Lane E's GLBs drop into named slots without anyone re-deriving them. Sun sits in the north (Australian yard), which puts the house's yard-facing wall in permanent backlight. That's correct, but the fascia line carries three of the seven anchors, so sky fill is turned up to keep it readable rather than a void. Camera collision resolves toward the geometry, not away from it: clamping up to a comfort distance after the raycast is what put the camera 17cm inside the north wall. Ground is handled by heightAt() instead of a raycast — exact, can't be tunnelled, and free. main.js runs a fixed-dt accumulator so sim modules only ever see FIXED_DT. That is what lets selftest fast-forward a 90s storm and get the numbers the player got. Nothing auto-runs on import; index.html calls boot(). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
402 lines
14 KiB
JavaScript
402 lines
14 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 };
|
|
|
|
// Sun: mid-afternoon, high and off the north-west shoulder. Elevation 55°.
|
|
// Stored as the direction from the GROUND toward the SUN (see contracts.js).
|
|
const SUN_ELEV = (55 * Math.PI) / 180;
|
|
const SUN_AZIM = (-125 * Math.PI) / 180;
|
|
const SUN_DIR = new THREE.Vector3(
|
|
Math.cos(SUN_ELEV) * Math.sin(SUN_AZIM),
|
|
Math.sin(SUN_ELEV),
|
|
Math.cos(SUN_ELEV) * Math.cos(SUN_AZIM),
|
|
).normalize();
|
|
|
|
const COLORS = {
|
|
sky: 0x9fc4dd,
|
|
grass: 0x4a7c3f,
|
|
soil: 0x6b4a2f,
|
|
plant: 0x7fce6a,
|
|
house: 0x8a8f96,
|
|
trim: 0x6e737a,
|
|
bark: 0x5a3d24,
|
|
leaf: 0x2f6b28,
|
|
steel: 0x9aa4ad,
|
|
timber: 0x7a6a4f,
|
|
};
|
|
// (kept explicit rather than clever — Lane E will replace these with materials
|
|
// baked into the GLBs, at which point this table shrinks to nothing.)
|
|
|
|
/**
|
|
* Build the yard.
|
|
*
|
|
* @param {THREE.Scene} scene
|
|
* @param {object} [opts]
|
|
* @param {import('./contracts.js').Wind} [opts.wind]
|
|
* Wind is injected because tree anchors sway with it, and sway is dynamic
|
|
* load — the whole reason a tree anchor is scarier than a post. Defaults to
|
|
* the calm stub so world.js is usable headless before Lane C lands.
|
|
* @returns {import('./contracts.js').World}
|
|
*/
|
|
export function createWorld(scene, opts = {}) {
|
|
const wind = opts.wind ?? createStubWind({ calm: true });
|
|
|
|
const root = new THREE.Group();
|
|
root.name = 'yard';
|
|
scene.add(root);
|
|
|
|
/** @type {THREE.Object3D[]} */
|
|
const solids = [];
|
|
/** @type {import('./contracts.js').Anchor[]} */
|
|
const anchors = [];
|
|
/** @type {{group: THREE.Object3D, phase: number, base: THREE.Euler}[]} */
|
|
const canopies = [];
|
|
|
|
// --- sky & light -------------------------------------------------------
|
|
// Calm-day only. Lane C's skyfx.js takes over the sky and this becomes the
|
|
// "before" state the storm darkens away from.
|
|
scene.background = new THREE.Color(COLORS.sky);
|
|
scene.fog = new THREE.Fog(COLORS.sky, 34, 95);
|
|
|
|
// Sky fill carries more here than it would in most scenes. The sun sits in
|
|
// the NORTH (this is an Australian yard — "southerly change", gum trees), and
|
|
// the house is the yard's north edge, so the wall the player spends the whole
|
|
// game looking at is permanently backlit. That's correct, and it's also how
|
|
// a real south-facing rear wall looks — but the fascia line carries three of
|
|
// the seven anchors, so it has to stay readable in shadow rather than going
|
|
// to a void. Sky bounce is what does that in the real yard too.
|
|
const hemi = new THREE.HemisphereLight(0xbfd8ea, COLORS.grass, 1.8);
|
|
scene.add(hemi);
|
|
|
|
const sun = new THREE.DirectionalLight(0xfff2dc, 2.0);
|
|
sun.position.copy(SUN_DIR).multiplyScalar(40);
|
|
sun.target.position.set(0, 0, 0);
|
|
sun.castShadow = true;
|
|
sun.shadow.mapSize.set(2048, 2048);
|
|
// Frame the yard tightly — a loose shadow frustum is why sail shadows go
|
|
// soft and blocky, and the sail's shadow IS the product here.
|
|
const sc = sun.shadow.camera;
|
|
sc.left = -19; sc.right = 19; sc.top = 15; sc.bottom = -15;
|
|
sc.near = 1; sc.far = 90;
|
|
sun.shadow.bias = -0.0006;
|
|
sun.shadow.normalBias = 0.02;
|
|
scene.add(sun);
|
|
scene.add(sun.target);
|
|
|
|
// --- terrain -----------------------------------------------------------
|
|
const groundGeo = new THREE.PlaneGeometry(YARD.width, YARD.depth, 60, 40);
|
|
groundGeo.rotateX(-Math.PI / 2);
|
|
const gpos = groundGeo.attributes.position;
|
|
for (let i = 0; i < gpos.count; i++) {
|
|
gpos.setY(i, heightAt(gpos.getX(i), gpos.getZ(i)));
|
|
}
|
|
gpos.needsUpdate = true;
|
|
groundGeo.computeVertexNormals();
|
|
const ground = new THREE.Mesh(
|
|
groundGeo,
|
|
new THREE.MeshStandardMaterial({ color: COLORS.grass, roughness: 0.95 }),
|
|
);
|
|
ground.name = 'ground';
|
|
ground.receiveShadow = true;
|
|
root.add(ground);
|
|
// Deliberately NOT in `solids`: the ground is answered by heightAt(), which
|
|
// is exact and free. Raycasting it would be 4800 triangles a frame to learn
|
|
// something we already know in closed form.
|
|
|
|
// --- house (north edge) ------------------------------------------------
|
|
// Rear wall sits exactly on z = -10 so the fascia anchors have a round
|
|
// number to live on. Lane E's house_yardside.glb replaces this group and
|
|
// should keep fascia_anchor_* at these positions.
|
|
const house = new THREE.Group();
|
|
house.name = 'house_yardside';
|
|
const wall = new THREE.Mesh(
|
|
new THREE.BoxGeometry(16, 3.0, 6),
|
|
new THREE.MeshStandardMaterial({ color: COLORS.house, roughness: 0.85 }),
|
|
);
|
|
wall.position.set(0, 1.5, -13);
|
|
wall.castShadow = true;
|
|
wall.receiveShadow = true;
|
|
house.add(wall);
|
|
|
|
const roof = new THREE.Mesh(
|
|
new THREE.BoxGeometry(16.8, 0.22, 6.8),
|
|
new THREE.MeshStandardMaterial({ color: COLORS.trim, roughness: 0.7 }),
|
|
);
|
|
roof.position.set(0, 3.1, -13);
|
|
roof.castShadow = true;
|
|
house.add(roof);
|
|
|
|
// The fascia line — the lie the player will be tempted by (DESIGN.md).
|
|
const fascia = new THREE.Mesh(
|
|
new THREE.BoxGeometry(16, 0.24, 0.12),
|
|
new THREE.MeshStandardMaterial({ color: COLORS.trim, roughness: 0.6 }),
|
|
);
|
|
fascia.position.set(0, 2.72, -9.98);
|
|
house.add(fascia);
|
|
|
|
root.add(house);
|
|
solids.push(wall, roof);
|
|
|
|
for (const [i, x] of [-5, 0, 5].entries()) {
|
|
anchors.push(makeStaticAnchor(`h${i + 1}`, 'house', new THREE.Vector3(x, 2.6, -9.9)));
|
|
}
|
|
|
|
// --- trees -------------------------------------------------------------
|
|
// Two, on opposite shoulders, mirroring the prototype's tree placement.
|
|
// Canopies are separate named nodes so they can be swayed independently —
|
|
// Lane E's tree_gum_01.glb must keep `trunk` / `canopy_*` / `branch_anchor_*`.
|
|
const treeSpecs = [
|
|
{ id: 't1', x: -9, z: 2, phase: 0.7, trunkH: 4.2, anchorY: 3.4 },
|
|
{ id: 't2', x: 8, z: -2, phase: 2.9, trunkH: 3.8, anchorY: 3.1 },
|
|
];
|
|
for (const spec of treeSpecs) {
|
|
const y0 = heightAt(spec.x, spec.z);
|
|
const tree = new THREE.Group();
|
|
tree.name = `tree_${spec.id}`;
|
|
tree.position.set(spec.x, y0, spec.z);
|
|
|
|
const trunk = new THREE.Mesh(
|
|
new THREE.CylinderGeometry(0.18, 0.28, spec.trunkH, 8),
|
|
new THREE.MeshStandardMaterial({ color: COLORS.bark, roughness: 1 }),
|
|
);
|
|
trunk.name = 'trunk';
|
|
trunk.position.y = spec.trunkH / 2;
|
|
trunk.castShadow = true;
|
|
tree.add(trunk);
|
|
solids.push(trunk);
|
|
|
|
const canopy = new THREE.Group();
|
|
canopy.name = 'canopy';
|
|
canopy.position.y = spec.trunkH;
|
|
const blobMat = new THREE.MeshStandardMaterial({ color: COLORS.leaf, roughness: 1 });
|
|
for (const [j, b] of [
|
|
{ x: 0, y: 0.7, z: 0, r: 2.1 },
|
|
{ x: 1.1, y: 0.1, z: 0.5, r: 1.4 },
|
|
{ x: -0.9, y: 0.3, z: -0.6, r: 1.5 },
|
|
].entries()) {
|
|
const blob = new THREE.Mesh(new THREE.SphereGeometry(b.r, 12, 8), blobMat);
|
|
blob.name = `canopy_${j}`;
|
|
blob.position.set(b.x, b.y, b.z);
|
|
blob.castShadow = true;
|
|
canopy.add(blob);
|
|
}
|
|
tree.add(canopy);
|
|
canopies.push({ group: canopy, phase: spec.phase, base: canopy.rotation.clone() });
|
|
|
|
root.add(tree);
|
|
|
|
// The anchor is at a branch fork, not the canopy centre.
|
|
anchors.push(makeSwayAnchor(
|
|
spec.id,
|
|
new THREE.Vector3(spec.x, y0 + spec.anchorY, spec.z),
|
|
spec.phase,
|
|
wind,
|
|
));
|
|
}
|
|
|
|
// --- posts -------------------------------------------------------------
|
|
// Raked away from the yard centre, because that is the correct practice and
|
|
// the shape should teach it before any text does (DESIGN.md: "rake the post
|
|
// away from the load").
|
|
const postSpecs = [
|
|
{ id: 'p1', x: -6, z: 7, h: 4.0 },
|
|
{ id: 'p2', x: 5, z: 7.5, h: 4.0 },
|
|
];
|
|
const RAKE = (8 * Math.PI) / 180;
|
|
for (const spec of postSpecs) {
|
|
const y0 = heightAt(spec.x, spec.z);
|
|
// Lean away from the centre of the yard, in the XZ plane.
|
|
const away = new THREE.Vector2(spec.x, spec.z).normalize();
|
|
const top = new THREE.Vector3(
|
|
spec.x + Math.sin(RAKE) * spec.h * away.x,
|
|
y0 + Math.cos(RAKE) * spec.h,
|
|
spec.z + Math.sin(RAKE) * spec.h * away.y,
|
|
);
|
|
|
|
const post = new THREE.Mesh(
|
|
new THREE.CylinderGeometry(0.06, 0.08, spec.h, 8),
|
|
new THREE.MeshStandardMaterial({ color: COLORS.steel, roughness: 0.5, metalness: 0.6 }),
|
|
);
|
|
post.name = `sail_post_${spec.id}`;
|
|
post.position.set((spec.x + top.x) / 2, (y0 + top.y) / 2, (spec.z + top.z) / 2);
|
|
post.quaternion.setFromUnitVectors(
|
|
new THREE.Vector3(0, 1, 0),
|
|
top.clone().sub(new THREE.Vector3(spec.x, y0, spec.z)).normalize(),
|
|
);
|
|
post.castShadow = true;
|
|
root.add(post);
|
|
solids.push(post);
|
|
|
|
const footing = new THREE.Mesh(
|
|
new THREE.CylinderGeometry(0.22, 0.26, 0.18, 10),
|
|
new THREE.MeshStandardMaterial({ color: 0x9c9c96, roughness: 0.95 }),
|
|
);
|
|
footing.position.set(spec.x, y0 + 0.06, spec.z);
|
|
footing.receiveShadow = true;
|
|
root.add(footing);
|
|
|
|
anchors.push(makeStaticAnchor(spec.id, 'post', top));
|
|
}
|
|
|
|
// --- garden bed (the thing you are protecting) -------------------------
|
|
const bed = new THREE.Group();
|
|
bed.name = 'garden_bed';
|
|
const bedY = heightAt(GARDEN_BED.x, GARDEN_BED.z);
|
|
bed.position.set(GARDEN_BED.x, bedY, GARDEN_BED.z);
|
|
|
|
const soil = new THREE.Mesh(
|
|
new THREE.BoxGeometry(GARDEN_BED.w, 0.3, GARDEN_BED.d),
|
|
new THREE.MeshStandardMaterial({ color: COLORS.soil, roughness: 1 }),
|
|
);
|
|
soil.position.y = 0.15;
|
|
soil.receiveShadow = true;
|
|
bed.add(soil);
|
|
|
|
const plantMat = new THREE.MeshStandardMaterial({ color: COLORS.plant, roughness: 0.9 });
|
|
for (let i = 0; i < 6; i++) {
|
|
for (let j = 0; j < 4; j++) {
|
|
const plant = new THREE.Mesh(new THREE.SphereGeometry(0.28, 8, 6), plantMat);
|
|
plant.position.set(
|
|
(-0.5 + (i + 0.5) / 6) * GARDEN_BED.w,
|
|
0.42,
|
|
(-0.5 + (j + 0.5) / 4) * GARDEN_BED.d,
|
|
);
|
|
plant.castShadow = true;
|
|
plant.receiveShadow = true;
|
|
bed.add(plant);
|
|
}
|
|
}
|
|
root.add(bed);
|
|
|
|
// --- boundary fence ----------------------------------------------------
|
|
// East, south and west only: the house is the north boundary.
|
|
const fence = new THREE.Group();
|
|
fence.name = 'fence';
|
|
const railMat = new THREE.MeshStandardMaterial({ color: 0x7a6a4f, roughness: 1 });
|
|
const hx = YARD.width / 2, hz = YARD.depth / 2;
|
|
const runs = [
|
|
{ from: [-hx, hz], to: [hx, hz] }, // south
|
|
{ from: [-hx, -hz], to: [-hx, hz] }, // west
|
|
{ from: [hx, -hz], to: [hx, hz] }, // east
|
|
];
|
|
for (const run of runs) {
|
|
const [x0, z0] = run.from, [x1, z1] = run.to;
|
|
const len = Math.hypot(x1 - x0, z1 - z0);
|
|
const n = Math.round(len / 2.5);
|
|
for (let i = 0; i <= n; i++) {
|
|
const f = i / n;
|
|
const x = x0 + (x1 - x0) * f, z = z0 + (z1 - z0) * f;
|
|
const p = new THREE.Mesh(new THREE.BoxGeometry(0.1, 1.6, 0.1), railMat);
|
|
p.position.set(x, heightAt(x, z) + 0.8, z);
|
|
p.castShadow = true;
|
|
fence.add(p);
|
|
}
|
|
for (const ry of [0.6, 1.35]) {
|
|
const rail = new THREE.Mesh(new THREE.BoxGeometry(len, 0.12, 0.04), railMat);
|
|
rail.position.set((x0 + x1) / 2, heightAt((x0 + x1) / 2, (z0 + z1) / 2) + ry, (z0 + z1) / 2);
|
|
rail.rotation.y = Math.atan2(-(z1 - z0), x1 - x0);
|
|
rail.castShadow = true;
|
|
fence.add(rail);
|
|
}
|
|
}
|
|
root.add(fence);
|
|
solids.push(fence);
|
|
|
|
// --- the world object --------------------------------------------------
|
|
return {
|
|
anchors,
|
|
heightAt,
|
|
gardenBed: GARDEN_BED,
|
|
sunDir: SUN_DIR.clone(),
|
|
solids,
|
|
root,
|
|
sun,
|
|
|
|
/** @param {string} id */
|
|
anchor(id) {
|
|
return anchors.find((a) => a.id === id) ?? null;
|
|
},
|
|
|
|
update(dt, t) {
|
|
// Canopies lean with the wind they actually stand in — this is the tell
|
|
// the player reads a gust front from, a beat before it reaches the sail.
|
|
for (const c of canopies) {
|
|
const w = wind.sample(c.group.getWorldPosition(_worldPos), t);
|
|
const speed = w.length();
|
|
const lean = Math.min(0.22, speed * 0.007);
|
|
const flutter = 0.55 + 0.45 * Math.sin(t * 2.3 + c.phase);
|
|
c.group.rotation.z = c.base.z - Math.cos(Math.atan2(w.z, w.x)) * lean * flutter;
|
|
c.group.rotation.x = c.base.x + Math.sin(Math.atan2(w.z, w.x)) * lean * flutter;
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
const _worldPos = new THREE.Vector3();
|
|
|
|
/** @returns {import('./contracts.js').Anchor} */
|
|
function makeStaticAnchor(id, type, pos) {
|
|
const p = pos.clone();
|
|
return { id, type, pos: p, sway: () => p };
|
|
}
|
|
|
|
/**
|
|
* A tree anchor. Wanders with the wind it stands in, which is exactly why it is
|
|
* the dangerous choice: the sway is dynamic load the rig has to eat, and a
|
|
* drum-tight rig on a swaying tree snaps turnbuckles (DESIGN.md).
|
|
*
|
|
* @returns {import('./contracts.js').Anchor}
|
|
*/
|
|
function makeSwayAnchor(id, pos, phase, wind) {
|
|
const p = pos.clone();
|
|
const scratch = new THREE.Vector3(); // per-anchor, so two anchors never alias
|
|
return {
|
|
id,
|
|
type: 'tree',
|
|
pos: p,
|
|
sway(t) {
|
|
const speed = wind.sample(p, t).length();
|
|
const amp = Math.min(0.35, speed * 0.012);
|
|
const s = Math.sin(t * 1.9 + phase);
|
|
return scratch.set(
|
|
p.x + s * amp,
|
|
p.y - Math.abs(s) * amp * 0.25,
|
|
p.z + Math.cos(t * 1.3 + phase) * amp * 0.5,
|
|
);
|
|
},
|
|
};
|
|
}
|