From ed1c3af17a51e0a66fc3a2eb1d047c42f02c2242 Mon Sep 17 00:00:00 2001 From: type-two Date: Sat, 25 Jul 2026 22:49:49 +1000 Subject: [PATCH] =?UTF-8?q?feat(mobile):=20touch=20controls=20+=20portrait?= =?UTF-8?q?=20camera=20=E2=80=94=20BLOBBO=20is=20playable=20on=20a=20phone?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- index.html | 5 +- src/blob/camera.ts | 26 +++++- src/blob/controller.ts | 14 ++++ src/blob/touch.ts | 174 +++++++++++++++++++++++++++++++++++++++++ src/world.ts | 19 +++-- 5 files changed, 229 insertions(+), 9 deletions(-) create mode 100644 src/blob/touch.ts diff --git a/index.html b/index.html index dd01e99..a471d92 100644 --- a/index.html +++ b/index.html @@ -5,7 +5,10 @@ BLOBBO diff --git a/src/blob/camera.ts b/src/blob/camera.ts index 57f3a73..279b138 100644 --- a/src/blob/camera.ts +++ b/src/blob/camera.ts @@ -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) diff --git a/src/blob/controller.ts b/src/blob/controller.ts index ea2e431..797f779 100644 --- a/src/blob/controller.ts +++ b/src/blob/controller.ts @@ -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 diff --git a/src/blob/touch.ts b/src/blob/touch.ts new file mode 100644 index 0000000..b520ba4 --- /dev/null +++ b/src/blob/touch.ts @@ -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 +} diff --git a/src/world.ts b/src/world.ts index eee94d3..da91adf 100644 --- a/src/world.ts +++ b/src/world.ts @@ -25,12 +25,20 @@ export async function createWorld(container: HTMLElement): Promise { 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 { } 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