tower / commit
feat(interior): flight is an autopilot; input turns the head, not the aircraft
README.md | 12 +-- decisions.md | 37 ++++++++ docs/screenshots/desktop-exterior-true.png | Bin 507719 -> 506587 bytes docs/screenshots/desktop-exterior.png | Bin 332404 -> 332584 bytes docs/screenshots/desktop-interior-dawn.png | Bin 491728 -> 491609 bytes docs/screenshots/desktop-interior-flying.png | Bin 553298 -> 374442 bytes docs/screenshots/desktop-interior-night.png | Bin 279347 -> 279389 bytes docs/screenshots/desktop-interior-overhead.png | Bin 545827 -> 545849 bytes docs/screenshots/desktop-interior-suncap.png | Bin 402798 -> 402816 bytes docs/screenshots/desktop-interior.png | Bin 547467 -> 547461 bytes docs/screenshots/mobile-exterior.png | Bin 81802 -> 81692 bytes docs/screenshots/mobile-interior.png | Bin 172341 -> 172349 bytes docs/screenshots/overview.png | Bin 1269165 -> 1246968 bytes index.html | 2 +- src/ui/app.ts | 11 ++- src/views/interior.ts | 119 ++++++++++++------------- tools/screenshots.mjs | 10 ++- 17 files changed, 116 insertions(+), 75 deletions(-) diff --git a/README.md b/README.md index aeb019e..1c103b1 100644 --- a/README.md +++ b/README.md @@ -239,11 +239,13 @@ glows by itself, so a lit beam lights the world whatever the mirrors are doing, aiming them away would leave the floor at about 100 W/m² — half of noon. The eye-height slider runs from 1.7 m to 25 km with the local gravity beside it, and -**Fly** lets go of the ground altogether — a camera that is always moving, steered -by the same drag that turns your head, held inside the shell by the caps at both -ends and the terrain underneath. There is no brake. Gravity falls linearly to zero -at the axis, so climbing makes flight *easier*: 0.91 g at 4.5 km up, and the beam -widens overhead as you close on it. +**Fly** lets go of the ground altogether — an autopilot on a permanent helix, 18° +off the axis, crossing the world lengthwise while it rolls around it and reflecting +off the caps rather than stopping. **Your input turns your head, not the aircraft**, +so you can watch whatever you like while it flies. The slider becomes the altitude +it holds, terrain-following underneath: climbing at once, sinking slowly. Gravity +falls linearly to zero at the axis, so height makes flight *easier* — 0.91 g at +4.3 km up, and the beam widens overhead as you close on it. Nothing *inside* rotates, because you, the ground and the air all turn together — so for 539 of the 540 km there is no evidence Tower turns at all. The exception is the diff --git a/decisions.md b/decisions.md index cb7a788..f4f10c9 100644 --- a/decisions.md +++ b/decisions.md @@ -1720,3 +1720,40 @@ too, so it widens as you climb. screenshot tour cares about. The shot fixes where the flight *starts* rather than trying to stop it: take off from a known 4 km, cruise at the slowest 150 m/s, and keep the shutter short. Drift between runs comes out below the measurement — 0.00%. + +## D-057 — Flight is an autopilot; input turns the head, not the aircraft + +D-056 flew the camera along the look direction: dragging steered. That is the +obvious way to build it and the wrong way to use it. Hand-flying makes you navigate +a world you came to *look* at — you end up watching your altitude and your heading +instead of the view, and every time something is worth turning to see, turning to +see it takes you somewhere else. + +So the two are separated. The autopilot flies a course; the drag handlers only turn +your head. Takeoff aims you along the course so you start out facing where you are +going, and after that the view is yours. + +**The course is a helix, 18° off the axis.** Straight down the axis would be simpler +and would show less: rolling around the world while crossing it is the only way to +see that the sky is ground. It reflects off the caps rather than stopping — flip the +along-axis component, keep the way it rolls — so the flight is genuinely permanent. + +**It is flown in the unrolled surface (θ, z), not in 3D**, and that is what makes it +safe rather than carefully guarded. The constraints live in those coordinates: the +caps are limits on z and θ simply wraps. There is no collision test anywhere in the +mode, because there is nothing it can hit. + +**Terrain-following is asymmetric on purpose**: climb at once, sink slowly. An +autopilot that lags on the way up flies into the mountain; one that drops into every +valley as fast as it climbs reads as a fault rather than as flight. + +**The eye-height slider becomes the altitude**, rather than being disabled as D-056 +had it. It is the same control over the same 1.7 m to 25 km, it is the only thing +left to fly with once the course is automatic, and disabling it would have left the +mode with no controls at all. The label switches between "Eye height" and "Altitude" +so it is not quietly lying about which it is. + +This also simplified the view rather than complicating it: `basis()` had grown a +separate branch for flight with its own radial fallback for the axis, and standing +and flying turn out to differ in exactly one term — what you are held above. The +free 3D position is gone, and with it the fallback and the ceiling clamp. diff --git a/docs/screenshots/desktop-exterior-true.png b/docs/screenshots/desktop-exterior-true.png index 888a6e4..8497f50 100644 Binary files a/docs/screenshots/desktop-exterior-true.png and b/docs/screenshots/desktop-exterior-true.png differ diff --git a/docs/screenshots/desktop-exterior.png b/docs/screenshots/desktop-exterior.png index c617fe4..abf5858 100644 Binary files a/docs/screenshots/desktop-exterior.png and b/docs/screenshots/desktop-exterior.png differ diff --git a/docs/screenshots/desktop-interior-dawn.png b/docs/screenshots/desktop-interior-dawn.png index c2ef621..42fad3f 100644 Binary files a/docs/screenshots/desktop-interior-dawn.png and b/docs/screenshots/desktop-interior-dawn.png differ diff --git a/docs/screenshots/desktop-interior-flying.png b/docs/screenshots/desktop-interior-flying.png index 597bf41..21ed2b5 100644 Binary files a/docs/screenshots/desktop-interior-flying.png and b/docs/screenshots/desktop-interior-flying.png differ diff --git a/docs/screenshots/desktop-interior-night.png b/docs/screenshots/desktop-interior-night.png index 4eb182b..3466858 100644 Binary files a/docs/screenshots/desktop-interior-night.png and b/docs/screenshots/desktop-interior-night.png differ diff --git a/docs/screenshots/desktop-interior-overhead.png b/docs/screenshots/desktop-interior-overhead.png index c538f97..e7822df 100644 Binary files a/docs/screenshots/desktop-interior-overhead.png and b/docs/screenshots/desktop-interior-overhead.png differ diff --git a/docs/screenshots/desktop-interior-suncap.png b/docs/screenshots/desktop-interior-suncap.png index 97cc702..d85eb50 100644 Binary files a/docs/screenshots/desktop-interior-suncap.png and b/docs/screenshots/desktop-interior-suncap.png differ diff --git a/docs/screenshots/desktop-interior.png b/docs/screenshots/desktop-interior.png index 0f40dca..a78354a 100644 Binary files a/docs/screenshots/desktop-interior.png and b/docs/screenshots/desktop-interior.png differ diff --git a/docs/screenshots/mobile-exterior.png b/docs/screenshots/mobile-exterior.png index c7b432f..26d9e8a 100644 Binary files a/docs/screenshots/mobile-exterior.png and b/docs/screenshots/mobile-exterior.png differ diff --git a/docs/screenshots/mobile-interior.png b/docs/screenshots/mobile-interior.png index 1cc4721..0da77f0 100644 Binary files a/docs/screenshots/mobile-interior.png and b/docs/screenshots/mobile-interior.png differ diff --git a/docs/screenshots/overview.png b/docs/screenshots/overview.png index 548d8ef..46de6b0 100644 Binary files a/docs/screenshots/overview.png and b/docs/screenshots/overview.png differ diff --git a/index.html b/index.html index 89f11b2..5c61f2a 100644 --- a/index.html +++ b/index.html @@ -59,7 +59,7 @@ zoom, click the hull to drop a point.</p> </div> <div id="interior-options" class="hidden"> - <label>Eye height <span id="eye-value" class="dim"></span> + <label><span id="eye-label">Eye height</span> <span id="eye-value" class="dim"></span> <input id="f-eye" type="range" min="0" max="1000" step="1" value="0" /></label> <label class="row"><input id="f-fly" type="checkbox" /> Fly</label> <label>Speed <span id="fly-value" class="dim"></span> diff --git a/src/ui/app.ts b/src/ui/app.ts index b978e56..bfa04a3 100644 --- a/src/ui/app.ts +++ b/src/ui/app.ts @@ -203,8 +203,13 @@ export class App { const applyFly = () => { const v = FLY[Number(flySpeed.value)]! $('fly-value').textContent = `— ${v} km/s, the world end to end in ${fmtDuration(540 / v)}` - // Eye height is ground-relative and means nothing once you leave the ground. - $<HTMLInputElement>('f-eye').disabled = fly.checked + /** + * The eye-height slider is not disabled while flying — it becomes the + * altitude the autopilot holds. Same control, same 1.7 m to 25 km range, and + * it is the only thing left for the reader to fly with once the course is + * automatic, so taking it away would leave the mode with no controls at all. + */ + $('eye-label').textContent = fly.checked ? 'Altitude' : 'Eye height' if (!this.interior) return this.interior.flySpeedKmS = v this.interior.setFlying(fly.checked) @@ -336,7 +341,7 @@ export class App { $<HTMLSelectElement>('f-light').value = this.interior.lighting $<HTMLInputElement>('f-cycle').checked = this.interior.cycling $<HTMLInputElement>('f-fly').checked = this.interior.flying - $<HTMLInputElement>('f-eye').disabled = this.interior.flying + $('eye-label').textContent = this.interior.flying ? 'Altitude' : 'Eye height' $<HTMLSelectElement>('f-light').disabled = this.interior.cycling } this.interior.resize() diff --git a/src/views/interior.ts b/src/views/interior.ts index 50dad18..09a41ba 100644 --- a/src/views/interior.ts +++ b/src/views/interior.ts @@ -581,13 +581,21 @@ export class InteriorView { /** Time of day, 0..1. 0 is midnight, 0.5 is noon. */ dayPhase = 0.5 /** - * Flight. The observer normally stands on the ground and `at` is the whole of - * their position; flying replaces that with a free point inside the shell, so - * `flyAt` becomes the truth and `at` follows it rather than the other way round. + * Flight, on autopilot. Where you *look* and where you *go* are separate: the + * course is flown for you and the drag handlers only turn your head. That is the + * point of the mode — hand-flying makes you navigate a world you came to look at, + * and you end up watching your altitude instead of the view. */ flying = false flySpeedKmS = 0.6 - private flyAt = new Vector3() + /** + * Heading over the unrolled surface: 0 is deepward, π/2 spinward. Deliberately + * not axis-aligned, so the course is a long helix — you cross the world lengthwise + * *and* roll around it, which is the only way to see that the sky is ground. + */ + private flyCourse = (18 * Math.PI) / 180 + /** The terrain height the autopilot is currently holding above, smoothed. */ + private flyGroundKm = 0 /** Whether the clock is running, and how many world seconds pass per real one. */ cycling = false cycleRate = 900 @@ -870,29 +878,17 @@ export class InteriorView { /** Basis at the observer: up is toward the axis, deepward is +z, spinward is theta-hat. */ private basis(): { up: Vector3; spinward: Vector3; deepward: Vector3; eye: Vector3 } { - if (this.flying) { - /** - * Up is still toward the axis, because that is what up *means* in here — it - * is the direction gravity does not point, at every radius including this - * one. On the axis itself the radial direction is undefined and the fallback - * keeps the horizon from tumbling; you are weightless there anyway (§1.2), so - * there is no physical up to get wrong. - */ - const rad = Math.hypot(this.flyAt.x, this.flyAt.y) - const radial = rad > 1e-4 - ? new Vector3(this.flyAt.x / rad, this.flyAt.y / rad, 0) - : new Vector3(1, 0, 0) - return { - up: radial.clone().negate(), - spinward: new Vector3(-radial.y, radial.x, 0), - deepward: new Vector3(0, 0, 1), - eye: this.flyAt.clone(), - } - } const { theta } = this.at const radial = new Vector3(Math.cos(theta), Math.sin(theta), 0) - // Up is toward the axis, so standing on high ground means a *smaller* radius. - const s = R - this.groundKm(this.at) - this.eyeM / KM + /** + * Standing and flying differ in exactly one term: what you are held above. On + * the ground that is the terrain under your feet; in the air it is the + * autopilot's smoothed idea of it. Up is toward the axis either way — that is + * what up means in here, the direction gravity does not point — so the rest of + * the frame is shared rather than duplicated. + */ + const ground = this.flying ? this.flyGroundKm : this.groundKm(this.at) + const s = R - ground - this.eyeM / KM return { up: radial.clone().negate(), spinward: new Vector3(-Math.sin(theta), Math.cos(theta), 0), @@ -902,40 +898,43 @@ export class InteriorView { } /** - * Fly forward. There is no throttle and no brake — the mode is *flying*, so the - * only control is where you point, and the drag handlers already do that. + * Fly the course. Nothing in here reads `yaw` or `pitch` — that is the whole + * decoupling: input turns the head, the autopilot flies the aircraft. * - * Held inside the shell rather than allowed to leave it: the caps stop you at - * both ends, and the ground stops you underneath. The floor test samples the - * terrain rather than using the datum, so the mountains are solid. + * Worked in the unrolled surface (θ, z) rather than in 3D, because that is where + * the constraints live: the caps are limits on z and θ simply wraps. It falls out + * that the autopilot cannot fly through the floor or out through an end, with no + * collision test anywhere. */ private stepFlight(dt: number): void { - const { up, spinward, deepward } = this.basis() - const dir = deepward.clone().multiplyScalar(Math.cos(this.pitch) * Math.cos(this.yaw)) - .addScaledVector(spinward, Math.cos(this.pitch) * Math.sin(this.yaw)) - .addScaledVector(up, Math.sin(this.pitch)) - this.flyAt.addScaledVector(dir, this.flySpeedKmS * dt) - - this.flyAt.z = Math.min(L - 0.2, Math.max(0.2, this.flyAt.z)) - const theta = normalizeTheta(Math.atan2(this.flyAt.y, this.flyAt.x)) - const ceiling = R - this.groundKm({ theta, z: this.flyAt.z * KM }) - 0.05 - const rad = Math.hypot(this.flyAt.x, this.flyAt.y) - if (rad > ceiling && rad > 1e-6) { - this.flyAt.x *= ceiling / rad - this.flyAt.y *= ceiling / rad + const d = this.flySpeedKmS * dt + let z = this.at.z / KM + Math.cos(this.flyCourse) * d + if (z > L - 2 || z < 2) { + // Reflect off the cap: flip the along-axis component, keep the way it rolls. + this.flyCourse = Math.PI - this.flyCourse + z = Math.min(L - 2, Math.max(2, z)) } - // Keep `at` honest: the readout, the labels and the haze all ask where we are. - this.at = { theta, z: this.flyAt.z * KM } + const theta = normalizeTheta(this.at.theta + (Math.sin(this.flyCourse) * d) / R) + this.at = { theta, z: z * KM } + + /** + * Terrain-following, asymmetric on purpose: climb at once, sink slowly. An + * autopilot that lags on the way up flies into the mountain, and one that drops + * into every valley as fast as it climbs reads as a fault rather than as + * flight. + */ + const g = this.groundKm(this.at) + this.flyGroundKm = Math.max(g, this.flyGroundKm + (g - this.flyGroundKm) * Math.min(1, dt * 0.6)) this.place() this.cb.onLook() } setFlying(on: boolean): void { if (on && !this.flying) { - // Take off from wherever the observer is standing, so the mode change does - // not also teleport them. - const { eye } = this.basis() - this.flyAt.copy(eye) + this.flyGroundKm = this.groundKm(this.at) + // Start out looking where you are going. After that the view is yours. + this.yaw = this.flyCourse + this.pitch = 0 } this.flying = on this.place() @@ -943,11 +942,8 @@ export class InteriorView { this.queueSave() } - /** Height above the terrain directly below, in metres. Only meaningful flying. */ - get altitudeM(): number { - const rad = Math.hypot(this.flyAt.x, this.flyAt.y) - return (R - this.groundKm(this.at) - rad) * KM - } + /** What the autopilot holds: the slider's height, measured over the terrain. */ + get altitudeM(): number { return this.eyeM } /** Put the camera where the observer is, pointing where they are looking. */ private place(): void { @@ -975,13 +971,9 @@ export class InteriorView { */ visit(p: SurfacePoint, facing: SurfacePoint | null = null): void { this.at = { theta: normalizeTheta(p.theta), z: Math.min(LENGTH_M, Math.max(0, p.z)) } - if (this.flying) { - // Arrive at the height you were flying at, not on the ground. - const alt = Math.max(0.2, this.altitudeM / KM) - const rad = R - this.groundKm(this.at) - alt - this.flyAt.set( - Math.cos(this.at.theta) * rad, Math.sin(this.at.theta) * rad, this.at.z / KM) - } + // The altitude is the slider's, so arriving somewhere resamples the ground + // under the new position and leaves you in the air rather than on it. + if (this.flying) this.flyGroundKm = this.groundKm(this.at) if (facing) this.yaw = this.headingTo(facing) // Arriving somewhere looking at your feet is never what was wanted. this.pitch = 0 @@ -1310,7 +1302,7 @@ export class InteriorView { * flying that is the distance from the flier to the axis, not from the ground. */ const overhead = this.flying - ? Math.hypot(this.flyAt.x, this.flyAt.y) * KM + ? RADIUS_M - this.flyGroundKm * KM - this.eyeM : RADIUS_M - elevation - this.eyeM const beamAngle = 2 * Math.atan(BEAM_RADIUS_M / overhead) // Height above the datum, which is what gravity actually falls off with. @@ -1324,6 +1316,9 @@ export class InteriorView { `ground ${elevation.toFixed(0)} m above the datum`, ...(this.flying ? [`altitude ${m(this.altitudeM)} over the ground, ${this.flySpeedKmS.toFixed(2)} km/s`, + `course ${deg(normalizeTheta(this.flyCourse)).toFixed(0)}° ${ + ['deepward', 'spinward', 'sunward', 'antispinward'][ + Math.round(deg(normalizeTheta(this.flyCourse)) / 90) % 4]!}, on autopilot`, `height ${m(aboveDatum)} · ${(gravityAtHeight(aboveDatum) / G0).toFixed(4)} g`] : [`eye ${eyeText} · ${(gravityAtHeight(aboveDatum) / G0).toFixed(4)} g`]), `looking ${deg(heading).toFixed(0)}° ${compass}, ${deg(this.pitch) >= 0 ? '+' : ''}${deg(this.pitch).toFixed(0)}°`, diff --git a/tools/screenshots.mjs b/tools/screenshots.mjs index ae1042f..21078a5 100644 --- a/tools/screenshots.mjs +++ b/tools/screenshots.mjs @@ -355,14 +355,11 @@ const SHOTS = [ }, { id: 'interior-flying', - caption: 'Interior — flying: 4 km up, off the ground, with the world closing overhead', + caption: 'Interior — flying on autopilot at 4 km, looking off the course rather than along it', on: ['desktop'], run: async (page) => { await openView(page, 'interior') await pick(page, 'The Capital') - const s = await stage(page) - // Pitch up before taking off, so the nose is above the horizon on the way. - await drag(page, [s.x + s.w * 0.5, s.y + s.h * 0.34], [s.x + s.w * 0.5, s.y + s.h * 0.34 + 90]) await withPanel(page, async () => { /** * Take off from a known height rather than climbing to one. A flying camera @@ -377,6 +374,11 @@ const SHOTS = [ await page.locator('#f-fly-speed').dispatchEvent('input') await page.check('#f-fly') }) + // Look away from the course, which is the whole point of the mode: takeoff + // aims you along it, and the drag only turns your head. + const s = await stage(page) + await drag(page, [s.x + s.w * 0.5, s.y + s.h * 0.38], + [s.x + s.w * 0.5 - 230, s.y + s.h * 0.38 + 70]) await settle(page, 900) }, },