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).

OP OpenVibers committed 29 Sept 2026 · 2abbb15935 · 16 files, +393 −58


Changes

ddiff --git a/apps/client/src/game/localPlayer.ts b/apps/client/src/game/localPlayer.ts
iindex e43c387..c976b81 100644
---- a/apps/client/src/game/localPlayer.ts
++++ b/apps/client/src/game/localPlayer.ts
@@@ -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)
}
ddiff --git a/apps/client/src/input/inputSource.test.ts b/apps/client/src/input/inputSource.test.ts
nnew file mode 100644
iindex 0000000..8135d0d
---- /dev/null
++++ b/apps/client/src/input/inputSource.test.ts
@@@ -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)
++ })
++})
ddiff --git a/apps/client/src/input/inputSource.ts b/apps/client/src/input/inputSource.ts
nnew file mode 100644
iindex 0000000..7658bfc
---- /dev/null
++++ b/apps/client/src/input/inputSource.ts
@@@ -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 }
++ }
++}
ddiff --git a/apps/client/src/main.ts b/apps/client/src/main.ts
iindex 2c97b98..29c7b22 100644
---- a/apps/client/src/main.ts
++++ b/apps/client/src/main.ts
@@@ -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],
ddiff --git a/apps/server/scripts/sliceTest.ts b/apps/server/scripts/sliceTest.ts
iindex 8578cc8..587ee58 100644
---- a/apps/server/scripts/sliceTest.ts
++++ b/apps/server/scripts/sliceTest.ts
@@@ -28,6 +28,7 @@ import {
type WireSkill,
} from '@openvibe/protocol'
import { encodeHeights } from '@openvibe/content'
++import { quantiseAngle } from '@openvibe/shared'
const PORT = 18123
const 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.
ddiff --git a/apps/server/src/game/systems/movement.ts b/apps/server/src/game/systems/movement.ts
iindex 65907f3..4b91585 100644
---- a/apps/server/src/game/systems/movement.ts
++++ b/apps/server/src/game/systems/movement.ts
@@@ -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 = input
session.starvedTicks = 0
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++
}
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) {
ddiff --git a/apps/server/src/game/systems/vehicles.ts b/apps/server/src/game/systems/vehicles.ts
iindex 9bbc279..ba458cf 100644
---- a/apps/server/src/game/systems/vehicles.ts
++++ b/apps/server/src/game/systems/vehicles.ts
@@@ -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 = input
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()
// Fuel: burn items from the trunk while driving.
ddiff --git a/packages/gameplay/src/movement/buttons.ts b/packages/gameplay/src/movement/buttons.ts
iindex 42418ed..e25b251 100644
---- a/packages/gameplay/src/movement/buttons.ts
++++ b/packages/gameplay/src/movement/buttons.ts
@@@ -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'
ddiff --git a/packages/protocol/src/codec.test.ts b/packages/protocol/src/codec.test.ts
iindex eb3ef81..51ec420 100644
---- a/packages/protocol/src/codec.test.ts
++++ b/packages/protocol/src/codec.test.ts
@@@ -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 smuggling
expect(
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({
ddiff --git a/packages/protocol/src/messages/client.ts b/packages/protocol/src/messages/client.ts
iindex 1f77b31..8949b89 100644
---- a/packages/protocol/src/messages/client.ts
++++ b/packages/protocol/src/messages/client.ts
@@@ -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...). */
ddiff --git a/packages/protocol/src/version.ts b/packages/protocol/src/version.ts
iindex 56aa3f4..02c273c 100644
---- a/packages/protocol/src/version.ts
++++ b/packages/protocol/src/version.ts
@@@ -1,2 +1,2 @@
/** Wire protocol version — bump on ANY breaking message change. */
--export const PROTOCOL_VERSION = 19
++export const PROTOCOL_VERSION = 20
ddiff --git a/packages/protocol/src/wire.ts b/packages/protocol/src/wire.ts
iindex 080ba54..d5fd233 100644
---- a/packages/protocol/src/wire.ts
++++ b/packages/protocol/src/wire.ts
@@@ -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'
ddiff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
iindex 5a11c22..be61133 100644
---- a/packages/shared/src/index.ts
++++ b/packages/shared/src/index.ts
@@@ -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'
ddiff --git a/packages/shared/src/intents.ts b/packages/shared/src/intents.ts
nnew file mode 100644
iindex 0000000..3e83d10
---- /dev/null
++++ b/packages/shared/src/intents.ts
@@@ -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
++}
ddiff --git a/packages/shared/src/math/scalar.test.ts b/packages/shared/src/math/scalar.test.ts
nnew file mode 100644
iindex 0000000..a77b36f
---- /dev/null
++++ b/packages/shared/src/math/scalar.test.ts
@@@ -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)
++ })
++})
ddiff --git a/packages/shared/src/math/scalar.ts b/packages/shared/src/math/scalar.ts
iindex 3a728d9..8b28ac6 100644
---- a/packages/shared/src/math/scalar.ts
++++ b/packages/shared/src/math/scalar.ts
@@@ -16,3 +16,29 @@ export function wrapAngle(a: number): number {
if (a <= -Math.PI) a += Math.PI * 2
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
++}