The game shipped to a public web arcade with ZERO touch input: on a phone you
could tap PLAY and then nothing moved. Fixes that, and the framing bug it
exposed.
- src/blob/touch.ts: a third input source beside keyboard and gamepad. Floating
thumbstick in the lower-left (touch anywhere in the zone and THAT point is the
stick centre — forgiving on a screen with no tactile edges) plus a JUMP button
lower-right. Emits the same {x,z,jump} shape the gamepad poll produces, so the
controller consumes all three sources through one path (including the same
buffered jump edge). Installs NOTHING on a non-touch device, so desktop gets no
phantom UI. Hidden until ui:play so it can't sit behind the title card.
`?touch=1` forces it on to preview the phone layout from a desktop.
- index.html: touch-action:none — the browser otherwise claims drags on the game
surface for scroll/pull-to-refresh, so steering panned the page.
- camera: FOV is VERTICAL, so a portrait phone saw ~30 degrees horizontally vs
~92 on 16:9 — the blob filled the screen and the course was invisible. Pull the
rig back on narrow aspects (no fisheye), recomputed per frame so rotating the
device adapts. Exactly 1.0 at 16:9 and wider, so desktop is untouched.
- world.ts: clamp the viewport to >=1px. A zero-height container made aspect
0/0 = NaN and poisoned the projection matrix (blank screen healed only by a
later resize); mobile browsers genuinely report 0 during orientation changes
and address-bar collapse.
Verified with synthetic TouchEvents at 375x812: stick drag-up drives vz -8.47,
byte-identical to keyboard W under the same settled camera; JUMP takes vy 0 to
6.85 and the blob rises; nub/ring track the drag; layer hidden pre-play, shown
post-play. Desktop at 1280x800 keeps base distance 9 / height 4.5 with no touch
layer. tsc + 10 test suites green.
ponytail: tried two portrait composition refinements (flatter rig, aim-ahead
lead) and reverted BOTH — each traded floor for sky and framed less course than
the plain pulled-back rig. Noted in-file so nobody re-derives them.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
110 lines
3.6 KiB
TypeScript
110 lines
3.6 KiB
TypeScript
/**
|
|
* Lane A — third-person soft-follow camera.
|
|
*
|
|
* Trails the blob with position lag, eases its yaw to sit behind the direction
|
|
* of travel, and kicks FOV a touch at speed for a sense of hustle. Runs as a
|
|
* per-frame (visual-only) hook. The controller reads `world.camera` for its
|
|
* move basis, so this cam swinging around keeps WASD camera-relative.
|
|
*/
|
|
import * as THREE from 'three'
|
|
import type { World } from '../contracts'
|
|
import type { BlobHandle } from './createBlob'
|
|
|
|
export interface FollowCameraOptions {
|
|
distance?: number
|
|
height?: number
|
|
/** How fast the camera position catches up (higher = tighter). */
|
|
followLag?: number
|
|
/** How fast the camera swings behind travel (higher = snappier). */
|
|
yawLag?: number
|
|
}
|
|
|
|
export interface FollowCamera {
|
|
update(dt: number): void
|
|
yaw(): number
|
|
}
|
|
|
|
function expo(rate: number, dt: number): number {
|
|
return 1 - Math.exp(-rate * dt)
|
|
}
|
|
|
|
export function createFollowCamera(
|
|
world: World,
|
|
blob: BlobHandle,
|
|
opts: FollowCameraOptions = {},
|
|
): FollowCamera {
|
|
const cam = world.camera
|
|
const distance = opts.distance ?? 9
|
|
const height = opts.height ?? 4.5
|
|
const followLag = opts.followLag ?? 6
|
|
const yawLag = opts.yawLag ?? 2.5
|
|
|
|
const baseFov = cam.fov
|
|
|
|
/**
|
|
* Narrow-screen pullback. FOV is VERTICAL, so the horizontal view shrinks
|
|
* with the aspect ratio (hFov = 2·atan(tan(vFov/2)·aspect)) — a portrait
|
|
* phone saw ~30° across vs ~92° on a 16:9 desktop, i.e. the blob filled the
|
|
* screen and the course was invisible. Pulling the rig back restores a
|
|
* playable view without a fisheye FOV. Recomputed each frame so rotating the
|
|
* device adapts. Exactly 1.0 at 16:9 and wider — desktop is untouched.
|
|
*/
|
|
const aspectPull = (): number => {
|
|
const a = cam.aspect
|
|
if (!isFinite(a) || a >= 1.6) return 1
|
|
return Math.min(1.75, Math.sqrt(1.6 / Math.max(a, 0.4)))
|
|
}
|
|
|
|
let camYaw = Math.PI // start looking toward -Z (down the course)
|
|
const desired = new THREE.Vector3()
|
|
const look = new THREE.Vector3()
|
|
let lookInit = false
|
|
|
|
return {
|
|
update(dt: number): void {
|
|
const p = blob.body.translation()
|
|
const v = blob.body.linvel()
|
|
const size = blob.modifiers.size
|
|
const speed = Math.hypot(v.x, v.z)
|
|
|
|
// ease the rig behind the direction of travel (only while actually moving)
|
|
if (speed > 1.5) {
|
|
const targetYaw = Math.atan2(v.x, v.z)
|
|
let delta = targetYaw - camYaw
|
|
delta = Math.atan2(Math.sin(delta), Math.cos(delta))
|
|
camYaw += delta * expo(yawLag, dt)
|
|
}
|
|
|
|
const pull = aspectPull()
|
|
const dist = distance * size * pull
|
|
const h = height * size * pull
|
|
desired.set(
|
|
p.x - Math.sin(camYaw) * dist,
|
|
p.y + h,
|
|
p.z - Math.cos(camYaw) * dist,
|
|
)
|
|
cam.position.lerp(desired, expo(followLag, dt))
|
|
|
|
// Look at a point a little above the blob, itself eased for smoothness.
|
|
// ponytail: tried two portrait refinements here and reverted both —
|
|
// flattening the rig and leading the aim down the track each traded floor
|
|
// for SKY and framed less course than the plain pulled-back rig above.
|
|
// On a tall screen, high-and-looking-down is what fills frame with track.
|
|
const lookTarget = new THREE.Vector3(p.x, p.y + 1.0 * size, p.z)
|
|
if (!lookInit) {
|
|
look.copy(lookTarget)
|
|
lookInit = true
|
|
} else {
|
|
look.lerp(lookTarget, expo(8, dt))
|
|
}
|
|
cam.lookAt(look)
|
|
|
|
// FOV kick at speed
|
|
const targetFov = baseFov + Math.min(speed * 0.9, 12)
|
|
cam.fov += (targetFov - cam.fov) * expo(4, dt)
|
|
cam.updateProjectionMatrix()
|
|
},
|
|
yaw: () => camYaw,
|
|
}
|
|
}
|