openvibers/openvibe.games Commits
Top-down worlds: a press on the game canvas acts without pointer lock (tools, gathering, the hotbar wheel); the HUD and menus keep their own clicks (#170)
Changes
nnew file mode 100644@@@ -0,0 +1,49 @@++// @vitest-environment jsdom++import { describe, expect, it } from 'vitest'++import { InputTracker } from './inputTracker.js'++++function setup(pointerLock: boolean) {++ document.body.innerHTML = '<canvas id="game"></canvas><button id="hud">Inventory</button>'++ const canvas = document.getElementById('game') as HTMLCanvasElement++ const hud = document.getElementById('hud') as HTMLButtonElement++ canvas.requestPointerLock = (() => undefined) as never++ const input = new InputTracker(canvas)++ input.pointerLock = pointerLock++ const actions: string[] = []++ const wheel: number[] = []++ input.onAction = (a) => actions.push(a.kind)++ input.onWheel = (d) => wheel.push(d)++ const press = (target: Element, buttons: number) =>++ target.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, buttons }))++ const release = (target: Element) =>++ target.dispatchEvent(new MouseEvent('pointerup', { bubbles: true, buttons: 0 }))++ const scroll = (target: Element) =>++ target.dispatchEvent(new WheelEvent('wheel', { bubbles: true, deltaY: 120 }))++ return { canvas, hud, actions, wheel, press, release, scroll }++}++++describe('input tracker presses', () => {++ it('acts on a press on the game canvas in a world seen from above (no pointer lock)', () => {++ const { canvas, hud, actions, wheel, press, release, scroll } = setup(false)++ press(canvas, 1)++ release(canvas)++ press(canvas, 2)++ expect(actions).toEqual(['primary_down', 'primary_up', 'rmb_down'])++ scroll(canvas)++ expect(wheel).toEqual([1])++ // The HUD and the menus keep their own clicks and wheel.++ actions.length = 0++ press(hud, 1)++ scroll(hud)++ expect(actions.filter((a) => a !== 'primary_up')).toEqual([])++ expect(wheel).toEqual([1])++ })++++ it('still waits for the pointer lock in first person', () => {++ const { canvas, actions, wheel, press, scroll } = setup(true)++ press(canvas, 1)++ scroll(canvas)++ expect(actions).toEqual([])++ expect(wheel).toEqual([])++ })++})@@@ -17,6 +17,17 @@ export class InputTracker {uiCapture = false/** False for a world seen from above: the pointer stays visible and aims instead of turning the view. */pointerLock = true++ /** Whether the current press started on the game canvas (not the HUD or a menu). */++ private pressOnCanvas = false++++ /**++ * Whether presses and the wheel act in the game: with pointer lock, while it is held; in a world seen from above++ * (no lock), when the press is on the game canvas. A top-down player used to walk but never swing, gather or scroll++ * the hotbar, because every press waited for a lock that never comes.++ */++ private engaged(onCanvas = this.pressOnCanvas): boolean {++ return this.locked || (!this.pointerLock && onCanvas)++ }onAction: ((action: InputAction) => void) | null = nullonWheel: ((delta: number) => void) | null = null@@@ -36,7 +47,7 @@ export class InputTracker {// Releases always fire — a grab must never stick because pointer lock// or the menu state changed mid-hold. New presses are gameplay-gated.if (released & 1) this.onAction?.({ kind: 'primary_up' })-- if (this.uiCapture || !this.locked) return++ if (this.uiCapture || !this.engaged()) returnif (pressed & 1) this.onAction?.({ kind: 'primary_down' })if (pressed & 2) this.onAction?.({ kind: 'rmb_down' })}@@@ -113,7 +124,8 @@ export class InputTracker {})document.addEventListener('pointerdown', (e) => {if (e.pointerType === 'touch') return-- if (!this.locked || this.uiCapture) {++ this.pressOnCanvas = e.target === this.canvas++ if (!this.engaged() || this.uiCapture) {this.buttonsState = e.buttonsreturn}@@@ -137,7 +149,8 @@ export class InputTracker {document.addEventListener('wheel',(e) => {-- if (this.locked && !this.uiCapture) this.onWheel?.(Math.sign(e.deltaY))++ if (this.engaged(e.target === this.canvas) && !this.uiCapture)++ this.onWheel?.(Math.sign(e.deltaY))},{ passive: true },)nnew file mode 100644@@@ -0,0 +1,3 @@++// file: 'apps/client/src/input/inputTracker.test.ts'++// covers: 'apps/client/src/input'++// Generated by node test/run.js --index; do not edit.
new file mode 100644
new file mode 100644
@@ -0,0 +1,49 @@
@@ -0,0 +1,49 @@
+// @vitest-environment jsdom
+import { describe, expect, it } from 'vitest'
+import { InputTracker } from './inputTracker.js'
+
+function setup(pointerLock: boolean) {
+ document.body.innerHTML = '<canvas id="game"></canvas><button id="hud">Inventory</button>'
+ const canvas = document.getElementById('game') as HTMLCanvasElement
+ const hud = document.getElementById('hud') as HTMLButtonElement
+ canvas.requestPointerLock = (() => undefined) as never
+ const input = new InputTracker(canvas)
+ input.pointerLock = pointerLock
+ const actions: string[] = []
+ const wheel: number[] = []
+ input.onAction = (a) => actions.push(a.kind)
+ input.onWheel = (d) => wheel.push(d)
+ const press = (target: Element, buttons: number) =>
+ target.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, buttons }))
+ const release = (target: Element) =>
+ target.dispatchEvent(new MouseEvent('pointerup', { bubbles: true, buttons: 0 }))
+ const scroll = (target: Element) =>
+ target.dispatchEvent(new WheelEvent('wheel', { bubbles: true, deltaY: 120 }))
+ return { canvas, hud, actions, wheel, press, release, scroll }
+}
+
+describe('input tracker presses', () => {
+ it('acts on a press on the game canvas in a world seen from above (no pointer lock)', () => {
+ const { canvas, hud, actions, wheel, press, release, scroll } = setup(false)
+ press(canvas, 1)
+ release(canvas)
+ press(canvas, 2)
+ expect(actions).toEqual(['primary_down', 'primary_up', 'rmb_down'])
+ scroll(canvas)
+ expect(wheel).toEqual([1])
+ // The HUD and the menus keep their own clicks and wheel.
+ actions.length = 0
+ press(hud, 1)
+ scroll(hud)
+ expect(actions.filter((a) => a !== 'primary_up')).toEqual([])
+ expect(wheel).toEqual([1])
+ })
+
+ it('still waits for the pointer lock in first person', () => {
+ const { canvas, actions, wheel, press, scroll } = setup(true)
+ press(canvas, 1)
+ scroll(canvas)
+ expect(actions).toEqual([])
+ expect(wheel).toEqual([])
+ })
+})
@@ -17,6 +17,17 @@ export class InputTracker {
@@ -17,6 +17,17 @@ export class InputTracker {
uiCapture = false
uiCapture = false
/** False for a world seen from above: the pointer stays visible and aims instead of turning the view. */
/** False for a world seen from above: the pointer stays visible and aims instead of turning the view. */
pointerLock = true
pointerLock = true
+ /** Whether the current press started on the game canvas (not the HUD or a menu). */
+ private pressOnCanvas = false
+
+ /**
+ * Whether presses and the wheel act in the game: with pointer lock, while it is held; in a world seen from above
+ * (no lock), when the press is on the game canvas. A top-down player used to walk but never swing, gather or scroll
+ * the hotbar, because every press waited for a lock that never comes.
+ */
+ private engaged(onCanvas = this.pressOnCanvas): boolean {
+ return this.locked || (!this.pointerLock && onCanvas)
+ }
onAction: ((action: InputAction) => void) | null = null
onAction: ((action: InputAction) => void) | null = null
onWheel: ((delta: number) => void) | null = null
onWheel: ((delta: number) => void) | null = null
@@ -36,7 +47,7 @@ export class InputTracker {
@@ -36,7 +47,7 @@ export class InputTracker {
// Releases always fire — a grab must never stick because pointer lock
// Releases always fire — a grab must never stick because pointer lock
// or the menu state changed mid-hold. New presses are gameplay-gated.
// or the menu state changed mid-hold. New presses are gameplay-gated.
if (released & 1) this.onAction?.({ kind: 'primary_up' })
if (released & 1) this.onAction?.({ kind: 'primary_up' })
- if (this.uiCapture || !this.locked) return
+ if (this.uiCapture || !this.engaged()) return
if (pressed & 1) this.onAction?.({ kind: 'primary_down' })
if (pressed & 1) this.onAction?.({ kind: 'primary_down' })
if (pressed & 2) this.onAction?.({ kind: 'rmb_down' })
if (pressed & 2) this.onAction?.({ kind: 'rmb_down' })
}
}
@@ -113,7 +124,8 @@ export class InputTracker {
@@ -113,7 +124,8 @@ export class InputTracker {
})
})
document.addEventListener('pointerdown', (e) => {
document.addEventListener('pointerdown', (e) => {
if (e.pointerType === 'touch') return
if (e.pointerType === 'touch') return
- if (!this.locked || this.uiCapture) {
+ this.pressOnCanvas = e.target === this.canvas
+ if (!this.engaged() || this.uiCapture) {
this.buttonsState = e.buttons
this.buttonsState = e.buttons
return
return
}
}
@@ -137,7 +149,8 @@ export class InputTracker {
@@ -137,7 +149,8 @@ export class InputTracker {
document.addEventListener(
document.addEventListener(
'wheel',
'wheel',
(e) => {
(e) => {
- if (this.locked && !this.uiCapture) this.onWheel?.(Math.sign(e.deltaY))
+ if (this.engaged(e.target === this.canvas) && !this.uiCapture)
+ this.onWheel?.(Math.sign(e.deltaY))
},
},
{ passive: true },
{ passive: true },
)
)
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
+// file: 'apps/client/src/input/inputTracker.test.ts'
+// covers: 'apps/client/src/input'
+// Generated by node test/run.js --index; do not edit.