openvibers/openvibe.games Commits

Add touch controls that drive the same input stream as the keyboard

OP OpenVibers committed 4 Oct 2026 · 49e561baf3 · 7 files, +661 −35

Asked: T8 step 6 — OpenVibe.Games M2.4: touch controls (the last M2 milestone; M2.1 pack format, M2.2 digest handshake and M2.3 Scraplandia pack are merged — PR #4 "Gate joins on content digests and load Scraplandia's default map"). ONE pull request from current origin/main (git fetch first; the local che…
Harness job games-t8-step-6-openvibe-games-110759 (plan T8).

Changes

ddiff --git a/apps/client/src/input/inputSource.test.ts b/apps/client/src/input/inputSource.test.ts
iindex 21b68f5..2962a8a 100644
---- a/apps/client/src/input/inputSource.test.ts
++++ b/apps/client/src/input/inputSource.test.ts
@@@ -2,10 +2,18 @@ import { describe, expect, it } from 'vitest'
import { Intents } from '@openvibe/shared'
import {
KeyboardInputSource,
++ SwitchableInputSource,
type InputSample,
-- type InputSource,
type KeyboardState,
} from './inputSource.js'
++import type { InputAction } from './inputTracker.js'
++import {
++ STICK_DEAD_ZONE,
++ TouchInputSource,
++ TouchPad,
++ stickVector,
++ type IntentButton,
++} from './touchInputSource.js'
/** A fake InputTracker key set (the real one needs a DOM). */
class FakeKeyboard implements KeyboardState {
@@@ -17,24 +25,6 @@ class FakeKeyboard implements KeyboardState {
}
}
--/** A synthetic touch pad: M2 ships the real one; this models the same stream, the M2.4 acceptance target. */
--class TouchSource implements InputSource {
-- yaw = 0
-- pitch = 0
-- moveX = 0
-- moveZ = 0
-- intents = 0
-- sample(): InputSample {
-- return {
-- moveX: this.moveX,
-- moveZ: this.moveZ,
-- yaw: this.yaw,
-- pitch: this.pitch,
-- intents: this.intents,
-- }
-- }
--}
--
/** One gesture, expressed once and played on both devices. */
interface Gesture {
intents: number
@@@ -70,26 +60,68 @@ function keysFor(g: Gesture): string[] {
return keys
}
++/** The touch buttons that express a gesture's intents. */
++function buttonsFor(g: Gesture): IntentButton[] {
++ const buttons: IntentButton[] = []
++ if (g.intents & Intents.Jump) buttons.push('jump')
++ if (g.intents & Intents.Sprint) buttons.push('sprint')
++ if (g.intents & Intents.Crouch) buttons.push('crouch')
++ if (g.intents & Intents.Prone) buttons.push('prone')
++ return buttons
++}
++
++const LIMIT = Math.PI / 2 - 0.01
++/** Stick centre on screen, CSS px. */
++const OX = 100
++const OY = 500
++/** Look drag start on screen, CSS px. */
++const LX = 900
++const LY = 300
++
++/** Plays a gesture with fingers on a real TouchPad; returns the ids to lift. */
++function play(pad: TouchPad, g: Gesture, firstId: number): number[] {
++ const ids: number[] = []
++ let id = firstId
++ for (const b of buttonsFor(g)) {
++ pad.buttonDown(id, b, 0)
++ ids.push(id++)
++ }
++ if (g.moveX !== 0 || g.moveZ !== 0) {
++ const r = pad.stickRadius
++ pad.stickDown(id, OX + g.moveX * r, OY - g.moveZ * r, OX, OY)
++ ids.push(id++)
++ }
++ pad.lookDown(id, LX, LY)
++ pad.move(
++ id,
++ LX + (g.yaw - pad.yaw) / pad.sensitivity,
++ LY - (g.pitch - pad.pitch) / pad.sensitivity,
++ )
++ ids.push(id)
++ return ids
++}
++
describe('input sources', () => {
it('a keyboard and a touch source produce identical intent streams', () => {
const keyboard = new FakeKeyboard()
const keyboardSource = new KeyboardInputSource(keyboard)
-- const touch = new TouchSource()
++ const pad = new TouchPad()
++ const touch = new TouchInputSource(pad)
const keyboardStream: InputSample[] = []
const touchStream: InputSample[] = []
++ let nextId = 1
for (const g of script) {
keyboard.keys.clear()
for (const code of keysFor(g)) keyboard.keys.add(code)
keyboard.yaw = g.yaw
keyboard.pitch = g.pitch
-- touch.moveX = g.moveX
-- touch.moveZ = g.moveZ
-- touch.yaw = g.yaw
-- touch.pitch = g.pitch
-- touch.intents = g.intents
++ const fingers = play(pad, g, nextId)
++ nextId += fingers.length
keyboardStream.push(keyboardSource.sample())
touchStream.push(touch.sample())
++ // Held well past a tap, so sprint never latches between gestures.
++ for (const id of fingers) pad.up(id, 1000)
}
const expected = script.map((g) => g.intents)
@@@ -99,8 +131,169 @@ describe('input sources', () => {
expect(fromKeyboard).toEqual(expected)
// The view follows the device too: both sources report the scripted angle.
expect(keyboardStream.map((s) => s.yaw)).toEqual(script.map((g) => g.yaw))
-- expect(touchStream.map((s) => s.yaw)).toEqual(script.map((g) => g.yaw))
++ touchStream.forEach((s, i) => {
++ expect(s.yaw).toBeCloseTo(script[i]!.yaw, 9)
++ expect(s.pitch).toBeCloseTo(script[i]!.pitch, 9)
++ })
++ // Same direction of travel: the stick is analog, the keys are not.
++ expect(touchStream.map((s) => [Math.sign(s.moveX), Math.sign(s.moveZ)])).toEqual(
++ keyboardStream.map((s) => [Math.sign(s.moveX), Math.sign(s.moveZ)]),
++ )
// The stream really does exercise several distinct intents.
expect(new Set(fromKeyboard).size).toBeGreaterThan(4)
})
++
++ it('the switchable source samples whichever device is current', () => {
++ const keyboard = new FakeKeyboard()
++ keyboard.keys.add('Space')
++ keyboard.yaw = 0.5
++ const pad = new TouchPad()
++ pad.buttonDown(1, 'crouch', 0)
++ const source = new SwitchableInputSource(new KeyboardInputSource(keyboard))
++ expect(source.sample().intents).toBe(Intents.Jump)
++ expect(source.yaw).toBe(0.5)
++ source.current = new TouchInputSource(pad)
++ expect(source.sample().intents).toBe(Intents.Crouch)
++ expect(source.yaw).toBe(0)
++ })
++})
++
++describe('touch input', () => {
++ it('a stick inside the dead zone reads as centred', () => {
++ expect(stickVector(0.1, 0)).toEqual({ x: 0, y: 0 })
++ expect(stickVector(0.1, -0.1)).toEqual({ x: 0, y: 0 })
++ expect(stickVector(0, STICK_DEAD_ZONE)).toEqual({ x: 0, y: 0 })
++ const pad = new TouchPad()
++ const source = new TouchInputSource(pad)
++ pad.stickDown(1, OX + pad.stickRadius * 0.1, OY, OX, OY)
++ expect(source.sample()).toMatchObject({ moveX: 0, moveZ: 0 })
++ // Just past the edge, motion starts from zero rather than jumping.
++ const edge = stickVector(0, STICK_DEAD_ZONE + 0.01)
++ expect(edge.x).toBe(0)
++ expect(edge.y).toBeGreaterThan(0)
++ expect(edge.y).toBeLessThan(0.02)
++ })
++
++ it('the stick vector is clamped to length 1, keeping its direction', () => {
++ const far = stickVector(3, 4)
++ expect(Math.hypot(far.x, far.y)).toBeCloseTo(1, 12)
++ expect(far.x / far.y).toBeCloseTo(3 / 4, 12)
++ const diagonal = stickVector(1, 1)
++ expect(Math.hypot(diagonal.x, diagonal.y)).toBeCloseTo(1, 12)
++ const pad = new TouchPad()
++ const source = new TouchInputSource(pad)
++ // Dragged well outside the base, up and to the left: forward-left at full speed.
++ pad.stickDown(1, OX - 5 * pad.stickRadius, OY - 5 * pad.stickRadius, OX, OY)
++ const s = source.sample()
++ expect(s.moveX).toBeCloseTo(-Math.SQRT1_2, 12)
++ expect(s.moveZ).toBeCloseTo(Math.SQRT1_2, 12)
++ expect(s.moveX).toBeGreaterThanOrEqual(-1)
++ expect(s.moveZ).toBeLessThanOrEqual(1)
++ })
++
++ it('look drag clamps pitch exactly like InputTracker', () => {
++ const pad = new TouchPad()
++ pad.lookDown(1, LX, LY)
++ pad.move(1, LX, LY - 100_000) // drag up: look up
++ expect(pad.pitch).toBe(LIMIT)
++ pad.move(1, LX, LY + 100_000) // drag down: look down
++ expect(pad.pitch).toBe(-LIMIT)
++ pad.move(1, LX + 10, LY + 100_000) // drag right: yaw grows, pitch stays pinned
++ expect(pad.yaw).toBeCloseTo(10 * pad.sensitivity, 12)
++ expect(pad.pitch).toBe(-LIMIT)
++ })
++
++ it('stick, look and buttons work at once, each finger by its own pointer id', () => {
++ const pad = new TouchPad()
++ const source = new TouchInputSource(pad)
++ pad.stickDown(1, OX, OY - pad.stickRadius, OX, OY) // full forward
++ pad.lookDown(2, LX, LY)
++ pad.buttonDown(3, 'jump', 0)
++ pad.move(2, LX + 50, LY) // the look finger turns...
++ pad.move(1, OX + pad.stickRadius, OY) // ...while the stick swings right
++ let s = source.sample()
++ expect(s.yaw).toBeCloseTo(50 * pad.sensitivity, 12)
++ expect(s.moveX).toBeCloseTo(1, 12)
++ expect(s.moveZ).toBeCloseTo(0, 12)
++ expect(s.intents).toBe(Intents.Jump)
++ // Lifting the look finger leaves the stick and the button alone.
++ pad.up(2, 100)
++ pad.move(2, LX + 500, LY)
++ s = source.sample()
++ expect(s.yaw).toBeCloseTo(50 * pad.sensitivity, 12)
++ expect(s.moveX).toBeCloseTo(1, 12)
++ expect(s.intents).toBe(Intents.Jump)
++ // A finger the pad never saw (a HUD tap) changes nothing.
++ pad.move(9, 0, 0)
++ pad.up(9, 100)
++ expect(source.sample()).toEqual(s)
++ })
++
++ it('lifting fingers clears intents and movement', () => {
++ const pad = new TouchPad()
++ const source = new TouchInputSource(pad)
++ pad.buttonDown(1, 'jump', 0)
++ pad.buttonDown(2, 'crouch', 0)
++ pad.buttonDown(3, 'prone', 0)
++ pad.buttonDown(4, 'sprint', 0)
++ pad.stickDown(5, OX, OY - pad.stickRadius, OX, OY)
++ expect(source.sample().intents).toBe(
++ Intents.Jump | Intents.Crouch | Intents.Prone | Intents.Sprint,
++ )
++ for (const id of [1, 2, 3, 4, 5]) pad.up(id, 1000)
++ expect(source.sample()).toMatchObject({ moveX: 0, moveZ: 0, intents: 0 })
++
++ // releaseAll (menu opened, tab hidden) lifts everything, latch included.
++ pad.buttonDown(6, 'sprint', 0)
++ pad.up(6, 50) // a tap latches sprint on
++ pad.buttonDown(7, 'jump', 100)
++ pad.stickDown(8, OX + 30, OY, OX, OY)
++ expect(source.sample().intents).toBe(Intents.Sprint | Intents.Jump)
++ pad.releaseAll(200)
++ expect(source.sample()).toMatchObject({ moveX: 0, moveZ: 0, intents: 0 })
++ })
++
++ it('sprint latches on a tap, holds on a long press, and a second press unlatches', () => {
++ const pad = new TouchPad()
++ const source = new TouchInputSource(pad)
++ pad.buttonDown(1, 'sprint', 0)
++ pad.up(1, 1000) // long press: hold only
++ expect(source.sample().intents).toBe(0)
++ pad.buttonDown(2, 'sprint', 2000)
++ pad.up(2, 2100) // tap: latched
++ expect(source.sample().intents).toBe(Intents.Sprint)
++ pad.buttonDown(3, 'sprint', 3000) // press again: off at once
++ expect(source.sample().intents).toBe(0)
++ pad.up(3, 3050)
++ expect(source.sample().intents).toBe(0)
++ })
++
++ it('use and primary emit the same actions as E and the left mouse button', () => {
++ const pad = new TouchPad()
++ const actions: InputAction['kind'][] = []
++ pad.onAction = (a) => actions.push(a.kind)
++ pad.buttonDown(1, 'use', 0)
++ pad.up(1, 10)
++ pad.buttonDown(2, 'primary', 20)
++ pad.buttonDown(3, 'menu', 30)
++ // The menu opens mid-grab: the release still fires, new presses do not.
++ pad.uiCapture = true
++ pad.up(2, 40)
++ pad.buttonDown(4, 'use', 50)
++ pad.buttonDown(5, 'jump', 50)
++ expect(actions).toEqual(['use_down', 'use_up', 'primary_down', 'toggle_menu', 'primary_up'])
++ expect(pad.held('jump')).toBe(false)
++ })
++
++ it('look drag is redirected to rotate_held while a held prop is rotating', () => {
++ const pad = new TouchPad()
++ const actions: InputAction[] = []
++ pad.onAction = (a) => actions.push(a)
++ pad.captureLook = () => true
++ pad.lookDown(1, LX, LY)
++ pad.move(1, LX + 20, LY - 10)
++ expect(pad.yaw).toBe(0)
++ expect(pad.pitch).toBe(0)
++ expect(actions).toEqual([{ kind: 'rotate_held', dyaw: 20 * 0.005, dpitch: -10 * 0.005 }])
++ })
})
ddiff --git a/apps/client/src/input/inputSource.ts b/apps/client/src/input/inputSource.ts
iindex 7658bfc..c144d06 100644
---- a/apps/client/src/input/inputSource.ts
++++ b/apps/client/src/input/inputSource.ts
@@@ -16,8 +16,8 @@ export interface InputSample {
/**
* Where movement input comes from. The protocol carries intents, not keys: a
-- * keyboard/mouse source implements this today and M2's touch source plugs in
-- * here, producing the same `InputSample`.
++ * keyboard/mouse source and a touch source (touchInputSource.ts) implement
++ * it, both producing the same `InputSample`.
*/
export interface InputSource {
/** Latest view yaw for the render camera (updates at frame rate). */
@@@ -59,3 +59,24 @@ export class KeyboardInputSource implements InputSource {
return { moveX, moveZ, yaw: keys.yaw, pitch: keys.pitch, intents }
}
}
++
++/**
++ * The source the player is using right now. A device with both a keyboard
++ * and a touch screen switches to whichever was used last; the simulation
++ * keeps one InputSource and never notices.
++ */
++export class SwitchableInputSource implements InputSource {
++ constructor(public current: InputSource) {}
++
++ get yaw(): number {
++ return this.current.yaw
++ }
++
++ get pitch(): number {
++ return this.current.pitch
++ }
++
++ sample(): InputSample {
++ return this.current.sample()
++ }
++}
ddiff --git a/apps/client/src/input/inputTracker.ts b/apps/client/src/input/inputTracker.ts
iindex ddfd39b..bbc4bf7 100644
---- a/apps/client/src/input/inputTracker.ts
++++ b/apps/client/src/input/inputTracker.ts
@@@ -43,7 +43,9 @@ export class InputTracker {
// Lock on button DOWN, not on click: click only fires on RELEASE, so a
// click-and-hold (the physgun grab gesture) would spend the entire hold
// unlocked — no look, no actions, "game frozen until I let go".
-- canvas.addEventListener('pointerdown', () => {
++ canvas.addEventListener('pointerdown', (e) => {
++ // Touch never locks the pointer: TouchControls owns touch pointers.
++ if (e.pointerType === 'touch') return
if (!this.uiCapture && !this.locked) {
canvas.requestPointerLock()
}
@@@ -61,6 +63,7 @@ export class InputTracker {
// for the rest of that press — including mousemove. Holding a button
// would freeze the view. Pointer events keep flowing regardless.
document.addEventListener('pointermove', (e) => {
++ if (e.pointerType === 'touch') return
// A mouse is ONE pointer: pressing a second button while another is
// held fires NO pointerdown — the chorded press arrives as a
// pointermove with an updated `buttons` bitmask (this is how RMB-
@@@ -107,6 +110,7 @@ export class InputTracker {
}
})
document.addEventListener('pointerdown', (e) => {
++ if (e.pointerType === 'touch') return
if (!this.locked || this.uiCapture) {
this.buttonsState = e.buttons
return
@@@ -125,7 +129,8 @@ export class InputTracker {
if (!this.uiCapture) e.preventDefault()
})
document.addEventListener('pointerup', (e) => {
-- this.diffButtons(e)
++ // A lifted finger is not a mouse button: it must not release a grab.
++ if (e.pointerType !== 'touch') this.diffButtons(e)
})
document.addEventListener(
'wheel',
ddiff --git a/apps/client/src/input/touchControls.ts b/apps/client/src/input/touchControls.ts
nnew file mode 100644
iindex 0000000..cd911d4
---- /dev/null
++++ b/apps/client/src/input/touchControls.ts
@@@ -0,0 +1,172 @@
++import type { TouchButton, TouchPad } from './touchInputSource.js'
++
++/**
++ * The on-screen touch overlay: a stick bottom-left, buttons bottom-right,
++ * and right-half drags on the canvas for look. This is only the DOM binding
++ * — every gesture is forwarded to a TouchPad, which owns the state.
++ *
++ * Only pointers with pointerType 'touch' are handled; a mouse or pen on a
++ * touch laptop stays with InputTracker.
++ */
++export class TouchControls {
++ private readonly root: HTMLElement
++ private readonly base: HTMLElement
++ private readonly knob: HTMLElement
++ private readonly buttons = new Map<TouchButton, HTMLElement>()
++ private stickFinger: number | null = null
++ private active = false
++ private uiCapture = false
++
++ constructor(
++ parent: HTMLElement,
++ private readonly canvas: HTMLCanvasElement,
++ private readonly pad: TouchPad,
++ ) {
++ injectStyles()
++ this.root = el('div', 'ov-touch')
++ this.root.hidden = true
++ this.base = el('div', 'ov-touch-stick')
++ this.knob = el('div', 'ov-touch-knob')
++ this.base.append(this.knob)
++ const grid = el('div', 'ov-touch-buttons')
++ for (const [button, label] of BUTTONS) {
++ const b = el('button', `ov-touch-btn ov-touch-${button}`)
++ b.textContent = label
++ b.addEventListener('pointerdown', (e) => {
++ if (e.pointerType !== 'touch') return
++ e.preventDefault()
++ b.setPointerCapture(e.pointerId)
++ this.pad.buttonDown(e.pointerId, button, e.timeStamp)
++ this.refresh()
++ })
++ this.buttons.set(button, b)
++ grid.append(b)
++ }
++ this.root.append(this.base, grid)
++ parent.append(this.root)
++
++ this.base.addEventListener('pointerdown', (e) => {
++ if (e.pointerType !== 'touch') return
++ e.preventDefault()
++ this.base.setPointerCapture(e.pointerId)
++ const r = this.base.getBoundingClientRect()
++ this.pad.stickRadius = r.width / 2
++ this.pad.stickDown(
++ e.pointerId,
++ e.clientX,
++ e.clientY,
++ r.left + r.width / 2,
++ r.top + r.height / 2,
++ )
++ this.stickFinger = e.pointerId
++ this.refresh()
++ })
++ canvas.addEventListener('pointerdown', (e) => {
++ if (e.pointerType !== 'touch' || !this.visible) return
++ e.preventDefault()
++ if (e.clientX >= window.innerWidth / 2) this.pad.lookDown(e.pointerId, e.clientX, e.clientY)
++ })
++ window.addEventListener('pointermove', (e) => {
++ if (e.pointerType !== 'touch') return
++ this.pad.move(e.pointerId, e.clientX, e.clientY)
++ if (e.pointerId === this.stickFinger) this.refresh()
++ })
++ const lift = (e: PointerEvent): void => {
++ if (e.pointerType !== 'touch') return
++ this.pad.up(e.pointerId, e.timeStamp)
++ if (e.pointerId === this.stickFinger) this.stickFinger = null
++ this.refresh()
++ }
++ window.addEventListener('pointerup', lift)
++ window.addEventListener('pointercancel', lift)
++ window.addEventListener('blur', () => this.releaseAll())
++ document.addEventListener('visibilitychange', () => {
++ if (document.hidden) this.releaseAll()
++ })
++ }
++
++ /** Whether touch is the active input source. */
++ setActive(active: boolean): void {
++ this.active = active
++ this.update()
++ }
++
++ /** Menu open/closed: the overlay hides and nothing stays held. */
++ setUiCapture(captured: boolean): void {
++ this.uiCapture = captured
++ this.pad.uiCapture = captured
++ this.update()
++ }
++
++ private get visible(): boolean {
++ return this.active && !this.uiCapture
++ }
++
++ private update(): void {
++ this.root.hidden = !this.visible
++ // The canvas must not pan or zoom under a thumb while the controls are up.
++ this.canvas.style.touchAction = this.visible ? 'none' : ''
++ if (!this.visible) this.releaseAll()
++ }
++
++ private releaseAll(): void {
++ this.pad.releaseAll(performance.now())
++ this.stickFinger = null
++ this.refresh()
++ }
++
++ /** Mirror the pad's state onto the knob and the buttons. */
++ private refresh(): void {
++ const len = Math.hypot(this.pad.stickX, this.pad.stickY)
++ const k = len > 1 ? 1 / len : 1
++ const r = this.pad.stickRadius
++ this.knob.style.transform = `translate(${this.pad.stickX * k * r}px, ${-this.pad.stickY * k * r}px)`
++ for (const [button, b] of this.buttons) {
++ const on =
++ button === 'jump' || button === 'sprint' || button === 'crouch' || button === 'prone'
++ ? this.pad.held(button)
++ : false
++ b.classList.toggle('ov-touch-on', on)
++ }
++ }
++}
++
++const BUTTONS: [TouchButton, string][] = [
++ ['menu', 'Menu'],
++ ['use', 'Use'],
++ ['primary', 'Fire'],
++ ['prone', 'Prone'],
++ ['crouch', 'Crouch'],
++ ['sprint', 'Sprint'],
++ ['jump', 'Jump'],
++]
++
++function el<K extends keyof HTMLElementTagNameMap>(
++ tag: K,
++ className: string,
++): HTMLElementTagNameMap[K] {
++ const e = document.createElement(tag)
++ e.className = className
++ return e
++}
++
++/** Functional layout only; the look is styles.css's to decide. */
++function injectStyles(): void {
++ if (document.getElementById('ov-touch-styles')) return
++ const style = document.createElement('style')
++ style.id = 'ov-touch-styles'
++ style.textContent = `
++.ov-touch { position: fixed; inset: 0; pointer-events: none; z-index: 5; user-select: none; -webkit-user-select: none; }
++.ov-touch[hidden] { display: none; }
++.ov-touch-stick { position: absolute; left: 24px; bottom: 24px; width: 120px; height: 120px; border-radius: 50%;
++ background: rgba(255,255,255,0.12); border: 2px solid rgba(255,255,255,0.35); pointer-events: auto; touch-action: none; }
++.ov-touch-knob { position: absolute; left: 35px; top: 35px; width: 50px; height: 50px; border-radius: 50%;
++ background: rgba(255,255,255,0.45); pointer-events: none; }
++.ov-touch-buttons { position: absolute; right: 16px; bottom: 16px; display: grid; grid-template-columns: repeat(3, 64px);
++ gap: 10px; direction: rtl; }
++.ov-touch-btn { width: 64px; height: 64px; border-radius: 50%; border: 2px solid rgba(255,255,255,0.35);
++ background: rgba(0,0,0,0.35); color: #fff; font: 600 12px sans-serif; pointer-events: auto; touch-action: none; }
++.ov-touch-btn.ov-touch-on { background: rgba(255,255,255,0.4); }
++`
++ document.head.append(style)
++}
ddiff --git a/apps/client/src/input/touchInputSource.ts b/apps/client/src/input/touchInputSource.ts
nnew file mode 100644
iindex 0000000..0da3f8e
---- /dev/null
++++ b/apps/client/src/input/touchInputSource.ts
@@@ -0,0 +1,195 @@
++import { Intents } from '@openvibe/shared'
++import type { InputSample, InputSource } from './inputSource.js'
++import type { InputAction } from './inputTracker.js'
++
++/** Stick deflection (in stick radii) below which the stick reads as centred. */
++export const STICK_DEAD_ZONE = 0.15
++/** Same limit as InputTracker: never look straight up or down. */
++const PITCH_LIMIT = Math.PI / 2 - 0.01
++/** A sprint press shorter than this latches sprint on; a longer one is a hold. */
++const SPRINT_TAP_MS = 250
++
++/** Buttons that hold an intent bit while pressed. */
++export type IntentButton = 'jump' | 'sprint' | 'crouch' | 'prone'
++/** Buttons that emit InputActions, routed like InputTracker's. */
++export type ActionButton = 'use' | 'primary' | 'menu'
++export type TouchButton = IntentButton | ActionButton
++
++/** What a touch source reads (DOM-free, like KeyboardState). */
++export interface TouchState {
++ /** Raw stick deflection in stick radii, +x right; unclamped. */
++ readonly stickX: number
++ /** Raw stick deflection in stick radii, +y forward (up the screen); unclamped. */
++ readonly stickY: number
++ readonly yaw: number
++ readonly pitch: number
++ held(button: IntentButton): boolean
++}
++
++/**
++ * Stick deflection → move vector: a radial dead zone, rescaled so motion
++ * starts from zero at its edge, and the length clamped to 1.
++ */
++export function stickVector(x: number, y: number): { x: number; y: number } {
++ const len = Math.hypot(x, y)
++ if (len <= STICK_DEAD_ZONE) return { x: 0, y: 0 }
++ const scale = (Math.min(len, 1) - STICK_DEAD_ZONE) / (1 - STICK_DEAD_ZONE) / len
++ return { x: x * scale, y: y * scale }
++}
++
++const INTENT_BITS: Record<IntentButton, number> = {
++ jump: Intents.Jump,
++ sprint: Intents.Sprint,
++ crouch: Intents.Crouch,
++ prone: Intents.Prone,
++}
++
++/** Touch: the on-screen stick, look drag and buttons, mapped onto the same intents. */
++export class TouchInputSource implements InputSource {
++ constructor(private readonly touch: TouchState) {}
++
++ get yaw(): number {
++ return this.touch.yaw
++ }
++
++ get pitch(): number {
++ return this.touch.pitch
++ }
++
++ sample(): InputSample {
++ const touch = this.touch
++ let intents = 0
++ for (const button of Object.keys(INTENT_BITS) as IntentButton[]) {
++ if (touch.held(button)) intents |= INTENT_BITS[button]
++ }
++ const move = stickVector(touch.stickX, touch.stickY)
++ return { moveX: move.x, moveZ: move.y, yaw: touch.yaw, pitch: touch.pitch, intents }
++ }
++}
++
++type Finger =
++ | { role: 'stick'; originX: number; originY: number }
++ | { role: 'look'; lastX: number; lastY: number }
++ | { role: 'button'; button: TouchButton }
++
++/**
++ * Gesture → state, keyed by pointer id so the stick, the look drag and the
++ * buttons work at once. Coordinates are CSS pixels; the DOM layer
++ * (touchControls.ts) only forwards pointer events here.
++ */
++export class TouchPad implements TouchState {
++ yaw = 0
++ pitch = 0
++ /** Radians per CSS pixel of look drag. */
++ sensitivity = 0.006
++ /** Stick radius in CSS pixels: a drag this far is full deflection. */
++ stickRadius = 60
++ stickX = 0
++ stickY = 0
++ /** UI-open state: no intents, no movement, no new actions. */
++ uiCapture = false
++
++ onAction: ((action: InputAction) => void) | null = null
++ /** When this returns true, look drag is redirected to rotate_held. */
++ captureLook: (() => boolean) | null = null
++
++ private fingers = new Map<number, Finger>()
++ /** Start time of the sprint press in progress, or null. */
++ private sprintPress: number | null = null
++ private sprintLatched = false
++
++ held(button: IntentButton): boolean {
++ if (this.uiCapture) return false
++ if (button === 'sprint') return this.sprintLatched || this.sprintPress !== null
++ for (const f of this.fingers.values()) {
++ if (f.role === 'button' && f.button === button) return true
++ }
++ return false
++ }
++
++ /** A finger lands on the stick whose centre is at (originX, originY). */
++ stickDown(id: number, x: number, y: number, originX = x, originY = y): void {
++ if (this.uiCapture) return
++ this.fingers.set(id, { role: 'stick', originX, originY })
++ this.moveStick(originX, originY, x, y)
++ }
++
++ /** A finger lands on the look area. */
++ lookDown(id: number, x: number, y: number): void {
++ if (this.uiCapture) return
++ this.fingers.set(id, { role: 'look', lastX: x, lastY: y })
++ }
++
++ buttonDown(id: number, button: TouchButton, now: number): void {
++ if (this.uiCapture) return
++ this.fingers.set(id, { role: 'button', button })
++ switch (button) {
++ case 'sprint':
++ // Pressing a latched sprint turns it off; otherwise sprint holds and
++ // the release decides whether it latches.
++ if (this.sprintLatched) this.sprintLatched = false
++ else this.sprintPress = now
++ return
++ case 'use':
++ this.onAction?.({ kind: 'use_down' })
++ return
++ case 'primary':
++ this.onAction?.({ kind: 'primary_down' })
++ return
++ case 'menu':
++ this.onAction?.({ kind: 'toggle_menu' })
++ return
++ }
++ }
++
++ move(id: number, x: number, y: number): void {
++ const f = this.fingers.get(id)
++ if (!f) return
++ if (f.role === 'stick') {
++ this.moveStick(f.originX, f.originY, x, y)
++ } else if (f.role === 'look') {
++ const dx = x - f.lastX
++ const dy = y - f.lastY
++ f.lastX = x
++ f.lastY = y
++ if (this.captureLook?.()) {
++ this.onAction?.({ kind: 'rotate_held', dyaw: dx * 0.005, dpitch: dy * 0.005 })
++ return
++ }
++ this.yaw += dx * this.sensitivity
++ this.pitch -= dy * this.sensitivity
++ this.pitch = Math.max(-PITCH_LIMIT, Math.min(PITCH_LIMIT, this.pitch))
++ }
++ }
++
++ /** A finger lifts (or the browser cancels it). Releases always fire. */
++ up(id: number, now: number): void {
++ const f = this.fingers.get(id)
++ if (!f) return
++ this.fingers.delete(id)
++ if (f.role === 'stick') {
++ this.stickX = 0
++ this.stickY = 0
++ } else if (f.role === 'button') {
++ if (f.button === 'sprint' && this.sprintPress !== null) {
++ if (now - this.sprintPress < SPRINT_TAP_MS) this.sprintLatched = true
++ this.sprintPress = null
++ } else if (f.button === 'use') {
++ this.onAction?.({ kind: 'use_up' })
++ } else if (f.button === 'primary') {
++ this.onAction?.({ kind: 'primary_up' })
++ }
++ }
++ }
++
++ /** Lift every finger and drop the sprint latch (menu opened, page hidden, source switched). */
++ releaseAll(now: number): void {
++ for (const id of [...this.fingers.keys()]) this.up(id, now)
++ this.sprintLatched = false
++ }
++
++ private moveStick(originX: number, originY: number, x: number, y: number): void {
++ this.stickX = (x - originX) / this.stickRadius
++ this.stickY = (originY - y) / this.stickRadius
++ }
++}
ddiff --git a/apps/client/src/main.ts b/apps/client/src/main.ts
iindex 17ea2ee..f7c193e 100644
---- a/apps/client/src/main.ts
++++ b/apps/client/src/main.ts
@@@ -19,7 +19,9 @@ import { WATER_LEVEL } from '@openvibe/content'
import { InteractionController } from './game/interactionController.js'
import { LocalPlayer } from './game/localPlayer.js'
import { InputTracker } from './input/inputTracker.js'
--import { KeyboardInputSource } from './input/inputSource.js'
++import { KeyboardInputSource, SwitchableInputSource } from './input/inputSource.js'
++import { TouchControls } from './input/touchControls.js'
++import { TouchInputSource, TouchPad } from './input/touchInputSource.js'
import { Connection, gameSocketUrl, getIdentity, saveName } from './net/connection.js'
import { BeamRenderer, type BeamState } from './render/beams.js'
import { EntityView } from './render/entityView.js'
@@@ -170,8 +172,41 @@ async function start(): Promise<void> {
registerRiggingModule()
const hud = new Hud(uiRoot, state, content, connection, icons, weaponSettings)
++ // Touch on a coarse pointer, keyboard otherwise; a device with both
++ // switches to whichever was used last, carrying the view angle across.
++ const keyboardSource = new KeyboardInputSource(input)
++ const touchPad = new TouchPad()
++ const touchSource = new TouchInputSource(touchPad)
++ const touchControls = new TouchControls(uiRoot, canvas, touchPad)
++ const inputSource = new SwitchableInputSource(keyboardSource)
++ const useTouch = (touch: boolean): void => {
++ const next = touch ? touchSource : keyboardSource
++ if (inputSource.current === next) return
++ if (touch) {
++ touchPad.yaw = input.yaw
++ touchPad.pitch = input.pitch
++ input.exitLock()
++ } else {
++ input.yaw = touchPad.yaw
++ input.pitch = touchPad.pitch
++ }
++ inputSource.current = next
++ touchControls.setActive(touch)
++ }
++ useTouch(matchMedia('(pointer: coarse)').matches)
++ // Capture phase: the switch happens before the press is handled.
++ document.addEventListener('pointerdown', (e) => useTouch(e.pointerType === 'touch'), true)
++ document.addEventListener(
++ 'keydown',
++ (e) => {
++ const typing = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement
++ if (!input.uiCapture && !typing) useTouch(false)
++ },
++ true,
++ )
++
const world = content.world
-- const player = new LocalPlayer(scene, physics, new KeyboardInputSource(input), connection, state, {
++ const player = new LocalPlayer(scene, physics, inputSource, connection, state, {
x: world.spawnPoint[0],
y: world.spawnPoint[1],
z: world.spawnPoint[2],
@@@ -229,8 +264,10 @@ async function start(): Promise<void> {
hud.onUiCaptureChange = (captured) => {
input.uiCapture = captured
++ touchControls.setUiCapture(captured)
if (captured) input.exitLock()
-- else input.requestLock() // straight back into the game when the menu closes
++ // Straight back into the game when the menu closes; touch never locks.
++ else if (inputSource.current === keyboardSource) input.requestLock()
}
input.onAction = (action) => {
switch (action.kind) {
@@@ -260,6 +297,9 @@ async function start(): Promise<void> {
interact.handle(action)
}
}
++ // Touch buttons and look drags route exactly like their mouse/keyboard twins.
++ touchPad.onAction = (action) => input.onAction?.(action)
++ touchPad.captureLook = () => input.captureLook?.() ?? false
interact.onShopOpen = (targetId) => hud.openShop(targetId)
input.onWheel = (delta) => interact.onWheel(delta)
ddiff --git a/docs/adr/0007-rewrite-the-moddable-base.md b/docs/adr/0007-rewrite-the-moddable-base.md
iindex d7dcc8f..67c0224 100644
---- a/docs/adr/0007-rewrite-the-moddable-base.md
++++ b/docs/adr/0007-rewrite-the-moddable-base.md
@@@ -115,11 +115,11 @@ The Live legacy import (`platform/liveLegacyImport.ts`, `scripts/importLiveLegac
- **M1: same game, new foundation.** Deletions above; `gameServer.ts` split into systems; PostgreSQL + Valkey
persistence with the write-behind flusher; Rapier; intent input; authenticated upgrade; pins current.
--- **M2: moddable content.** Pack loader, `games-content@2`, def-set handshake, Scraplandia as a pack, touch controls.
++- **M2: moddable content — done** (pack format and loader → handshake → Scraplandia → touch). Pack loader, `games-content@2`, def-set handshake, Scraplandia as a pack, touch controls.
- **M2.1 pack format and loader (`games-content@2`) — done for items, recipes, crops and NPC archetypes (`packages/content/src/pack.ts`, `createContent(packs)`; the server merges enabled packs granted `games.def.define` at boot; the client still builds base content until M2.2; zones and maps are not pack defs yet).** A pack declares definitions (items, recipes, crops, NPCs, zones, maps), not only placements, and the runtime loader merges them into the content registry.
- **M2.2 def-set handshake — done.** Client and server exchange the versioned definition digest in `hello` and `welcome`; a mismatch refuses the join before player state is created.
- **M2.3 Scraplandia as a pack — default map done.** The base loads Scraplandia's blank-slate map through the built-in `games-content@2` pack. The released spawn, zone and empty seeded entity set are unchanged. Moving gameplay modules behind pack enablement remains future work.
-- - **M2.4 touch controls.** Touch maps intents (move vector, look delta, named actions) to the same input stream as the keyboard.
++ - **M2.4 touch controls — done.** `TouchInputSource` (`apps/client/src/input/touchInputSource.ts`) produces the same `InputSample` stream as the keyboard: an analog left stick (radial dead zone 0.15, length clamped to 1), right-half look drag with InputTracker's pitch clamp, and on-screen jump, sprint (tap latches, press holds), crouch and prone buttons on the existing `Intents` bits — no protocol change. Use, fire and menu buttons emit the same `InputAction`s as E, the left mouse button and Tab. Gesture state is DOM-free and unit-tested; `touchControls.ts` is the pointer-event overlay. The client starts on touch for a coarse pointer, switches to whichever device was used last, and never pointer-locks for touch.
- **M3: scripted mods.** QuickJS runtime, capability API v2, budgets enforced, hot reload, escape tests.
- **M4: instances and scale.** Several instances per place, registry and leases, transfer, region interest, load tests.
- **M5: platform game services.** OpenVibe.Play with Games as first consumer; the second game on the same base.