• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

excaliburjs / Excalibur / 18563859721

16 Oct 2025 02:00PM UTC coverage: 88.6%. Remained the same
18563859721

push

github

web-flow
perf: Fix pointer perf on tilemaps (#3539)


Closes #3522

## Changes:

- Changes pointer event strategy on tilemaps to improve performance dramatically

5152 of 7039 branches covered (73.19%)

25 of 25 new or added lines in 3 files covered. (100.0%)

2 existing lines in 2 files now uncovered.

14261 of 16096 relevant lines covered (88.6%)

24614.03 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

95.81
/src/engine/TileMap/IsometricMap.ts
1
import { BodyComponent } from '../Collision/BodyComponent';
2
import { BoundingBox } from '../Collision/BoundingBox';
3
import { ColliderComponent } from '../Collision/ColliderComponent';
4
import type { Collider } from '../Collision/Colliders/Collider';
5
import { CollisionType } from '../Collision/CollisionType';
6
import { CompositeCollider } from '../Collision/Colliders/CompositeCollider';
7
import { vec, Vector } from '../Math/vector';
8
import { TransformComponent } from '../EntityComponentSystem/Components/TransformComponent';
9
import type { EntityEvents } from '../EntityComponentSystem/Entity';
10
import { Entity } from '../EntityComponentSystem/Entity';
11
import type { ExcaliburGraphicsContext, Graphic } from '../Graphics';
12
import { DebugGraphicsComponent, GraphicsComponent } from '../Graphics';
13
import { IsometricEntityComponent } from './IsometricEntityComponent';
14
import type { DebugConfig } from '../Debug';
15
import { PointerComponent } from '../Input/PointerComponent';
16
import type { PointerEvent } from '../Input/PointerEvent';
17
import { EventEmitter } from '../EventEmitter';
18
import type { HasNestedPointerEvents } from '../Input/PointerEventsToObjectDispatcher';
19
import { PointerEventsToObjectDispatcher } from '../Input/PointerEventsToObjectDispatcher';
20
import type { PointerEventReceiver } from '../Input/PointerEventReceiver';
21
import type { GlobalCoordinates } from '../Math';
22
import { CoordPlane } from '../Math';
23

24
export type IsometricTilePointerEvents = {
25
  pointerup: PointerEvent;
26
  pointerdown: PointerEvent;
27
  pointermove: PointerEvent;
28
  pointercancel: PointerEvent;
29
  pointerenter: PointerEvent;
30
  pointerleave: PointerEvent;
31
};
32

33
export class IsometricTile extends Entity {
34
  /**
35
   * Indicates whether this tile is solid
36
   */
37
  public solid: boolean = false;
2,850✔
38

39
  public events = new EventEmitter<EntityEvents & IsometricTilePointerEvents>();
2,850✔
40

41
  private _gfx: GraphicsComponent;
42
  private _tileBounds = new BoundingBox();
2,850✔
43
  private _graphics: Graphic[] = [];
2,850✔
44
  public getGraphics(): readonly Graphic[] {
45
    return this._graphics;
3✔
46
  }
47
  /**
48
   * Tile graphics
49
   */
50
  public addGraphic(graphic: Graphic, options?: { offset?: Vector }) {
51
    this._graphics.push(graphic);
902✔
52
    this._gfx.isVisible = this.map.isVisible;
902✔
53
    this._gfx.opacity = this.map.opacity;
902✔
54
    if (options?.offset) {
902!
55
      this._gfx.offset = options.offset;
×
56
    }
57
    // TODO detect when this changes on the map and apply to all tiles
58
    this._gfx.localBounds = this._recalculateBounds();
902✔
59
  }
60

61
  private _recalculateBounds(): BoundingBox {
62
    let bounds = this._tileBounds.clone();
904✔
63
    for (const graphic of this._graphics) {
904✔
64
      const offset = vec(
904✔
65
        this.map.graphicsOffset.x - this.map.tileWidth / 2,
66
        this.map.graphicsOffset.y - (this.map.renderFromTopOfGraphic ? 0 : graphic.height - this.map.tileHeight)
904✔
67
      );
68
      bounds = bounds.combine(graphic.localBounds.translate(offset));
904✔
69
    }
70
    return bounds;
904✔
71
  }
72

73
  public removeGraphic(graphic: Graphic) {
74
    const index = this._graphics.indexOf(graphic);
1✔
75
    if (index > -1) {
1!
76
      this._graphics.splice(index, 1);
1✔
77
    }
78
    this._gfx.localBounds = this._recalculateBounds();
1✔
79
  }
80

81
  public clearGraphics() {
82
    this._graphics.length = 0;
1✔
83
    this._gfx.isVisible = false;
1✔
84
    this._gfx.localBounds = this._recalculateBounds();
1✔
85
  }
86

87
  /**
88
   * Tile colliders
89
   */
90
  private _colliders: Collider[] = [];
2,850✔
91
  public getColliders(): readonly Collider[] {
92
    return this._colliders;
4✔
93
  }
94

95
  /**
96
   * Adds a collider to the IsometricTile
97
   *
98
   * **Note!** the {@apilink Tile.solid} must be set to true for it to act as a "fixed" collider
99
   * @param collider
100
   */
101
  public addCollider(collider: Collider) {
102
    this._colliders.push(collider);
2✔
103
    this.map.flagCollidersDirty();
2✔
104
  }
105

106
  /**
107
   * Removes a collider from the IsometricTile
108
   * @param collider
109
   */
110
  public removeCollider(collider: Collider) {
111
    const index = this._colliders.indexOf(collider);
1✔
112
    if (index > -1) {
1!
113
      this._colliders.splice(index, 1);
1✔
114
    }
115
    this.map.flagCollidersDirty();
1✔
116
  }
117

118
  /**
119
   * Clears all colliders from the IsometricTile
120
   */
121
  public clearColliders(): void {
122
    this._colliders.length = 0;
1✔
123
    this.map.flagCollidersDirty();
1✔
124
  }
125

126
  /**
127
   * Integer tile x coordinate
128
   */
129
  public readonly x: number;
130
  /**
131
   * Integer tile y coordinate
132
   */
133
  public readonly y: number;
134
  /**
135
   * Reference to the {@apilink IsometricMap} this tile is part of
136
   */
137
  public readonly map: IsometricMap;
138

139
  private _transform: TransformComponent;
140
  private _isometricEntityComponent: IsometricEntityComponent;
141

142
  /**
143
   * Returns the top left corner of the {@apilink IsometricTile} in world space
144
   */
145
  public get pos(): Vector {
146
    return this.map.tileToWorld(vec(this.x, this.y));
7✔
147
  }
148

149
  /**
150
   * Returns the center of the {@apilink IsometricTile}
151
   */
152
  public get center(): Vector {
153
    return this.pos.add(vec(0, this.map.tileHeight / 2));
1✔
154
  }
155

156
  /**
157
   * Arbitrary data storage per tile, useful for any game specific data
158
   */
159
  public data = new Map<string, any>();
2,850✔
160

161
  /**
162
   * Construct a new IsometricTile
163
   * @param x tile coordinate in x (not world position)
164
   * @param y tile coordinate in y (not world position)
165
   * @param graphicsOffset offset that tile should be shifted by (default (0, 0))
166
   * @param map reference to owning IsometricMap
167
   */
168
  constructor(x: number, y: number, graphicsOffset: Vector | null, map: IsometricMap) {
169
    super([
2,850✔
170
      new TransformComponent(),
171
      new GraphicsComponent({
172
        offset: graphicsOffset ?? Vector.Zero,
2,850!
173
        onPostDraw: (gfx, elapsed) => this.draw(gfx, elapsed)
900✔
174
      }),
175
      new IsometricEntityComponent(map)
176
    ]);
177
    this.x = x;
2,850✔
178
    this.y = y;
2,850✔
179
    this.map = map;
2,850✔
180
    this._transform = this.get(TransformComponent);
2,850✔
181
    this._isometricEntityComponent = this.get(IsometricEntityComponent);
2,850✔
182

183
    const halfTileWidth = this.map.tileWidth / 2;
2,850✔
184
    const halfTileHeight = this.map.tileHeight / 2;
2,850✔
185
    // See https://clintbellanger.net/articles/isometric_math/ for formula
186
    // The x position shifts left with every y step
187
    const xPos = (this.x - this.y) * halfTileWidth;
2,850✔
188
    // The y position needs to go down with every x step
189
    const yPos = (this.x + this.y) * halfTileHeight;
2,850✔
190
    this._transform.pos = vec(xPos, yPos);
2,850✔
191
    this._isometricEntityComponent.elevation = map.elevation;
2,850✔
192

193
    this._gfx = this.get(GraphicsComponent);
2,850✔
194
    this._gfx.isVisible = false; // start not visible
2,850✔
195
    const totalWidth = this.map.tileWidth;
2,850✔
196
    const totalHeight = this.map.tileHeight;
2,850✔
197

198
    // initial guess at gfx bounds based on the tile
199
    const offset = vec(0, this.map.renderFromTopOfGraphic ? totalHeight : 0);
2,850✔
200
    this._gfx.localBounds = this._tileBounds = new BoundingBox({
2,850✔
201
      left: -totalWidth / 2,
202
      top: -totalHeight,
203
      right: totalWidth / 2,
204
      bottom: totalHeight
205
    }).translate(offset);
206
  }
207

208
  draw(gfx: ExcaliburGraphicsContext, _elapsed: number) {
209
    const halfTileWidth = this.map.tileWidth / 2;
900✔
210
    gfx.save();
900✔
211
    // shift left origin to corner of map, not the left corner of the first sprite
212
    gfx.translate(-halfTileWidth, 0);
900✔
213
    for (const graphic of this._graphics) {
900✔
214
      graphic.draw(
900✔
215
        gfx,
216
        this.map.graphicsOffset.x,
217
        this.map.graphicsOffset.y - (this.map.renderFromTopOfGraphic ? 0 : graphic.height - this.map.tileHeight)
900✔
218
      );
219
    }
220
    gfx.restore();
900✔
221
  }
222
}
223

224
export interface IsometricMapOptions {
225
  /**
226
   * Optionally name the isometric tile map
227
   */
228
  name?: string;
229
  /**
230
   * Optionally specify the position of the isometric tile map
231
   */
232
  pos?: Vector;
233
  /**
234
   * Optionally render from the top of the graphic, by default tiles are rendered from the bottom
235
   */
236
  renderFromTopOfGraphic?: boolean;
237
  /**
238
   * Optionally present a graphics offset, this can be useful depending on your tile graphics
239
   */
240
  graphicsOffset?: Vector;
241
  /**
242
   * Width of an individual tile in pixels, this should be the width of the parallelogram of the base of the tile art asset.
243
   */
244
  tileWidth: number;
245
  /**
246
   * Height of an individual tile in pixels, this should be the height of the parallelogram of the base of the tile art asset.
247
   */
248
  tileHeight: number;
249
  /**
250
   * The number of tile columns, or the number of tiles wide
251
   */
252
  columns: number;
253
  /**
254
   * The number of tile  rows, or the number of tiles high
255
   */
256
  rows: number;
257

258
  elevation?: number;
259
}
260

261
/**
262
 * The IsometricMap is a special tile map that provides isometric rendering support to Excalibur
263
 *
264
 * The tileWidth and tileHeight should be the height and width in pixels of the parallelogram of the base of the tile art asset.
265
 * The tileWidth and tileHeight is not necessarily the same as your graphic pixel width and height.
266
 *
267
 * Please refer to the docs https://excaliburjs.com for more details calculating what your tile width and height should be given
268
 * your art assets.
269
 */
270
export class IsometricMap extends Entity implements HasNestedPointerEvents {
271
  public readonly elevation: number = 0;
12✔
272

273
  /**
274
   * Width of individual tile in pixels
275
   */
276
  public readonly tileWidth: number;
277
  /**
278
   * Height of individual tile in pixels
279
   */
280
  public readonly tileHeight: number;
281
  /**
282
   * Number of tiles wide
283
   */
284
  public readonly columns: number;
285
  /**
286
   * Number of tiles high
287
   */
288
  public readonly rows: number;
289
  /**
290
   * List containing all of the tiles in IsometricMap
291
   */
292
  public readonly tiles: IsometricTile[];
293

294
  /**
295
   * Whether tiles should be visible
296
   * @deprecated use isVisible
297
   */
298
  public get visible(): boolean {
299
    return this.isVisible;
×
300
  }
301

302
  /**
303
   * Whether tiles should be visible
304
   * @deprecated use isVisible
305
   */
306
  public set visible(val: boolean) {
307
    this.isVisible = val;
×
308
  }
309

310
  /**
311
   * Whether tiles should be visible
312
   */
313
  public isVisible = true;
12✔
314

315
  /**
316
   * Opacity of tiles
317
   */
318
  public opacity = 1.0;
12✔
319

320
  /**
321
   * Render the tile graphic from the top instead of the bottom
322
   *
323
   * default is `false` meaning rendering from the bottom
324
   */
325
  public renderFromTopOfGraphic: boolean = false;
12✔
326
  public graphicsOffset: Vector = vec(0, 0);
12✔
327

328
  /**
329
   * Isometric map {@apilink TransformComponent}
330
   */
331
  public transform: TransformComponent;
332

333
  /**
334
   * Isometric map {@apilink ColliderComponent}
335
   */
336
  public collider: ColliderComponent;
337

338
  public pointer: PointerComponent;
339

340
  private _composite!: CompositeCollider;
341
  private _pointerEventDispatcher: PointerEventsToObjectDispatcher<IsometricTile>;
342

343
  constructor(options: IsometricMapOptions) {
344
    super(
12✔
345
      [
346
        new TransformComponent(),
347
        new BodyComponent({
348
          type: CollisionType.Fixed
349
        }),
350
        new ColliderComponent(),
351
        new PointerComponent(),
352
        new DebugGraphicsComponent((ctx, debugFlags) => this.debug(ctx, debugFlags), false)
1✔
353
      ],
354
      options.name
355
    );
356
    const { pos, tileWidth, tileHeight, columns: width, rows: height, renderFromTopOfGraphic, graphicsOffset, elevation } = options;
12✔
357

358
    this.transform = this.get(TransformComponent);
12✔
359
    if (pos) {
12!
360
      this.transform.pos = pos;
12✔
361
    }
362

363
    this.collider = this.get(ColliderComponent);
12✔
364
    if (this.collider) {
12!
365
      this.collider.set((this._composite = new CompositeCollider([])));
12✔
366
    }
367

368
    this.pointer = this.get(PointerComponent);
12✔
369

370
    this.renderFromTopOfGraphic = renderFromTopOfGraphic ?? this.renderFromTopOfGraphic;
12✔
371
    this.graphicsOffset = graphicsOffset ?? this.graphicsOffset;
12!
372

373
    this.elevation = elevation ?? this.elevation;
12!
374
    this.tileWidth = tileWidth;
12✔
375
    this.tileHeight = tileHeight;
12✔
376
    this.columns = width;
12✔
377
    this.rows = height;
12✔
378

379
    this._pointerEventDispatcher = new PointerEventsToObjectDispatcher();
12✔
380

381
    this.tiles = new Array(width * height);
12✔
382

383
    // build up tile representation
384
    for (let y = 0; y < height; y++) {
12✔
385
      for (let x = 0; x < width; x++) {
180✔
386
        const tile = new IsometricTile(x, y, this.graphicsOffset, this);
2,850✔
387
        this.tiles[x + y * width] = tile;
2,850✔
388
        this.addChild(tile);
2,850✔
389
        this._pointerEventDispatcher.addObject(
2,850✔
390
          tile,
UNCOV
391
          (p) => this.getTileByPoint(p.worldPos) === tile,
×
392
          () => true
5✔
393
        );
394
      }
395
    }
396

397
    this.pointer.localBounds = BoundingBox.fromDimension(
12✔
398
      tileWidth * width * this.transform.scale.x,
399
      tileHeight * height * this.transform.scale.y,
400
      vec(0.5, 0)
401
    );
402
  }
403

404
  /**
405
   * @internal
406
   */
407
  public _processPointerToObject(receiver: PointerEventReceiver) {
408
    // custom processor for tilmaps because it can be done VERY efficiently
409
    // DO NOT CALL this._pointerEventDispatcher.processPointerToObject
410
    const pointers: [pointerId: number, pos: GlobalCoordinates][] = Array.from(receiver.currentFramePointerCoords.entries());
8✔
411

412
    // find specific tiles tile for pointer
413
    for (const [pointerId, pos] of pointers) {
8✔
414
      const tile = this.getTileByPoint(this.transform.coordPlane === CoordPlane.World ? pos.worldPos : pos.screenPos);
4!
415
      if (tile) {
4!
416
        this._pointerEventDispatcher.addPointerToObject(tile, pointerId);
4✔
417
      }
418
    }
419
  }
420

421
  /**
422
   * @internal
423
   */
424
  public _dispatchPointerEvents(receiver: PointerEventReceiver) {
425
    this._pointerEventDispatcher.dispatchEvents(receiver, this.tiles);
8✔
426
  }
427

428
  public update(): void {
429
    if (this._collidersDirty) {
10✔
430
      this.updateColliders();
4✔
431
      this._collidersDirty = false;
4✔
432
    }
433

434
    this._pointerEventDispatcher.clear();
10✔
435
  }
436

437
  private _collidersDirty = false;
12✔
438
  public flagCollidersDirty() {
439
    this._collidersDirty = true;
4✔
440
  }
441

442
  private _originalOffsets = new WeakMap<Collider, Vector>();
12✔
443
  private _getOrSetColliderOriginalOffset(collider: Collider): Vector {
444
    if (!this._originalOffsets.has(collider)) {
2!
445
      const originalOffset = collider.offset;
2✔
446
      this._originalOffsets.set(collider, originalOffset);
2✔
447
      return originalOffset;
2✔
448
    } else {
449
      return this._originalOffsets.get(collider) ?? Vector.Zero;
×
450
    }
451
  }
452
  public updateColliders() {
453
    this._composite.clearColliders();
4✔
454
    const pos = this.get(TransformComponent).pos;
4✔
455
    for (const tile of this.tiles) {
4✔
456
      if (tile.solid) {
900✔
457
        for (const collider of tile.getColliders()) {
4✔
458
          const originalOffset = this._getOrSetColliderOriginalOffset(collider);
2✔
459
          collider.offset = this.tileToWorld(vec(tile.x, tile.y))
2✔
460
            .sub(pos)
461
            .add(originalOffset)
462
            .sub(vec(this.tileWidth / 2, this.tileHeight)); // We need to unshift height based on drawing
463
          collider.owner = this;
2✔
464
          this._composite.addCollider(collider);
2✔
465
        }
466
      }
467
    }
468
    this.collider.update();
4✔
469
  }
470

471
  /**
472
   * Convert world space coordinates to the tile x, y coordinate
473
   * @param worldCoordinate
474
   */
475
  public worldToTile(worldCoordinate: Vector): Vector {
476
    // TODO I don't think this handles parent transform see TileMap
477
    worldCoordinate = worldCoordinate.sub(this.transform.globalPos);
17✔
478

479
    const halfTileWidth = this.tileWidth / 2;
17✔
480
    const halfTileHeight = this.tileHeight / 2;
17✔
481
    // See https://clintbellanger.net/articles/isometric_math/ for formula
482
    return vec(
17✔
483
      ~~((worldCoordinate.x / halfTileWidth + worldCoordinate.y / halfTileHeight) / 2),
484
      ~~((worldCoordinate.y / halfTileHeight - worldCoordinate.x / halfTileWidth) / 2)
485
    );
486
  }
487

488
  /**
489
   * Given a tile coordinate, return the top left corner in world space
490
   * @param tileCoordinate
491
   */
492
  public tileToWorld(tileCoordinate: Vector): Vector {
493
    const halfTileWidth = this.tileWidth / 2;
303✔
494
    const halfTileHeight = this.tileHeight / 2;
303✔
495
    // The x position shifts left with every y step
496
    const xPos = (tileCoordinate.x - tileCoordinate.y) * halfTileWidth;
303✔
497
    // The y position needs to go down with every x step
498
    const yPos = (tileCoordinate.x + tileCoordinate.y) * halfTileHeight;
303✔
499
    return vec(xPos, yPos).add(this.transform.pos);
303✔
500
  }
501

502
  /**
503
   * Returns the {@apilink IsometricTile} by its x and y coordinates
504
   */
505
  public getTile(x: number, y: number): IsometricTile | null {
506
    if (x < 0 || y < 0 || x >= this.columns || y >= this.rows) {
20✔
507
      return null;
2✔
508
    }
509
    return this.tiles[x + y * this.columns];
18✔
510
  }
511

512
  /**
513
   * Returns the {@apilink IsometricTile} by testing a point in world coordinates,
514
   * returns `null` if no Tile was found.
515
   */
516
  public getTileByPoint(point: Vector): IsometricTile | null {
517
    const tileCoord = this.worldToTile(point);
12✔
518
    const tile = this.getTile(tileCoord.x, tileCoord.y);
12✔
519
    return tile;
12✔
520
  }
521

522
  private _getMaxZIndex(): number {
523
    let maxZ = Number.NEGATIVE_INFINITY;
1✔
524
    for (const tile of this.tiles) {
1✔
525
      const currentZ = tile.get(TransformComponent).z;
225✔
526
      if (currentZ > maxZ) {
225✔
527
        maxZ = currentZ;
29✔
528
      }
529
    }
530
    return maxZ;
1✔
531
  }
532

533
  /**
534
   * Debug draw for IsometricMap, called internally by excalibur when debug mode is toggled on
535
   * @param gfx
536
   */
537
  public debug(gfx: ExcaliburGraphicsContext, debugFlags: DebugConfig) {
538
    const { showAll, showPosition, positionColor, positionSize, showGrid, gridColor, gridWidth, showColliderGeometry } =
539
      debugFlags.isometric;
1✔
540

541
    const { geometryColor, geometryLineWidth, geometryPointSize } = debugFlags.collider;
1✔
542
    gfx.save();
1✔
543
    gfx.z = this._getMaxZIndex() + 0.5;
1✔
544
    if (showAll || showGrid) {
1!
545
      for (let y = 0; y < this.rows + 1; y++) {
1✔
546
        const left = this.tileToWorld(vec(0, y));
16✔
547
        const right = this.tileToWorld(vec(this.columns, y));
16✔
548
        gfx.drawLine(left, right, gridColor, gridWidth);
16✔
549
      }
550

551
      for (let x = 0; x < this.columns + 1; x++) {
1✔
552
        const top = this.tileToWorld(vec(x, 0));
16✔
553
        const bottom = this.tileToWorld(vec(x, this.rows));
16✔
554
        gfx.drawLine(top, bottom, gridColor, gridWidth);
16✔
555
      }
556
    }
557

558
    if (showAll || showPosition) {
1!
559
      for (const tile of this.tiles) {
1✔
560
        gfx.drawCircle(this.tileToWorld(vec(tile.x, tile.y)), positionSize, positionColor);
225✔
561
      }
562
    }
563
    if (showAll || showColliderGeometry) {
1!
564
      for (const tile of this.tiles) {
1✔
565
        if (tile.solid) {
225!
566
          // only draw solid tiles
567
          for (const collider of tile.getColliders()) {
×
568
            collider.debug(gfx, geometryColor, { lineWidth: geometryLineWidth, pointSize: geometryPointSize });
×
569
          }
570
        }
571
      }
572
    }
573
    gfx.restore();
1✔
574
  }
575
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc