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

excaliburjs / Excalibur / 14804036802

02 May 2025 09:58PM UTC coverage: 5.927% (-83.4%) from 89.28%
14804036802

Pull #3404

github

web-flow
Merge 5c103d7f8 into 0f2ccaeb2
Pull Request #3404: feat: added Graph module to Math

234 of 8383 branches covered (2.79%)

229 of 246 new or added lines in 1 file covered. (93.09%)

13145 existing lines in 208 files now uncovered.

934 of 15759 relevant lines covered (5.93%)

4.72 hits per line

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

0.0
/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 { 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 { Entity, EntityEvents } from '../EntityComponentSystem/Entity';
10
import { DebugGraphicsComponent, ExcaliburGraphicsContext, Graphic, GraphicsComponent } from '../Graphics';
11
import { IsometricEntityComponent } from './IsometricEntityComponent';
12
import { DebugConfig } from '../Debug';
13
import { PointerComponent } from '../Input/PointerComponent';
14
import { PointerEvent } from '../Input/PointerEvent';
15
import { EventEmitter } from '../EventEmitter';
16
import { HasNestedPointerEvents, PointerEventsToObjectDispatcher } from '../Input/PointerEventsToObjectDispatcher';
17
import { PointerEventReceiver } from '../Input/PointerEventReceiver';
18

19
export type IsometricTilePointerEvents = {
20
  pointerup: PointerEvent;
21
  pointerdown: PointerEvent;
22
  pointermove: PointerEvent;
23
  pointercancel: PointerEvent;
24
  pointerenter: PointerEvent;
25
  pointerleave: PointerEvent;
26
};
27

28
export class IsometricTile extends Entity {
29
  /**
30
   * Indicates whether this tile is solid
31
   */
UNCOV
32
  public solid: boolean = false;
×
33

UNCOV
34
  public events = new EventEmitter<EntityEvents & IsometricTilePointerEvents>();
×
35

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

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

68
  public removeGraphic(graphic: Graphic) {
UNCOV
69
    const index = this._graphics.indexOf(graphic);
×
UNCOV
70
    if (index > -1) {
×
UNCOV
71
      this._graphics.splice(index, 1);
×
72
    }
UNCOV
73
    this._gfx.localBounds = this._recalculateBounds();
×
74
  }
75

76
  public clearGraphics() {
UNCOV
77
    this._graphics.length = 0;
×
UNCOV
78
    this._gfx.isVisible = false;
×
UNCOV
79
    this._gfx.localBounds = this._recalculateBounds();
×
80
  }
81

82
  /**
83
   * Tile colliders
84
   */
UNCOV
85
  private _colliders: Collider[] = [];
×
86
  public getColliders(): readonly Collider[] {
UNCOV
87
    return this._colliders;
×
88
  }
89

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

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

113
  /**
114
   * Clears all colliders from the IsometricTile
115
   */
116
  public clearColliders(): void {
UNCOV
117
    this._colliders.length = 0;
×
UNCOV
118
    this.map.flagCollidersDirty();
×
119
  }
120

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

134
  private _transform: TransformComponent;
135
  private _isometricEntityComponent: IsometricEntityComponent;
136

137
  /**
138
   * Returns the top left corner of the {@apilink IsometricTile} in world space
139
   */
140
  public get pos(): Vector {
UNCOV
141
    return this.map.tileToWorld(vec(this.x, this.y));
×
142
  }
143

144
  /**
145
   * Returns the center of the {@apilink IsometricTile}
146
   */
147
  public get center(): Vector {
UNCOV
148
    return this.pos.add(vec(0, this.map.tileHeight / 2));
×
149
  }
150

151
  /**
152
   * Arbitrary data storage per tile, useful for any game specific data
153
   */
UNCOV
154
  public data = new Map<string, any>();
×
155

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

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

UNCOV
188
    this._gfx = this.get(GraphicsComponent);
×
UNCOV
189
    this._gfx.isVisible = false; // start not visible
×
UNCOV
190
    const totalWidth = this.map.tileWidth;
×
UNCOV
191
    const totalHeight = this.map.tileHeight;
×
192

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

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

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

253
  elevation?: number;
254
}
255

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

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

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

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

305
  /**
306
   * Whether tiles should be visible
307
   */
UNCOV
308
  public isVisible = true;
×
309

310
  /**
311
   * Opacity of tiles
312
   */
UNCOV
313
  public opacity = 1.0;
×
314

315
  /**
316
   * Render the tile graphic from the top instead of the bottom
317
   *
318
   * default is `false` meaning rendering from the bottom
319
   */
UNCOV
320
  public renderFromTopOfGraphic: boolean = false;
×
UNCOV
321
  public graphicsOffset: Vector = vec(0, 0);
×
322

323
  /**
324
   * Isometric map {@apilink TransformComponent}
325
   */
326
  public transform: TransformComponent;
327

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

333
  public pointer: PointerComponent;
334

335
  private _composite!: CompositeCollider;
336
  private _pointerEventDispatcher: PointerEventsToObjectDispatcher<IsometricTile>;
337

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

UNCOV
353
    this.transform = this.get(TransformComponent);
×
UNCOV
354
    if (pos) {
×
UNCOV
355
      this.transform.pos = pos;
×
356
    }
357

UNCOV
358
    this.collider = this.get(ColliderComponent);
×
UNCOV
359
    if (this.collider) {
×
UNCOV
360
      this.collider.set((this._composite = new CompositeCollider([])));
×
361
    }
362

UNCOV
363
    this.pointer = this.get(PointerComponent);
×
364

UNCOV
365
    this.renderFromTopOfGraphic = renderFromTopOfGraphic ?? this.renderFromTopOfGraphic;
×
UNCOV
366
    this.graphicsOffset = graphicsOffset ?? this.graphicsOffset;
×
367

UNCOV
368
    this.elevation = elevation ?? this.elevation;
×
UNCOV
369
    this.tileWidth = tileWidth;
×
UNCOV
370
    this.tileHeight = tileHeight;
×
UNCOV
371
    this.columns = width;
×
UNCOV
372
    this.rows = height;
×
373

UNCOV
374
    this._pointerEventDispatcher = new PointerEventsToObjectDispatcher();
×
375

UNCOV
376
    this.tiles = new Array(width * height);
×
377

378
    // build up tile representation
UNCOV
379
    for (let y = 0; y < height; y++) {
×
UNCOV
380
      for (let x = 0; x < width; x++) {
×
UNCOV
381
        const tile = new IsometricTile(x, y, this.graphicsOffset, this);
×
UNCOV
382
        this.tiles[x + y * width] = tile;
×
UNCOV
383
        this.addChild(tile);
×
UNCOV
384
        this._pointerEventDispatcher.addObject(
×
385
          tile,
UNCOV
386
          (p) => this.getTileByPoint(p.worldPos) === tile,
×
UNCOV
387
          () => true
×
388
        );
389
      }
390
    }
391

UNCOV
392
    this.pointer.localBounds = BoundingBox.fromDimension(
×
393
      tileWidth * width * this.transform.scale.x,
394
      tileHeight * height * this.transform.scale.y,
395
      vec(0.5, 0)
396
    );
397
  }
398

399
  /**
400
   * @internal
401
   */
402
  public _processPointerToObject(receiver: PointerEventReceiver) {
UNCOV
403
    this._pointerEventDispatcher.processPointerToObject(receiver, this.tiles);
×
404
  }
405

406
  /**
407
   * @internal
408
   */
409
  public _dispatchPointerEvents(receiver: PointerEventReceiver) {
UNCOV
410
    this._pointerEventDispatcher.dispatchEvents(receiver, this.tiles);
×
411
  }
412

413
  public update(): void {
UNCOV
414
    if (this._collidersDirty) {
×
UNCOV
415
      this.updateColliders();
×
UNCOV
416
      this._collidersDirty = false;
×
417
    }
418

UNCOV
419
    this._pointerEventDispatcher.clear();
×
420
  }
421

UNCOV
422
  private _collidersDirty = false;
×
423
  public flagCollidersDirty() {
UNCOV
424
    this._collidersDirty = true;
×
425
  }
426

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

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

UNCOV
464
    const halfTileWidth = this.tileWidth / 2;
×
UNCOV
465
    const halfTileHeight = this.tileHeight / 2;
×
466
    // See https://clintbellanger.net/articles/isometric_math/ for formula
UNCOV
467
    return vec(
×
468
      ~~((worldCoordinate.x / halfTileWidth + worldCoordinate.y / halfTileHeight) / 2),
469
      ~~((worldCoordinate.y / halfTileHeight - worldCoordinate.x / halfTileWidth) / 2)
470
    );
471
  }
472

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

487
  /**
488
   * Returns the {@apilink IsometricTile} by its x and y coordinates
489
   */
490
  public getTile(x: number, y: number): IsometricTile | null {
UNCOV
491
    if (x < 0 || y < 0 || x >= this.columns || y >= this.rows) {
×
UNCOV
492
      return null;
×
493
    }
UNCOV
494
    return this.tiles[x + y * this.columns];
×
495
  }
496

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

507
  private _getMaxZIndex(): number {
UNCOV
508
    let maxZ = Number.NEGATIVE_INFINITY;
×
UNCOV
509
    for (const tile of this.tiles) {
×
UNCOV
510
      const currentZ = tile.get(TransformComponent).z;
×
UNCOV
511
      if (currentZ > maxZ) {
×
UNCOV
512
        maxZ = currentZ;
×
513
      }
514
    }
UNCOV
515
    return maxZ;
×
516
  }
517

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

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

UNCOV
536
      for (let x = 0; x < this.columns + 1; x++) {
×
UNCOV
537
        const top = this.tileToWorld(vec(x, 0));
×
UNCOV
538
        const bottom = this.tileToWorld(vec(x, this.rows));
×
UNCOV
539
        gfx.drawLine(top, bottom, gridColor, gridWidth);
×
540
      }
541
    }
542

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