diff --git a/.changeset/consume-once.md b/.changeset/consume-once.md index 0f4ce510..648a221f 100644 --- a/.changeset/consume-once.md +++ b/.changeset/consume-once.md @@ -6,6 +6,6 @@ **Draining a service is declared, checked at compile time, and observed at run time.** - **`System.Consumes<"snapshot">`** marks the methods of a service type that drain it. A system that declares the service with `Game.System.service` sees those methods as a compile error naming the fix (`snapshot() drains Game/Controls: declare it with Game.System.consumeService, or read the resource its capture writes`); `Game.System.consumeService` calls them. Its other methods stay available through `service` (a settings screen can still `rebind`). -- Every input source is marked: `Keyboard.Actions`, `Keyboard.Presses`, `Gamepad.Actions`, `Touch.Touch`, `Pointer.Actions`, `Controls.Device`, `Controls.Controls`, and `InputCapture.Source`. **Breaking:** a system that called `snapshot()` through `Game.System.service` no longer compiles; read the resource `InputCapture.system` writes instead. +- Every input source is marked: `Keyboard.Actions`, `Keyboard.Presses`, `Gamepad.Actions`, `Touch.Actions`, `Pointer.Actions`, `Controls.Device`, `Controls.Controls`, and `InputCapture.Source`. **Breaking:** a system that called `snapshot()` through `Game.System.service` no longer compiles; read the resource `InputCapture.system` writes instead. - `Game.System.service` returns `ServiceRead` and `consumeService` returns `ServiceRead`. - The `service-consumed-twice` lint also reports what a debug runtime saw: a consumed service whose consumers ran at more than one placement in the session, in unnamed schedules too (`Game/Drain (schedule#2, step 0, unnamed)`). Named schedules are reported once, by the static lint. diff --git a/.changeset/pointer-wheel-lock.md b/.changeset/pointer-wheel-lock.md new file mode 100644 index 00000000..af33e54e --- /dev/null +++ b/.changeset/pointer-wheel-lock.md @@ -0,0 +1,10 @@ +--- +"@typeonce/ritmo-browser": minor +--- + +**`Pointer` reads the wheel and the pointer lock, and `Touch.track` is `Touch.actions`, so every device is built the same way.** + +- **The wheel**, read two ways for two jobs. `wheel` is how far it scrolled since the previous snapshot, in CSS pixels (lines and pages are converted), for zoom. The `wheelUp`, `wheelDown`, `wheelLeft`, and `wheelRight` buttons pulse (pressed and released in one window) once per `Pointer.wheelStep` (100 px, one notch), so a stepping action binds them (`nextWeapon: ["wheelDown"]`) and merges with a gamepad bumper in `Controls`. The page does not scroll over the element unless `preventScroll: false`. `PointerTarget` gains the `wheel` listener. +- **Mouse look:** `movement` is the raw motion since the previous snapshot (`movementX`/`movementY`), locked or not. With `lock: document`, a click on the element locks the pointer (the browser grants a lock only inside a click, so the module asks in its own listener); that click is not reported as a press. `locked` turns `false` when the player leaves with Escape, which releases what was held. `used` also counts motion and scrolling. +- Timelines record and replay `wheel`, `movement`, and `locked`. Their `press`/`release` take `Pointer.PhysicalButton`s; wheel pulses come from `wheel`. +- **Breaking: `Touch.track(target, blurHost, layout, options?)` is `Touch.actions(...)`, the `Touch.Touch` type is `Touch.Actions`, and `Touch.TrackOptions` is `Touch.ActionsOptions`,** matching `Keyboard.actions`, `Gamepad.actions`, and `Pointer.actions`. Arguments and behavior are unchanged. diff --git a/packages/browser/dtslint/Controls.tst.ts b/packages/browser/dtslint/Controls.tst.ts index 6500af9c..db39f70d 100644 --- a/packages/browser/dtslint/Controls.tst.ts +++ b/packages/browser/dtslint/Controls.tst.ts @@ -3,7 +3,7 @@ import { Controls, Gamepad, InputCapture, Keyboard, Pointer, Touch } from "@type import { describe, expect, it } from "tstyche" declare const host: Keyboard.KeyboardHost & Gamepad.GamepadHost -declare const touch: Touch.Touch<"jump"> +declare const touch: Touch.Actions<"jump"> declare const canvas: Pointer.PointerTarget const keyBindings = { jump: [" "], pause: ["Escape"] } as const @@ -58,7 +58,7 @@ describe("Controls", () => { }) it("requires lacking to name exactly the missing actions", () => { - expect(Actions.lacking(touch, ["pause"])).type.toBe, "pause">>() + expect(Actions.lacking(touch, ["pause"])).type.toBe, "pause">>() // @ts-expect-error This device also lacks pause: bind it, or list it here Actions.lacking(touch, []) // @ts-expect-error Type '"paus"' is not assignable to type '"pause"' diff --git a/packages/browser/dtslint/Pointer.tst.ts b/packages/browser/dtslint/Pointer.tst.ts index 04d7a0b7..3766f461 100644 --- a/packages/browser/dtslint/Pointer.tst.ts +++ b/packages/browser/dtslint/Pointer.tst.ts @@ -18,7 +18,7 @@ describe("Pointer", () => { expect(input.used).type.toBe() // @ts-expect-error Property 'fier' does not exist input.actions.fier - expect(Pointer.buttonNames).type.toBe() + expect(Pointer.buttonNames).type.toBe() }) it("binds only pointer buttons, at least one per action", () => { @@ -60,4 +60,24 @@ describe("Pointer", () => { services.mouse.snapshot() }) }) + + it("reports the wheel, raw motion, and the lock, and binds wheel pulses", () => { + const mouse = Pointer.actions(canvas, blurHost, { next: ["wheelDown"], previous: ["wheelUp"] }) + const input = mouse.snapshot() + expect(input.wheel).type.toBe() + expect(input.movement).type.toBe() + expect(input.locked).type.toBe() + expect(input.buttons.wheelLeft).type.toBe() + }) + + it("presses only physical buttons in a timeline", () => { + expect(Pointer.scripted(bindings, [{ frame: 0, wheel: { x: 0, y: 100 }, press: ["primary"] }])).type.toBe>() + // @ts-expect-error Type '"wheelUp"' is not assignable + Pointer.scripted(bindings, [{ frame: 0, press: ["wheelUp"] }]) + }) + + it("takes a DOM element and the document as hosts", () => { + const element = {} as HTMLCanvasElement + expect(Pointer.actions(element, window, bindings, { lock: document })).type.toBe>() + }) }) diff --git a/packages/browser/dtslint/Touch.tst.ts b/packages/browser/dtslint/Touch.tst.ts index e8082a60..96752a29 100644 --- a/packages/browser/dtslint/Touch.tst.ts +++ b/packages/browser/dtslint/Touch.tst.ts @@ -4,7 +4,7 @@ import { describe, expect, it } from "tstyche" declare const target: Touch.TouchTarget declare const blurHost: Touch.BlurHost -const touch = Touch.track(target, blurHost, ({ width, height }) => ({ +const touch = Touch.actions(target, blurHost, ({ width, height }) => ({ stick: { region: { left: 0, top: 0, width: width / 2, height }, radius: 60 }, buttons: { attack: { center: { x: width - 90, y: height - 90 }, radius: 55, aimRadius: 80 }, @@ -14,7 +14,7 @@ const touch = Touch.track(target, blurHost, ({ width, height }) => ({ describe("Touch", () => { it("infers the button names from the layout", () => { - expect(touch).type.toBe>() + expect(touch).type.toBe>() expect(touch.snapshot().actions.attack).type.toBe() expect(touch.snapshot().stick.vector).type.toBe() // @ts-expect-error Property 'jump' does not exist on type diff --git a/packages/browser/src/Controls.ts b/packages/browser/src/Controls.ts index 6e816a00..ae073d84 100644 --- a/packages/browser/src/Controls.ts +++ b/packages/browser/src/Controls.ts @@ -4,7 +4,7 @@ * * Declare the game's actions once with `Controls.actions([...])`, bind them * on every device (`Keyboard.actions`, `Gamepad.actions`, `Pointer.actions`, - * `Touch.track`), and merge the devices with the declaration's `merge`. + * `Touch.actions`), and merge the devices with the declaration's `merge`. * Merged devices * behave like one device with all their bindings: an action is `held` while * any device holds it, `pressed` when any device pressed it, and `released` @@ -77,7 +77,7 @@ export interface DeviceSnapshot { readonly used: boolean } -/** A device `Controls` can merge: `Keyboard.actions`, `Gamepad.actions`, `Pointer.actions`, `Touch.track`, or their scripted forms. */ +/** A device `Controls` can merge: `Keyboard.actions`, `Gamepad.actions`, `Pointer.actions`, `Touch.actions`, or their scripted forms. */ export interface Device extends System.Consumes<"snapshot"> { snapshot(): DeviceSnapshot dispose(): void diff --git a/packages/browser/src/Pointer.ts b/packages/browser/src/Pointer.ts index e655d9ae..6477ead9 100644 --- a/packages/browser/src/Pointer.ts +++ b/packages/browser/src/Pointer.ts @@ -19,19 +19,30 @@ * code. `rebind(...)` swaps the buttons of the same actions, and * `parseRebinding(...)` validates saved ones. * + * The wheel reports both ways games use it: `wheel` is how far it scrolled + * (zoom), and the `wheelUp`/`wheelDown`/`wheelLeft`/`wheelRight` buttons + * pulse once per notch, so a stepping action (next weapon) binds them and + * merges with a gamepad bumper. For mouse look, pass `lock: document`: a + * click locks the pointer to the element, `movement` keeps reporting raw + * motion, and `locked` turns `false` when the player presses Escape. + * * @module Pointer * @docGroup browser * * @example * ```ts - * const bindings = { fire: ["primary"], aim: ["secondary"] } as const - * const mouse = Pointer.actions(canvas, window, bindings) + * const bindings = { fire: ["primary"], aim: ["secondary"], nextWeapon: ["wheelDown"] } as const + * const mouse = Pointer.actions(canvas, window, bindings, { lock: document }) * * // Once per update (through InputCapture in a game): * const input = mouse.snapshot() * aimAt(input.position) * if (input.actions.fire.pressed) fire() * + * // Zoom with the wheel, look with a locked pointer: + * zoomBy(input.wheel.y) + * if (input.locked) turn(input.movement.x) + * * // Settings screen: bind the button the player clicks. * const clicked = Pointer.buttonNames.find((button) => input.buttons[button].pressed) * if (clicked !== undefined) mouse.rebind({ fire: [clicked], aim: ["secondary"] }) @@ -55,17 +66,32 @@ import { anyUsed } from "./internal/used.ts" export type { ActionState } +/** The physical buttons, in `PointerEvent.button` order 0 to 4. */ +const physicalButtons = ["primary", "middle", "secondary", "back", "forward"] as const + +/** A button that goes down and up, unlike the wheel's pulses: what a timeline presses and releases. */ +export type PhysicalButton = (typeof physicalButtons)[number] + +const isPhysical = (name: unknown): name is PhysicalButton => (physicalButtons as ReadonlyArray).includes(name) + /** - * The buttons a snapshot reports, in `PointerEvent.button` order 0 to 4: - * the main (usually left) button, the wheel button, the secondary (usually - * right) button, and the side buttons. + * The buttons a snapshot reports: the main (usually left) button, the wheel + * button, the secondary (usually right) button, the side buttons, then the + * wheel's four directions. A wheel direction is a pulse, pressed and + * released in the same window, once per {@link wheelStep} pixels scrolled, + * so it binds actions that step (next weapon) and merges with a gamepad + * bumper in `Controls`; read the snapshot's `wheel` for how far it scrolled + * (zoom). */ -export const buttonNames = ["primary", "middle", "secondary", "back", "forward"] as const +export const buttonNames = [...physicalButtons, "wheelUp", "wheelDown", "wheelLeft", "wheelRight"] as const /** One pointer button, see {@link buttonNames}. */ export type Button = (typeof buttonNames)[number] -const buttonOf = (code: number): Button | undefined => buttonNames[code] +/** Pixels scrolled per wheel pulse: one notch of a typical mouse wheel. */ +export const wheelStep = 100 + +const buttonOf = (code: number): Button | undefined => physicalButtons[code] const buttonSet: ReadonlySet = new Set(buttonNames) @@ -98,6 +124,12 @@ export interface Position { readonly y: number } +/** A change in CSS pixels since the previous snapshot. */ +export interface Delta { + readonly x: number + readonly y: number +} + export interface Snapshot { /** Every bound action's state. */ readonly actions: { readonly [Action in keyof B]: ActionState } @@ -105,11 +137,25 @@ export interface Snapshot { readonly buttons: { readonly [Name in Button]: ActionState } /** The last position the pointer was seen at, or where it was last left. */ readonly position: Position + /** + * How far the pointer moved since the previous snapshot, from the raw + * motion (`movementX`/`movementY`), which keeps coming while the pointer is + * locked and the position stands still: read it for mouse look. + */ + readonly movement: Delta + /** How far the wheel scrolled since the previous snapshot, in CSS pixels (positive `y` scrolls down). */ + readonly wheel: Delta /** Whether the pointer is currently over the element (or captured by it). */ readonly over: boolean + /** + * Whether the pointer is locked to the element (with the `lock` option). + * The player leaves the lock with Escape; show "click to resume" while it + * is `false`. + */ + readonly locked: boolean /** * Some button was pressed in this window or is held, or the pointer moved - * over the element: the player is using the pointer. + * or scrolled over the element: the player is using the pointer. */ readonly used: boolean } @@ -136,6 +182,9 @@ export interface Actions extends System.Consumes<"snapshot"> export interface PointerEventLike { readonly clientX: number readonly clientY: number + /** Raw motion since the previous event; events without it count as 0. */ + readonly movementX?: number + readonly movementY?: number readonly button: number readonly pointerId: number /** `"mouse"`, `"pen"`, or `"touch"`; events without it count as mouse. */ @@ -157,9 +206,32 @@ export interface PointerTarget { listener: (event: PointerEventLike) => void ): void removeEventListener(type: "contextmenu", listener: (event: { preventDefault(): void }) => void): void + addEventListener(type: "wheel", listener: (event: WheelEventLike) => void, options: { readonly passive: false }): void + removeEventListener(type: "wheel", listener: (event: WheelEventLike) => void): void getBoundingClientRect(): { readonly left: number; readonly top: number } setPointerCapture?(pointerId: number): void releasePointerCapture?(pointerId: number): void + /** Called on a click with the `lock` option while the pointer is not locked. */ + requestPointerLock?(): unknown +} + +/** The subset of `WheelEvent` the module reads. */ +export interface WheelEventLike { + readonly deltaX: number + readonly deltaY: number + /** 0 for pixels, 1 for lines (16 px each), 2 for pages (800 px each). */ + readonly deltaMode: number + preventDefault(): void +} + +/** + * Where the pointer lock is observed, usually `document`, for the `lock` + * option. + */ +export interface LockHost { + readonly pointerLockElement: unknown + addEventListener(type: "pointerlockchange", listener: () => void): void + removeEventListener(type: "pointerlockchange", listener: () => void): void } /** @@ -183,6 +255,19 @@ export interface ActionsOptions { * also a click. */ readonly pointerTypes?: ReadonlyArray + /** + * Keep the page from scrolling while the wheel turns over the element. + * Defaults to `true`. + */ + readonly preventScroll?: boolean + /** + * Lock the pointer to the element on a click, for mouse look: pass where + * the lock is observed (usually `document`). Browsers grant a lock only + * inside a click, so the module requests it in its own listener; the + * click that locks is not reported as a press. Read `locked` and + * `movement`. + */ + readonly lock?: LockHost } /** @@ -193,15 +278,40 @@ export const idle = (bindings: B, position: Position = actions: idleActions(bindings), buttons: idleButtons(buttonNames), position: { x: position.x, y: position.y }, + movement: { x: 0, y: 0 }, + wheel: { x: 0, y: 0 }, over: false, + locked: false, used: false }) -/** The button tracker with the position, shared by live and scripted pointers. */ +const pixelsPer = [1, 16, 800] + +/** + * The button tracker with the position, motion, and wheel, shared by live + * and scripted pointers. + */ const tracker = (initial: B, start: Position) => { const buttons = buttonTracker(buttonNames, initial) let position = start let moved = false + let movement = { x: 0, y: 0 } + let wheel = { x: 0, y: 0 } + // Scrolled toward the next pulse, per axis; reset when the direction turns. + const pending = { x: 0, y: 0 } + const pulse = (button: Button): void => { + buttons.down(button) + buttons.up(button) + } + const step = (axis: "x" | "y", delta: number, negative: Button, positive: Button): void => { + if (delta === 0) return + if (Math.sign(pending[axis]) !== Math.sign(delta)) pending[axis] = 0 + pending[axis] += delta + while (Math.abs(pending[axis]) >= wheelStep) { + pulse(pending[axis] < 0 ? negative : positive) + pending[axis] -= Math.sign(pending[axis]) * wheelStep + } + } return { buttons, move(next: Position) { @@ -209,11 +319,22 @@ const tracker = (initial: B, start: Position) => { position = next moved = true }, - snapshot(over: boolean): Snapshot { + motion(delta: Delta) { + movement = { x: movement.x + delta.x, y: movement.y + delta.y } + }, + scroll(delta: Delta) { + wheel = { x: wheel.x + delta.x, y: wheel.y + delta.y } + step("y", delta.y, "wheelUp", "wheelDown") + step("x", delta.x, "wheelLeft", "wheelRight") + }, + snapshot(over: boolean, locked: boolean): Snapshot { const taken = buttons.take() - const used = moved || anyUsed(taken.buttons) + const used = moved || movement.x !== 0 || movement.y !== 0 || wheel.x !== 0 || wheel.y !== 0 || anyUsed(taken.buttons) + const snapshot = { ...taken, position: { x: position.x, y: position.y }, movement, wheel, over, locked, used } moved = false - return { ...taken, position: { x: position.x, y: position.y }, over, used } + movement = { x: 0, y: 0 } + wheel = { x: 0, y: 0 } + return snapshot } } } @@ -229,14 +350,18 @@ export const actions = ( options: ActionsOptions = {} ): Actions => { const preventContextMenu = options.preventContextMenu ?? true + const preventScroll = options.preventScroll ?? true + const lock = options.lock const pointerTypes = options.pointerTypes ?? ["mouse", "pen"] const tracked = (event: PointerEventLike): boolean => pointerTypes.includes(event.pointerType ?? "mouse") const state = tracker(bindings, { x: 0, y: 0 }) let over = false + const isLocked = (): boolean => lock !== undefined && lock.pointerLockElement === target const locate = (event: PointerEventLike): void => { const rect = target.getBoundingClientRect() state.move({ x: event.clientX - rect.left, y: event.clientY - rect.top }) + state.motion({ x: event.movementX ?? 0, y: event.movementY ?? 0 }) } const onDown = (event: PointerEventLike): void => { @@ -246,6 +371,11 @@ export const actions = ( const button = buttonOf(event.button) if (button === undefined) return event.preventDefault() + if (lock !== undefined && !isLocked()) { + // The click that locks is not a press: "click to resume" does not also fire. + target.requestPointerLock?.() + return + } target.setPointerCapture?.(event.pointerId) state.buttons.down(button) } @@ -277,6 +407,17 @@ export const actions = ( if (preventContextMenu) event.preventDefault() } + const onWheel = (event: WheelEventLike): void => { + if (preventScroll) event.preventDefault() + const scale = pixelsPer[event.deltaMode] ?? 1 + state.scroll({ x: event.deltaX * scale, y: event.deltaY * scale }) + } + + // Leaving the lock (Escape) releases what was held, like a blur. + const onLockChange = (): void => { + if (!isLocked()) state.buttons.upAll() + } + target.addEventListener("pointerdown", onDown) target.addEventListener("pointerup", onUp) target.addEventListener("pointermove", onMove) @@ -284,10 +425,15 @@ export const actions = ( target.addEventListener("pointerleave", onLeave) target.addEventListener("pointercancel", onCancel) target.addEventListener("contextmenu", onContextMenu) + target.addEventListener("wheel", onWheel, { passive: false }) blurHost.addEventListener("blur", state.buttons.upAll) + lock?.addEventListener("pointerlockchange", onLockChange) return { - snapshot: () => state.snapshot(over), + snapshot: () => { + const locked = isLocked() + return state.snapshot(over || locked, locked) + }, rebind: state.buttons.rebind, dispose() { target.removeEventListener("pointerdown", onDown) @@ -297,22 +443,29 @@ export const actions = ( target.removeEventListener("pointerleave", onLeave) target.removeEventListener("pointercancel", onCancel) target.removeEventListener("contextmenu", onContextMenu) + target.removeEventListener("wheel", onWheel) blurHost.removeEventListener("blur", state.buttons.upAll) + lock?.removeEventListener("pointerlockchange", onLockChange) } } } /** - * Pointer changes applied just before one snapshot: the move first, then - * presses, then releases. `frame` is the snapshot's index, counting from 0 at - * the first `snapshot()` call. A button both pressed and released in one frame - * is a click shorter than a frame. + * Pointer changes applied just before one snapshot: the move, the raw + * motion, and the wheel first, then presses, then releases. `frame` is the + * snapshot's index, counting from 0 at the first `snapshot()` call. A button + * both pressed and released in one frame is a click shorter than a frame. + * Wheel pulses come from `wheel`, not from `press`. */ export interface TimelineEntry { readonly frame: number readonly move?: Position - readonly press?: ReadonlyArray