feat(mobile): touch controls + portrait camera — BLOBBO is playable on a phone

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>
This commit is contained in:
type-two 2026-07-25 22:49:49 +10:00
parent 7918f9f6f1
commit ed1c3af17a
5 changed files with 229 additions and 9 deletions

View File

@ -5,7 +5,10 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>BLOBBO</title>
<style>
html, body { margin: 0; height: 100%; overflow: hidden; }
/* touch-action:none — on a phone the browser otherwise claims drags on the
game surface for scroll / pull-to-refresh, so steering would pan the page
instead of the blob. Nothing here scrolls, so nothing is lost. */
html, body { margin: 0; height: 100%; overflow: hidden; touch-action: none; }
#app { width: 100%; height: 100%; }
</style>
</head>

View File

@ -40,6 +40,21 @@ export function createFollowCamera(
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()
@ -60,8 +75,9 @@ export function createFollowCamera(
camYaw += delta * expo(yawLag, dt)
}
const dist = distance * size
const h = height * size
const pull = aspectPull()
const dist = distance * size * pull
const h = height * size * pull
desired.set(
p.x - Math.sin(camYaw) * dist,
p.y + h,
@ -69,7 +85,11 @@ export function createFollowCamera(
)
cam.position.lerp(desired, expo(followLag, dt))
// look at a point a little above the blob, itself eased for smoothness
// 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)

View File

@ -14,6 +14,7 @@ import * as THREE from 'three'
import type { System, World } from '../contracts'
import type { BlobHandle } from './createBlob'
import { SURFACES, type SurfaceName } from '../machine/surfaces'
import { installTouchControls } from './touch'
export interface BlobControllerOptions {
/** Base top speed (m/s) before speedMul. */
@ -80,6 +81,12 @@ export function createBlobController(
padJumpHeld = jump
}
// ---- touch (on-screen stick + JUMP; null on a non-touch device) ----
// Same {x,z} convention as the gamepad, so it folds into the move basis and
// the buffered jump edge through the identical path.
const touch = installTouchControls(world)
let touchJumpHeld = false
// ---- state ----
let grounded = false
let coyote = 0 // seconds of "still counts as grounded" after leaving ground
@ -163,6 +170,13 @@ export function createBlobController(
if (pad.x !== 0 || pad.z !== 0) {
move.addScaledVector(fwd, -pad.z).addScaledVector(right, pad.x) // stick up = away from camera
}
if (touch && (touch.x !== 0 || touch.z !== 0)) {
move.addScaledVector(fwd, -touch.z).addScaledVector(right, touch.x)
}
if (touch) {
if (touch.jump && !touchJumpHeld) jumpBuffer = 0.12 // same buffered edge as Space / A
touchJumpHeld = touch.jump
}
const hasInput = move.lengthSq() > 1e-6
if (move.lengthSq() > 1) move.normalize() // keep sub-unit analog magnitudes

174
src/blob/touch.ts Normal file
View File

@ -0,0 +1,174 @@
/**
* TOUCH CONTROLS the third input source, alongside keyboard and gamepad.
*
* Without this the game is unplayable on a phone: you can tap PLAY and then
* nothing moves. Layout is the standard one for a rolling-ball game:
*
* · lower-LEFT a *floating* thumbstick. Touch anywhere in the zone and
* that point becomes the stick centre, then drag. Forgiving on a screen
* with no tactile edges, and it never fights where your thumb landed.
* · lower-RIGHT JUMP.
*
* Exposes the same shape the gamepad poll produces (`x`/`z` in -1..1 with
* z negative = away from camera, `jump` held-flag) so the controller consumes
* all three sources identically. Installs nothing on a non-touch device, so
* desktop is untouched and there is no phantom UI.
*
* Hidden until `ui:play` so it can't sit behind the title card.
*/
import type { World } from '../contracts'
export interface TouchInput {
/** -1..1 strafe (right positive), matching gamepad axes[0]. */
x: number
/** -1..1 forward (AWAY from camera is negative), matching gamepad axes[1]. */
z: number
/** True while JUMP is held; the controller edge-detects it. */
jump: boolean
}
/** Radius in px a drag must reach for full deflection. */
const STICK_RADIUS = 56
/** Ignore micro-drags so a resting thumb doesn't creep. */
const DEADZONE = 0.12
const isTouchDevice = (): boolean =>
typeof navigator !== 'undefined' &&
(navigator.maxTouchPoints > 0 || 'ontouchstart' in window)
/** `?touch=1` forces the controls on preview the phone layout from a desktop
* (and it's how this module gets verified without a physical device). */
const forcedOn = (): boolean =>
typeof location !== 'undefined' &&
new URLSearchParams(location.search).get('touch') === '1'
/**
* Install the on-screen controls. Returns the live input object, or null on a
* device with no touch (desktop) the controller then simply never reads it.
*/
export function installTouchControls(world: World): TouchInput | null {
if (typeof document === 'undefined' || (!isTouchDevice() && !forcedOn())) return null
const input: TouchInput = { x: 0, z: 0, jump: false }
const layer = document.createElement('div')
layer.id = 'blobbo-touch'
// Hidden until play starts; `touch-action:none` on the children stops the
// browser stealing drags for scroll/zoom (otherwise steering pans the page).
layer.style.cssText =
'position:fixed;inset:0;z-index:14;pointer-events:none;display:none;' +
'touch-action:none;-webkit-user-select:none;user-select:none'
// ---- floating thumbstick (lower-left) ----
const stickZone = document.createElement('div')
stickZone.style.cssText =
'position:absolute;left:0;bottom:0;width:52%;height:62%;pointer-events:auto;touch-action:none'
const ring = document.createElement('div')
ring.style.cssText =
`position:absolute;width:${STICK_RADIUS * 2}px;height:${STICK_RADIUS * 2}px;` +
'border-radius:50%;border:2px solid rgba(255,255,255,.35);' +
'background:rgba(255,255,255,.10);display:none;pointer-events:none;' +
'box-shadow:0 2px 12px rgba(0,0,0,.25)'
const nub = document.createElement('div')
nub.style.cssText =
'position:absolute;width:54px;height:54px;border-radius:50%;display:none;' +
'background:radial-gradient(circle at 35% 30%,#fff,#bfe6ff 65%,#7ab6e6);' +
'pointer-events:none;box-shadow:0 3px 10px rgba(0,0,0,.35)'
layer.append(stickZone, ring, nub)
// ---- JUMP (lower-right) ----
const jumpBtn = document.createElement('div')
jumpBtn.textContent = 'JUMP'
jumpBtn.style.cssText =
'position:absolute;right:22px;bottom:26px;width:104px;height:104px;' +
'border-radius:50%;pointer-events:auto;touch-action:none;' +
'display:flex;align-items:center;justify-content:center;' +
"font:800 17px ui-monospace,Menlo,monospace;color:#16264a;letter-spacing:.06em;" +
'background:linear-gradient(180deg,#fff,#ffe14d);' +
'box-shadow:0 6px 0 #b98a00,0 10px 18px rgba(0,0,0,.32)'
layer.appendChild(jumpBtn)
document.body.appendChild(layer)
// ---- stick tracking -----------------------------------------------------
let stickId: number | null = null
let originX = 0
let originY = 0
const showStick = (x: number, y: number): void => {
ring.style.left = `${x - STICK_RADIUS}px`
ring.style.top = `${y - STICK_RADIUS}px`
ring.style.display = 'block'
nub.style.display = 'block'
moveNub(x, y)
}
const moveNub = (x: number, y: number): void => {
nub.style.left = `${x - 27}px`
nub.style.top = `${y - 27}px`
}
const hideStick = (): void => {
ring.style.display = 'none'
nub.style.display = 'none'
input.x = 0
input.z = 0
}
const findTouch = (list: TouchList, id: number): Touch | null => {
for (let i = 0; i < list.length; i++) if (list[i].identifier === id) return list[i]
return null
}
stickZone.addEventListener('touchstart', (e: TouchEvent) => {
if (stickId !== null) return
const t = e.changedTouches[0]
if (!t) return
stickId = t.identifier
originX = t.clientX
originY = t.clientY
showStick(originX, originY)
e.preventDefault()
}, { passive: false })
stickZone.addEventListener('touchmove', (e: TouchEvent) => {
if (stickId === null) return
const t = findTouch(e.touches, stickId)
if (!t) return
let dx = (t.clientX - originX) / STICK_RADIUS
let dy = (t.clientY - originY) / STICK_RADIUS
const mag = Math.hypot(dx, dy)
if (mag > 1) { dx /= mag; dy /= mag } // clamp to the ring
// Screen Y grows downward and the gamepad's axes[1] is negative when pushed
// away from the camera — so dy maps straight onto z with no flip.
input.x = Math.abs(dx) < DEADZONE ? 0 : dx
input.z = Math.abs(dy) < DEADZONE ? 0 : dy
moveNub(originX + dx * STICK_RADIUS, originY + dy * STICK_RADIUS)
e.preventDefault()
}, { passive: false })
const endStick = (e: TouchEvent): void => {
if (stickId === null) return
if (findTouch(e.touches, stickId)) return // still down (another finger lifted)
stickId = null
hideStick()
}
stickZone.addEventListener('touchend', endStick, { passive: false })
stickZone.addEventListener('touchcancel', endStick, { passive: false })
// ---- jump ---------------------------------------------------------------
const press = (down: boolean) => (e: TouchEvent): void => {
input.jump = down
jumpBtn.style.transform = down ? 'translateY(5px)' : ''
jumpBtn.style.boxShadow = down
? '0 1px 0 #b98a00,0 3px 8px rgba(0,0,0,.32)'
: '0 6px 0 #b98a00,0 10px 18px rgba(0,0,0,.32)'
e.preventDefault()
}
jumpBtn.addEventListener('touchstart', press(true), { passive: false })
jumpBtn.addEventListener('touchend', press(false), { passive: false })
jumpBtn.addEventListener('touchcancel', press(false), { passive: false })
// Reveal once the title card is gone (it owns the screen until then).
world.events.on('ui:play', () => { layer.style.display = 'block' })
return input
}

View File

@ -25,12 +25,20 @@ export async function createWorld(container: HTMLElement): Promise<World> {
scene.background = new THREE.Color('#bfe3ff')
scene.fog = new THREE.Fog('#bfe3ff', 60, 160)
const camera = new THREE.PerspectiveCamera(
60, container.clientWidth / container.clientHeight, 0.1, 400)
// Never divide by a zero-height container: mobile browsers report 0 during
// orientation changes and address-bar collapse, and 0/0 = NaN poisons the
// projection matrix (blank screen that only a later resize can heal).
const viewport = (): { w: number; h: number } => ({
w: Math.max(1, container.clientWidth),
h: Math.max(1, container.clientHeight),
})
const vp0 = viewport()
const camera = new THREE.PerspectiveCamera(60, vp0.w / vp0.h, 0.1, 400)
camera.position.set(0, 6, 12)
const renderer = new THREE.WebGLRenderer({ antialias: true })
renderer.setSize(container.clientWidth, container.clientHeight)
renderer.setSize(vp0.w, vp0.h)
renderer.setPixelRatio(Math.min(devicePixelRatio, 2))
renderer.shadowMap.enabled = true
container.appendChild(renderer.domElement)
@ -94,9 +102,10 @@ export async function createWorld(container: HTMLElement): Promise<World> {
}
addEventListener('resize', () => {
camera.aspect = container.clientWidth / container.clientHeight
const { w, h } = viewport()
camera.aspect = w / h
camera.updateProjectionMatrix()
renderer.setSize(container.clientWidth, container.clientHeight)
renderer.setSize(w, h)
})
return world