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:
parent
7918f9f6f1
commit
ed1c3af17a
@ -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>
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
174
src/blob/touch.ts
Normal 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
|
||||
}
|
||||
19
src/world.ts
19
src/world.ts
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user