diff --git a/web/world/js/camera.js b/web/world/js/camera.js index 4701751..5af6b52 100644 --- a/web/world/js/camera.js +++ b/web/world/js/camera.js @@ -11,6 +11,100 @@ import * as THREE from '../vendor/three.module.js'; +/** + * Perpendicular distance in XZ from point `p` to the segment a→b. The camera + * cares about the whole segment, not the endpoints: a post the camera stands + * clear of can still be planted squarely between it and the player's head. + */ +function distToSegmentXZ(p, a, b) { + const abx = b.x - a.x, abz = b.z - a.z; + const len2 = abx * abx + abz * abz; + let t = len2 ? ((p.x - a.x) * abx + (p.z - a.z) * abz) / len2 : 0; + t = t < 0 ? 0 : t > 1 ? 1 : t; + return Math.hypot(p.x - (a.x + abx * t), p.z - (a.z + abz * t)); +} + +/** + * The opening yaw: point the camera so the first frame is player + what matters, + * with nothing skewered through the middle of it. (SPRINT13 gate 2.5.) + * + * The QA pass put it plainly: "the boot camera puts a pole dead-centre through + * the player on every single first impression". Measured rather than eyeballed — + * the player spawns at (0,6) on backyard_01 looking up the yard at yaw 0, which + * parks the camera near z≈10.4, and post p3 stands at (0,7). Dead between the + * camera and the head, every boot, on a public URL. + * + * Two things this is NOT: + * · not a magic yaw. A hand-picked angle is one site's answer, and sites are + * data now — site_02 spawns somewhere else with its posts somewhere else, and + * a constant tuned against the backyard would frame the corner block by luck. + * · not the camera's existing collision. That pulls IN to the first solid in + * the way, so a post behind the player trades a skewer for a shoulder-filling + * close-up. Both are the bad frame; this picks a different angle instead. + * + * Sweeps outward from the ideal (camera opposite `lookAt`, so the player stands + * in front of the thing they're here to protect) and takes the FIRST yaw with + * real clearance — nearest the ideal wins, so the framing gives up as little as + * it can. + * + * TWO NUMBERS HERE ARE MEASURED, not chosen, and both were wrong first: + * + * `clearance` 0.6 — my first pass asked for 1.1 m and produced a WORSE frame + * than the bug it fixed: the camera swung 105° off the garden and stared at a + * fence. Reason, measured in the running game: p3 stands at (0,7) and the player + * spawns at (0,6), so the post is **1.0 m away** — and the greatest perpendicular + * distance any obstacle can ever have from the camera→head line is its own + * distance from the player. 1.1 was unreachable by construction. The sweep + * dutifully tried all 24 candidates, failed every one, and fell back to + * "roomiest", which is a rule that knows nothing about the garden. Clearance is + * `sin(off) × 1.0 m` here, so 0.6 buys ~37° of turn and leaves the bed 23° off + * centre in a 62° FOV: in frame, next to the player, no pole through the head. + * + * `maxOff` 90° — the fallback's bound, and the lesson from the same failure. A + * frame that has turned more than a quarter-circle off the bed is no longer a + * frame of the bed, so it cannot be the "best available" answer no matter how + * roomy it is. Past this, a pole in shot is the lesser evil, and the sweep says + * so by returning the roomiest angle WITHIN the arc rather than outside it. + * + * Never throws, never returns NaN: a yard boxed in on every side still gets its + * best available frame. + * + * @param {{x,z}} player + * @param {{x,z}} lookAt what the frame should be about — the garden bed + * @param {{x,z}[]} obstacles vertical things, by XZ: posts, trunks, structures + * @param {object} [opts] + * @returns {number} yaw in radians + */ +export function spawnYawFor(player, lookAt, obstacles = [], opts = {}) { + const distance = opts.distance ?? DEFAULTS.distance; + const clearance = opts.clearance ?? 0.6; + const maxOff = opts.maxOff ?? Math.PI / 2; + const steps = opts.steps ?? 24; + const ideal = Math.atan2(player.x - lookAt.x, player.z - lookAt.z); + + const clearAt = (yaw) => { + const cam = { x: player.x + Math.sin(yaw) * distance, z: player.z + Math.cos(yaw) * distance }; + let clear = Infinity; + for (const o of obstacles) clear = Math.min(clear, distToSegmentXZ(o, player, cam)); + return clear; + }; + + let best = ideal, bestClear = clearAt(ideal); + if (bestClear >= clearance) return ideal; + + // Alternate outward — +7.5°, −7.5°, +15° … — so "first acceptable" also means + // "least turned away from the bed". Bounded by maxOff: see above. + for (let i = 1; i <= steps; i++) { + const mag = (Math.ceil(i / 2) * maxOff) / Math.ceil(steps / 2); + if (mag > maxOff) break; + const yaw = ideal + (i % 2 ? mag : -mag); + const clear = clearAt(yaw); + if (clear >= clearance) return yaw; + if (clear > bestClear) { bestClear = clear; best = yaw; } + } + return best; +} + /** 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. */ @@ -108,6 +202,14 @@ export function createCameraRig(domElement, opts = {}) { */ setSolids(list) { solids = list ?? []; }, + /** + * What the camera is currently colliding against. A read-only view (spread, + * so a caller can't mutate the live array). SPRINT13: added so the + * aftermath cloth-swallow fix is observable — "did the dead sail actually + * join the solid set" was otherwise a claim with no way to check it. + */ + get solids() { return [...solids]; }, + /** @param {(x:number, z:number) => number} fn Pass world.heightAt. */ setGround(fn) { groundAt = fn ?? (() => -Infinity); }, diff --git a/web/world/js/main.js b/web/world/js/main.js index 2ccfb24..4bb614f 100644 --- a/web/world/js/main.js +++ b/web/world/js/main.js @@ -16,7 +16,7 @@ import * as THREE from '../vendor/three.module.js'; import { FIXED_DT, PHASES, STORM_LEN, HARDWARE, SPARE_COST, Emitter } from './contracts.js'; import { createWorld, loadSite } from './world.js'; -import { createCameraRig } from './camera.js'; +import { createCameraRig, spawnYawFor } from './camera.js'; import { loadStorm, createWind } from './weather.js'; import { SailRig, createSailView } from './sail.js'; import { createPlayer } from './player.js'; @@ -507,6 +507,13 @@ export async function boot(opts = {}) { // temporal-dead-zone throw. Boot builds them against the same fresh world. let rig; let rigging; + // Same reason, same trap (SPRINT13): loadSiteInto now refreshes the camera's + // solid set, and that reads sailView — on a first call that happens at boot, + // before any cloth exists. Declared up here with the others so it reads + // `undefined` (no cloth yet: use the yard's solids) instead of throwing TDZ + // from a line whose only crime was being honest about what the camera needs. + // Caught by the page going blank, which is the loudest a TDZ ever gets. + let sailView = null; const cameraRig = createCameraRig(canvas); const interact = new Interact(); @@ -533,7 +540,7 @@ export async function boot(opts = {}) { await world.dress(); currentSite = siteName; - cameraRig.setSolids(world.solids); + refreshCameraSolids(); cameraRig.setGround(world.heightAt); // Lane C: local wind effects are per-yard. A venturi (site_02's screaming // gap) and tree shelters both re-register here; empty/absent is a no-op, @@ -547,6 +554,23 @@ export async function boot(opts = {}) { // forecast, never mid-storm, so a clean rebuild is honest and cheap. player = await createPlayer(scene, world, cameraRig, { wind, interact }); + // SPRINT13 gate 2.5 — the opening frame, per YARD rather than per game. + // Done here because the spawn is here: the player and the yard are rebuilt + // together, so the frame that introduces them is a property of the site, not + // a constant. site_02 spawns somewhere else with its posts somewhere else, + // and a yaw hand-tuned against the backyard would frame the corner block by + // luck. Obstacles come from the site's own data — every vertical thing a + // pole-through-the-head could be. + cameraRig.yaw = spawnYawFor( + player.pos, + { x: world.gardenBed.x, z: world.gardenBed.z }, + [ + ...(siteDef.posts ?? []), + ...(siteDef.trees ?? []), + ...(siteDef.structures ?? []), + ].map((o) => ({ x: o.x, z: o.z })), + ); + // Re-point everything that captured the old anchor set. Done HERE, right // after the rebuild, rather than in the caller — a caller-side `if (switched)` // was fragile (it broke the moment a debug path had already advanced @@ -578,7 +602,28 @@ export async function boot(opts = {}) { // --- 3. sail ------------------------------------------------------------ rig = new SailRig({ anchors: world.anchors }); - let sailView = null; + + /** + * What the camera may not pass through: the yard's solids, PLUS the cloth. + * + * SPRINT13 gate 2.5 — "in aftermath the dead draped sail can swallow the + * camera whole" (QA pass). The camera has collided with the house since + * Sprint 2 for exactly this reason, and the sail was simply never in the list: + * while it is up it hangs above head height and nothing notices, but a sail + * that has FAILED lies in the yard at head height, which is the one moment the + * player most wants to look at it. + * + * Called from both rebuilds, because they invalidate the list independently: + * loadSiteInto() makes new world.solids, rigSail() makes a new cloth. Either + * one alone leaves the camera holding a mesh that was disposed. + * + * The cloth's bounding sphere is recomputed in sailView.update() every frame, + * so the raycast reads live geometry rather than the shape it had at rig time. + * Cheap: the default grid is 10x10, so ~162 triangles against a whole house. + */ + function refreshCameraSolids() { + cameraRig.setSolids(sailView ? [...world.solids, sailView] : world.solids); + } /** * Attach the cloth across 4 anchors and (re)build its view. @@ -602,6 +647,7 @@ export async function boot(opts = {}) { } sailView = await createSailView(rig); scene.add(sailView); + refreshCameraSolids(); // the new cloth; the one it replaced is disposed wireYardActions(interact, { sailRig: rig, world }); return sailView; } diff --git a/web/world/js/tests/a.test.js b/web/world/js/tests/a.test.js index aa221a3..097c864 100644 --- a/web/world/js/tests/a.test.js +++ b/web/world/js/tests/a.test.js @@ -7,7 +7,7 @@ import * as THREE from '../../vendor/three.module.js'; import { ANCHOR_TYPE, FIXED_DT, PHASES, STORM_LEN, YARD, checkContract, createStubWind } from '../contracts.js'; import { createWindField } from '../weather.core.js'; import { createWorld, heightAt, loadSite, validateSite } from '../world.js'; -import { createCameraRig } from '../camera.js'; +import { createCameraRig, spawnYawFor } from '../camera.js'; import { CALM_STORM, accumulate, canPlayHere, createGame, createWindRouter, enterCommits, stormsToPreload, verdictFor, @@ -131,6 +131,51 @@ export default async function run(t) { assertEq(canPlayHere(() => ({})), true, 'a browser that answers nothing: let them in'); }); + t.test('the spawn frame points at the garden with no pole through the player', () => { + // The QA pass: "the boot camera puts a pole dead-centre through the player on + // every single first impression." Measured, not eyeballed — these are the + // real backyard_01 numbers that produced the bug. + const player = { x: 0, z: 6 }; + const bed = { x: 1, z: 2 }; + + // No obstacles: the ideal frame is the camera OPPOSITE the bed, so the player + // stands in front of what they're protecting. The view direction (from camera + // through the player) must point at the bed. + const clean = spawnYawFor(player, bed, []); + const viewOff = (yaw) => { + const view = { x: -Math.sin(yaw), z: -Math.cos(yaw) }; + const tb = { x: bed.x - player.x, z: bed.z - player.z }; + const l = Math.hypot(tb.x, tb.z); + return Math.acos(Math.max(-1, Math.min(1, (view.x * tb.x + view.z * tb.z) / (l || 1)))) * 180 / Math.PI; + }; + assertLess(viewOff(clean), 1, 'with nothing in the way, the frame looks straight at the bed'); + + // The bug: p3 stands at (0,7), one metre behind the spawn, dead on the ideal + // view line. The fix must TURN to clear it — and still keep the bed in a 62° + // FOV (< 31° off centre). + const withPole = spawnYawFor(player, bed, [{ x: 0, z: 7 }]); + const cam = { x: player.x + Math.sin(withPole) * 4.5, z: player.z + Math.cos(withPole) * 4.5 }; + const abx = cam.x - player.x, abz = cam.z - player.z, l2 = abx * abx + abz * abz; + let t = ((0 - player.x) * abx + (7 - player.z) * abz) / l2; t = Math.max(0, Math.min(1, t)); + const poleClear = Math.hypot(0 - (player.x + abx * t), 7 - (player.z + abz * t)); + assert(poleClear > 0.5, `the pole is off the view line (${poleClear.toFixed(2)}m), not through the head`); + assertLess(viewOff(withPole), 31, 'and the bed is still in frame after the turn'); + + // The failure mode I actually shipped first, reproduced from the real yard. + // With the full backyard_01 obstacle set and a clearance the geometry cannot + // meet, the sweep falls back to "roomiest" — and UNBOUNDED, roomiest swung + // 105° off the garden to stare at a fence (the exact number the QA pass would + // have seen). maxOff caps the fallback to the 90° arc, and this config brings + // it back to 68°. Without the cap this assert goes red at 105°. + const yardObstacles = [ + { x: -4.5, z: 5.5 }, { x: 4, z: 6 }, { x: 0, z: 7 }, { x: -3.2, z: -1.2 }, + { x: -9, z: 2 }, { x: 8, z: -2 }, + ]; + const unreachable = spawnYawFor(player, bed, yardObstacles, { clearance: 5 }); + assert(Number.isFinite(unreachable), 'an impossible clearance still yields a finite yaw, never NaN'); + assertLess(viewOff(unreachable), 91, 'and never turns its back on the bed — the cap holds (105° without it)'); + }); + // --- the week (SPRINT8 gate 1) ------------------------------------------- t.test('the week is five escalating nights, each a storm and a site', () => {