openvibers/openvibe.games Commits
Add touch controls that drive the same input stream as the keyboard
Changes
@@@ -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 = 1for (const g of script) {keyboard.keys.clear()for (const code of keysFor(g)) keyboard.keys.add(code)keyboard.yaw = g.yawkeyboard.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.lengthkeyboardStream.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 }])++ })})@@@ -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()++ }++}@@@ -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') returnif (!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') returnif (!this.locked || this.uiCapture) {this.buttonsState = e.buttonsreturn@@@ -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',nnew file mode 100644@@@ -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)++}nnew file mode 100644@@@ -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++ }++}@@@ -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?.() ?? falseinteract.onShopOpen = (targetId) => hud.openShop(targetId)input.onWheel = (delta) => interact.onWheel(delta)@@@ -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 + Valkeypersistence 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.
@@ -2,10 +2,18 @@ import { describe, expect, it } from 'vitest'
@@ -2,10 +2,18 @@ import { describe, expect, it } from 'vitest'
import { Intents } from '@openvibe/shared'
import { Intents } from '@openvibe/shared'
import {
import {
KeyboardInputSource,
KeyboardInputSource,
+ SwitchableInputSource,
type InputSample,
type InputSample,
- type InputSource,
type KeyboardState,
type KeyboardState,
} from './inputSource.js'
} 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). */
/** A fake InputTracker key set (the real one needs a DOM). */
class FakeKeyboard implements KeyboardState {
class FakeKeyboard implements KeyboardState {
@@ -17,24 +25,6 @@ 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. */
/** One gesture, expressed once and played on both devices. */
interface Gesture {
interface Gesture {
intents: number
intents: number
@@ -70,26 +60,68 @@ function keysFor(g: Gesture): string[] {
@@ -70,26 +60,68 @@ function keysFor(g: Gesture): string[] {
return keys
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', () => {
describe('input sources', () => {
it('a keyboard and a touch source produce identical intent streams', () => {
it('a keyboard and a touch source produce identical intent streams', () => {
const keyboard = new FakeKeyboard()
const keyboard = new FakeKeyboard()
const keyboardSource = new KeyboardInputSource(keyboard)
const keyboardSource = new KeyboardInputSource(keyboard)
- const touch = new TouchSource()
+ const pad = new TouchPad()
+ const touch = new TouchInputSource(pad)
const keyboardStream: InputSample[] = []
const keyboardStream: InputSample[] = []
const touchStream: InputSample[] = []
const touchStream: InputSample[] = []
+ let nextId = 1
for (const g of script) {
for (const g of script) {
keyboard.keys.clear()
keyboard.keys.clear()
for (const code of keysFor(g)) keyboard.keys.add(code)
for (const code of keysFor(g)) keyboard.keys.add(code)
keyboard.yaw = g.yaw
keyboard.yaw = g.yaw
keyboard.pitch = g.pitch
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())
keyboardStream.push(keyboardSource.sample())
touchStream.push(touch.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)
const expected = script.map((g) => g.intents)
@@ -99,8 +131,169 @@ describe('input sources', () => {
@@ -99,8 +131,169 @@ describe('input sources', () => {
expect(fromKeyboard).toEqual(expected)
expect(fromKeyboard).toEqual(expected)
// The view follows the device too: both sources report the scripted angle.
// 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(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.
// The stream really does exercise several distinct intents.
expect(new Set(fromKeyboard).size).toBeGreaterThan(4)
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 }])
+ })
})
})
@@ -16,8 +16,8 @@ export interface InputSample {
@@ -16,8 +16,8 @@ export interface InputSample {
/**
/**
* Where movement input comes from. The protocol carries intents, not keys: a
* 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 {
export interface InputSource {
/** Latest view yaw for the render camera (updates at frame rate). */
/** Latest view yaw for the render camera (updates at frame rate). */
@@ -59,3 +59,24 @@ export class KeyboardInputSource implements InputSource {
@@ -59,3 +59,24 @@ export class KeyboardInputSource implements InputSource {
return { moveX, moveZ, yaw: keys.yaw, pitch: keys.pitch, intents }
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()
+ }
+}
@@ -43,7 +43,9 @@ export class InputTracker {
@@ -43,7 +43,9 @@ export class InputTracker {
// Lock on button DOWN, not on click: click only fires on RELEASE, so a
// 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
// click-and-hold (the physgun grab gesture) would spend the entire hold
// unlocked — no look, no actions, "game frozen until I let go".
// 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) {
if (!this.uiCapture && !this.locked) {
canvas.requestPointerLock()
canvas.requestPointerLock()
}
}
@@ -61,6 +63,7 @@ export class InputTracker {
@@ -61,6 +63,7 @@ export class InputTracker {
// for the rest of that press — including mousemove. Holding a button
// for the rest of that press — including mousemove. Holding a button
// would freeze the view. Pointer events keep flowing regardless.
// would freeze the view. Pointer events keep flowing regardless.
document.addEventListener('pointermove', (e) => {
document.addEventListener('pointermove', (e) => {
+ if (e.pointerType === 'touch') return
// A mouse is ONE pointer: pressing a second button while another is
// A mouse is ONE pointer: pressing a second button while another is
// held fires NO pointerdown — the chorded press arrives as a
// held fires NO pointerdown — the chorded press arrives as a
// pointermove with an updated `buttons` bitmask (this is how RMB-
// pointermove with an updated `buttons` bitmask (this is how RMB-
@@ -107,6 +110,7 @@ export class InputTracker {
@@ -107,6 +110,7 @@ export class InputTracker {
}
}
})
})
document.addEventListener('pointerdown', (e) => {
document.addEventListener('pointerdown', (e) => {
+ if (e.pointerType === 'touch') return
if (!this.locked || this.uiCapture) {
if (!this.locked || this.uiCapture) {
this.buttonsState = e.buttons
this.buttonsState = e.buttons
return
return
@@ -125,7 +129,8 @@ export class InputTracker {
@@ -125,7 +129,8 @@ export class InputTracker {
if (!this.uiCapture) e.preventDefault()
if (!this.uiCapture) e.preventDefault()
})
})
document.addEventListener('pointerup', (e) => {
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(
document.addEventListener(
'wheel',
'wheel',
new file mode 100644
new file mode 100644
@@ -0,0 +1,172 @@
@@ -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)
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,195 @@
@@ -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
+ }
+}
@@ -19,7 +19,9 @@ import { WATER_LEVEL } from '@openvibe/content'
@@ -19,7 +19,9 @@ import { WATER_LEVEL } from '@openvibe/content'
import { InteractionController } from './game/interactionController.js'
import { InteractionController } from './game/interactionController.js'
import { LocalPlayer } from './game/localPlayer.js'
import { LocalPlayer } from './game/localPlayer.js'
import { InputTracker } from './input/inputTracker.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 { Connection, gameSocketUrl, getIdentity, saveName } from './net/connection.js'
import { BeamRenderer, type BeamState } from './render/beams.js'
import { BeamRenderer, type BeamState } from './render/beams.js'
import { EntityView } from './render/entityView.js'
import { EntityView } from './render/entityView.js'
@@ -170,8 +172,41 @@ async function start(): Promise<void> {
@@ -170,8 +172,41 @@ async function start(): Promise<void> {
registerRiggingModule()
registerRiggingModule()
const hud = new Hud(uiRoot, state, content, connection, icons, weaponSettings)
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 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],
x: world.spawnPoint[0],
y: world.spawnPoint[1],
y: world.spawnPoint[1],
z: world.spawnPoint[2],
z: world.spawnPoint[2],
@@ -229,8 +264,10 @@ async function start(): Promise<void> {
@@ -229,8 +264,10 @@ async function start(): Promise<void> {
hud.onUiCaptureChange = (captured) => {
hud.onUiCaptureChange = (captured) => {
input.uiCapture = captured
input.uiCapture = captured
+ touchControls.setUiCapture(captured)
if (captured) input.exitLock()
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) => {
input.onAction = (action) => {
switch (action.kind) {
switch (action.kind) {
@@ -260,6 +297,9 @@ async function start(): Promise<void> {
@@ -260,6 +297,9 @@ async function start(): Promise<void> {
interact.handle(action)
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)
interact.onShopOpen = (targetId) => hud.openShop(targetId)
input.onWheel = (delta) => interact.onWheel(delta)
input.onWheel = (delta) => interact.onWheel(delta)
@@ -115,11 +115,11 @@ The Live legacy import (`platform/liveLegacyImport.ts`, `scripts/importLiveLegac
@@ -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
- **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.
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.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.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.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.
- **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.
- **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.
- **M5: platform game services.** OpenVibe.Play with Games as first consumer; the second game on the same base.