tower / commit
feat(interior): one finger looks, two fingers move
README.md | 13 +++-- decisions.md | 63 ++++++++++++++++++++++ index.html | 4 +- src/views/interior.ts | 144 +++++++++++++++++++++++++++++++++++++++++++++----- 4 files changed, 207 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 9b8d9dd..5a17d21 100644 --- a/README.md +++ b/README.md @@ -226,9 +226,16 @@ every pixel, which reproduces the strangest fact about the place: the land 100 k straight up is **78%** clear while the ground 100 km away is **31%** — distant geography here is best seen by looking up. -Drag to look, scroll to zoom in like a pair of binoculars, and select a place — in -the list or by its caption — to **walk there**. Most of the world is beyond the haze -horizon from any given spot, so travelling is more use than turning. +**One finger looks and two fingers move** (D-059). Dragging with two takes hold of +the ground under them and keeps it under them, so the distance covered comes out +right by itself and the tilt of your head is the gearing: level, a screen of drag +walks you 1.9 km, and tipped 23° down it steps 26 m. The same two fingers pinch the +field of view like a pair of binoculars. A +mouse has only ever one pointer, so shift-drag or the middle button stands in for +the second finger, and the wheel zooms. Selecting a place — in the list or by its +caption — still **walks you there**, which is the only way to cross the world in +one move: most of it is beyond the haze horizon from any given spot, so travelling +is more use than turning. **The far cap is a window** — 100 km of it, looking into deep anti-sun space. By day it is simply a disc of blue, because 540 km of air in front of it scatters more diff --git a/decisions.md b/decisions.md index 4698123..f85d4d7 100644 --- a/decisions.md +++ b/decisions.md @@ -1799,3 +1799,66 @@ threshold that caught the fault would have fired every time anyone looked at the horizon. The recorder therefore records and the human decides; a quarter-minute of history is long enough that noticing the fault, reaching for the console and typing still catches the frames on both sides of the change. + +## D-059 — Inside, the finger count is the mode + +Until now the interior could not be moved through at all. Dragging looked around, +the wheel zoomed, and the only way to change where you stood was to select a place +and be walked there (D-038). That is fine for the places that exist and useless for +the ground between them, and on a phone it left the whole view as something to be +turned on the spot. + +**The gesture had to carry the mode, because a switch would have to live on the +world.** The alternatives were considered and dropped. A Look/Move toggle in the +corner is the CAD convention and it is honest, but it is a second pill bar over a +canvas that is already carrying the view switcher, the standing readout and the +place captions, and on a phone that is the scarcest surface in the application +(D-043). A virtual joystick is the mobile-FPS convention and reads as a game rather +than a map. So: **one pointer looks, two move.** The count is the mode, nothing is +drawn, and the gesture is the one every map on the device already uses. + +**The cost is discoverability, and it is paid in the panel.** A gesture with no +chrome is a gesture nobody finds, so the interior hint now leads with it in bold +rather than mentioning it in passing. This is the known trade and it was taken +deliberately. + +**A mouse has only ever one pointer**, so shift-drag or the middle button stands in +for the second finger. Without that the feature would exist only on touchscreens, +which is not what was wrong. + +**Moving means grabbing the ground, not steering.** The ray through the pointer is +met with the ground plane before the move and after it, and the observer is shifted +by the difference — so the patch of land under the finger stays under the finger. +The point of doing it this way is that no speed has to be chosen: the distance +covered falls out of the eye height and the field of view, and the same drag is a +few metres of pavement at 1.7 m and a hundred kilometres of plain from 10 km up. +Tower is 540 km deep and 314 km around, and no fixed rate is right at both ends of +that. + +**Two caps keep it from flinging.** A ray at the horizon meets the ground at +infinity and one above it never does, so the grab distance is capped at 1,200 eye +heights and at 60 km outright. Both numbers were measured rather than guessed, by +dragging the running view a screen at a time at five eye heights: a screen of level +drag carries 1.9 km at a standing 1.7 m, and 56 km from 100 m up, where the ceiling +has taken over. The ceiling sits above the honest grab distance from the highest +the eye goes — 25 km up, tilted well down, meets the ground about 60 km out — so it +only ever bites near the skyline and never lags the finger on a view aimed at the +ground. A first pass capped at 150 eye heights and a screen of drag moved four +metres, which is how the numbers came to be measured. + +**The tilt is the gearing, and it was not arranged.** Looking level grabs the far +ground and covers kilometres; looking down grabs what is underfoot and covers +metres — 26 m to the screen at standing height, tilted 23° down. That falls out of +grabbing the ground rather than being a control, which is the argument for the +whole approach: no speed had to be chosen and none can be got wrong. + +**The grab plane is flat, deliberately.** Meeting the ray with the terrain instead +would make the grab point jump as the ray crossed a ridge half a screen away, and +throw the observer with it. The plane through the observer's feet keeps the drag +smooth, and the height they actually stand at is resolved afterwards, as it always +was. + +**Both readings of the pointer set are taken over the same set.** The centroid is +sampled before the moving pointer is folded in and after, never across a finger +arriving or leaving — otherwise the second finger touching down would jerk the +world by half the distance between the two. diff --git a/index.html b/index.html index 3d9ae5c..d704ba3 100644 --- a/index.html +++ b/index.html @@ -74,7 +74,9 @@ <option value="temperate" selected>Temperate — 200 W/m²</option> <option value="full">Full — 319 W/m²</option> </select></label> - <p class="hint">Drag to look, scroll to zoom in like a pair of binoculars. + <p class="hint"><b>One finger looks. Two fingers move</b> — drag with two + and the ground follows them, pinch to zoom in like a pair of binoculars. + With a mouse: drag to look, shift-drag to move, scroll to zoom. Selecting a place walks you there. Nothing inside here rotates: you, the ground and the air all turn together. The far cap is a window, so at night the sky through it wheels once every 7 min 29 s about a single diff --git a/src/views/interior.ts b/src/views/interior.ts index ee834fe..fe749e8 100644 --- a/src/views/interior.ts +++ b/src/views/interior.ts @@ -112,6 +112,28 @@ export type Lighting = keyof typeof LIGHTING export const isLighting = (v: unknown): v is Lighting => typeof v === 'string' && v in LIGHTING +/** + * How far a two-finger drag can reach, as a multiple of eye height, and as a + * hard ceiling in kilometres. + * + * The first cap is what makes the gesture stable near the skyline, where the grab + * point runs away to infinity. Both were measured rather than guessed, by dragging + * the running view a screen at a time at five eye heights. What matters is how far + * a screen of drag carries when looking level, which is when the cap governs: at a + * standing 1.7 m that is about 1.9 km, walking pace made useful; from 100 m up the + * ceiling takes over at some 56 km. The ceiling is set above the honest grab + * distance from the highest the eye goes — 25 km up, tilted well down, meets the + * ground around 60 km out — so that it only ever bites near the skyline and never + * lags the finger on a view that is aimed at the ground. + * + * Tilt is the gearing, and that falls out rather than being arranged: looking level + * grabs the far ground and covers kilometres, looking down grabs what is underfoot + * and covers metres — 26 m to the screen at standing height, tilted 23° down. It is + * how every map behaves and it wants no control of its own. + */ +const GRAB_REACH = 1200 +const GRAB_MAX_KM = 60 + const STATE_KEY = 'tower.interior' const STATE_SAVE_MS = 400 @@ -608,8 +630,14 @@ export class InteriorView { private shared: ShaderMaterial[] = [] private running = false private lastFrame = 0 - private dragging = false - private lastPointer = { x: 0, y: 0 } + /** + * Every pointer currently down on the canvas, in client coordinates. + * + * A count rather than a mode is what decides the gesture (D-059): one pointer + * looks, two move the observer over the ground and pinch the field of view. So + * the whole set has to be tracked, not just the last one to move. + */ + private pointers = new Map<number, { x: number; y: number }>() private saveTimer: ReturnType<typeof setTimeout> | null = null /** * The flight recorder (D-058). A ring of what the last few seconds looked like, @@ -1247,27 +1275,117 @@ export class InteriorView { // --------------------------------------------------------------- interaction + /** The mean of the live pointers, and the span between the first two. */ + private gesture(): { x: number; y: number; span: number } { + let x = 0 + let y = 0 + for (const p of this.pointers.values()) { x += p.x; y += p.y } + const n = Math.max(1, this.pointers.size) + const [a, b] = [...this.pointers.values()] + return { x: x / n, y: y / n, span: a && b ? Math.hypot(a.x - b.x, a.y - b.y) : 0 } + } + + /** + * Turn on the spot. `dx`/`dy` are pixels of pointer travel. + */ + private look(dx: number, dy: number): void { + // Scale with the field of view, so zoomed in does not mean flung around. + const rate = (this.fovDeg / 60) * 0.0032 + this.yaw = normalizeTheta(this.yaw + dx * rate) + this.pitch = Math.max(-1.55, Math.min(1.55, this.pitch + dy * rate)) + } + + /** + * Where the ray through a pixel meets the ground, as an offset from the + * observer in metres along the axis and metres around it. + * + * The ground is taken as the plane through the observer's feet rather than as + * the terrain, because this answers a question about the gesture and not about + * the world: the hills under the far half of the screen would make the grab + * point jump about as the ray crossed a ridge, and the observer would be flung + * with it. A plane keeps the drag smooth, and the height they end up standing + * at is resolved by `place()` afterwards anyway. + */ + private groundOffset(clientX: number, clientY: number): { along: number; around: number } { + const r = this.canvas.getBoundingClientRect() + const dir = new Vector3( + ((clientX - r.left) / r.width) * 2 - 1, + -((clientY - r.top) / r.height) * 2 + 1, + 0.5, + ).unproject(this.camera).sub(this.camera.position).normalize() + + const { up, spinward, deepward } = this.basis() + /** + * How far below the horizontal the ray runs. Floored, because a ray at the + * horizon meets the ground at infinity and one above it never does: without + * the floor a pixel of drag near the skyline would throw the observer across + * a continent. The floor is what caps the reach at `GRAB_REACH` eye heights. + */ + const down = Math.max(-dir.dot(up), 1 / GRAB_REACH) + const t = Math.min((this.eyeM / KM) / down, GRAB_MAX_KM) + const v = dir.multiplyScalar(t) + return { along: v.dot(deepward) * KM, around: v.dot(spinward) * KM } + } + + /** + * Move so that the ground under the pointer stays under the pointer. + * + * The grab point is worked out for where the pointer was and for where it now + * is, both from the observer's present position, and the observer is shifted by + * the difference — so the world is dragged rather than the observer steered, + * and the distance covered comes out right by itself at every eye height and + * field of view, from a metre of pavement to a hundred kilometres of plain. + */ + private moveOver(from: { x: number; y: number }, to: { x: number; y: number }): void { + const a = this.groundOffset(from.x, from.y) + const b = this.groundOffset(to.x, to.y) + // Radius at the observer's feet: an arc of ground is r * dTheta. + const radius = Math.max(1, RADIUS_M - this.elevationM) + this.at = { + theta: normalizeTheta(this.at.theta + (a.around - b.around) / radius), + z: Math.min(LENGTH_M, Math.max(0, this.at.z + (a.along - b.along))), + } + } + + private zoom(factor: number): void { + this.fovDeg = Math.max(3, Math.min(90, this.fovDeg * factor)) + } + + /** + * One pointer looks, two move — the count is the mode, so there is no switch to + * find and nothing on screen to get in the way of the view (D-059). A mouse has + * only ever one pointer, so shift or the middle button stands in for the second + * finger. + */ private attach(): void { this.canvas.addEventListener('pointerdown', (e) => { - this.dragging = true - this.lastPointer = { x: e.clientX, y: e.clientY } + this.pointers.set(e.pointerId, { x: e.clientX, y: e.clientY }) this.canvas.setPointerCapture(e.pointerId) }) this.canvas.addEventListener('pointermove', (e) => { - if (!this.dragging) return - const dx = e.clientX - this.lastPointer.x - const dy = e.clientY - this.lastPointer.y - this.lastPointer = { x: e.clientX, y: e.clientY } - // Scale with the field of view, so zoomed in does not mean flung around. - const rate = (this.fovDeg / 60) * 0.0032 - this.yaw = normalizeTheta(this.yaw + dx * rate) - this.pitch = Math.max(-1.55, Math.min(1.55, this.pitch + dy * rate)) + if (!this.pointers.has(e.pointerId)) return + /** + * Both readings are taken over the same set of pointers, one before this + * event is folded in and one after, so a finger arriving or leaving never + * shows up as travel. Otherwise the second finger touching down would jerk + * the world by half the distance between the two. + */ + const before = this.gesture() + this.pointers.set(e.pointerId, { x: e.clientX, y: e.clientY }) + const after = this.gesture() + + if (this.pointers.size === 1 && !e.shiftKey && (e.buttons & 4) === 0) { + this.look(after.x - before.x, after.y - before.y) + } else { + this.moveOver(before, after) + if (before.span > 0 && after.span > 0) this.zoom(before.span / after.span) + } this.place() this.cb.onLook() this.queueSave() }) const end = (e: PointerEvent) => { - this.dragging = false + this.pointers.delete(e.pointerId) this.canvas.releasePointerCapture?.(e.pointerId) } this.canvas.addEventListener('pointerup', end)