openvibers/openvibe.games Commits
Games M1 step 3 (ADR-0007): intent input. One frozen, versioned intent bitfield in packages/shared (the movement layer re-exports it, the protocol's copy is gone); look is absolute and quantised to int16 (1/65536 turn) on the wire, the server takes the client's angles as sent (yaw wrapped, pitch clamped to ±90°) so prediction integrates identical values; the input schema refuses unknown bits and out-of-range angles; PROTOCOL_VERSION 20 (old tabs reload). The client samples an InputSource (keyboard today, touch in M2 maps to the same intents; a test drives both to identical streams). Slice test: walks around the driven cart and up to the scattered loot instead of racing physics. typecheck, test, lint, slice and bundle green (verified).
Changes
@@@ -2,7 +2,6 @@ import { UniversalCamera } from '@babylonjs/core/Cameras/universalCamera.js'import { Vector3 } from '@babylonjs/core/Maths/math.vector.js'import type { Scene } from '@babylonjs/core/scene.js'import {-- Buttons,DEFAULT_MOVEMENT,createMoveState,eyeOffsetFor,@@@ -13,10 +12,13 @@ import {} from '@openvibe/gameplay'import { CollisionLayer, type PhysicsWorld } from '@openvibe/physics'import type { ClientInput, ServerSnapshot } from '@openvibe/protocol'++import { clamp, dequantiseAngle, quantiseAngle } from '@openvibe/shared'import type { Connection } from '../net/connection.js'--import type { InputTracker } from '../input/inputTracker.js'++import type { InputSource } from '../input/inputSource.js'import type { ClientState } from '../state/clientState.js'++const HALF_PI = Math.PI / 2++/*** Client-side prediction of the local player.*@@@ -53,7 +55,7 @@ export class LocalPlayer {constructor(scene: Scene,private readonly physics: PhysicsWorld,-- private readonly input: InputTracker,++ private readonly input: InputSource,private readonly connection: Connection,private readonly state: ClientState,spawn: { x: number; y: number; z: number },@@@ -88,22 +90,16 @@ export class LocalPlayer {/** One fixed simulation tick: capture, send, predict. */fixedUpdate(): void {-- const buttons =-- (this.input.keyDown('Space') ? Buttons.Jump : 0) |-- (this.input.keyDown('ShiftLeft') ? Buttons.Sprint : 0) |-- (this.input.keyDown('ControlLeft') || this.input.keyDown('KeyC') ? Buttons.Crouch : 0) |-- (this.input.keyDown('KeyZ') ? Buttons.Prone : 0)-- const moveX = (this.input.keyDown('KeyD') ? 1 : 0) - (this.input.keyDown('KeyA') ? 1 : 0)-- const moveZ = (this.input.keyDown('KeyW') ? 1 : 0) - (this.input.keyDown('KeyS') ? 1 : 0)++ const sample = this.input.sample()const cmd: ClientInput = {t: 'input',seq: ++this.seq,-- moveX,-- moveZ,-- yaw: this.input.yaw,-- pitch: this.input.pitch,-- buttons,++ moveX: sample.moveX,++ moveZ: sample.moveZ,++ yawQ: quantiseAngle(sample.yaw),++ pitchQ: quantiseAngle(clamp(sample.pitch, -HALF_PI, HALF_PI)),++ intents: sample.intents,}this.connection.send(cmd)this.pending.push(cmd)@@@ -125,12 +121,14 @@ export class LocalPlayer {}private applyInput(cmd: ClientInput): void {++ // Prediction integrates exactly what the server will: the dequantised++ // angles, not the raw device values.const input: MoveInput = {moveX: cmd.moveX,moveZ: cmd.moveZ,-- yaw: cmd.yaw,-- pitch: cmd.pitch,-- buttons: cmd.buttons,++ yaw: dequantiseAngle(cmd.yawQ),++ pitch: dequantiseAngle(cmd.pitchQ),++ buttons: cmd.intents,}stepMovement(this.move, input, MOVE, this.queries, 1 / this.state.tickRate)}nnew file mode 100644@@@ -0,0 +1,106 @@++import { describe, expect, it } from 'vitest'++import { Intents } from '@openvibe/shared'++import {++ KeyboardInputSource,++ type InputSample,++ type InputSource,++ type KeyboardState,++} from './inputSource.js'++++/** A fake InputTracker key set (the real one needs a DOM). */++class FakeKeyboard implements KeyboardState {++ readonly keys = new Set<string>()++ yaw = 0++ pitch = 0++ keyDown(code: string): boolean {++ return this.keys.has(code)++ }++}++++/** A synthetic touch pad: M2 ships the real one; this models the same stream. */++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++ moveX: number++ moveZ: number++ yaw: number++ pitch: number++}++++const script: Gesture[] = [++ { intents: 0, moveX: 0, moveZ: 0, yaw: 0, pitch: 0 },++ { intents: Intents.Jump, moveX: 0, moveZ: 1, yaw: 0.3, pitch: -0.2 },++ { intents: Intents.Jump | Intents.Sprint, moveX: 0.2, moveZ: 1, yaw: 0.3, pitch: -0.2 },++ { intents: Intents.Sprint, moveX: -1, moveZ: 0.4, yaw: -0.7, pitch: 0 },++ { intents: Intents.Crouch, moveX: 0.6, moveZ: 1, yaw: 1.1, pitch: 0.4 },++ { intents: Intents.Crouch | Intents.Prone, moveX: 0, moveZ: 0, yaw: 1.1, pitch: 0.4 },++ { intents: Intents.Prone, moveX: 0, moveZ: -1, yaw: -3.0, pitch: -0.5 },++ { intents: 0, moveX: 0, moveZ: 0, yaw: -3.0, pitch: 0 },++ { intents: Intents.Jump | Intents.Crouch, moveX: -0.5, moveZ: -0.5, yaw: 3.0, pitch: 0.1 },++]++++/** The desktop keys that express a gesture (mirrors KeyboardInputSource). */++function keysFor(g: Gesture): string[] {++ const keys: string[] = []++ if (g.intents & Intents.Jump) keys.push('Space')++ if (g.intents & Intents.Sprint) keys.push('ShiftLeft')++ if (g.intents & Intents.Crouch) keys.push('KeyC')++ if (g.intents & Intents.Prone) keys.push('KeyZ')++ if (g.moveX > 0) keys.push('KeyD')++ if (g.moveX < 0) keys.push('KeyA')++ if (g.moveZ > 0) keys.push('KeyW')++ if (g.moveZ < 0) keys.push('KeyS')++ return keys++}++++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 keyboardStream: InputSample[] = []++ const touchStream: InputSample[] = []++ 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++ keyboardStream.push(keyboardSource.sample())++ touchStream.push(touch.sample())++ }++++ const expected = script.map((g) => g.intents)++ const fromKeyboard = keyboardStream.map((s) => s.intents)++ const fromTouch = touchStream.map((s) => s.intents)++ expect(fromKeyboard).toEqual(fromTouch)++ 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))++ // The stream really does exercise several distinct intents.++ expect(new Set(fromKeyboard).size).toBeGreaterThan(4)++ })++})nnew file mode 100644@@@ -0,0 +1,61 @@++import { Intents } from '@openvibe/shared'++++/** One simulation tick of intent input, independent of the device behind it. */++export interface InputSample {++ /** Strafe axis [-1,1], +X right. */++ moveX: number++ /** Forward axis [-1,1], +Z forward. */++ moveZ: number++ /** Absolute view yaw, radians. */++ yaw: number++ /** Absolute view pitch, radians (clamped to ±90°). */++ pitch: number++ /** Intent bitfield (see @openvibe/shared Intents). */++ intents: number++}++++/**++ * 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`.++ */++export interface InputSource {++ /** Latest view yaw for the render camera (updates at frame rate). */++ readonly yaw: number++ /** Latest view pitch for the render camera (updates at frame rate). */++ readonly pitch: number++ /** Sample the current tick's intent input. */++ sample(): InputSample++}++++/** The subset of InputTracker a keyboard source reads (DOM-free, so tests can fake it). */++export interface KeyboardState {++ keyDown(code: string): boolean++ readonly yaw: number++ readonly pitch: number++}++++/** Keyboard + mouse: today's desktop bindings, mapped onto named intents. */++export class KeyboardInputSource implements InputSource {++ constructor(private readonly keys: KeyboardState) {}++++ get yaw(): number {++ return this.keys.yaw++ }++++ get pitch(): number {++ return this.keys.pitch++ }++++ sample(): InputSample {++ const keys = this.keys++ let intents = 0++ if (keys.keyDown('Space')) intents |= Intents.Jump++ if (keys.keyDown('ShiftLeft')) intents |= Intents.Sprint++ if (keys.keyDown('ControlLeft') || keys.keyDown('KeyC')) intents |= Intents.Crouch++ if (keys.keyDown('KeyZ')) intents |= Intents.Prone++ const moveX = (keys.keyDown('KeyD') ? 1 : 0) - (keys.keyDown('KeyA') ? 1 : 0)++ const moveZ = (keys.keyDown('KeyW') ? 1 : 0) - (keys.keyDown('KeyS') ? 1 : 0)++ return { moveX, moveZ, yaw: keys.yaw, pitch: keys.pitch, intents }++ }++}@@@ -21,6 +21,7 @@ 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 { Connection, gameSocketUrl, getIdentity, saveName } from './net/connection.js'import { BeamRenderer, type BeamState } from './render/beams.js'import { EntityView } from './render/entityView.js'@@@ -171,7 +172,7 @@ async function start(): Promise<void> {const hud = new Hud(uiRoot, state, content, connection, icons, weaponSettings)const world = content.world-- const player = new LocalPlayer(scene, physics, input, connection, state, {++ const player = new LocalPlayer(scene, physics, new KeyboardInputSource(input), connection, state, {x: world.spawnPoint[0],y: world.spawnPoint[1],z: world.spawnPoint[2],@@@ -28,6 +28,7 @@ import {type WireSkill,} from '@openvibe/protocol'import { encodeHeights } from '@openvibe/content'++import { quantiseAngle } from '@openvibe/shared'const PORT = 18123const URL = `ws://127.0.0.1:${PORT}/ws`@@@ -331,8 +332,16 @@ class TestClient {})}-- input(moveX: number, moveZ: number, yaw: number, buttons = 0, pitch = 0): void {-- this.send({ t: 'input', seq: ++this.seq, moveX, moveZ, yaw, pitch, buttons })++ input(moveX: number, moveZ: number, yaw: number, intents = 0, pitch = 0): void {++ this.send({++ t: 'input',++ seq: ++this.seq,++ moveX,++ moveZ,++ yawQ: quantiseAngle(yaw),++ pitchQ: quantiseAngle(pitch),++ intents,++ })}/** Sends a use and waits for its result. */@@@ -1225,6 +1234,16 @@ async function main(): Promise<void> {const generatorEnt = [...a.entities.values()].find((e) => e.def === 'scrap_generator')assert(sawmillEnt && generatorEnt, 'authored production yard replicated')assert(a.count('wood_log') >= 2, `has logs to process (${a.count('wood_log')})`)++ // The cart just driven can settle across the straight line to the sawmill: first walk down the++ // side the rider got off on, past the cart's tail, then on to the sawmill.++ const cart = [...a.entities.values()].find((e) => e.def === 'cart_chassis')++ if (cart && a.me) {++ const side = a.me.pos[0] >= cart.pos[0] ? 1 : -1++ await walkPath(a, [++ [cart.pos[0] + side * 3.5, a.me.pos[2]],++ [cart.pos[0] + side * 3.5, cart.pos[2] - 4],++ ])++ }await walkTo(a, sawmillEnt.pos[0] - 1.5, sawmillEnt.pos[2] - 1.5)await settle(a)// Load a log into the sawmill input.@@@ -1635,9 +1654,14 @@ async function main(): Promise<void> {}}assert(lootProp, 'thug death scattered loot')++ // The scattered prop is dynamic and may land beyond hand reach (4 m from the eye): let it settle,++ // then walk up to where it is now before picking it up.await sleep(700)++ const loot = a.entities.get(lootProp.id) ?? lootProp++ await walkTo(a, loot.pos[0], loot.pos[2])++ await settle(a)const grab = await a.use(lootProp.id)-- assert(grab.ok, 'loot recovered')++ assert(grab.ok, `loot recovered (${grab.error ?? 'ok'})`)await pollUntil(() => a.count('scrap_metal') > scrapBefore)assert(a.count('scrap_metal') > scrapBefore, 'loot in inventory')// Walk back toward the north gate for the remaining phases.@@@ -4,10 +4,11 @@ import {hullHeightFor,stepMovement,type CollisionQueries,++ type MoveInput,} from '@openvibe/gameplay'import { CollisionLayer, type BodyId } from '@openvibe/physics'import type { ClientEditMode, ClientInput } from '@openvibe/protocol'--import { qfromYaw, vec3 } from '@openvibe/shared'++import { clamp, dequantiseAngle, qfromYaw, vec3, wrapAngle } from '@openvibe/shared'import { canEditMap } from '../../net/networkAuth.js'import type { PlayerSession } from '../playerSession.js'import type { ServerContext } from './context.js'@@@ -16,9 +17,30 @@ import type { HandlerMap, System } from './system.js'export const MOVE = DEFAULT_MOVEMENT/** Bounded input queue: the cap stops a client from buying simulation speed with backlog. */export const MAX_INPUT_QUEUE = 6--const _bodyPosScratch = vec3()++/**++ * Apply a client's absolute, quantised view angles (ADR-0007 decision 3). Look is the client's: the++ * server takes the angles as sent, like client prediction does, so the two integrate identical++ * values. Yaw stays wrapped to one turn and pitch is clamped to ±90°; the wire schema already bounds++ * both.++ */++export function applyLookInput(session: PlayerSession, input: ClientInput): void {++ session.yaw = wrapAngle(dequantiseAngle(input.yawQ))++ session.pitch = clamp(dequantiseAngle(input.pitchQ), -Math.PI / 2, Math.PI / 2)++}++++/** A client command as the movement sim's input, reading the session's applied view. */++function toMoveInput(session: PlayerSession, input: ClientInput): MoveInput {++ return {++ moveX: input.moveX,++ moveZ: input.moveZ,++ yaw: session.yaw,++ pitch: session.pitch,++ buttons: input.intents,++ }++}++/*** Authoritative movement: queued input commands become positions, the kinematic capsule follows,* stance swaps rebuild the hull, and falling hurts. Vehicle driving is the one exception — it@@@ -90,10 +112,9 @@ export class MovementSystem implements System {session.lastInput = inputsession.starvedTicks = 0session.lastProcessedSeq = input.seq-- session.yaw = input.yaw-- session.pitch = input.pitch-- session.buttons = input.buttons-- stepMovement(session.move, input, MOVE, this.moveQueries, dt)++ applyLookInput(session, input)++ session.buttons = input.intents++ stepMovement(session.move, toMoveInput(session, input), MOVE, this.moveQueries, dt)simulated++}if (simulated === 0 && session.lastInput) {@@@ -105,7 +126,7 @@ export class MovementSystem implements System {// longer than 3 ticks made the server flap stances endlessly.const input =session.starvedTicks <= 3 ? session.lastInput : { ...session.lastInput, moveX: 0, moveZ: 0 }-- stepMovement(session.move, input, MOVE, this.moveQueries, dt)++ stepMovement(session.move, toMoveInput(session, input), MOVE, this.moveQueries, dt)}let bodyId = ctx.sessions.bodyOf(session.playerId)if (bodyId !== undefined && session.bodyStance !== session.move.stance) {@@@ -2,6 +2,7 @@ import type { GameEntity } from '@openvibe/gameplay'import { qfromYaw, qrotateVec, v3dist, vec3 } from '@openvibe/shared'import type { PlayerSession } from '../playerSession.js'import type { ServerContext } from './context.js'++import { applyLookInput } from './movement.js'import type { System } from './system.js'const _bodyPosScratch = vec3()@@@ -105,9 +106,8 @@ export class VehicleSystem implements System {input = session.inputQueue.shift()!session.lastInput = inputsession.lastProcessedSeq = input.seq-- session.yaw = input.yaw-- session.pitch = input.pitch-- session.buttons = input.buttons++ applyLookInput(session, input)++ session.buttons = input.intents}const nowMs = Date.now()// Fuel: burn items from the trunk while driving.@@@ -1,9 +1,13 @@--/** Input button bitfield — mirrors @openvibe/protocol Buttons; gameplay stays protocol-free. */--export const Buttons = {-- Jump: 1 << 0,-- Crouch: 1 << 1,-- Sprint: 1 << 2,-- Use: 1 << 3,-- Attack: 1 << 4,-- Prone: 1 << 5,--} as const++/**++ * Input intent bitfield. The single definition lives in @openvibe/shared++ * (protocol + gameplay + M2 touch); `Buttons` stays the movement layer's++ * alias so the simulation keeps reading button bits.++ */++export {++ Intents,++ Intents as Buttons,++ INTENTS_VERSION,++ intentMask,++ hasIntent,++ type IntentName,++} from '@openvibe/shared'@@@ -9,7 +9,7 @@ describe('protocol codec', () => {// hello carries no identity field: authentication happens at the// WebSocket upgrade (ADR-0007 decision 8).{ t: 'hello', v: 1, slot: 0, name: 'Tester', appearance: defaultAppearance() },-- { t: 'input', seq: 42, moveX: 1, moveZ: -0.5, yaw: 1.2, pitch: -0.3, buttons: 5 },++ { t: 'input', seq: 42, moveX: 1, moveZ: -0.5, yawQ: 12345, pitchQ: -4096, intents: 5 },{ t: 'use', target: 'abc123' },{ t: 'craft', recipe: 'craft_wooden_crate' },{ t: 'drop', slot: 3, count: 5 },@@@ -31,17 +31,31 @@ describe('protocol codec', () => {// NaN/Infinity smugglingexpect(decodeClientMessage(-- '{"t":"input","seq":1,"moveX":1e999,"moveZ":0,"yaw":0,"pitch":0,"buttons":0}',++ '{"t":"input","seq":1,"moveX":1e999,"moveZ":0,"yawQ":0,"pitchQ":0,"intents":0}',),).toBeNull()// out-of-range movement axes (speedhack attempt)expect(decodeClientMessage(-- '{"t":"input","seq":1,"moveX":5,"moveZ":0,"yaw":0,"pitch":0,"buttons":0}',++ '{"t":"input","seq":1,"moveX":5,"moveZ":0,"yawQ":0,"pitchQ":0,"intents":0}',),).toBeNull()})++ it('refuses unknown intent bits and out-of-range quantised angles', () => {++ const decodeInput = (fields: string) =>++ decodeClientMessage(`{"t":"input","seq":1,"moveX":0,"moveZ":0,${fields}}`)++ const known = '"yawQ":0,"pitchQ":0,"intents":0'++ // A bit outside intentMask is refused (64 = the first bit past the list).++ expect(decodeInput('"yawQ":0,"pitchQ":0,"intents":64')).toBeNull()++ // Angles outside the int16 / ±90° range are refused.++ expect(decodeInput('"yawQ":32768,"pitchQ":0,"intents":0')).toBeNull()++ expect(decodeInput('"yawQ":0,"pitchQ":16385,"intents":0')).toBeNull()++ // A full known mask and the extreme in-range values are fine.++ expect(decodeInput('"yawQ":-32768,"pitchQ":16384,"intents":63')).not.toBeNull()++ expect(decodeInput(known)).not.toBeNull()++ })++it('ignores an identity field in hello (the upgrade is the only authn gate)', () => {const decoded = decodeClientMessage(JSON.stringify({@@@ -1,4 +1,5 @@import { z } from 'zod'++import { ANGLE_Q_MAX, ANGLE_Q_MIN, PITCH_Q_MAX, intentMask } from '@openvibe/shared'import { AppearanceSchema } from '../appearance.js'/**@@@ -33,9 +34,20 @@ export const ClientInputSchema = z.object({seq: z.number().int().nonnegative(),moveX: z.number().min(-1).max(1),moveZ: z.number().min(-1).max(1),-- yaw: z.number().finite(),-- pitch: z.number().finite(),-- buttons: z.number().int().nonnegative(),++ /** Absolute view yaw, quantised to int16 (1/65536 of a turn). */++ yawQ: z.number().int().min(ANGLE_Q_MIN).max(ANGLE_Q_MAX),++ /** Absolute view pitch, quantised to int16; ±90° is the clamp. */++ pitchQ: z.number().int().min(-PITCH_Q_MAX).max(PITCH_Q_MAX),++ /**++ * Named action bits (see @openvibe/shared Intents). A bit outside++ * intentMask is refused — the client may only express known actions.++ */++ intents: z++ .number()++ .int()++ .min(0)++ .max(0x7fffffff)++ .refine((n) => (n & ~intentMask) === 0, { message: 'unknown intent bit' }),})/** Interact with a world entity (gather a resource, open a container...). */@@@ -1,2 +1,2 @@/** Wire protocol version — bump on ANY breaking message change. */--export const PROTOCOL_VERSION = 19++export const PROTOCOL_VERSION = 20@@@ -16,16 +16,6 @@ export type WireVec3 = [number, number, number]/** [x, y, z, w] unit quaternion. */export type WireQuat = [number, number, number, number]--/** Input button bitfield. */--export const Buttons = {-- Jump: 1 << 0,-- Crouch: 1 << 1,-- Sprint: 1 << 2,-- Use: 1 << 3,-- Attack: 1 << 4,-- Prone: 1 << 5,--} as const--/** How a wire entity should be represented client-side. */export type WireEntityKind = 'player' | 'prop' | 'resource' | 'npc'@@@ -1,4 +1,5 @@export * from './ids.js'++export * from './intents.js'export * from './math/vec3.js'export * from './math/quat.js'export * from './math/scalar.js'nnew file mode 100644@@@ -0,0 +1,30 @@++/**++ * Intent bitfield: the named actions an input command expresses, as fixed++ * bits. This is the single definition — the protocol, the movement layer and++ * (M2) touch controls all map onto these names, so a key press and a touch++ * button produce the same intent.++ *++ * Adding a flag takes the next free bit and bumps INTENTS_VERSION (and so the++ * wire protocol version, because the bits are on the wire).++ */++export const Intents = Object.freeze({++ Jump: 1 << 0,++ Crouch: 1 << 1,++ Sprint: 1 << 2,++ Use: 1 << 3,++ Attack: 1 << 4,++ Prone: 1 << 5,++} as const)++++export type IntentName = keyof typeof Intents++++/** Bump when a flag is added or a bit changes meaning. */++export const INTENTS_VERSION = 1++++/** Every known bit; an inbound command may carry no bit outside this mask. */++export const intentMask = Object.values(Intents).reduce((mask, bit) => mask | bit, 0)++++/** Is the named intent set in a bitfield? */++export function hasIntent(bits: number, name: IntentName): boolean {++ return (bits & Intents[name]) !== 0++}nnew file mode 100644@@@ -0,0 +1,47 @@++import { describe, expect, it } from 'vitest'++import {++ ANGLE_Q_MAX,++ ANGLE_Q_MIN,++ ANGLE_STEP,++ PITCH_Q_MAX,++ dequantiseAngle,++ quantiseAngle,++ wrapAngle,++} from './scalar.js'++++describe('angle quantisation', () => {++ it('round-trips within half a step', () => {++ const angles = [++ 0,++ 0.1,++ -0.1,++ Math.PI / 2,++ -Math.PI / 2,++ 3.0,++ -3.0,++ Math.PI,++ -Math.PI + 1e-6,++ 100,++ -100,++ ]++ for (const a of angles) {++ const q = quantiseAngle(a)++ expect(Number.isInteger(q)).toBe(true)++ expect(q).toBeGreaterThanOrEqual(ANGLE_Q_MIN)++ expect(q).toBeLessThanOrEqual(ANGLE_Q_MAX)++ // Measured on the circle: the extremes -PI and PI-step are adjacent.++ const error = Math.abs(wrapAngle(dequantiseAngle(q) - a))++ expect(error).toBeLessThanOrEqual(ANGLE_STEP / 2 + 1e-12)++ }++ })++++ it('keeps every angle inside the int16 range and pitch inside a quarter turn', () => {++ for (const a of [Math.PI, -Math.PI, 1e6, -1e6, 0.5]) {++ const q = quantiseAngle(a)++ expect(q).toBeGreaterThanOrEqual(ANGLE_Q_MIN)++ expect(q).toBeLessThanOrEqual(ANGLE_Q_MAX)++ }++ expect(quantiseAngle(Math.PI / 2)).toBe(PITCH_Q_MAX)++ expect(quantiseAngle(-Math.PI / 2)).toBe(-PITCH_Q_MAX)++ })++})@@@ -16,3 +16,29 @@ export function wrapAngle(a: number): number {if (a <= -Math.PI) a += Math.PI * 2return a}++++/**++ * Absolute-angle quantisation for the wire: a signed 16-bit step per++ * 1/65536 of a turn. Both sides of the protocol share these so the client's++ * prediction and the server's simulation integrate the identical angles.++ */++export const ANGLE_STEPS_PER_TURN = 65536++export const ANGLE_STEP = (Math.PI * 2) / ANGLE_STEPS_PER_TURN++export const ANGLE_Q_MIN = -32768++export const ANGLE_Q_MAX = 32767++/** ±90° (a quarter turn) in quantised units — the pitch clamp. */++export const PITCH_Q_MAX = ANGLE_STEPS_PER_TURN / 4++++/** Absolute angle -> nearest signed 16-bit step (wrapped to one turn). */++export function quantiseAngle(angle: number): number {++ const q = Math.round(wrapAngle(angle) / ANGLE_STEP)++ // Wrap the step into the int16 range: -PI and PI-step are the same circle,++ // so the nearest step to PI is -32768, not an out-of-range 32768.++ const half = ANGLE_STEPS_PER_TURN / 2++ return ((q + half) % ANGLE_STEPS_PER_TURN) - half++}++++/** Signed 16-bit step -> absolute angle; error is at most half a step. */++export function dequantiseAngle(q: number): number {++ return q * ANGLE_STEP++}
@@ -2,7 +2,6 @@ import { UniversalCamera } from '@babylonjs/core/Cameras/universalCamera.js'
@@ -2,7 +2,6 @@ import { UniversalCamera } from '@babylonjs/core/Cameras/universalCamera.js'
import { Vector3 } from '@babylonjs/core/Maths/math.vector.js'
import { Vector3 } from '@babylonjs/core/Maths/math.vector.js'
import type { Scene } from '@babylonjs/core/scene.js'
import type { Scene } from '@babylonjs/core/scene.js'
import {
import {
- Buttons,
DEFAULT_MOVEMENT,
DEFAULT_MOVEMENT,
createMoveState,
createMoveState,
eyeOffsetFor,
eyeOffsetFor,
@@ -13,10 +12,13 @@ import {
@@ -13,10 +12,13 @@ import {
} from '@openvibe/gameplay'
} from '@openvibe/gameplay'
import { CollisionLayer, type PhysicsWorld } from '@openvibe/physics'
import { CollisionLayer, type PhysicsWorld } from '@openvibe/physics'
import type { ClientInput, ServerSnapshot } from '@openvibe/protocol'
import type { ClientInput, ServerSnapshot } from '@openvibe/protocol'
+import { clamp, dequantiseAngle, quantiseAngle } from '@openvibe/shared'
import type { Connection } from '../net/connection.js'
import type { Connection } from '../net/connection.js'
-import type { InputTracker } from '../input/inputTracker.js'
+import type { InputSource } from '../input/inputSource.js'
import type { ClientState } from '../state/clientState.js'
import type { ClientState } from '../state/clientState.js'
+const HALF_PI = Math.PI / 2
+
/**
/**
* Client-side prediction of the local player.
* Client-side prediction of the local player.
*
*
@@ -53,7 +55,7 @@ export class LocalPlayer {
@@ -53,7 +55,7 @@ export class LocalPlayer {
constructor(
constructor(
scene: Scene,
scene: Scene,
private readonly physics: PhysicsWorld,
private readonly physics: PhysicsWorld,
- private readonly input: InputTracker,
+ private readonly input: InputSource,
private readonly connection: Connection,
private readonly connection: Connection,
private readonly state: ClientState,
private readonly state: ClientState,
spawn: { x: number; y: number; z: number },
spawn: { x: number; y: number; z: number },
@@ -88,22 +90,16 @@ export class LocalPlayer {
@@ -88,22 +90,16 @@ export class LocalPlayer {
/** One fixed simulation tick: capture, send, predict. */
/** One fixed simulation tick: capture, send, predict. */
fixedUpdate(): void {
fixedUpdate(): void {
- const buttons =
- (this.input.keyDown('Space') ? Buttons.Jump : 0) |
- (this.input.keyDown('ShiftLeft') ? Buttons.Sprint : 0) |
- (this.input.keyDown('ControlLeft') || this.input.keyDown('KeyC') ? Buttons.Crouch : 0) |
- (this.input.keyDown('KeyZ') ? Buttons.Prone : 0)
- const moveX = (this.input.keyDown('KeyD') ? 1 : 0) - (this.input.keyDown('KeyA') ? 1 : 0)
- const moveZ = (this.input.keyDown('KeyW') ? 1 : 0) - (this.input.keyDown('KeyS') ? 1 : 0)
+ const sample = this.input.sample()
const cmd: ClientInput = {
const cmd: ClientInput = {
t: 'input',
t: 'input',
seq: ++this.seq,
seq: ++this.seq,
- moveX,
- moveZ,
- yaw: this.input.yaw,
- pitch: this.input.pitch,
- buttons,
+ moveX: sample.moveX,
+ moveZ: sample.moveZ,
+ yawQ: quantiseAngle(sample.yaw),
+ pitchQ: quantiseAngle(clamp(sample.pitch, -HALF_PI, HALF_PI)),
+ intents: sample.intents,
}
}
this.connection.send(cmd)
this.connection.send(cmd)
this.pending.push(cmd)
this.pending.push(cmd)
@@ -125,12 +121,14 @@ export class LocalPlayer {
@@ -125,12 +121,14 @@ export class LocalPlayer {
}
}
private applyInput(cmd: ClientInput): void {
private applyInput(cmd: ClientInput): void {
+ // Prediction integrates exactly what the server will: the dequantised
+ // angles, not the raw device values.
const input: MoveInput = {
const input: MoveInput = {
moveX: cmd.moveX,
moveX: cmd.moveX,
moveZ: cmd.moveZ,
moveZ: cmd.moveZ,
- yaw: cmd.yaw,
- pitch: cmd.pitch,
- buttons: cmd.buttons,
+ yaw: dequantiseAngle(cmd.yawQ),
+ pitch: dequantiseAngle(cmd.pitchQ),
+ buttons: cmd.intents,
}
}
stepMovement(this.move, input, MOVE, this.queries, 1 / this.state.tickRate)
stepMovement(this.move, input, MOVE, this.queries, 1 / this.state.tickRate)
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,106 @@
@@ -0,0 +1,106 @@
+import { describe, expect, it } from 'vitest'
+import { Intents } from '@openvibe/shared'
+import {
+ KeyboardInputSource,
+ type InputSample,
+ type InputSource,
+ type KeyboardState,
+} from './inputSource.js'
+
+/** A fake InputTracker key set (the real one needs a DOM). */
+class FakeKeyboard implements KeyboardState {
+ readonly keys = new Set<string>()
+ yaw = 0
+ pitch = 0
+ keyDown(code: string): boolean {
+ return this.keys.has(code)
+ }
+}
+
+/** A synthetic touch pad: M2 ships the real one; this models the same stream. */
+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
+ moveX: number
+ moveZ: number
+ yaw: number
+ pitch: number
+}
+
+const script: Gesture[] = [
+ { intents: 0, moveX: 0, moveZ: 0, yaw: 0, pitch: 0 },
+ { intents: Intents.Jump, moveX: 0, moveZ: 1, yaw: 0.3, pitch: -0.2 },
+ { intents: Intents.Jump | Intents.Sprint, moveX: 0.2, moveZ: 1, yaw: 0.3, pitch: -0.2 },
+ { intents: Intents.Sprint, moveX: -1, moveZ: 0.4, yaw: -0.7, pitch: 0 },
+ { intents: Intents.Crouch, moveX: 0.6, moveZ: 1, yaw: 1.1, pitch: 0.4 },
+ { intents: Intents.Crouch | Intents.Prone, moveX: 0, moveZ: 0, yaw: 1.1, pitch: 0.4 },
+ { intents: Intents.Prone, moveX: 0, moveZ: -1, yaw: -3.0, pitch: -0.5 },
+ { intents: 0, moveX: 0, moveZ: 0, yaw: -3.0, pitch: 0 },
+ { intents: Intents.Jump | Intents.Crouch, moveX: -0.5, moveZ: -0.5, yaw: 3.0, pitch: 0.1 },
+]
+
+/** The desktop keys that express a gesture (mirrors KeyboardInputSource). */
+function keysFor(g: Gesture): string[] {
+ const keys: string[] = []
+ if (g.intents & Intents.Jump) keys.push('Space')
+ if (g.intents & Intents.Sprint) keys.push('ShiftLeft')
+ if (g.intents & Intents.Crouch) keys.push('KeyC')
+ if (g.intents & Intents.Prone) keys.push('KeyZ')
+ if (g.moveX > 0) keys.push('KeyD')
+ if (g.moveX < 0) keys.push('KeyA')
+ if (g.moveZ > 0) keys.push('KeyW')
+ if (g.moveZ < 0) keys.push('KeyS')
+ return keys
+}
+
+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 keyboardStream: InputSample[] = []
+ const touchStream: InputSample[] = []
+ 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
+ keyboardStream.push(keyboardSource.sample())
+ touchStream.push(touch.sample())
+ }
+
+ const expected = script.map((g) => g.intents)
+ const fromKeyboard = keyboardStream.map((s) => s.intents)
+ const fromTouch = touchStream.map((s) => s.intents)
+ expect(fromKeyboard).toEqual(fromTouch)
+ 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))
+ // The stream really does exercise several distinct intents.
+ expect(new Set(fromKeyboard).size).toBeGreaterThan(4)
+ })
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,61 @@
@@ -0,0 +1,61 @@
+import { Intents } from '@openvibe/shared'
+
+/** One simulation tick of intent input, independent of the device behind it. */
+export interface InputSample {
+ /** Strafe axis [-1,1], +X right. */
+ moveX: number
+ /** Forward axis [-1,1], +Z forward. */
+ moveZ: number
+ /** Absolute view yaw, radians. */
+ yaw: number
+ /** Absolute view pitch, radians (clamped to ±90°). */
+ pitch: number
+ /** Intent bitfield (see @openvibe/shared Intents). */
+ intents: number
+}
+
+/**
+ * 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`.
+ */
+export interface InputSource {
+ /** Latest view yaw for the render camera (updates at frame rate). */
+ readonly yaw: number
+ /** Latest view pitch for the render camera (updates at frame rate). */
+ readonly pitch: number
+ /** Sample the current tick's intent input. */
+ sample(): InputSample
+}
+
+/** The subset of InputTracker a keyboard source reads (DOM-free, so tests can fake it). */
+export interface KeyboardState {
+ keyDown(code: string): boolean
+ readonly yaw: number
+ readonly pitch: number
+}
+
+/** Keyboard + mouse: today's desktop bindings, mapped onto named intents. */
+export class KeyboardInputSource implements InputSource {
+ constructor(private readonly keys: KeyboardState) {}
+
+ get yaw(): number {
+ return this.keys.yaw
+ }
+
+ get pitch(): number {
+ return this.keys.pitch
+ }
+
+ sample(): InputSample {
+ const keys = this.keys
+ let intents = 0
+ if (keys.keyDown('Space')) intents |= Intents.Jump
+ if (keys.keyDown('ShiftLeft')) intents |= Intents.Sprint
+ if (keys.keyDown('ControlLeft') || keys.keyDown('KeyC')) intents |= Intents.Crouch
+ if (keys.keyDown('KeyZ')) intents |= Intents.Prone
+ const moveX = (keys.keyDown('KeyD') ? 1 : 0) - (keys.keyDown('KeyA') ? 1 : 0)
+ const moveZ = (keys.keyDown('KeyW') ? 1 : 0) - (keys.keyDown('KeyS') ? 1 : 0)
+ return { moveX, moveZ, yaw: keys.yaw, pitch: keys.pitch, intents }
+ }
+}
@@ -21,6 +21,7 @@ import { WATER_LEVEL } from '@openvibe/content'
@@ -21,6 +21,7 @@ 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 { 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'
@@ -171,7 +172,7 @@ async function start(): Promise<void> {
@@ -171,7 +172,7 @@ async function start(): Promise<void> {
const hud = new Hud(uiRoot, state, content, connection, icons, weaponSettings)
const hud = new Hud(uiRoot, state, content, connection, icons, weaponSettings)
const world = content.world
const world = content.world
- const player = new LocalPlayer(scene, physics, input, connection, state, {
+ const player = new LocalPlayer(scene, physics, new KeyboardInputSource(input), 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],
@@ -28,6 +28,7 @@ import {
@@ -28,6 +28,7 @@ import {
type WireSkill,
type WireSkill,
} from '@openvibe/protocol'
} from '@openvibe/protocol'
import { encodeHeights } from '@openvibe/content'
import { encodeHeights } from '@openvibe/content'
+import { quantiseAngle } from '@openvibe/shared'
const PORT = 18123
const PORT = 18123
const URL = `ws://127.0.0.1:${PORT}/ws`
const URL = `ws://127.0.0.1:${PORT}/ws`
@@ -331,8 +332,16 @@ class TestClient {
@@ -331,8 +332,16 @@ class TestClient {
})
})
}
}
- input(moveX: number, moveZ: number, yaw: number, buttons = 0, pitch = 0): void {
- this.send({ t: 'input', seq: ++this.seq, moveX, moveZ, yaw, pitch, buttons })
+ input(moveX: number, moveZ: number, yaw: number, intents = 0, pitch = 0): void {
+ this.send({
+ t: 'input',
+ seq: ++this.seq,
+ moveX,
+ moveZ,
+ yawQ: quantiseAngle(yaw),
+ pitchQ: quantiseAngle(pitch),
+ intents,
+ })
}
}
/** Sends a use and waits for its result. */
/** Sends a use and waits for its result. */
@@ -1225,6 +1234,16 @@ async function main(): Promise<void> {
@@ -1225,6 +1234,16 @@ async function main(): Promise<void> {
const generatorEnt = [...a.entities.values()].find((e) => e.def === 'scrap_generator')
const generatorEnt = [...a.entities.values()].find((e) => e.def === 'scrap_generator')
assert(sawmillEnt && generatorEnt, 'authored production yard replicated')
assert(sawmillEnt && generatorEnt, 'authored production yard replicated')
assert(a.count('wood_log') >= 2, `has logs to process (${a.count('wood_log')})`)
assert(a.count('wood_log') >= 2, `has logs to process (${a.count('wood_log')})`)
+ // The cart just driven can settle across the straight line to the sawmill: first walk down the
+ // side the rider got off on, past the cart's tail, then on to the sawmill.
+ const cart = [...a.entities.values()].find((e) => e.def === 'cart_chassis')
+ if (cart && a.me) {
+ const side = a.me.pos[0] >= cart.pos[0] ? 1 : -1
+ await walkPath(a, [
+ [cart.pos[0] + side * 3.5, a.me.pos[2]],
+ [cart.pos[0] + side * 3.5, cart.pos[2] - 4],
+ ])
+ }
await walkTo(a, sawmillEnt.pos[0] - 1.5, sawmillEnt.pos[2] - 1.5)
await walkTo(a, sawmillEnt.pos[0] - 1.5, sawmillEnt.pos[2] - 1.5)
await settle(a)
await settle(a)
// Load a log into the sawmill input.
// Load a log into the sawmill input.
@@ -1635,9 +1654,14 @@ async function main(): Promise<void> {
@@ -1635,9 +1654,14 @@ async function main(): Promise<void> {
}
}
}
}
assert(lootProp, 'thug death scattered loot')
assert(lootProp, 'thug death scattered loot')
+ // The scattered prop is dynamic and may land beyond hand reach (4 m from the eye): let it settle,
+ // then walk up to where it is now before picking it up.
await sleep(700)
await sleep(700)
+ const loot = a.entities.get(lootProp.id) ?? lootProp
+ await walkTo(a, loot.pos[0], loot.pos[2])
+ await settle(a)
const grab = await a.use(lootProp.id)
const grab = await a.use(lootProp.id)
- assert(grab.ok, 'loot recovered')
+ assert(grab.ok, `loot recovered (${grab.error ?? 'ok'})`)
await pollUntil(() => a.count('scrap_metal') > scrapBefore)
await pollUntil(() => a.count('scrap_metal') > scrapBefore)
assert(a.count('scrap_metal') > scrapBefore, 'loot in inventory')
assert(a.count('scrap_metal') > scrapBefore, 'loot in inventory')
// Walk back toward the north gate for the remaining phases.
// Walk back toward the north gate for the remaining phases.
@@ -4,10 +4,11 @@ import {
@@ -4,10 +4,11 @@ import {
hullHeightFor,
hullHeightFor,
stepMovement,
stepMovement,
type CollisionQueries,
type CollisionQueries,
+ type MoveInput,
} from '@openvibe/gameplay'
} from '@openvibe/gameplay'
import { CollisionLayer, type BodyId } from '@openvibe/physics'
import { CollisionLayer, type BodyId } from '@openvibe/physics'
import type { ClientEditMode, ClientInput } from '@openvibe/protocol'
import type { ClientEditMode, ClientInput } from '@openvibe/protocol'
-import { qfromYaw, vec3 } from '@openvibe/shared'
+import { clamp, dequantiseAngle, qfromYaw, vec3, wrapAngle } from '@openvibe/shared'
import { canEditMap } from '../../net/networkAuth.js'
import { canEditMap } from '../../net/networkAuth.js'
import type { PlayerSession } from '../playerSession.js'
import type { PlayerSession } from '../playerSession.js'
import type { ServerContext } from './context.js'
import type { ServerContext } from './context.js'
@@ -16,9 +17,30 @@ import type { HandlerMap, System } from './system.js'
@@ -16,9 +17,30 @@ import type { HandlerMap, System } from './system.js'
export const MOVE = DEFAULT_MOVEMENT
export const MOVE = DEFAULT_MOVEMENT
/** Bounded input queue: the cap stops a client from buying simulation speed with backlog. */
/** Bounded input queue: the cap stops a client from buying simulation speed with backlog. */
export const MAX_INPUT_QUEUE = 6
export const MAX_INPUT_QUEUE = 6
-
const _bodyPosScratch = vec3()
const _bodyPosScratch = vec3()
+/**
+ * Apply a client's absolute, quantised view angles (ADR-0007 decision 3). Look is the client's: the
+ * server takes the angles as sent, like client prediction does, so the two integrate identical
+ * values. Yaw stays wrapped to one turn and pitch is clamped to ±90°; the wire schema already bounds
+ * both.
+ */
+export function applyLookInput(session: PlayerSession, input: ClientInput): void {
+ session.yaw = wrapAngle(dequantiseAngle(input.yawQ))
+ session.pitch = clamp(dequantiseAngle(input.pitchQ), -Math.PI / 2, Math.PI / 2)
+}
+
+/** A client command as the movement sim's input, reading the session's applied view. */
+function toMoveInput(session: PlayerSession, input: ClientInput): MoveInput {
+ return {
+ moveX: input.moveX,
+ moveZ: input.moveZ,
+ yaw: session.yaw,
+ pitch: session.pitch,
+ buttons: input.intents,
+ }
+}
+
/**
/**
* Authoritative movement: queued input commands become positions, the kinematic capsule follows,
* Authoritative movement: queued input commands become positions, the kinematic capsule follows,
* stance swaps rebuild the hull, and falling hurts. Vehicle driving is the one exception — it
* stance swaps rebuild the hull, and falling hurts. Vehicle driving is the one exception — it
@@ -90,10 +112,9 @@ export class MovementSystem implements System {
@@ -90,10 +112,9 @@ export class MovementSystem implements System {
session.lastInput = input
session.lastInput = input
session.starvedTicks = 0
session.starvedTicks = 0
session.lastProcessedSeq = input.seq
session.lastProcessedSeq = input.seq
- session.yaw = input.yaw
- session.pitch = input.pitch
- session.buttons = input.buttons
- stepMovement(session.move, input, MOVE, this.moveQueries, dt)
+ applyLookInput(session, input)
+ session.buttons = input.intents
+ stepMovement(session.move, toMoveInput(session, input), MOVE, this.moveQueries, dt)
simulated++
simulated++
}
}
if (simulated === 0 && session.lastInput) {
if (simulated === 0 && session.lastInput) {
@@ -105,7 +126,7 @@ export class MovementSystem implements System {
@@ -105,7 +126,7 @@ export class MovementSystem implements System {
// longer than 3 ticks made the server flap stances endlessly.
// longer than 3 ticks made the server flap stances endlessly.
const input =
const input =
session.starvedTicks <= 3 ? session.lastInput : { ...session.lastInput, moveX: 0, moveZ: 0 }
session.starvedTicks <= 3 ? session.lastInput : { ...session.lastInput, moveX: 0, moveZ: 0 }
- stepMovement(session.move, input, MOVE, this.moveQueries, dt)
+ stepMovement(session.move, toMoveInput(session, input), MOVE, this.moveQueries, dt)
}
}
let bodyId = ctx.sessions.bodyOf(session.playerId)
let bodyId = ctx.sessions.bodyOf(session.playerId)
if (bodyId !== undefined && session.bodyStance !== session.move.stance) {
if (bodyId !== undefined && session.bodyStance !== session.move.stance) {
@@ -2,6 +2,7 @@ import type { GameEntity } from '@openvibe/gameplay'
@@ -2,6 +2,7 @@ import type { GameEntity } from '@openvibe/gameplay'
import { qfromYaw, qrotateVec, v3dist, vec3 } from '@openvibe/shared'
import { qfromYaw, qrotateVec, v3dist, vec3 } from '@openvibe/shared'
import type { PlayerSession } from '../playerSession.js'
import type { PlayerSession } from '../playerSession.js'
import type { ServerContext } from './context.js'
import type { ServerContext } from './context.js'
+import { applyLookInput } from './movement.js'
import type { System } from './system.js'
import type { System } from './system.js'
const _bodyPosScratch = vec3()
const _bodyPosScratch = vec3()
@@ -105,9 +106,8 @@ export class VehicleSystem implements System {
@@ -105,9 +106,8 @@ export class VehicleSystem implements System {
input = session.inputQueue.shift()!
input = session.inputQueue.shift()!
session.lastInput = input
session.lastInput = input
session.lastProcessedSeq = input.seq
session.lastProcessedSeq = input.seq
- session.yaw = input.yaw
- session.pitch = input.pitch
- session.buttons = input.buttons
+ applyLookInput(session, input)
+ session.buttons = input.intents
}
}
const nowMs = Date.now()
const nowMs = Date.now()
// Fuel: burn items from the trunk while driving.
// Fuel: burn items from the trunk while driving.
@@ -1,9 +1,13 @@
@@ -1,9 +1,13 @@
-/** Input button bitfield — mirrors @openvibe/protocol Buttons; gameplay stays protocol-free. */
-export const Buttons = {
- Jump: 1 << 0,
- Crouch: 1 << 1,
- Sprint: 1 << 2,
- Use: 1 << 3,
- Attack: 1 << 4,
- Prone: 1 << 5,
-} as const
+/**
+ * Input intent bitfield. The single definition lives in @openvibe/shared
+ * (protocol + gameplay + M2 touch); `Buttons` stays the movement layer's
+ * alias so the simulation keeps reading button bits.
+ */
+export {
+ Intents,
+ Intents as Buttons,
+ INTENTS_VERSION,
+ intentMask,
+ hasIntent,
+ type IntentName,
+} from '@openvibe/shared'
@@ -9,7 +9,7 @@ describe('protocol codec', () => {
@@ -9,7 +9,7 @@ describe('protocol codec', () => {
// hello carries no identity field: authentication happens at the
// hello carries no identity field: authentication happens at the
// WebSocket upgrade (ADR-0007 decision 8).
// WebSocket upgrade (ADR-0007 decision 8).
{ t: 'hello', v: 1, slot: 0, name: 'Tester', appearance: defaultAppearance() },
{ t: 'hello', v: 1, slot: 0, name: 'Tester', appearance: defaultAppearance() },
- { t: 'input', seq: 42, moveX: 1, moveZ: -0.5, yaw: 1.2, pitch: -0.3, buttons: 5 },
+ { t: 'input', seq: 42, moveX: 1, moveZ: -0.5, yawQ: 12345, pitchQ: -4096, intents: 5 },
{ t: 'use', target: 'abc123' },
{ t: 'use', target: 'abc123' },
{ t: 'craft', recipe: 'craft_wooden_crate' },
{ t: 'craft', recipe: 'craft_wooden_crate' },
{ t: 'drop', slot: 3, count: 5 },
{ t: 'drop', slot: 3, count: 5 },
@@ -31,17 +31,31 @@ describe('protocol codec', () => {
@@ -31,17 +31,31 @@ describe('protocol codec', () => {
// NaN/Infinity smuggling
// NaN/Infinity smuggling
expect(
expect(
decodeClientMessage(
decodeClientMessage(
- '{"t":"input","seq":1,"moveX":1e999,"moveZ":0,"yaw":0,"pitch":0,"buttons":0}',
+ '{"t":"input","seq":1,"moveX":1e999,"moveZ":0,"yawQ":0,"pitchQ":0,"intents":0}',
),
),
).toBeNull()
).toBeNull()
// out-of-range movement axes (speedhack attempt)
// out-of-range movement axes (speedhack attempt)
expect(
expect(
decodeClientMessage(
decodeClientMessage(
- '{"t":"input","seq":1,"moveX":5,"moveZ":0,"yaw":0,"pitch":0,"buttons":0}',
+ '{"t":"input","seq":1,"moveX":5,"moveZ":0,"yawQ":0,"pitchQ":0,"intents":0}',
),
),
).toBeNull()
).toBeNull()
})
})
+ it('refuses unknown intent bits and out-of-range quantised angles', () => {
+ const decodeInput = (fields: string) =>
+ decodeClientMessage(`{"t":"input","seq":1,"moveX":0,"moveZ":0,${fields}}`)
+ const known = '"yawQ":0,"pitchQ":0,"intents":0'
+ // A bit outside intentMask is refused (64 = the first bit past the list).
+ expect(decodeInput('"yawQ":0,"pitchQ":0,"intents":64')).toBeNull()
+ // Angles outside the int16 / ±90° range are refused.
+ expect(decodeInput('"yawQ":32768,"pitchQ":0,"intents":0')).toBeNull()
+ expect(decodeInput('"yawQ":0,"pitchQ":16385,"intents":0')).toBeNull()
+ // A full known mask and the extreme in-range values are fine.
+ expect(decodeInput('"yawQ":-32768,"pitchQ":16384,"intents":63')).not.toBeNull()
+ expect(decodeInput(known)).not.toBeNull()
+ })
+
it('ignores an identity field in hello (the upgrade is the only authn gate)', () => {
it('ignores an identity field in hello (the upgrade is the only authn gate)', () => {
const decoded = decodeClientMessage(
const decoded = decodeClientMessage(
JSON.stringify({
JSON.stringify({
@@ -1,4 +1,5 @@
@@ -1,4 +1,5 @@
import { z } from 'zod'
import { z } from 'zod'
+import { ANGLE_Q_MAX, ANGLE_Q_MIN, PITCH_Q_MAX, intentMask } from '@openvibe/shared'
import { AppearanceSchema } from '../appearance.js'
import { AppearanceSchema } from '../appearance.js'
/**
/**
@@ -33,9 +34,20 @@ export const ClientInputSchema = z.object({
@@ -33,9 +34,20 @@ export const ClientInputSchema = z.object({
seq: z.number().int().nonnegative(),
seq: z.number().int().nonnegative(),
moveX: z.number().min(-1).max(1),
moveX: z.number().min(-1).max(1),
moveZ: z.number().min(-1).max(1),
moveZ: z.number().min(-1).max(1),
- yaw: z.number().finite(),
- pitch: z.number().finite(),
- buttons: z.number().int().nonnegative(),
+ /** Absolute view yaw, quantised to int16 (1/65536 of a turn). */
+ yawQ: z.number().int().min(ANGLE_Q_MIN).max(ANGLE_Q_MAX),
+ /** Absolute view pitch, quantised to int16; ±90° is the clamp. */
+ pitchQ: z.number().int().min(-PITCH_Q_MAX).max(PITCH_Q_MAX),
+ /**
+ * Named action bits (see @openvibe/shared Intents). A bit outside
+ * intentMask is refused — the client may only express known actions.
+ */
+ intents: z
+ .number()
+ .int()
+ .min(0)
+ .max(0x7fffffff)
+ .refine((n) => (n & ~intentMask) === 0, { message: 'unknown intent bit' }),
})
})
/** Interact with a world entity (gather a resource, open a container...). */
/** Interact with a world entity (gather a resource, open a container...). */
@@ -1,2 +1,2 @@
@@ -1,2 +1,2 @@
/** Wire protocol version — bump on ANY breaking message change. */
/** Wire protocol version — bump on ANY breaking message change. */
-export const PROTOCOL_VERSION = 19
+export const PROTOCOL_VERSION = 20
@@ -16,16 +16,6 @@ export type WireVec3 = [number, number, number]
@@ -16,16 +16,6 @@ export type WireVec3 = [number, number, number]
/** [x, y, z, w] unit quaternion. */
/** [x, y, z, w] unit quaternion. */
export type WireQuat = [number, number, number, number]
export type WireQuat = [number, number, number, number]
-/** Input button bitfield. */
-export const Buttons = {
- Jump: 1 << 0,
- Crouch: 1 << 1,
- Sprint: 1 << 2,
- Use: 1 << 3,
- Attack: 1 << 4,
- Prone: 1 << 5,
-} as const
-
/** How a wire entity should be represented client-side. */
/** How a wire entity should be represented client-side. */
export type WireEntityKind = 'player' | 'prop' | 'resource' | 'npc'
export type WireEntityKind = 'player' | 'prop' | 'resource' | 'npc'
@@ -1,4 +1,5 @@
@@ -1,4 +1,5 @@
export * from './ids.js'
export * from './ids.js'
+export * from './intents.js'
export * from './math/vec3.js'
export * from './math/vec3.js'
export * from './math/quat.js'
export * from './math/quat.js'
export * from './math/scalar.js'
export * from './math/scalar.js'
new file mode 100644
new file mode 100644
@@ -0,0 +1,30 @@
@@ -0,0 +1,30 @@
+/**
+ * Intent bitfield: the named actions an input command expresses, as fixed
+ * bits. This is the single definition — the protocol, the movement layer and
+ * (M2) touch controls all map onto these names, so a key press and a touch
+ * button produce the same intent.
+ *
+ * Adding a flag takes the next free bit and bumps INTENTS_VERSION (and so the
+ * wire protocol version, because the bits are on the wire).
+ */
+export const Intents = Object.freeze({
+ Jump: 1 << 0,
+ Crouch: 1 << 1,
+ Sprint: 1 << 2,
+ Use: 1 << 3,
+ Attack: 1 << 4,
+ Prone: 1 << 5,
+} as const)
+
+export type IntentName = keyof typeof Intents
+
+/** Bump when a flag is added or a bit changes meaning. */
+export const INTENTS_VERSION = 1
+
+/** Every known bit; an inbound command may carry no bit outside this mask. */
+export const intentMask = Object.values(Intents).reduce((mask, bit) => mask | bit, 0)
+
+/** Is the named intent set in a bitfield? */
+export function hasIntent(bits: number, name: IntentName): boolean {
+ return (bits & Intents[name]) !== 0
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,47 @@
@@ -0,0 +1,47 @@
+import { describe, expect, it } from 'vitest'
+import {
+ ANGLE_Q_MAX,
+ ANGLE_Q_MIN,
+ ANGLE_STEP,
+ PITCH_Q_MAX,
+ dequantiseAngle,
+ quantiseAngle,
+ wrapAngle,
+} from './scalar.js'
+
+describe('angle quantisation', () => {
+ it('round-trips within half a step', () => {
+ const angles = [
+ 0,
+ 0.1,
+ -0.1,
+ Math.PI / 2,
+ -Math.PI / 2,
+ 3.0,
+ -3.0,
+ Math.PI,
+ -Math.PI + 1e-6,
+ 100,
+ -100,
+ ]
+ for (const a of angles) {
+ const q = quantiseAngle(a)
+ expect(Number.isInteger(q)).toBe(true)
+ expect(q).toBeGreaterThanOrEqual(ANGLE_Q_MIN)
+ expect(q).toBeLessThanOrEqual(ANGLE_Q_MAX)
+ // Measured on the circle: the extremes -PI and PI-step are adjacent.
+ const error = Math.abs(wrapAngle(dequantiseAngle(q) - a))
+ expect(error).toBeLessThanOrEqual(ANGLE_STEP / 2 + 1e-12)
+ }
+ })
+
+ it('keeps every angle inside the int16 range and pitch inside a quarter turn', () => {
+ for (const a of [Math.PI, -Math.PI, 1e6, -1e6, 0.5]) {
+ const q = quantiseAngle(a)
+ expect(q).toBeGreaterThanOrEqual(ANGLE_Q_MIN)
+ expect(q).toBeLessThanOrEqual(ANGLE_Q_MAX)
+ }
+ expect(quantiseAngle(Math.PI / 2)).toBe(PITCH_Q_MAX)
+ expect(quantiseAngle(-Math.PI / 2)).toBe(-PITCH_Q_MAX)
+ })
+})
@@ -16,3 +16,29 @@ export function wrapAngle(a: number): number {
@@ -16,3 +16,29 @@ export function wrapAngle(a: number): number {
if (a <= -Math.PI) a += Math.PI * 2
if (a <= -Math.PI) a += Math.PI * 2
return a
return a
}
}
+
+/**
+ * Absolute-angle quantisation for the wire: a signed 16-bit step per
+ * 1/65536 of a turn. Both sides of the protocol share these so the client's
+ * prediction and the server's simulation integrate the identical angles.
+ */
+export const ANGLE_STEPS_PER_TURN = 65536
+export const ANGLE_STEP = (Math.PI * 2) / ANGLE_STEPS_PER_TURN
+export const ANGLE_Q_MIN = -32768
+export const ANGLE_Q_MAX = 32767
+/** ±90° (a quarter turn) in quantised units — the pitch clamp. */
+export const PITCH_Q_MAX = ANGLE_STEPS_PER_TURN / 4
+
+/** Absolute angle -> nearest signed 16-bit step (wrapped to one turn). */
+export function quantiseAngle(angle: number): number {
+ const q = Math.round(wrapAngle(angle) / ANGLE_STEP)
+ // Wrap the step into the int16 range: -PI and PI-step are the same circle,
+ // so the nearest step to PI is -32768, not an out-of-range 32768.
+ const half = ANGLE_STEPS_PER_TURN / 2
+ return ((q + half) % ANGLE_STEPS_PER_TURN) - half
+}
+
+/** Signed 16-bit step -> absolute angle; error is at most half a step. */
+export function dequantiseAngle(q: number): number {
+ return q * ANGLE_STEP
+}