@woosh/meep-engine
Version:
Pure JavaScript game engine. Fully featured and production ready.
503 lines (393 loc) • 12.5 kB
JavaScript
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);
}
function handleDragEnd(position) {
up.remove(handleDragEnd);
move.remove(handleDrag);
dragEnd.send1(position);
}
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();
function handleDrag(position, event) {
drag.send4(position, origin, lastDragPosition, event);
lastDragPosition.copy(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);
this.on.down.send2(this.position, event);
// update button state and dispatch specific signal
const button_index = event.button;
const button = this.buttons[button_index];
button?.press();
}
/**
*
* @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);
}
}