From 48ddac9ff93ee7c4370b524e61ecbf68e6ad0cc0 Mon Sep 17 00:00:00 2001 From: Erik Onarheim Date: Thu, 28 Mar 2024 20:41:30 -0500 Subject: [PATCH] feat: Add pointer events to TileMap/IsometricMap and tiles (#2968) This PR adds: - Pointer event support to `ex.TileMap`'s and individual `ex.Tile`'s - Pointer event support to `ex.IsometricMap`'s and individual `ex.IsometricTile`'s ```typescript // Tilemap const tileMap = new TileMap({...}); for (const tile of tileMap.tiles) { tile.on('pointerdown', (evt: ex.PointerEvent) => { console.log(tile.x, tile.y); }); } // Isometric const isoMap = new IsometricMap({...}); for (const tile of isoMap.tiles) { tile.on('pointerdown', (evt: ex.PointerEvent) => { console.log(tile.x, tile.y); }); } ``` --- CHANGELOG.md | 2 + sandbox/src/game.ts | 7 +++ sandbox/tests/isometric/index.ts | 5 ++ src/engine/Input/PointerComponent.ts | 19 +++++++ src/engine/Input/PointerSystem.ts | 10 ++++ src/engine/TileMap/IsometricMap.ts | 42 +++++++++++++++- src/engine/TileMap/TileMap.ts | 75 ++++++++++++++++++++++++++-- src/spec/IsometricMapSpec.ts | 35 +++++++++++++ src/spec/TileMapSpec.ts | 35 +++++++++++++ 9 files changed, 224 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 44bd29124..00d882615 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ This project adheres to [Semantic Versioning](http://semver.org/). ### Added +- Added pointer event support to `ex.TileMap`'s and individual `ex.Tile`'s +- Added pointer event support to `ex.IsometricMap`'s and individual `ex.IsometricTile`'s - Added `useAnchor` parameter to `ex.GraphicsGroup` to allow users to opt out of anchor based positioning, if set to false all graphics members will be positioned with the top left of the graphic at the actor's position. ```typescript diff --git a/sandbox/src/game.ts b/sandbox/src/game.ts index 6bec798c6..e24dc3d7c 100644 --- a/sandbox/src/game.ts +++ b/sandbox/src/game.ts @@ -463,6 +463,13 @@ tileMap.tiles.forEach(function(cell: ex.Tile) { cell.solid = true; cell.addGraphic(spriteTiles.sprites[0]); }); + +for (const tile of tileMap.tiles) { + tile.on('pointerdown', (evt: ex.PointerEvent) => { + console.log(tile.x, tile.y); + }); +} + game.add(tileMap); // Create spriteFont diff --git a/sandbox/tests/isometric/index.ts b/sandbox/tests/isometric/index.ts index 5ed39b38a..ad8250ea9 100644 --- a/sandbox/tests/isometric/index.ts +++ b/sandbox/tests/isometric/index.ts @@ -40,6 +40,11 @@ var isoMap2 = new ex.IsometricMap({ }); isoMap2.tiles.forEach(t => t.addGraphic(isoTileSprite)); game.currentScene.add(isoMap2); +for (const tile of isoMap2.tiles) { + tile.on('pointerdown', (evt: ex.PointerEvent) => { + console.log(tile.x, tile.y); + }); +} var tileCoord = ex.vec(0, 0); game.input.pointers.on('move', evt => { diff --git a/src/engine/Input/PointerComponent.ts b/src/engine/Input/PointerComponent.ts index 728fd0fb9..aa035b43d 100644 --- a/src/engine/Input/PointerComponent.ts +++ b/src/engine/Input/PointerComponent.ts @@ -1,4 +1,11 @@ import { Component } from '../EntityComponentSystem/Component'; +import { BoundingBox } from '../excalibur'; + +export interface PointerComponentOptions { + useColliderShape?: boolean; + useGraphicsBounds?: boolean; + localBounds?: BoundingBox; +} /** * Add this component to optionally configure how the pointer @@ -21,4 +28,16 @@ export class PointerComponent extends Component { * bounds around the graphic to trigger pointer events. Default is `true`. */ public useGraphicsBounds = true; + + /** + * Optionally use other bounds for pointer testing + */ + public localBounds?: BoundingBox; + + constructor(options?: PointerComponentOptions) { + super(); + this.useColliderShape = options?.useColliderShape ?? this.useColliderShape; + this.useGraphicsBounds = options?.useGraphicsBounds ?? this.useGraphicsBounds; + this.localBounds = options?.localBounds; + } } \ No newline at end of file diff --git a/src/engine/Input/PointerSystem.ts b/src/engine/Input/PointerSystem.ts index ce10f6979..4d0e8e72f 100644 --- a/src/engine/Input/PointerSystem.ts +++ b/src/engine/Input/PointerSystem.ts @@ -153,6 +153,16 @@ export class PointerSystem extends System { for (const entity of entities) { transform = entity.get(TransformComponent); pointer = entity.get(PointerComponent) ?? new PointerComponent; + // If pointer bounds defined + if (pointer.localBounds) { + const pointerBounds = pointer.localBounds.transform(transform.get().matrix); + for (const [pointerId, pos] of receiver.currentFramePointerCoords.entries()) { + if (pointerBounds.contains(transform.coordPlane === CoordPlane.World ? pos.worldPos : pos.screenPos)) { + this.addPointerToEntity(entity, pointerId); + } + } + } + // Check collider contains pointer collider = entity.get(ColliderComponent); if (collider && (pointer.useColliderShape || this.overrideUseColliderShape)) { diff --git a/src/engine/TileMap/IsometricMap.ts b/src/engine/TileMap/IsometricMap.ts index 29ae28b66..15c4bcdc5 100644 --- a/src/engine/TileMap/IsometricMap.ts +++ b/src/engine/TileMap/IsometricMap.ts @@ -6,16 +6,29 @@ import { CollisionType } from '../Collision/CollisionType'; import { CompositeCollider } from '../Collision/Colliders/CompositeCollider'; import { vec, Vector } from '../Math/vector'; import { TransformComponent } from '../EntityComponentSystem/Components/TransformComponent'; -import { Entity } from '../EntityComponentSystem/Entity'; +import { Entity, EntityEvents } from '../EntityComponentSystem/Entity'; import { DebugGraphicsComponent, ExcaliburGraphicsContext, Graphic, GraphicsComponent } from '../Graphics'; import { IsometricEntityComponent } from './IsometricEntityComponent'; import { DebugConfig } from '../Debug'; +import { PointerComponent } from '../Input/PointerComponent'; +import { PointerEvent } from '../Input/PointerEvent'; +import { EventEmitter } from '../EventEmitter'; + +export type IsometricTilePointerEvents = { + pointerup: PointerEvent; + pointerdown: PointerEvent; + pointermove: PointerEvent; + pointercancel: PointerEvent; +} + export class IsometricTile extends Entity { /** * Indicates whether this tile is solid */ public solid: boolean = false; + public events = new EventEmitter(); + private _gfx: GraphicsComponent; private _tileBounds = new BoundingBox(); private _graphics: Graphic[] = []; @@ -295,6 +308,8 @@ export class IsometricMap extends Entity { */ public collider: ColliderComponent; + public pointer: PointerComponent; + private _composite: CompositeCollider; constructor(options: IsometricMapOptions) { @@ -304,6 +319,7 @@ export class IsometricMap extends Entity { type: CollisionType.Fixed }), new ColliderComponent(), + new PointerComponent(), new DebugGraphicsComponent((ctx, debugFlags) => this.debug(ctx, debugFlags), false) ], options.name); const { pos, tileWidth, tileHeight, columns: width, rows: height, renderFromTopOfGraphic, graphicsOffset, elevation } = options; @@ -318,6 +334,7 @@ export class IsometricMap extends Entity { this.collider.set(this._composite = new CompositeCollider([])); } + this.pointer = this.get(PointerComponent); this.renderFromTopOfGraphic = renderFromTopOfGraphic ?? this.renderFromTopOfGraphic; this.graphicsOffset = graphicsOffset ?? this.graphicsOffset; @@ -338,6 +355,28 @@ export class IsometricMap extends Entity { this.addChild(tile); } } + + this.pointer.localBounds = BoundingBox.fromDimension( + tileWidth * width * this.transform.scale.x, + tileHeight * height * this.transform.scale.y, + vec(.5, 0) + ); + + this._setupPointerToTile(); + } + + private _forwardPointerEventToTile = (eventType: string) => (evt: PointerEvent) => { + const tile = this.getTileByPoint(evt.worldPos); + if (tile) { + tile.events.emit(eventType, evt); + } + }; + + private _setupPointerToTile() { + this.events.on('pointerup', this._forwardPointerEventToTile('pointerup')); + this.events.on('pointerdown', this._forwardPointerEventToTile('pointerdown')); + this.events.on('pointermove', this._forwardPointerEventToTile('pointermove')); + this.events.on('pointercancel', this._forwardPointerEventToTile('pointercancel')); } public update(): void { @@ -386,6 +425,7 @@ export class IsometricMap extends Entity { * @param worldCoordinate */ public worldToTile(worldCoordinate: Vector): Vector { + // TODO I don't think this handles parent transform see TileMap worldCoordinate = worldCoordinate.sub(this.transform.globalPos); const halfTileWidth = this.tileWidth / 2; diff --git a/src/engine/TileMap/TileMap.ts b/src/engine/TileMap/TileMap.ts index 090c08181..a06e3eaa3 100644 --- a/src/engine/TileMap/TileMap.ts +++ b/src/engine/TileMap/TileMap.ts @@ -18,6 +18,8 @@ import { EventEmitter, EventKey, Handler, Subscription } from '../EventEmitter'; import { CoordPlane } from '../Math/coord-plane'; import { DebugConfig } from '../Debug'; import { clamp } from '../Math/util'; +import { PointerComponent } from '../Input/PointerComponent'; +import { PointerEvent } from '../Input/PointerEvent'; export interface TileMapOptions { /** @@ -61,18 +63,29 @@ export interface TileMapOptions { meshingLookBehind?: number; } -export type TileMapEvents = EntityEvents & { +export type TilePointerEvents = { + pointerup: PointerEvent; + pointerdown: PointerEvent; + pointermove: PointerEvent; + pointercancel: PointerEvent; +} + +export type TileMapEvents = EntityEvents & TilePointerEvents & { preupdate: PreUpdateEvent; postupdate: PostUpdateEvent; predraw: PreDrawEvent; - postdraw: PostDrawEvent + postdraw: PostDrawEvent; } export const TileMapEvents = { PreUpdate: 'preupdate', PostUpdate: 'postupdate', PreDraw: 'predraw', - PostDraw: 'postdraw' + PostDraw: 'postdraw', + PointerUp: 'pointerup', + PointerDown: 'pointerdown', + PointerMove: 'pointermove', + PointerCancel: 'pointercancel' }; /** @@ -111,6 +124,7 @@ export class TileMap extends Entity { } } + public pointer: PointerComponent; public transform: TransformComponent; private _motion: MotionComponent; private _graphics: GraphicsComponent; @@ -232,6 +246,8 @@ export class TileMap extends Entity { ); this.addComponent(new DebugGraphicsComponent((ctx, debugFlags) => this.debug(ctx, debugFlags), false)); this.addComponent(new ColliderComponent()); + this.addComponent(new PointerComponent); + this.pointer = this.get(PointerComponent); this._graphics = this.get(GraphicsComponent); this.transform = this.get(TransformComponent); this._motion = this.get(MotionComponent); @@ -270,6 +286,8 @@ export class TileMap extends Entity { currentCol = []; } + this._setupPointerToTile(); + this._graphics.localBounds = new BoundingBox({ left: 0, top: 0, @@ -283,6 +301,19 @@ export class TileMap extends Entity { this._engine = engine; } + private _forwardPointerEventToTile = (eventType: string) => (evt: PointerEvent) => { + const tile = this.getTileByPoint(evt.worldPos); + if (tile) { + tile.events.emit(eventType, evt); + } + }; + + private _setupPointerToTile() { + this.events.on('pointerup', this._forwardPointerEventToTile('pointerup')); + this.events.on('pointerdown', this._forwardPointerEventToTile('pointerdown')); + this.events.on('pointermove', this._forwardPointerEventToTile('pointermove')); + this.events.on('pointercancel', this._forwardPointerEventToTile('pointercancel')); + } private _originalOffsets = new WeakMap(); private _getOrSetColliderOriginalOffset(collider: Collider): Vector { @@ -657,12 +688,14 @@ export interface TileOptions { * of the sprites in the array so the last one will be drawn on top. You can * use transparency to create layers this way. */ -export class Tile extends Entity { +export class Tile { private _bounds: BoundingBox; private _geometry: BoundingBox; private _pos: Vector; private _posDirty = false; + public events = new EventEmitter(); + /** * Return the world position of the top left corner of the tile */ @@ -820,7 +853,6 @@ export class Tile extends Entity { public data = new Map(); constructor(options: TileOptions) { - super(); this.x = options.x; this.y = options.y; this.map = options.map; @@ -877,4 +909,37 @@ export class Tile extends Entity { } return new Vector(this._pos.x + this._width / 2, this._pos.y + this._height / 2); } + + public emit> + (eventName: TEventName, event: TilePointerEvents[TEventName]): void; + public emit(eventName: string, event?: any): void; + public emit | string>(eventName: TEventName, event?: any): void { + this.events.emit(eventName, event); + } + + public on> + (eventName: TEventName, handler: Handler): Subscription; + public on(eventName: string, handler: Handler): Subscription; + public on | string>(eventName: TEventName, handler: Handler): Subscription { + return this.events.on(eventName, handler); + } + + public once> + (eventName: TEventName, handler: Handler): Subscription; + public once(eventName: string, handler: Handler): Subscription; + public once | string>(eventName: TEventName, handler: Handler): Subscription { + return this.events.once(eventName, handler); + } + + public off> + (eventName: TEventName, handler: Handler): void; + public off(eventName: string, handler: Handler): void; + public off(eventName: string): void; + public off | string>(eventName: TEventName, handler?: Handler): void { + if (handler) { + this.events.off(eventName, handler); + } else { + this.events.off(eventName); + } + } } diff --git a/src/spec/IsometricMapSpec.ts b/src/spec/IsometricMapSpec.ts index 65ac943ad..44711ef3d 100644 --- a/src/spec/IsometricMapSpec.ts +++ b/src/spec/IsometricMapSpec.ts @@ -301,4 +301,39 @@ describe('A IsometricMap', () => { expect(sut.getTileByPoint(ex.vec(250, 234)).y).toBe(14); }); + it('can respond to pointer events', async () => { + const engine = TestUtils.engine({width: 100, height: 100}); + await TestUtils.runToReady(engine); + const sut = new ex.IsometricMap({ + pos: ex.vec(100, 100), + tileWidth: 64, + tileHeight: 48, + rows: 20, + columns: 20 + }); + engine.add(sut); + + const tile = sut.getTile(0, 0); + + const pointerdown = jasmine.createSpy('pointerdown'); + const pointerup = jasmine.createSpy('pointerup'); + const pointermove = jasmine.createSpy('pointermove'); + const pointercancel = jasmine.createSpy('pointercancel'); + tile.on('pointerdown', pointerdown); + tile.on('pointerup', pointerup); + tile.on('pointermove', pointermove); + tile.on('pointercancel', pointercancel); + + engine.input.pointers.triggerEvent('down', ex.vec(110, 110)); + engine.input.pointers.triggerEvent('up', ex.vec(110, 110)); + engine.input.pointers.triggerEvent('move', ex.vec(110, 110)); + engine.input.pointers.triggerEvent('cancel', ex.vec(110, 110)); + expect(pointerdown).toHaveBeenCalledTimes(1); + expect(pointerup).toHaveBeenCalledTimes(1); + expect(pointermove).toHaveBeenCalledTimes(1); + expect(pointercancel).toHaveBeenCalledTimes(1); + + engine.dispose(); + }); + }); \ No newline at end of file diff --git a/src/spec/TileMapSpec.ts b/src/spec/TileMapSpec.ts index 133d74489..715b021dc 100644 --- a/src/spec/TileMapSpec.ts +++ b/src/spec/TileMapSpec.ts @@ -538,6 +538,41 @@ describe('A TileMap', () => { expect(tile.center).toBeVector(ex.vec(132, 124)); }); + it('can respond to pointer events', async () => { + const engine = TestUtils.engine({width: 100, height: 100}); + await TestUtils.runToReady(engine); + const sut = new ex.TileMap({ + pos: ex.vec(100, 100), + tileWidth: 64, + tileHeight: 48, + rows: 20, + columns: 20 + }); + engine.add(sut); + + const tile = sut.getTile(0, 0); + + const pointerdown = jasmine.createSpy('pointerdown'); + const pointerup = jasmine.createSpy('pointerup'); + const pointermove = jasmine.createSpy('pointermove'); + const pointercancel = jasmine.createSpy('pointercancel'); + tile.on('pointerdown', pointerdown); + tile.on('pointerup', pointerup); + tile.on('pointermove', pointermove); + tile.on('pointercancel', pointercancel); + + engine.input.pointers.triggerEvent('down', ex.vec(110, 110)); + engine.input.pointers.triggerEvent('up', ex.vec(110, 110)); + engine.input.pointers.triggerEvent('move', ex.vec(110, 110)); + engine.input.pointers.triggerEvent('cancel', ex.vec(110, 110)); + expect(pointerdown).toHaveBeenCalledTimes(1); + expect(pointerup).toHaveBeenCalledTimes(1); + expect(pointermove).toHaveBeenCalledTimes(1); + expect(pointercancel).toHaveBeenCalledTimes(1); + + engine.dispose(); + }); + describe('with an actor', () => { let tm: ex.TileMap; beforeEach(() => {