Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/excaliburjs/Excalibur. 🎮 Your friendly TypeScript 2D game engine for the web 🗡️ excaliburjs.com
excalibur excaliburjs game-development game-engine game-framework gamedev games html5-canvas typescript
Something went wrong. Try again.
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616import type { Engine } from '../engine';import { ScrollPreventionMode } from '../engine';import { GlobalCoordinates } from '../math/global-coordinates';import type { Vector } from '../math/vector';import { vec } from '../math/vector';import { PointerEvent } from './pointer-event';import { WheelEvent } from './wheel-event';import { PointerAbstraction } from './pointer-abstraction';
import { WheelDeltaMode } from './wheel-delta-mode';import { PointerSystem } from './pointer-system';import { NativePointerButton } from './native-pointer-button';import { PointerButton } from './pointer-button';import { fail } from '../util/util';import { PointerType } from './pointer-type';import { isCrossOriginIframe, isIframe } from '../util/iframe';import type { EventKey, Handler, Subscription } from '../event-emitter';import { EventEmitter } from '../event-emitter';
export type NativePointerEvent = globalThis.PointerEvent;export type NativeMouseEvent = globalThis.MouseEvent;export type NativeTouchEvent = globalThis.TouchEvent;export type NativeWheelEvent = globalThis.WheelEvent;
export interface PointerEvents { move: PointerEvent; down: PointerEvent; up: PointerEvent; wheel: WheelEvent;}
export const PointerEvents = { Move: 'move', Down: 'down', Up: 'up', Wheel: 'wheel'};
/** * Is this event a native touch event? */function isTouchEvent(value: any): value is NativeTouchEvent { // Guard for Safari <= 13.1 return globalThis.TouchEvent && value instanceof globalThis.TouchEvent;}
/** * Is this event a native pointer event */function isPointerEvent(value: any): value is NativePointerEvent { // Guard for Safari <= 13.1 return globalThis.PointerEvent && value instanceof globalThis.PointerEvent;}
export interface PointerInitOptions { grabWindowFocus?: boolean;}
/** * The PointerEventProcessor is responsible for collecting all the events from the canvas and transforming them into GlobalCoordinates */export class PointerEventReceiver { public events = new EventEmitter<PointerEvents>(); public primary: PointerAbstraction = new PointerAbstraction();
private _activeNativePointerIdsToNormalized = new Map<number, number>(); private _freedNormalizedIds: number[] = []; private _nextNormalizedId = 0; public lastFramePointerCoords = new Map<number, GlobalCoordinates>(); public currentFramePointerCoords = new Map<number, GlobalCoordinates>();
public currentFramePointerDown = new Map<number, boolean>(); public lastFramePointerDown = new Map<number, boolean>();
public currentFrameDown: PointerEvent[] = []; public currentFrameUp: PointerEvent[] = []; public currentFrameMove: PointerEvent[] = []; public currentFrameCancel: PointerEvent[] = []; public currentFrameWheel: WheelEvent[] = [];
private _enabled = true;
constructor( public readonly target: GlobalEventHandlers & EventTarget, public engine: Engine ) {}
public toggleEnabled(enabled: boolean) { this._enabled = enabled; }
/** * Creates a new PointerEventReceiver with a new target and engine while preserving existing pointer event * handlers. * @param target * @param engine */ public recreate(target: GlobalEventHandlers & EventTarget, engine: Engine) { const eventReceiver = new PointerEventReceiver(target, engine); eventReceiver.primary = this.primary; eventReceiver._pointers = this._pointers; return eventReceiver; }
private _pointers: PointerAbstraction[] = [this.primary]; /** * Locates a specific pointer by id, creates it if it doesn't exist * @param index */ public at(index: number): PointerAbstraction { if (index >= this._pointers.length) { // Ensure there is a pointer to retrieve for (let i = this._pointers.length - 1, max = index; i < max; i++) { this._pointers.push(new PointerAbstraction()); } } return this._pointers[index]; }
/** * The number of pointers currently being tracked by excalibur */ public count(): number { return this._pointers.length; }
/** * Is the specified pointer id down this frame * @param pointerId */ public isDown(pointerId: number) { if (!this._enabled) { return false; } return this.currentFramePointerDown.get(pointerId) ?? false; }
/** * Was the specified pointer id down last frame * @param pointerId */ public wasDown(pointerId: number) { if (!this._enabled) { return false; } return this.lastFramePointerDown.get(pointerId) ?? false; }
/** * Whether the Pointer is currently dragging. */ public isDragging(pointerId: number): boolean { if (!this._enabled) { return false; } return this.isDown(pointerId); }
/** * Whether the Pointer just started dragging. */ public isDragStart(pointerId: number): boolean { if (!this._enabled) { return false; } return this.isDown(pointerId) && !this.wasDown(pointerId); }
/** * Whether the Pointer just ended dragging. */ public isDragEnd(pointerId: number): boolean { if (!this._enabled) { return false; } return !this.isDown(pointerId) && this.wasDown(pointerId); }
public emit<TEventName extends EventKey<PointerEvents>>(eventName: TEventName, event: PointerEvents[TEventName]): void; public emit(eventName: string, event?: any): void; public emit<TEventName extends EventKey<PointerEvents> | string>(eventName: TEventName, event?: any): void { this.events.emit(eventName, event); }
public on<TEventName extends EventKey<PointerEvents>>(eventName: TEventName, handler: Handler<PointerEvents[TEventName]>): Subscription; public on(eventName: string, handler: Handler<unknown>): Subscription; public on<TEventName extends EventKey<PointerEvents> | string>(eventName: TEventName, handler: Handler<any>): Subscription { return this.events.on(eventName, handler); }
public once<TEventName extends EventKey<PointerEvents>>(eventName: TEventName, handler: Handler<PointerEvents[TEventName]>): Subscription; public once(eventName: string, handler: Handler<unknown>): Subscription; public once<TEventName extends EventKey<PointerEvents> | string>(eventName: TEventName, handler: Handler<any>): Subscription { return this.events.once(eventName, handler); }
public off<TEventName extends EventKey<PointerEvents>>(eventName: TEventName, handler: Handler<PointerEvents[TEventName]>): void; public off(eventName: string, handler: Handler<unknown>): void; public off(eventName: string): void; public off<TEventName extends EventKey<PointerEvents> | string>(eventName: TEventName, handler?: Handler<any>): void { this.events.off(eventName, handler as any); }
/** * Called internally by excalibur * * Updates the current frame pointer info and emits raw pointer events * * This does not emit events to entities, see PointerSystem * @internal */ public update() { this.lastFramePointerDown = new Map(this.currentFramePointerDown); this.lastFramePointerCoords = new Map(this.currentFramePointerCoords);
for (const event of this.currentFrameDown) { if (!event.active) { continue; } this.emit('down', event); const pointer = this.at(event.pointerId); pointer.emit('down', event); this.primary.emit('pointerdown', event); }
for (const event of this.currentFrameUp) { if (!event.active) { continue; } this.emit('up', event); const pointer = this.at(event.pointerId); pointer.emit('up', event); }
for (const event of this.currentFrameMove) { if (!event.active) { continue; } this.emit('move', event); const pointer = this.at(event.pointerId); pointer.emit('move', event); }
for (const event of this.currentFrameCancel) { if (!event.active) { continue; } this.emit('cancel', event); const pointer = this.at(event.pointerId); pointer.emit('cancel', event); }
for (const event of this.currentFrameWheel) { if (!event.active) { continue; } this.emit('pointerwheel', event); this.emit('wheel', event); this.primary.emit('pointerwheel', event); this.primary.emit('wheel', event); }
if (this.engine.currentScene.camera.hasChanged()) { for (const pointer of this._pointers) { pointer._updateWorldPosition(this.engine); } } }
private _releaseEndedPointer(event: PointerEvent, freedThisFrame: Set<number>): void { this.currentFramePointerCoords.delete(event.pointerId); // A mouse remains in range after 'up' and keeps producing pointermove events under the same // native pointerId: the spec fires pointerout/pointerleave after pointerup only "for input // devices that do not support hover", and permits the UA to "always reuse the same pointerId // for a particular pointing device" (https://www.w3.org/TR/pointerevents3/#the-pointerup-event, // https://www.w3.org/TR/pointerevents3/#pointerid-attribute). Keep its id reserved so a later // touch can't steal it and inherit the mouse's per-id hover state. if (event.pointerType === PointerType.Mouse) { return; } const ids = this._activeNativePointerIdsToNormalized.entries(); for (const [native, normalized] of ids) { if (normalized === event.pointerId && !freedThisFrame.has(normalized)) { this._activeNativePointerIdsToNormalized.delete(native); this._freedNormalizedIds.push(normalized); freedThisFrame.add(normalized); } } }
/** * Clears the current frame event and pointer data */ public clear() { const freedThisFrame = new Set<number>(); for (const event of this.currentFrameUp) { this._releaseEndedPointer(event, freedThisFrame); } for (const event of this.currentFrameCancel) { this._releaseEndedPointer(event, freedThisFrame); } this.currentFrameDown.length = 0; this.currentFrameUp.length = 0; this.currentFrameMove.length = 0; this.currentFrameCancel.length = 0; this.currentFrameWheel.length = 0; }
private _boundHandle = this._handle.bind(this); private _boundWheel = this._handleWheel.bind(this); /** * Initializes the pointer event receiver so that it can start listening to native * browser events. */ public init(options?: PointerInitOptions) { if (this.engine.isDisposed()) { return; } // Disabling the touch action avoids browser/platform gestures from firing on the canvas // It is important on mobile to have touch action 'none' // https://stackoverflow.com/questions/48124372/pointermove-event-not-working-with-touch-why-not if (this.target === this.engine.canvas) { this.engine.canvas.style.touchAction = 'none'; } else { document.body.style.touchAction = 'none'; } // Preferred pointer events if (window.PointerEvent) { this.target.addEventListener('pointerdown', this._boundHandle); this.target.addEventListener('pointerup', this._boundHandle); this.target.addEventListener('pointermove', this._boundHandle); this.target.addEventListener('pointercancel', this._boundHandle); } else { // Touch Events this.target.addEventListener('touchstart', this._boundHandle); this.target.addEventListener('touchend', this._boundHandle); this.target.addEventListener('touchmove', this._boundHandle); this.target.addEventListener('touchcancel', this._boundHandle);
// Mouse Events this.target.addEventListener('mousedown', this._boundHandle); this.target.addEventListener('mouseup', this._boundHandle); this.target.addEventListener('mousemove', this._boundHandle); }
// MDN MouseWheelEvent const wheelOptions = { passive: !( this.engine.pageScrollPreventionMode === ScrollPreventionMode.All || this.engine.pageScrollPreventionMode === ScrollPreventionMode.Canvas ) }; if ('onwheel' in document.createElement('div')) { // Modern Browsers this.target.addEventListener('wheel', this._boundWheel, wheelOptions); } else if (document.onmousewheel !== undefined) { // Webkit and IE this.target.addEventListener('mousewheel' as any, this._boundWheel, wheelOptions); } else { // Remaining browser and older Firefox this.target.addEventListener('MozMousePixelScroll' as any, this._boundWheel, wheelOptions); }
const grabWindowFocus = options?.grabWindowFocus ?? true; // Handle cross origin iframe if (grabWindowFocus && (isCrossOriginIframe() || isIframe())) { const grabFocus = () => { window.focus(); }; // Preferred pointer events if (window.PointerEvent) { this.target.addEventListener('pointerdown', grabFocus); } else { // Touch Events this.target.addEventListener('touchstart', grabFocus);
// Mouse Events this.target.addEventListener('mousedown', grabFocus); } } }
public detach() { // Preferred pointer events if (window.PointerEvent) { this.target.removeEventListener('pointerdown', this._boundHandle); this.target.removeEventListener('pointerup', this._boundHandle); this.target.removeEventListener('pointermove', this._boundHandle); this.target.removeEventListener('pointercancel', this._boundHandle); } else { // Touch Events this.target.removeEventListener('touchstart', this._boundHandle); this.target.removeEventListener('touchend', this._boundHandle); this.target.removeEventListener('touchmove', this._boundHandle); this.target.removeEventListener('touchcancel', this._boundHandle);
// Mouse Events this.target.removeEventListener('mousedown', this._boundHandle); this.target.removeEventListener('mouseup', this._boundHandle); this.target.removeEventListener('mousemove', this._boundHandle); }
if ('onwheel' in document.createElement('div')) { // Modern Browsers this.target.removeEventListener('wheel', this._boundWheel); } else if (document.onmousewheel !== undefined) { // Webkit and IE this.target.removeEventListener('mousewheel' as any, this._boundWheel); } else { // Remaining browser and older Firefox this.target.removeEventListener('MozMousePixelScroll' as any, this._boundWheel); } }
/** * Take native pointer id and map it to index in active pointers * @param nativePointerId */ private _normalizePointerId(nativePointerId: number): number { const existing = this._activeNativePointerIdsToNormalized.get(nativePointerId); if (existing !== undefined) { return existing; }
// Reuse the smallest freed id (so a lone new contact lands back on the primary pointer at id 0), otherwise mint a new one let id: number; if (this._freedNormalizedIds.length > 0) { let minIndex = 0; for (let i = 1; i < this._freedNormalizedIds.length; i++) { if (this._freedNormalizedIds[i] < this._freedNormalizedIds[minIndex]) { minIndex = i; } } id = this._freedNormalizedIds.splice(minIndex, 1)[0]; } else { id = this._nextNormalizedId++; }
// Save the mapping so we can reverse it later this._activeNativePointerIdsToNormalized.set(nativePointerId, id);
return id; }
/** * Responsible for handling and parsing pointer events */ private _handle(ev: NativeTouchEvent | NativePointerEvent | NativeMouseEvent) { if (this.engine.isDisposed() || !this._enabled) { return; } ev.preventDefault(); const eventCoords = new Map<number, GlobalCoordinates>(); let button: PointerButton; let pointerType: PointerType; if (isTouchEvent(ev)) { button = PointerButton.Unknown; pointerType = PointerType.Touch; // https://developer.mozilla.org/en-US/docs/Web/API/TouchEvent for (let i = 0; i < ev.changedTouches.length; i++) { const touch = ev.changedTouches[i]; const coordinates = GlobalCoordinates.fromPagePosition(touch.pageX, touch.pageY, this.engine); const nativePointerId = i + 1; const pointerId = this._normalizePointerId(nativePointerId); this.currentFramePointerCoords.set(pointerId, coordinates); eventCoords.set(pointerId, coordinates); } } else { button = this._nativeButtonToPointerButton(ev.button); pointerType = PointerType.Mouse; const coordinates = GlobalCoordinates.fromPagePosition(ev.pageX, ev.pageY, this.engine); let nativePointerId = 1; if (isPointerEvent(ev)) { nativePointerId = ev.pointerId; pointerType = this._stringToPointerType(ev.pointerType); } const pointerId = this._normalizePointerId(nativePointerId); this.currentFramePointerCoords.set(pointerId, coordinates); eventCoords.set(pointerId, coordinates); }
for (const [pointerId, coord] of eventCoords.entries()) { switch (ev.type) { case 'mousedown': case 'pointerdown': case 'touchstart': this.currentFrameDown.push(new PointerEvent('down', pointerId, button, pointerType, coord, ev)); this.currentFramePointerDown.set(pointerId, true); break; case 'mouseup': case 'pointerup': case 'touchend': this.currentFrameUp.push(new PointerEvent('up', pointerId, button, pointerType, coord, ev)); this.currentFramePointerDown.set(pointerId, false); break; case 'mousemove': case 'pointermove': case 'touchmove': this.currentFrameMove.push(new PointerEvent('move', pointerId, button, pointerType, coord, ev)); break; case 'touchcancel': case 'pointercancel': this.currentFrameCancel.push(new PointerEvent('cancel', pointerId, button, pointerType, coord, ev)); this.currentFramePointerDown.set(pointerId, false); break; } } }
private _handleWheel(ev: NativeWheelEvent) { if (this.engine.isDisposed() || !this._enabled) { return; } // Should we prevent page scroll because of this event if ( this.engine.pageScrollPreventionMode === ScrollPreventionMode.All || (this.engine.pageScrollPreventionMode === ScrollPreventionMode.Canvas && ev.target === this.engine.canvas) ) { ev.preventDefault(); } const screen = this.engine.screen.pageToScreenCoordinates(vec(ev.pageX, ev.pageY)); const world = this.engine.screen.screenToWorldCoordinates(screen);
/** * A constant used to normalize wheel events across different browsers * * This normalization factor is pulled from * https://developer.mozilla.org/en-US/docs/Web/Events/wheel#Listening_to_this_event_across_browser */ const ScrollWheelNormalizationFactor = -1 / 40;
const deltaX = ev.deltaX || ev.wheelDeltaX * ScrollWheelNormalizationFactor || 0; const deltaY = ev.deltaY || ev.wheelDeltaY * ScrollWheelNormalizationFactor || ev.wheelDelta * ScrollWheelNormalizationFactor || ev.detail || 0; const deltaZ = ev.deltaZ || 0; let deltaMode = WheelDeltaMode.Pixel;
if (ev.deltaMode) { if (ev.deltaMode === 1) { deltaMode = WheelDeltaMode.Line; } else if (ev.deltaMode === 2) { deltaMode = WheelDeltaMode.Page; } }
const we = new WheelEvent(world.x, world.y, ev.pageX, ev.pageY, screen.x, screen.y, 0, deltaX, deltaY, deltaZ, deltaMode, ev); this.currentFrameWheel.push(we); }
/** * Triggers an excalibur pointer event in a world space pos * * Useful for testing pointers in excalibur * @param type * @param pos */ public triggerEvent(type: 'down' | 'up' | 'move' | 'cancel', pos: Vector) { const page = this.engine.screen.worldToPageCoordinates(pos); // Send an event to the event receiver if (window.PointerEvent) { this._handle( new window.PointerEvent('pointer' + type, { pointerId: 0, clientX: page.x, clientY: page.y }) ); } else { // Safari hack this._handle( new window.MouseEvent('mouse' + type, { clientX: page.x, clientY: page.y }) ); }
// Force update pointer system const pointerSystem = this.engine.currentScene.world.get(PointerSystem); if (pointerSystem) { pointerSystem!.preupdate!(this.engine.currentScene, 1); pointerSystem!.update(1); } }
private _nativeButtonToPointerButton(s: NativePointerButton): PointerButton { switch (s) { case NativePointerButton.NoButton: return PointerButton.NoButton; case NativePointerButton.Left: return PointerButton.Left; case NativePointerButton.Middle: return PointerButton.Middle; case NativePointerButton.Right: return PointerButton.Right; case NativePointerButton.Unknown: return PointerButton.Unknown; default: return fail(s); } }
private _stringToPointerType(s: string) { switch (s) { case 'touch': return PointerType.Touch; case 'mouse': return PointerType.Mouse; case 'pen': return PointerType.Pen; default: return PointerType.Unknown; } }}