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