UNPKG

@woosh/meep-engine

Version:

Pure JavaScript game engine. Fully featured and production ready.

523 lines (411 loc) • 12.9 kB
import { assert } from "../../../core/assert.js"; import Signal from "../../../core/events/signal/Signal.js"; import Vector2 from "../../../core/geom/Vector2.js"; import Vector3 from "../../../core/geom/Vector3.js"; import { sign } from "../../../core/math/sign.js"; import { current_time_in_seconds } from "../../../core/time/current_time_in_seconds.js"; import { MouseEvents } from "./events/MouseEvents.js"; import { PointerEvents } from "./events/PointerEvents.js"; import { InputDeviceSwitch } from "./InputDeviceSwitch.js"; import { LocationalInteractionMetadata } from "./LocationalInteractionMetadata.js"; import { suppressContextMenu } from "./mouse/suppressContextMenu.js"; /** * * @param {Signal} up * @param {Signal} down * @param {Signal} move * @param {number} [maxDistance] in pixels * @param {number} [maxDelay] Maximum delay between down and up events in seconds * @param {Signal} signal */ function observeTap({ up, down, move = new Signal(), maxDistance = 10, maxDelay = 1, signal }) { /** * * @type {Map<number, LocationalInteractionMetadata>} */ const active = new Map(); /** * * @param {number} id */ function reset(id) { assert.isNonNegativeInteger(id, 'id'); const deleted = active.delete(id); if (deleted) { up.remove(handleUp); move.remove(handleMove); } } /** * * @param {Vector2} position * @param {PointerEvent} event */ function handleUp(position, event) { const id = event.pointerId; const meta = active.get(id); if (meta === undefined) { // this should not happen console.warn(`Unregistered up event handler`); return; } reset(id); const time_now = current_time_in_seconds(); const delay = time_now - meta.timestamp; if (delay > maxDelay) { // too much time has passed, swallow event return; } signal.send2(position, event); } /** * * @param {Vector2} position * @param {PointerEvent} event */ function handleMove(position, event) { const id = event.pointerId; const meta = active.get(id); if (meta === undefined) { // this should not happen console.warn(`Unregistered move event handler`); reset(id); return; } if (meta.position.distanceTo(position) > maxDistance) { //we moved too far, abort tap reset(id); } } /** * * @param {Vector2} position * @param {PointerEvent} event */ function handleDown(position, event) { const id = event.pointerId; // make sure to cancel previous pending resolution reset(id); active.set(id, LocationalInteractionMetadata.from(position)); up.addOne(handleUp); //track move move.add(handleMove); } down.add(handleDown); } /** * * @param {Signal} up * @param {Signal} down * @param {Signal} move * @param {Signal} dragStart * @param {Signal} dragEnd * @param {Signal} drag */ function observeDrag(up, down, move, dragStart, dragEnd, drag) { const origin = new Vector2(); /** * * @param {Vector2} position */ function noDrag(position) { up.remove(noDrag); move.remove(handleDragStart); } /** * * @param {Vector2} position */ function handleDragEnd(position) { up.remove(handleDragEnd); move.remove(handleDrag); dragEnd.send1(position); } /** * * @param {Vector2} position * @param {PointerEvent} event */ function handleDragStart(position, event) { move.remove(handleDragStart); move.add(handleDrag); up.remove(noDrag); up.add(handleDragEnd); lastDragPosition.copy(origin); dragStart.send2(origin, event); handleDrag(position, event); } const lastDragPosition = new Vector2(); /** * * @param {Vector2} position * @param {PointerEvent} event */ function handleDrag(position, event) { drag.send4(position, origin, lastDragPosition, event); lastDragPosition.copy(position); } /** * * @param {Vector2} position */ function handleDown(position) { origin.copy(position); up.add(noDrag); move.add(handleDragStart); } down.add(handleDown); } /** * * @param {Vector2} result * @param {MouseEvent|Touch} event * @param {Element} source */ export function readPositionFromMouseEvent(result, event, source = event.target) { let x = event.clientX; let y = event.clientY; if (typeof source.getBoundingClientRect === "function") { // there are cases when source element something like "document" object, which doesn't expose bounds API, so we're guarding against that const bounds = source.getBoundingClientRect(); y -= bounds.top; x -= bounds.left; } result.set(x, y); } /** * Abstracts Mouse and Touch interfaces as single "pointer" device. * * @author Alex Goldring * @copyright Company Named Limited (c) 2025 */ export class PointerDevice { /** * Current live position of the pointer * @readonly * @type {Vector2} */ position = new Vector2(); #globalUp = new Signal(); /** * @readonly */ on = { down: new Signal(), up: new Signal(), move: new Signal(), /** * @type {Signal<Vector2, (MouseEvent|TouchEvent)>} */ tap: new Signal(), drag: new Signal(), dragStart: new Signal(), dragEnd: new Signal(), wheel: new Signal(), pinch: new Signal(), pinchStart: new Signal(), pinchEnd: new Signal(), }; /** * * @type {Element|null} * @private */ #target = null; /** * * @type {Element|null} */ #domElement = null; /** * @private * @type {boolean} */ isRunning = false; /** * The {@link MouseEvent.buttons} is a 32bit field, which means we can encode up to 32 buttons * @readonly * @type {InputDeviceSwitch[]} */ buttons = new Array(32); /** * * @returns {InputDeviceSwitch} */ get mouseButtonLeft() { return this.buttons[0]; } /** * * @returns {InputDeviceSwitch} */ get mouseButtonRight() { return this.buttons[2]; } /** * * @returns {InputDeviceSwitch} */ get mouseButtonMiddle() { return this.buttons[1]; } /** * * @param {EventTarget} domElement html element * @constructor */ constructor(domElement) { assert.defined(domElement, "domElement"); // initialize buttons for (let i = 0; i < this.buttons.length; i++) { this.buttons[i] = new InputDeviceSwitch(); } /** * * @type {EventTarget} */ this.#domElement = domElement; //constructed events observeTap({ up: this.on.up, down: this.on.down, move: this.on.move, maxDistance: 10, signal: this.on.tap }); observeDrag(this.#globalUp, this.on.down, this.on.move, this.on.dragStart, this.on.dragEnd, this.on.drag); } /** * * @param {PointerEvent} event */ #eventHandlerPointerDown = (event) => { this.readPointerPositionFromEvent(this.position, event); // update button state and dispatch specific signal const button_index = event.button; const button = this.buttons[button_index]; button?.press(); this.on.down.send2(this.position, event); } /** * * @param {PointerEvent} event */ #eventHandlerPointerUp = (event) => { this.readPointerPositionFromEvent(this.position, event); this.on.up.send2(this.position, event); } /** * * @param {PointerEvent} event */ #eventHandlerGlobalPointerUp = (event) => { this.readPointerPositionFromEvent(this.position, event); this.#globalUp.send2(this.position, event); // update button state and dispatch specific signal const button_index = event.button; const button = this.buttons[button_index]; button?.release(); } /** * * @param {WheelEvent} event */ #eventHandlerWheel = (event) => { event.preventDefault(); //deltas have inconsistent values across browsers, so we will normalize them const x = sign(event.deltaX); const y = sign(event.deltaY); const z = sign(event.deltaZ); const delta = new Vector3(x, y, z); this.readPointerPositionFromEvent(this.position, event); this.on.wheel.send3(delta, this.position, event); } /** * * @param {PointerEvent} event */ #eventHandlerPointerMove = (event) => { event.preventDefault(); this.#target = event.target; this.readPointerPositionFromEvent(this.position, event); this.on.move.send3(this.position, event, new Vector2(event.movementX, event.movementY)); } /** * * @return {Element} */ getTargetElement() { return this.#target; } /** * * @param {Element} el */ set domElement(el) { assert.defined(el, 'el'); assert.notNull(el, 'el'); if (this.#domElement === el) { // no change return; } let was_running = this.isRunning; if (was_running) { // disconnect from previous target this.stop(); } this.#domElement = el; if (was_running) { // restart to maintain original state this.start(); } } get domElement() { return this.#domElement; } /** * * @param {Vector2} result * @param {MouseEvent|Touch} event */ readPointerPositionFromEvent(result, event) { readPositionFromMouseEvent(result, event, this.domElement); } start() { if (this.isRunning) { //already running return; } this.isRunning = true; // console.warn("PointerDevice.start"); const domElement = this.#domElement; assert.notEqual(domElement, null, "domElement is null"); assert.notEqual(domElement, undefined, "domElement is undefined"); domElement.addEventListener(PointerEvents.Move, this.#eventHandlerPointerMove); domElement.addEventListener(PointerEvents.Up, this.#eventHandlerPointerUp); domElement.addEventListener(PointerEvents.Down, this.#eventHandlerPointerDown); window.addEventListener(PointerEvents.Up, this.#eventHandlerGlobalPointerUp); /* In some cases wheel event gets registered as "passive" by default. This interferes with "preventDefault()" see https://www.chromestatus.com/features/6662647093133312 */ domElement.addEventListener(MouseEvents.Wheel, this.#eventHandlerWheel, { passive: false }); domElement.addEventListener("contextmenu", suppressContextMenu); } stop() { if (!this.isRunning) { //not running return; } this.isRunning = false; // console.warn("PointerDevice.stop"); const domElement = this.domElement; domElement.removeEventListener(PointerEvents.Move, this.#eventHandlerPointerMove); domElement.removeEventListener(PointerEvents.Up, this.#eventHandlerPointerUp); domElement.removeEventListener(PointerEvents.Down, this.#eventHandlerPointerDown); window.removeEventListener(PointerEvents.Up, this.#eventHandlerGlobalPointerUp); domElement.removeEventListener(MouseEvents.Wheel, this.#eventHandlerWheel); domElement.removeEventListener("contextmenu", suppressContextMenu); } }