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

excaliburjs / Excalibur / 15354777440

30 May 2025 08:03PM UTC coverage: 87.858% (-1.5%) from 89.344%
15354777440

Pull #3385

github

web-flow
Merge a00f57733 into e6ec66358
Pull Request #3385: updated Meet action to add tolerance

5002 of 6948 branches covered (71.99%)

3 of 5 new or added lines in 2 files covered. (60.0%)

872 existing lines in 83 files now uncovered.

13661 of 15549 relevant lines covered (87.86%)

25187.01 hits per line

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

96.32
/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

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

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

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

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

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

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

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

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

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

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

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

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

137
  private _transform: TransformComponent;
138
  private _isometricEntityComponent: IsometricEntityComponent;
139

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

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

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

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

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

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

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

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

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

256
  elevation?: number;
257
}
258

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

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

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

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

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

313
  /**
314
   * Opacity of tiles
315
   */
316
  public opacity = 1.0;
12✔
317

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

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

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

336
  public pointer: PointerComponent;
337

338
  private _composite!: CompositeCollider;
339
  private _pointerEventDispatcher: PointerEventsToObjectDispatcher<IsometricTile>;
340

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

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

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

366
    this.pointer = this.get(PointerComponent);
12✔
367

368
    this.renderFromTopOfGraphic = renderFromTopOfGraphic ?? this.renderFromTopOfGraphic;
12✔
369
    this.graphicsOffset = graphicsOffset ?? this.graphicsOffset;
12!
370

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

377
    this._pointerEventDispatcher = new PointerEventsToObjectDispatcher();
12✔
378

379
    this.tiles = new Array(width * height);
12✔
380

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

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

402
  /**
403
   * @internal
404
   */
405
  public _processPointerToObject(receiver: PointerEventReceiver) {
406
    this._pointerEventDispatcher.processPointerToObject(receiver, this.tiles);
8✔
407
  }
408

409
  /**
410
   * @internal
411
   */
412
  public _dispatchPointerEvents(receiver: PointerEventReceiver) {
413
    this._pointerEventDispatcher.dispatchEvents(receiver, this.tiles);
8✔
414
  }
415

416
  public update(): void {
417
    if (this._collidersDirty) {
10✔
418
      this.updateColliders();
4✔
419
      this._collidersDirty = false;
4✔
420
    }
421

422
    this._pointerEventDispatcher.clear();
10✔
423
  }
424

425
  private _collidersDirty = false;
12✔
426
  public flagCollidersDirty() {
427
    this._collidersDirty = true;
4✔
428
  }
429

430
  private _originalOffsets = new WeakMap<Collider, Vector>();
12✔
431
  private _getOrSetColliderOriginalOffset(collider: Collider): Vector {
432
    if (!this._originalOffsets.has(collider)) {
2!
433
      const originalOffset = collider.offset;
2✔
434
      this._originalOffsets.set(collider, originalOffset);
2✔
435
      return originalOffset;
2✔
436
    } else {
UNCOV
437
      return this._originalOffsets.get(collider) ?? Vector.Zero;
×
438
    }
439
  }
440
  public updateColliders() {
441
    this._composite.clearColliders();
4✔
442
    const pos = this.get(TransformComponent).pos;
4✔
443
    for (const tile of this.tiles) {
4✔
444
      if (tile.solid) {
900✔
445
        for (const collider of tile.getColliders()) {
4✔
446
          const originalOffset = this._getOrSetColliderOriginalOffset(collider);
2✔
447
          collider.offset = this.tileToWorld(vec(tile.x, tile.y))
2✔
448
            .sub(pos)
449
            .add(originalOffset)
450
            .sub(vec(this.tileWidth / 2, this.tileHeight)); // We need to unshift height based on drawing
451
          collider.owner = this;
2✔
452
          this._composite.addCollider(collider);
2✔
453
        }
454
      }
455
    }
456
    this.collider.update();
4✔
457
  }
458

459
  /**
460
   * Convert world space coordinates to the tile x, y coordinate
461
   * @param worldCoordinate
462
   */
463
  public worldToTile(worldCoordinate: Vector): Vector {
464
    // TODO I don't think this handles parent transform see TileMap
465
    worldCoordinate = worldCoordinate.sub(this.transform.globalPos);
1,613✔
466

467
    const halfTileWidth = this.tileWidth / 2;
1,613✔
468
    const halfTileHeight = this.tileHeight / 2;
1,613✔
469
    // See https://clintbellanger.net/articles/isometric_math/ for formula
470
    return vec(
1,613✔
471
      ~~((worldCoordinate.x / halfTileWidth + worldCoordinate.y / halfTileHeight) / 2),
472
      ~~((worldCoordinate.y / halfTileHeight - worldCoordinate.x / halfTileWidth) / 2)
473
    );
474
  }
475

476
  /**
477
   * Given a tile coordinate, return the top left corner in world space
478
   * @param tileCoordinate
479
   */
480
  public tileToWorld(tileCoordinate: Vector): Vector {
481
    const halfTileWidth = this.tileWidth / 2;
303✔
482
    const halfTileHeight = this.tileHeight / 2;
303✔
483
    // The x position shifts left with every y step
484
    const xPos = (tileCoordinate.x - tileCoordinate.y) * halfTileWidth;
303✔
485
    // The y position needs to go down with every x step
486
    const yPos = (tileCoordinate.x + tileCoordinate.y) * halfTileHeight;
303✔
487
    return vec(xPos, yPos).add(this.transform.pos);
303✔
488
  }
489

490
  /**
491
   * Returns the {@apilink IsometricTile} by its x and y coordinates
492
   */
493
  public getTile(x: number, y: number): IsometricTile | null {
494
    if (x < 0 || y < 0 || x >= this.columns || y >= this.rows) {
1,616✔
495
      return null;
2✔
496
    }
497
    return this.tiles[x + y * this.columns];
1,614✔
498
  }
499

500
  /**
501
   * Returns the {@apilink IsometricTile} by testing a point in world coordinates,
502
   * returns `null` if no Tile was found.
503
   */
504
  public getTileByPoint(point: Vector): IsometricTile | null {
505
    const tileCoord = this.worldToTile(point);
1,608✔
506
    const tile = this.getTile(tileCoord.x, tileCoord.y);
1,608✔
507
    return tile;
1,608✔
508
  }
509

510
  private _getMaxZIndex(): number {
511
    let maxZ = Number.NEGATIVE_INFINITY;
1✔
512
    for (const tile of this.tiles) {
1✔
513
      const currentZ = tile.get(TransformComponent).z;
225✔
514
      if (currentZ > maxZ) {
225✔
515
        maxZ = currentZ;
29✔
516
      }
517
    }
518
    return maxZ;
1✔
519
  }
520

521
  /**
522
   * Debug draw for IsometricMap, called internally by excalibur when debug mode is toggled on
523
   * @param gfx
524
   */
525
  public debug(gfx: ExcaliburGraphicsContext, debugFlags: DebugConfig) {
526
    const { showAll, showPosition, positionColor, positionSize, showGrid, gridColor, gridWidth, showColliderGeometry } =
527
      debugFlags.isometric;
1✔
528

529
    const { geometryColor, geometryLineWidth, geometryPointSize } = debugFlags.collider;
1✔
530
    gfx.save();
1✔
531
    gfx.z = this._getMaxZIndex() + 0.5;
1✔
532
    if (showAll || showGrid) {
1!
533
      for (let y = 0; y < this.rows + 1; y++) {
1✔
534
        const left = this.tileToWorld(vec(0, y));
16✔
535
        const right = this.tileToWorld(vec(this.columns, y));
16✔
536
        gfx.drawLine(left, right, gridColor, gridWidth);
16✔
537
      }
538

539
      for (let x = 0; x < this.columns + 1; x++) {
1✔
540
        const top = this.tileToWorld(vec(x, 0));
16✔
541
        const bottom = this.tileToWorld(vec(x, this.rows));
16✔
542
        gfx.drawLine(top, bottom, gridColor, gridWidth);
16✔
543
      }
544
    }
545

546
    if (showAll || showPosition) {
1!
547
      for (const tile of this.tiles) {
1✔
548
        gfx.drawCircle(this.tileToWorld(vec(tile.x, tile.y)), positionSize, positionColor);
225✔
549
      }
550
    }
551
    if (showAll || showColliderGeometry) {
1!
552
      for (const tile of this.tiles) {
1✔
553
        if (tile.solid) {
225!
554
          // only draw solid tiles
UNCOV
555
          for (const collider of tile.getColliders()) {
×
UNCOV
556
            collider.debug(gfx, geometryColor, { lineWidth: geometryLineWidth, pointSize: geometryPointSize });
×
557
          }
558
        }
559
      }
560
    }
561
    gfx.restore();
1✔
562
  }
563
}
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