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