Add graybox yard, third-person camera and game shell
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>
This commit is contained in:
parent
ae6e444996
commit
a41328a219
38
web/world/index.html
Normal file
38
web/world/index.html
Normal file
@ -0,0 +1,38 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>SHADES — yard (M0)</title>
|
||||
<style>
|
||||
html, body { margin: 0; height: 100%; overflow: hidden; background: #9fc4dd; }
|
||||
canvas { display: block; width: 100%; height: 100%; }
|
||||
#dev, #help {
|
||||
position: fixed; left: 10px;
|
||||
font: 12px ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
color: #eef4f8; text-shadow: 0 1px 2px #0009;
|
||||
pointer-events: none; user-select: none;
|
||||
}
|
||||
#dev { top: 10px; }
|
||||
#help { bottom: 10px; opacity: .75; }
|
||||
#banner {
|
||||
position: fixed; top: 38%; left: 0; right: 0;
|
||||
text-align: center; pointer-events: none; user-select: none;
|
||||
font: 700 42px/1 ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
letter-spacing: .18em; color: #fff; text-shadow: 0 2px 12px #0007;
|
||||
opacity: 0; transition: opacity .45s ease;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="c"></canvas>
|
||||
<div id="banner"></div>
|
||||
<div id="dev">booting…</div>
|
||||
<div id="help">WASD move · shift run · RMB drag orbit · wheel zoom · Enter next phase</div>
|
||||
|
||||
<script type="module">
|
||||
import { boot } from './js/main.js';
|
||||
boot();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
176
web/world/js/camera.js
Normal file
176
web/world/js/camera.js
Normal file
@ -0,0 +1,176 @@
|
||||
/**
|
||||
* SHADES — third-person camera rig. Lane A owns this file.
|
||||
*
|
||||
* Shoulder-follow by default, orbit while the right mouse button is held.
|
||||
* Two things here are load-bearing for other lanes:
|
||||
* - `yaw` is what Lane D's WASD is relative to.
|
||||
* - the camera collides against world.solids, because the house sits on the
|
||||
* north edge of a 20 m yard and a naive follow cam walks straight through
|
||||
* the wall the moment the player stands with their back to it.
|
||||
*/
|
||||
|
||||
import * as THREE from '../vendor/three.module.js';
|
||||
|
||||
/** Stand-off kept between the camera and whatever it collided with. */
|
||||
const WALL_MARGIN = 0.25;
|
||||
/** Absolute floor on camera-to-head distance. Above the 0.1 near plane. */
|
||||
const NEAR_FLOOR = 0.15;
|
||||
/** How far the camera stays above the terrain. */
|
||||
const GROUND_CLEARANCE = 0.35;
|
||||
|
||||
const DEFAULTS = {
|
||||
distance: 4.5, // meters behind the target
|
||||
height: 1.55, // look-at height on the player (roughly the head)
|
||||
shoulder: 0.55, // lateral offset — the "third person" of it
|
||||
pitch: -0.18, // radians, slightly down
|
||||
minPitch: -1.15,
|
||||
maxPitch: 0.55,
|
||||
yaw: 0.0,
|
||||
sensitivity: 0.0042, // radians per pixel
|
||||
followLambda: 9, // position smoothing rate (higher = stiffer)
|
||||
fov: 62,
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} domElement Element that receives the orbit drag.
|
||||
* @param {object} [opts]
|
||||
* @returns {import('./contracts.js').CameraRig}
|
||||
*/
|
||||
export function createCameraRig(domElement, opts = {}) {
|
||||
const cfg = { ...DEFAULTS, ...opts };
|
||||
|
||||
const object = new THREE.PerspectiveCamera(cfg.fov, 1, 0.1, 400);
|
||||
object.position.set(0, 3, 8);
|
||||
|
||||
let yaw = cfg.yaw;
|
||||
let pitch = cfg.pitch;
|
||||
let dragging = false;
|
||||
let lastX = 0, lastY = 0;
|
||||
|
||||
// Smoothed camera position. Seeded on the first update so the camera doesn't
|
||||
// fly in from the origin on frame one.
|
||||
const smoothed = new THREE.Vector3();
|
||||
let seeded = false;
|
||||
|
||||
const onContextMenu = (e) => e.preventDefault();
|
||||
const onPointerDown = (e) => {
|
||||
if (e.button !== 2) return; // RMB only
|
||||
dragging = true;
|
||||
lastX = e.clientX; lastY = e.clientY;
|
||||
domElement.setPointerCapture?.(e.pointerId);
|
||||
};
|
||||
const onPointerMove = (e) => {
|
||||
if (!dragging) return;
|
||||
yaw -= (e.clientX - lastX) * cfg.sensitivity;
|
||||
pitch -= (e.clientY - lastY) * cfg.sensitivity;
|
||||
pitch = Math.max(cfg.minPitch, Math.min(cfg.maxPitch, pitch));
|
||||
lastX = e.clientX; lastY = e.clientY;
|
||||
};
|
||||
const onPointerUp = (e) => {
|
||||
if (e.button !== 2) return;
|
||||
dragging = false;
|
||||
domElement.releasePointerCapture?.(e.pointerId);
|
||||
};
|
||||
const onWheel = (e) => {
|
||||
cfg.distance = Math.max(2, Math.min(9, cfg.distance + Math.sign(e.deltaY) * 0.4));
|
||||
};
|
||||
|
||||
domElement.addEventListener('contextmenu', onContextMenu);
|
||||
domElement.addEventListener('pointerdown', onPointerDown);
|
||||
domElement.addEventListener('pointermove', onPointerMove);
|
||||
domElement.addEventListener('pointerup', onPointerUp);
|
||||
domElement.addEventListener('wheel', onWheel, { passive: true });
|
||||
|
||||
const target = new THREE.Vector3();
|
||||
const desired = new THREE.Vector3();
|
||||
const dir = new THREE.Vector3();
|
||||
const ray = new THREE.Raycaster();
|
||||
ray.far = 20;
|
||||
|
||||
/** @type {THREE.Object3D[]} */
|
||||
let solids = [];
|
||||
/** @type {(x:number, z:number) => number} */
|
||||
let groundAt = () => -Infinity;
|
||||
|
||||
return {
|
||||
object,
|
||||
|
||||
get yaw() { return yaw; },
|
||||
set yaw(v) { yaw = v; },
|
||||
|
||||
get pitch() { return pitch; },
|
||||
|
||||
/**
|
||||
* Obstacle meshes the camera must not pass through. NOT the ground — see
|
||||
* setGround(). Raycasting a 4800-triangle terrain every frame to learn
|
||||
* something world.heightAt() answers in closed form is the kind of waste
|
||||
* that only shows up once four other systems are also running.
|
||||
*/
|
||||
setSolids(list) { solids = list ?? []; },
|
||||
|
||||
/** @param {(x:number, z:number) => number} fn Pass world.heightAt. */
|
||||
setGround(fn) { groundAt = fn ?? (() => -Infinity); },
|
||||
|
||||
/**
|
||||
* @param {number} dt
|
||||
* @param {THREE.Vector3} targetPos Player feet position.
|
||||
*/
|
||||
update(dt, targetPos) {
|
||||
target.set(targetPos.x, targetPos.y + cfg.height, targetPos.z);
|
||||
|
||||
// Orbit offset, then slide sideways for the over-the-shoulder framing.
|
||||
const cp = Math.cos(pitch);
|
||||
dir.set(Math.sin(yaw) * cp, -Math.sin(pitch), Math.cos(yaw) * cp).normalize();
|
||||
desired.copy(target).addScaledVector(dir, cfg.distance);
|
||||
desired.x += Math.cos(yaw) * cfg.shoulder;
|
||||
desired.z -= Math.sin(yaw) * cfg.shoulder;
|
||||
|
||||
// Collision: cast from the head toward where the camera wants to be and
|
||||
// pull in to the first thing in the way.
|
||||
if (solids.length) {
|
||||
const toCam = desired.clone().sub(target);
|
||||
const dist = toCam.length();
|
||||
ray.set(target, toCam.divideScalar(dist || 1));
|
||||
ray.far = dist;
|
||||
const hit = ray.intersectObjects(solids, true)[0];
|
||||
if (hit) {
|
||||
// The wall wins. A comfortable stand-off distance is a preference; a
|
||||
// camera inside the house is a bug, so when the two disagree the
|
||||
// geometry gets the last word and the player eats a shoulder-filling
|
||||
// close-up instead. (Getting this backwards — clamping UP to a
|
||||
// minimum distance after the raycast — is what put the camera 17 cm
|
||||
// inside the north wall the first time round.)
|
||||
const safe = Math.min(dist, hit.distance - WALL_MARGIN);
|
||||
desired.copy(target).addScaledVector(ray.ray.direction, Math.max(NEAR_FLOOR, safe));
|
||||
}
|
||||
}
|
||||
|
||||
// Ground is handled analytically rather than by raycast: exact, can't be
|
||||
// tunnelled through, and free.
|
||||
desired.y = Math.max(desired.y, groundAt(desired.x, desired.z) + GROUND_CLEARANCE);
|
||||
|
||||
if (!seeded) { smoothed.copy(desired); seeded = true; }
|
||||
// Exponential smoothing that is correct at any dt (not the usual
|
||||
// frame-rate-dependent lerp).
|
||||
const a = 1 - Math.exp(-cfg.followLambda * dt);
|
||||
smoothed.lerp(desired, a);
|
||||
|
||||
object.position.copy(smoothed);
|
||||
object.lookAt(target);
|
||||
},
|
||||
|
||||
/** Keep the projection square with the canvas. */
|
||||
resize(width, height) {
|
||||
object.aspect = width / Math.max(1, height);
|
||||
object.updateProjectionMatrix();
|
||||
},
|
||||
|
||||
dispose() {
|
||||
domElement.removeEventListener('contextmenu', onContextMenu);
|
||||
domElement.removeEventListener('pointerdown', onPointerDown);
|
||||
domElement.removeEventListener('pointermove', onPointerMove);
|
||||
domElement.removeEventListener('pointerup', onPointerUp);
|
||||
domElement.removeEventListener('wheel', onWheel);
|
||||
},
|
||||
};
|
||||
}
|
||||
239
web/world/js/main.js
Normal file
239
web/world/js/main.js
Normal file
@ -0,0 +1,239 @@
|
||||
/**
|
||||
* SHADES — boot, game loop, phase machine. Lane A owns this file.
|
||||
*
|
||||
* Nothing is auto-run on import: index.html calls boot(). That keeps createGame()
|
||||
* importable from selftest.html, which must never construct a WebGLRenderer.
|
||||
*
|
||||
* The loop is a fixed-dt accumulator. Sim modules only ever see FIXED_DT, never
|
||||
* a real frame delta — that is the whole reason selftest can fast-forward a 90 s
|
||||
* storm in a few milliseconds and get the same numbers the player got.
|
||||
*/
|
||||
|
||||
import * as THREE from '../vendor/three.module.js';
|
||||
import { FIXED_DT, PHASES, STORM_LEN, YARD, Emitter, createStubWind } from './contracts.js';
|
||||
import { createWorld, heightAt } from './world.js';
|
||||
import { createCameraRig } from './camera.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase machine
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* forecast → prep → storm → aftermath → forecast.
|
||||
* @returns {import('./contracts.js').Game}
|
||||
*/
|
||||
export function createGame() {
|
||||
const emitter = new Emitter();
|
||||
let phase = 'forecast';
|
||||
let phaseT = 0;
|
||||
|
||||
return {
|
||||
get phase() { return phase; },
|
||||
/** Seconds since this phase began. The storm clock. */
|
||||
get phaseT() { return phaseT; },
|
||||
|
||||
on(type, fn) { return emitter.on(type, fn); },
|
||||
|
||||
/** @param {'forecast'|'prep'|'storm'|'aftermath'} next */
|
||||
setPhase(next) {
|
||||
if (!PHASES.includes(next)) throw new Error(`unknown phase '${next}'`);
|
||||
if (next === phase) return;
|
||||
const from = phase;
|
||||
phase = next;
|
||||
phaseT = 0;
|
||||
emitter.emit('phaseChange', { from, to: next });
|
||||
},
|
||||
|
||||
/** Advance to the next phase in loop order. */
|
||||
advance() {
|
||||
this.setPhase(PHASES[(PHASES.indexOf(phase) + 1) % PHASES.length]);
|
||||
},
|
||||
|
||||
tick(dt) {
|
||||
phaseT += dt;
|
||||
// The storm is on a timer; every other phase waits for the player.
|
||||
if (phase === 'storm' && phaseT >= STORM_LEN) this.setPhase('aftermath');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// M0 placeholder player
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A 1.7 m capsule that walks. This exists ONLY so the camera has something to
|
||||
* follow and the yard scale is legible before Lane D lands.
|
||||
*
|
||||
* Lane D: replace the call site in boot() with your player.js factory — the
|
||||
* shape you need to satisfy is `Player` in contracts.js ({pos, carrying, busy,
|
||||
* update}) — then delete this function. Everything else in this file already
|
||||
* talks to you through that contract, so nothing else should need to change.
|
||||
*/
|
||||
function createPlaceholderPlayer(scene, world, cameraRig) {
|
||||
const WALK = 2.2, RUN = 4.5; // m/s
|
||||
|
||||
const mesh = new THREE.Mesh(
|
||||
new THREE.CapsuleGeometry(0.28, 1.14, 4, 12),
|
||||
new THREE.MeshStandardMaterial({ color: 0xffd27a, roughness: 0.7 }),
|
||||
);
|
||||
mesh.name = 'player_placeholder';
|
||||
mesh.castShadow = true;
|
||||
scene.add(mesh);
|
||||
|
||||
const keys = new Set();
|
||||
const onDown = (e) => {
|
||||
keys.add(e.key.toLowerCase());
|
||||
if ([' ', 'arrowup', 'arrowdown', 'arrowleft', 'arrowright'].includes(e.key.toLowerCase())) e.preventDefault();
|
||||
};
|
||||
const onUp = (e) => keys.delete(e.key.toLowerCase());
|
||||
addEventListener('keydown', onDown);
|
||||
addEventListener('keyup', onUp);
|
||||
|
||||
const pos = new THREE.Vector3(0, heightAt(0, 6), 6);
|
||||
const move = new THREE.Vector3();
|
||||
const fwd = new THREE.Vector3();
|
||||
const right = new THREE.Vector3();
|
||||
let facing = 0;
|
||||
|
||||
const hx = YARD.width / 2 - 0.5, hz = YARD.depth / 2 - 0.5;
|
||||
|
||||
return {
|
||||
pos,
|
||||
carrying: null,
|
||||
busy: false,
|
||||
mesh,
|
||||
|
||||
update(dt) {
|
||||
const yaw = cameraRig.yaw;
|
||||
// Camera-relative: forward is where the camera is looking, flattened.
|
||||
fwd.set(-Math.sin(yaw), 0, -Math.cos(yaw));
|
||||
right.set(Math.cos(yaw), 0, -Math.sin(yaw));
|
||||
|
||||
move.set(0, 0, 0);
|
||||
if (keys.has('w') || keys.has('arrowup')) move.add(fwd);
|
||||
if (keys.has('s') || keys.has('arrowdown')) move.sub(fwd);
|
||||
if (keys.has('d') || keys.has('arrowright')) move.add(right);
|
||||
if (keys.has('a') || keys.has('arrowleft')) move.sub(right);
|
||||
|
||||
if (move.lengthSq() > 0) {
|
||||
move.normalize().multiplyScalar((keys.has('shift') ? RUN : WALK) * dt);
|
||||
pos.x = Math.max(-hx, Math.min(hx, pos.x + move.x));
|
||||
pos.z = Math.max(-hz, Math.min(hz, pos.z + move.z));
|
||||
facing = Math.atan2(move.x, move.z);
|
||||
}
|
||||
pos.y = world.heightAt(pos.x, pos.z);
|
||||
|
||||
mesh.position.set(pos.x, pos.y + 0.85, pos.z); // capsule centre
|
||||
mesh.rotation.y = facing;
|
||||
},
|
||||
|
||||
dispose() {
|
||||
removeEventListener('keydown', onDown);
|
||||
removeEventListener('keyup', onUp);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Boot
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {object} [opts]
|
||||
* @param {HTMLCanvasElement} [opts.canvas]
|
||||
*/
|
||||
export function boot(opts = {}) {
|
||||
const canvas = opts.canvas ?? document.getElementById('c');
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
|
||||
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
|
||||
renderer.shadowMap.enabled = true;
|
||||
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
||||
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||
renderer.toneMappingExposure = 1.0;
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
|
||||
// Lane C: swap createStubWind() for your createWeather(). Everything that
|
||||
// moves reads this one object, so that swap is the whole integration.
|
||||
const wind = createStubWind({ calm: true });
|
||||
|
||||
const world = createWorld(scene, { wind });
|
||||
const cameraRig = createCameraRig(canvas);
|
||||
cameraRig.setSolids(world.solids);
|
||||
cameraRig.setGround(world.heightAt);
|
||||
|
||||
const player = createPlaceholderPlayer(scene, world, cameraRig);
|
||||
const game = createGame();
|
||||
|
||||
// --- dev overlay (temporary — Lane A's hud.js replaces it after M0) ----
|
||||
const hud = document.getElementById('dev');
|
||||
const banner = document.getElementById('banner');
|
||||
game.on('phaseChange', ({ to }) => {
|
||||
if (banner) {
|
||||
banner.textContent = to.toUpperCase();
|
||||
banner.style.opacity = '1';
|
||||
setTimeout(() => { banner.style.opacity = '0'; }, 1400);
|
||||
}
|
||||
});
|
||||
addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') game.advance();
|
||||
});
|
||||
|
||||
// --- resize ------------------------------------------------------------
|
||||
function resize() {
|
||||
const w = canvas.clientWidth || innerWidth;
|
||||
const h = canvas.clientHeight || innerHeight;
|
||||
renderer.setSize(w, h, false);
|
||||
cameraRig.resize(w, h);
|
||||
}
|
||||
addEventListener('resize', resize);
|
||||
resize();
|
||||
|
||||
// --- loop --------------------------------------------------------------
|
||||
const clock = new THREE.Clock();
|
||||
let acc = 0;
|
||||
let simT = 0;
|
||||
let frames = 0, fpsT = 0, fps = 0;
|
||||
|
||||
function step(dt, t) {
|
||||
game.tick(dt);
|
||||
world.update(dt, t);
|
||||
player.update(dt, t);
|
||||
// Lane B: sailRig.step(dt, wind, t) goes here.
|
||||
// Lane C: debris.step(dt, wind, t) goes here.
|
||||
}
|
||||
|
||||
function frame() {
|
||||
// Clamped so a background tab or a breakpoint doesn't make the sim try to
|
||||
// catch up over thousands of steps and lock the page.
|
||||
const raw = Math.min(0.25, clock.getDelta());
|
||||
acc += raw;
|
||||
let guard = 0;
|
||||
while (acc >= FIXED_DT && guard++ < 60) {
|
||||
step(FIXED_DT, simT);
|
||||
simT += FIXED_DT;
|
||||
acc -= FIXED_DT;
|
||||
}
|
||||
|
||||
cameraRig.update(raw, player.pos);
|
||||
renderer.render(scene, cameraRig.object);
|
||||
|
||||
frames++; fpsT += raw;
|
||||
if (fpsT >= 0.5) { fps = frames / fpsT; frames = 0; fpsT = 0; }
|
||||
if (hud) {
|
||||
const w = wind.sample(player.pos, simT);
|
||||
hud.textContent =
|
||||
`${fps.toFixed(0)} fps | phase ${game.phase} ${game.phaseT.toFixed(1)}s | ` +
|
||||
`wind ${w.length().toFixed(1)} m/s | t ${simT.toFixed(1)}s`;
|
||||
}
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
requestAnimationFrame(frame);
|
||||
|
||||
// Handy for poking at the world from the console.
|
||||
const api = { renderer, scene, world, cameraRig, player, game, wind, get simT() { return simT; } };
|
||||
globalThis.SHADES = api;
|
||||
return api;
|
||||
}
|
||||
401
web/world/js/world.js
Normal file
401
web/world/js/world.js
Normal file
@ -0,0 +1,401 @@
|
||||
/**
|
||||
* 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,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user