[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
2

Configure Feed

Select the types of activity you want to include in your feed.

feat: Implement IsometricMap & refactor TileMap with some perf boosts (#2254)

Roadmap related

This PR implements Isometric TileMap support with a separate `IsometricMap` type. Individual tiles are implemented as individual entities to support unique sorting to preserve the 2.5D illusion. `IsometricEntityComponent` flags tiles as isometric and are processed by `IsometricEntitySystem` to update the z-index according to the elevation and y coordinate.

<img width="178" alt="image" src="https://user-images.githubusercontent.com/612071/154827957-4ca1d753-12a6-4a5c-8c1f-5205aa632162.png">

The isometric tiles are drawn from the bottom by default (can be configured to draw from top) to mirror how Tiled renders tiles as well as to preserve the illusion of placing objects down on a grid.

- New `ex.IsometricMap` for drawing isometric grids! (They also support custom colliders via the same mechanism as `ex.TileMap`)
```typescript
new ex.IsometricMap({
pos: ex.vec(250, 10),
tileWidth: 32,
tileHeight: 16,
width: 15,
height: 15
});
```
- `ex.IsometricTile` now come with a `ex.IsometricEntityComponent` which can be applied to any entity that needs to be correctly sorted to preserve the isometric illusion
- `ex.IsometricEntitySystem` generates a new z-index based on the `elevation` and y position of an entity with `ex.IsometricEntityComponent`


TileMaps also have some changes, notably custom colliders!
```typescript
const tileMap = new ex.TileMap(...);
const tile = tileMap.getTile(0, 0);
tile.solid = true;
tile.addCollider(...); // add your custom collider!
```
This will work with the Tiled plugin to allow custom colliders
<img width="158" alt="image" src="https://user-images.githubusercontent.com/612071/154827872-30f30c34-ca64-49b1-9257-a74df2c4fe57.png">



## Changes:
- Refactored and breaking changes to `TileMap`
- New `IsometricMap`
- New `IsometricEntityComponent`
- New `IsometricEntityComponent`
- New `OffscreenSystem` to handle offscreen tagging
- Performance enhancements to help enable larger maps
- Cache sorted transforms in GraphicSystem
- Improve Camera Transform performance

authored by

Erik Onarheim and committed by
GitHub
(Feb 20, 2022, 6:02 PM -0600) d07518fc 7803b03f

+2172 -819
+41 -2
CHANGELOG.md
··· 7 7 8 8 ## Breaking Changes 9 9 10 - - 10 + - `ex.TileMap` has several breaking changes around naming, but brings it consistent with Tiled terminology and the new `ex.IsometricMap`. Additionally the new names are easier to follow. 11 + - Constructor has been changed to the following 12 + ```typescript 13 + new ex.TileMap({ 14 + pos: ex.vec(100, 100), 15 + tileWidth: 64, 16 + tileHeight: 48, 17 + height: 20, 18 + width: 20 19 + }); 20 + ``` 21 + - `ex.Cell` has been renamed to `ex.Tile` 22 + - `ex.Tile` now uses `addGraphic(...)`, `removeGraphic(...)`, `clearGraphics()` and `getGraphics()` instead of having an accessible `ex.Tile.graphics` array. 23 + - `ex.TileMap.data` has been renamed to `ex.TileMap.tiles` 24 + - `ex.TileMap.getCell(..)` has been renamed to `ex.TileMap.getTile(...)` 25 + - `ex.TileMap.getCellByIndex(...)` has been renamed to `ex.TileMap.getTileByIndex(...)` 26 + - `ex.TileMap.getCellByPoint(...)` has been renamed to `ex.TileMap.getTileByPoint(...)` 27 + 11 28 12 29 ### Deprecated 13 30 14 31 - 15 32 16 33 ### Added 34 + 35 + 36 + - `ex.TileMap` now supports per Tile custom colliders! 37 + ```typescript 38 + const tileMap = new ex.TileMap(...); 39 + const tile = tileMap.getTile(0, 0); 40 + tile.solid = true; 41 + tile.addCollider(...); // add your custom collider! 42 + ``` 43 + - New `ex.IsometricMap` for drawing isometric grids! (They also support custom colliders via the same mechanism as `ex.TileMap`) 44 + ```typescript 45 + new ex.IsometricMap({ 46 + pos: ex.vec(250, 10), 47 + tileWidth: 32, 48 + tileHeight: 16, 49 + width: 15, 50 + height: 15 51 + }); 52 + ``` 53 + - `ex.IsometricTile` now come with a `ex.IsometricEntityComponent` which can be applied to any entity that needs to be correctly sorted to preserve the isometric illusion 54 + - `ex.IsometricEntitySystem` generates a new z-index based on the `elevation` and y position of an entity with `ex.IsometricEntityComponent` 17 55 18 56 - Added arbitrary non-convex polygon support (only non-self intersecting) with `ex.PolygonCollider(...).triangulate()` which builds a new `ex.CompositeCollider` composed of triangles. 19 57 - Added faster `ex.BoundingBox.transform(...)` implementation. ··· 27 65 28 66 ### Updates 29 67 30 - - 68 + - Performance improvement to GraphicsSystem 31 69 32 70 ### Changed 33 71 72 + - Split offscreen detection into a separate system 34 73 - Renamed `ex.Matrix.multv()` and `ex.Matrix.multm()` to `ex.Matrix.multiply()` which matches our naming conventions 35 74 36 75 <!--------------------------------- DO NOT EDIT BELOW THIS LINE --------------------------------->
+1
sandbox/index.html
··· 9 9 10 10 <ul> 11 11 <li><a href="html/index.html">Sandbox Platformer</a></li> 12 + <li><a href="tests/isometric/">Isometric Map</a></li> 12 13 <li><a href="tests/triangulation/index.html">Triangulation</a></li> 13 14 <li><a href="tests/drawcalls/index.html">Draw Calls</a></li> 14 15 <li><a href="tests/imageloading/index.html">Large Image Loading</a></li>
+3 -3
sandbox/src/game.ts
··· 343 343 344 344 // create a collision map 345 345 // var tileMap = new ex.TileMap(100, 300, tileBlockWidth, tileBlockHeight, 4, 500); 346 - var tileMap = new ex.TileMap({ x: 100, y: 300, cellWidth: tileBlockWidth, cellHeight: tileBlockHeight, rows: 4, cols: 500 }); 346 + var tileMap = new ex.TileMap({ pos: ex.vec(100, 300), tileWidth: tileBlockWidth, tileHeight: tileBlockHeight, height: 4, width: 500 }); 347 347 var blocks = ex.Sprite.from(imageBlocks); 348 348 // var flipped = spriteTiles.sprites[0].clone(); 349 349 // flipped.flipVertical = true; ··· 353 353 // { graphic: flipped, duration: 200 } 354 354 // ] 355 355 // }) 356 - tileMap.data.forEach(function(cell: ex.Cell) { 356 + tileMap.tiles.forEach(function(cell: ex.Tile) { 357 357 cell.solid = true; 358 358 cell.addGraphic(spriteTiles.sprites[0]); 359 359 }); ··· 784 784 game.add(trigger); 785 785 786 786 game.input.pointers.primary.on('down', (evt: ex.Input.PointerEvent) => { 787 - var c = tileMap.getCellByPoint(evt.worldPos.x, evt.worldPos.y); 787 + var c = tileMap.getTileByPoint(evt.worldPos); 788 788 if (c) { 789 789 if (c.solid) { 790 790 c.solid = false;
+9 -2
src/engine/Camera.ts
··· 615 615 // First frame bootstrap 616 616 617 617 // Ensure camera tx is correct 618 + // Run update twice to ensure properties are init'd 618 619 this.updateTransform(); 619 620 620 621 // Run strategies for first frame ··· 622 623 623 624 // Setup the first frame viewport 624 625 this.updateViewport(); 626 + 627 + // It's important to update the camera after strategies 628 + // This prevents jitter 629 + this.updateTransform(); 625 630 626 631 this.onInitialize(_engine); 627 632 super.emit('initialize', new InitializeEvent(_engine, this)); ··· 741 746 this._yShake = ((Math.random() * this._shakeMagnitudeY) | 0) + 1; 742 747 } 743 748 744 - this.updateTransform(); 745 - 746 749 this.runStrategies(_engine, delta); 747 750 748 751 this.updateViewport(); 752 + 753 + // It's important to update the camera after strategies 754 + // This prevents jitter 755 + this.updateTransform(); 749 756 750 757 this._postupdate(_engine, delta); 751 758 }
+2
src/engine/Engine.ts
··· 837 837 */ 838 838 public add(actor: Actor): void; 839 839 840 + public add(entity: Entity): void; 841 + 840 842 /** 841 843 * Adds a [[ScreenElement]] to the [[currentScene]] of the game, 842 844 * ScreenElements do not participate in collisions, instead the
+2 -1
src/engine/Particles.ts
··· 13 13 import { Entity } from './EntityComponentSystem/Entity'; 14 14 import { CanvasDrawComponent } from './Drawing/Index'; 15 15 import { Sprite } from './Graphics/Sprite'; 16 - import { LegacyDrawing } from '.'; 16 + import { BoundingBox, LegacyDrawing } from '.'; 17 17 import { clamp, randomInRange } from './Math/util'; 18 18 19 19 /** ··· 132 132 this.graphics.opacity = this.opacity; 133 133 this.graphics.use(Sprite.fromLegacySprite(this.particleSprite)); 134 134 } else { 135 + this.graphics.localBounds = BoundingBox.fromDimension(this.particleSize, this.particleSize, Vector.Half); 135 136 this.graphics.onPostDraw = (ctx) => { 136 137 ctx.save(); 137 138 this.graphics.opacity = this.opacity;
+4
src/engine/Scene.ts
··· 35 35 import { DebugSystem } from './Debug/DebugSystem'; 36 36 import { PointerSystem } from './Input/PointerSystem'; 37 37 import { ActionsSystem } from './Actions/ActionsSystem'; 38 + import { IsometricEntitySystem } from './TileMap/IsometricEntitySystem'; 39 + import { OffscreenSystem } from './Graphics/OffscreenSystem'; 38 40 /** 39 41 * [[Actor|Actors]] are composed together into groupings called Scenes in 40 42 * Excalibur. The metaphor models the same idea behind real world ··· 119 121 this.world.add(new MotionSystem()); 120 122 this.world.add(new CollisionSystem()); 121 123 this.world.add(new PointerSystem()); 124 + this.world.add(new IsometricEntitySystem()); 122 125 if (Flags.isEnabled(Legacy.LegacyDrawing)) { 123 126 this.world.add(new CanvasDrawingSystem()); 124 127 } else { 128 + this.world.add(new OffscreenSystem()); 125 129 this.world.add(new GraphicsSystem()); 126 130 } 127 131 this.world.add(new DebugSystem());
-606
src/engine/TileMap.ts
··· 1 - import { BoundingBox } from './Collision/BoundingBox'; 2 - import { Engine } from './Engine'; 3 - import { Vector, vec } from './Math/vector'; 4 - import { Logger } from './Util/Log'; 5 - import { SpriteSheet } from './Drawing/SpriteSheet'; 6 - import * as Events from './Events'; 7 - import { Configurable } from './Configurable'; 8 - import { Entity } from './EntityComponentSystem/Entity'; 9 - import { TransformComponent } from './EntityComponentSystem/Components/TransformComponent'; 10 - import { BodyComponent } from './Collision/BodyComponent'; 11 - import { CollisionType } from './Collision/CollisionType'; 12 - import { Shape } from './Collision/Colliders/Shape'; 13 - import { ExcaliburGraphicsContext, GraphicsComponent, hasGraphicsTick } from './Graphics'; 14 - import * as Graphics from './Graphics'; 15 - import { CanvasDrawComponent, Sprite } from './Drawing/Index'; 16 - import { Sprite as LegacySprite } from './Drawing/Index'; 17 - import { removeItemFromArray } from './Util/Util'; 18 - import { obsolete } from './Util/Decorators'; 19 - import { MotionComponent } from './EntityComponentSystem/Components/MotionComponent'; 20 - import { ColliderComponent } from './Collision/ColliderComponent'; 21 - import { CompositeCollider } from './Collision/Colliders/CompositeCollider'; 22 - import { Color } from './Color'; 23 - import { DebugGraphicsComponent } from './Graphics/DebugGraphicsComponent'; 24 - 25 - /** 26 - * @hidden 27 - */ 28 - export class TileMapImpl extends Entity { 29 - private _token = 0; 30 - private _onScreenXStart: number = 0; 31 - private _onScreenXEnd: number = 9999; 32 - private _onScreenYStart: number = 0; 33 - private _onScreenYEnd: number = 9999; 34 - private _spriteSheets: { [key: string]: Graphics.SpriteSheet } = {}; 35 - 36 - private _legacySpriteMap = new Map<Graphics.Sprite, Sprite>(); 37 - public logger: Logger = Logger.getInstance(); 38 - public readonly data: Cell[] = []; 39 - private _rows: Cell[][] = []; 40 - private _cols: Cell[][] = []; 41 - public visible = true; 42 - public isOffscreen = false; 43 - public readonly cellWidth: number; 44 - public readonly cellHeight: number; 45 - public readonly rows: number; 46 - public readonly cols: number; 47 - 48 - private _dirty = true; 49 - public flagDirty() { 50 - this._dirty = true; 51 - } 52 - private _transform: TransformComponent; 53 - private _motion: MotionComponent; 54 - private _collider: ColliderComponent; 55 - private _composite: CompositeCollider; 56 - 57 - public get x(): number { 58 - return this._transform.pos.x ?? 0; 59 - } 60 - 61 - public set x(val: number) { 62 - if (this._transform?.pos) { 63 - this.get(TransformComponent).pos = vec(val, this.y); 64 - } 65 - } 66 - 67 - public get y(): number { 68 - return this._transform?.pos.y ?? 0; 69 - } 70 - 71 - public set y(val: number) { 72 - if (this._transform?.pos) { 73 - this._transform.pos = vec(this.x, val); 74 - } 75 - } 76 - 77 - public get z(): number { 78 - return this._transform.z ?? 0; 79 - } 80 - 81 - public set z(val: number) { 82 - if (this._transform) { 83 - this._transform.z = val; 84 - } 85 - } 86 - 87 - public get rotation(): number { 88 - return this._transform?.rotation ?? 0; 89 - } 90 - 91 - public set rotation(val: number) { 92 - if (this._transform?.rotation) { 93 - this._transform.rotation = val; 94 - } 95 - } 96 - 97 - public get scale(): Vector { 98 - return this._transform?.scale ?? Vector.One; 99 - } 100 - public set scale(val: Vector) { 101 - if (this._transform?.scale) { 102 - this._transform.scale = val; 103 - } 104 - } 105 - 106 - public get pos(): Vector { 107 - return this._transform.pos; 108 - } 109 - 110 - public set pos(val: Vector) { 111 - this._transform.pos = val; 112 - } 113 - 114 - public get vel(): Vector { 115 - return this._motion.vel; 116 - } 117 - 118 - public set vel(val: Vector) { 119 - this._motion.vel = val; 120 - } 121 - 122 - public on(eventName: Events.preupdate, handler: (event: Events.PreUpdateEvent<TileMap>) => void): void; 123 - public on(eventName: Events.postupdate, handler: (event: Events.PostUpdateEvent<TileMap>) => void): void; 124 - public on(eventName: Events.predraw, handler: (event: Events.PreDrawEvent) => void): void; 125 - public on(eventName: Events.postdraw, handler: (event: Events.PostDrawEvent) => void): void; 126 - public on(eventName: string, handler: (event: Events.GameEvent<any>) => void): void; 127 - public on(eventName: string, handler: (event: any) => void): void { 128 - super.on(eventName, handler); 129 - } 130 - 131 - /** 132 - * @param xOrConfig The x coordinate to anchor the TileMap's upper left corner (should not be changed once set) or TileMap option bag 133 - * @param y The y coordinate to anchor the TileMap's upper left corner (should not be changed once set) 134 - * @param cellWidth The individual width of each cell (in pixels) (should not be changed once set) 135 - * @param cellHeight The individual height of each cell (in pixels) (should not be changed once set) 136 - * @param rows The number of rows in the TileMap (should not be changed once set) 137 - * @param cols The number of cols in the TileMap (should not be changed once set) 138 - */ 139 - constructor(xOrConfig: number | TileMapArgs, y: number, cellWidth: number, cellHeight: number, rows: number, cols: number) { 140 - super(); 141 - if (xOrConfig && typeof xOrConfig === 'object') { 142 - const config = xOrConfig; 143 - xOrConfig = config.x; 144 - y = config.y; 145 - cellWidth = config.cellWidth; 146 - cellHeight = config.cellHeight; 147 - rows = config.rows; 148 - cols = config.cols; 149 - } 150 - this.addComponent(new TransformComponent()); 151 - this.addComponent(new MotionComponent()); 152 - this.addComponent( 153 - new BodyComponent({ 154 - type: CollisionType.Fixed 155 - }) 156 - ); 157 - this.addComponent(new CanvasDrawComponent((ctx, delta) => this.draw(ctx, delta))); 158 - this.addComponent( 159 - new GraphicsComponent({ 160 - onPostDraw: (ctx, delta) => this.draw(ctx, delta) 161 - }) 162 - ); 163 - this.addComponent(new DebugGraphicsComponent((ctx) => this.debug(ctx))); 164 - this.addComponent(new ColliderComponent()); 165 - this._transform = this.get(TransformComponent); 166 - this._motion = this.get(MotionComponent); 167 - this._collider = this.get(ColliderComponent); 168 - this._composite = this._collider.useCompositeCollider([]); 169 - 170 - this.x = <number>xOrConfig; 171 - this.y = y; 172 - this.cellWidth = cellWidth; 173 - this.cellHeight = cellHeight; 174 - this.rows = rows; 175 - this.cols = cols; 176 - this.data = new Array<Cell>(rows * cols); 177 - this._rows = new Array(rows); 178 - this._cols = new Array(cols); 179 - let currentCol: Cell[] = []; 180 - for (let i = 0; i < cols; i++) { 181 - for (let j = 0; j < rows; j++) { 182 - const cd = new Cell(i * cellWidth + <number>xOrConfig, j * cellHeight + y, cellWidth, cellHeight, i + j * cols); 183 - cd.map = this; 184 - this.data[i + j * cols] = cd; 185 - currentCol.push(cd); 186 - if (!this._rows[j]) { 187 - this._rows[j] = []; 188 - } 189 - this._rows[j].push(cd); 190 - } 191 - this._cols[i] = currentCol; 192 - currentCol = []; 193 - } 194 - 195 - this.get(GraphicsComponent).localBounds = new BoundingBox({ 196 - left: 0, 197 - top: 0, 198 - right: this.cols * this.cellWidth, 199 - bottom: this.rows * this.cellHeight 200 - }); 201 - } 202 - 203 - public _initialize(engine: Engine) { 204 - super._initialize(engine); 205 - } 206 - 207 - /** 208 - * 209 - * @param key 210 - * @param spriteSheet 211 - * @deprecated No longer used, will be removed in v0.26.0 212 - */ 213 - public registerSpriteSheet(key: string, spriteSheet: SpriteSheet): void; 214 - public registerSpriteSheet(key: string, spriteSheet: Graphics.SpriteSheet): void; 215 - @obsolete({ message: 'No longer used, will be removed in v0.26.0' }) 216 - public registerSpriteSheet(key: string, spriteSheet: SpriteSheet | Graphics.SpriteSheet): void { 217 - if (spriteSheet instanceof Graphics.SpriteSheet) { 218 - this._spriteSheets[key] = spriteSheet; 219 - } else { 220 - this._spriteSheets[key] = Graphics.SpriteSheet.fromLegacySpriteSheet(spriteSheet); 221 - } 222 - } 223 - 224 - /** 225 - * Tiles colliders based on the solid tiles in the tilemap. 226 - */ 227 - private _updateColliders(): void { 228 - this._composite.clearColliders(); 229 - const colliders: BoundingBox[] = []; 230 - let current: BoundingBox; 231 - // Bad square tesselation algo 232 - for (let i = 0; i < this.cols; i++) { 233 - // Scan column for colliders 234 - for (let j = 0; j < this.rows; j++) { 235 - // Columns start with a new collider 236 - if (j === 0) { 237 - current = null; 238 - } 239 - const tile = this.data[i + j * this.cols]; 240 - // Current tile in column is solid build up current collider 241 - if (tile.solid) { 242 - if (!current) { 243 - current = tile.bounds; 244 - } else { 245 - current = current.combine(tile.bounds); 246 - } 247 - } else { 248 - // Not solid skip and cut off the current collider 249 - if (current) { 250 - colliders.push(current); 251 - } 252 - current = null; 253 - } 254 - } 255 - // After a column is complete check to see if it can be merged into the last one 256 - if (current) { 257 - // if previous is the same combine it 258 - const prev = colliders[colliders.length - 1]; 259 - if (prev && prev.top === current.top && prev.bottom === current.bottom) { 260 - colliders[colliders.length - 1] = prev.combine(current); 261 - } else { 262 - // else new collider 263 - colliders.push(current); 264 - } 265 - } 266 - } 267 - this._composite = this._collider.useCompositeCollider([]); 268 - for (const c of colliders) { 269 - const collider = Shape.Box(c.width, c.height, Vector.Zero, vec(c.left - this.pos.x, c.top - this.pos.y)); 270 - collider.owner = this; 271 - this._composite.addCollider(collider); 272 - } 273 - this._collider.update(); 274 - } 275 - 276 - /** 277 - * Returns the [[Cell]] by index (row major order) 278 - */ 279 - public getCellByIndex(index: number): Cell { 280 - return this.data[index]; 281 - } 282 - /** 283 - * Returns the [[Cell]] by its x and y coordinates 284 - */ 285 - public getCell(x: number, y: number): Cell { 286 - if (x < 0 || y < 0 || x >= this.cols || y >= this.rows) { 287 - return null; 288 - } 289 - return this.data[x + y * this.cols]; 290 - } 291 - /** 292 - * Returns the [[Cell]] by testing a point in global coordinates, 293 - * returns `null` if no cell was found. 294 - */ 295 - public getCellByPoint(x: number, y: number): Cell { 296 - x = Math.floor((x - this.pos.x) / this.cellWidth); 297 - y = Math.floor((y - this.pos.y) / this.cellHeight); 298 - const cell = this.getCell(x, y); 299 - if (x >= 0 && y >= 0 && x < this.cols && y < this.rows && cell) { 300 - return cell; 301 - } 302 - return null; 303 - } 304 - 305 - public getRows(): readonly Cell[][] { 306 - return this._rows; 307 - } 308 - 309 - public getColumns(): readonly Cell[][] { 310 - return this._cols; 311 - } 312 - 313 - public onPreUpdate(_engine: Engine, _delta: number) { 314 - // Override me 315 - } 316 - 317 - public onPostUpdate(_engine: Engine, _delta: number) { 318 - // Override me 319 - } 320 - 321 - public update(engine: Engine, delta: number) { 322 - this.onPreUpdate(engine, delta); 323 - this.emit('preupdate', new Events.PreUpdateEvent(engine, delta, this)); 324 - if (this._dirty) { 325 - this._dirty = false; 326 - this._updateColliders(); 327 - } 328 - 329 - this._token++; 330 - const worldBounds = engine.getWorldBounds(); 331 - const worldCoordsUpperLeft = vec(worldBounds.left, worldBounds.top); 332 - const worldCoordsLowerRight = vec(worldBounds.right, worldBounds.bottom); 333 - 334 - this._onScreenXStart = Math.max(Math.floor((worldCoordsUpperLeft.x - this.x) / this.cellWidth) - 2, 0); 335 - this._onScreenYStart = Math.max(Math.floor((worldCoordsUpperLeft.y - this.y) / this.cellHeight) - 2, 0); 336 - this._onScreenXEnd = Math.max(Math.floor((worldCoordsLowerRight.x - this.x) / this.cellWidth) + 2, 0); 337 - this._onScreenYEnd = Math.max(Math.floor((worldCoordsLowerRight.y - this.y) / this.cellHeight) + 2, 0); 338 - this._transform.pos = vec(this.x, this.y); 339 - 340 - this.onPostUpdate(engine, delta); 341 - this.emit('postupdate', new Events.PostUpdateEvent(engine, delta, this)); 342 - } 343 - 344 - /** 345 - * Draws the tile map to the screen. Called by the [[Scene]]. 346 - * @param ctx CanvasRenderingContext2D or ExcaliburGraphicsContext 347 - * @param delta The number of milliseconds since the last draw 348 - */ 349 - public draw(ctx: CanvasRenderingContext2D | ExcaliburGraphicsContext, delta: number): void { 350 - this.emit('predraw', new Events.PreDrawEvent(ctx as any, delta, this)); // TODO fix event 351 - 352 - let x = this._onScreenXStart; 353 - const xEnd = Math.min(this._onScreenXEnd, this.cols); 354 - let y = this._onScreenYStart; 355 - const yEnd = Math.min(this._onScreenYEnd, this.rows); 356 - 357 - let graphics: Graphics.Graphic[], graphicsIndex: number, graphicsLen: number; 358 - 359 - for (x; x < xEnd; x++) { 360 - for (y; y < yEnd; y++) { 361 - // get non-negative tile sprites 362 - graphics = this.getCell(x, y).graphics; 363 - 364 - for (graphicsIndex = 0, graphicsLen = graphics.length; graphicsIndex < graphicsLen; graphicsIndex++) { 365 - // draw sprite, warning if sprite doesn't exist 366 - const graphic = graphics[graphicsIndex]; 367 - if (graphic) { 368 - if (!(ctx instanceof CanvasRenderingContext2D)) { 369 - if (hasGraphicsTick(graphic)) { 370 - graphic?.tick(delta, this._token); 371 - } 372 - graphic.draw(ctx, x * this.cellWidth, y * this.cellHeight); 373 - } else if (graphic instanceof Graphics.Sprite) { 374 - // TODO legacy drawing mode 375 - if (!this._legacySpriteMap.has(graphic)) { 376 - this._legacySpriteMap.set(graphic, Graphics.Sprite.toLegacySprite(graphic)); 377 - } 378 - this._legacySpriteMap.get(graphic).draw(ctx, x * this.cellWidth, y * this.cellHeight); 379 - } 380 - } 381 - } 382 - } 383 - y = this._onScreenYStart; 384 - } 385 - 386 - this.emit('postdraw', new Events.PostDrawEvent(ctx as any, delta, this)); 387 - } 388 - 389 - public debug(gfx: ExcaliburGraphicsContext) { 390 - const width = this.cellWidth * this.cols; 391 - const height = this.cellHeight * this.rows; 392 - const pos = Vector.Zero; 393 - for (let r = 0; r < this.rows + 1; r++) { 394 - const yOffset = vec(0, r * this.cellHeight); 395 - gfx.drawLine(pos.add(yOffset), pos.add(vec(width, yOffset.y)), Color.Red, 2); 396 - } 397 - 398 - for (let c = 0; c < this.cols +1; c++) { 399 - const xOffset = vec(c * this.cellWidth, 0); 400 - gfx.drawLine(pos.add(xOffset), pos.add(vec(xOffset.x, height)), Color.Red, 2); 401 - } 402 - 403 - const colliders = this._composite.getColliders(); 404 - for (const collider of colliders) { 405 - const grayish = Color.Gray; 406 - grayish.a = 0.5; 407 - const bounds = collider.localBounds; 408 - const pos = collider.worldPos.sub(this.pos); 409 - gfx.drawRectangle(pos, bounds.width, bounds.height, grayish); 410 - } 411 - } 412 - } 413 - 414 - export interface TileMapArgs extends Partial<TileMapImpl> { 415 - x: number; 416 - y: number; 417 - cellWidth: number; 418 - cellHeight: number; 419 - rows: number; 420 - cols: number; 421 - } 422 - 423 - /** 424 - * The [[TileMap]] class provides a lightweight way to do large complex scenes with collision 425 - * without the overhead of actors. 426 - */ 427 - export class TileMap extends Configurable(TileMapImpl) { 428 - constructor(config: TileMapArgs); 429 - constructor(x: number, y: number, cellWidth: number, cellHeight: number, rows: number, cols: number); 430 - constructor(xOrConfig: number | TileMapArgs, y?: number, cellWidth?: number, cellHeight?: number, rows?: number, cols?: number) { 431 - super(xOrConfig, y, cellWidth, cellHeight, rows, cols); 432 - } 433 - } 434 - 435 - /** 436 - * @hidden 437 - */ 438 - export class CellImpl extends Entity { 439 - private _bounds: BoundingBox; 440 - /** 441 - * World space x coordinate of the left of the cell 442 - */ 443 - public readonly x: number; 444 - /** 445 - * World space y coordinate of the top of the cell 446 - */ 447 - public readonly y: number; 448 - /** 449 - * Width of the cell in pixels 450 - */ 451 - public readonly width: number; 452 - /** 453 - * Height of the cell in pixels 454 - */ 455 - public readonly height: number; 456 - /** 457 - * Current index in the tilemap 458 - */ 459 - public readonly index: number; 460 - 461 - /** 462 - * Reference to the TileMap this Cell is associated with 463 - */ 464 - public map: TileMap; 465 - 466 - private _solid = false; 467 - /** 468 - * Wether this cell should be treated as solid by the tilemap 469 - */ 470 - public get solid(): boolean { 471 - return this._solid; 472 - } 473 - /** 474 - * Wether this cell should be treated as solid by the tilemap 475 - */ 476 - public set solid(val: boolean) { 477 - this.map?.flagDirty(); 478 - this._solid = val; 479 - } 480 - /** 481 - * Current list of graphics for this cell 482 - */ 483 - public readonly graphics: Graphics.Graphic[] = []; 484 - /** 485 - * Arbitrary data storage per cell, useful for any game specific data 486 - */ 487 - public data = new Map<string, any>(); 488 - 489 - /** 490 - * @param xOrConfig Gets or sets x coordinate of the cell in world coordinates or cell option bag 491 - * @param y Gets or sets y coordinate of the cell in world coordinates 492 - * @param width Gets or sets the width of the cell 493 - * @param height Gets or sets the height of the cell 494 - * @param index The index of the cell in row major order 495 - * @param solid Gets or sets whether this cell is solid 496 - * @param graphics The list of tile graphics to use to draw in this cell (in order) 497 - */ 498 - constructor( 499 - xOrConfig: number | CellArgs, 500 - y: number, 501 - width: number, 502 - height: number, 503 - index: number, 504 - solid: boolean = false, 505 - graphics: Graphics.Graphic[] = [] 506 - ) { 507 - super(); 508 - if (xOrConfig && typeof xOrConfig === 'object') { 509 - const config = xOrConfig; 510 - xOrConfig = config.x; 511 - y = config.y; 512 - width = config.width; 513 - height = config.height; 514 - index = config.index; 515 - solid = config.solid; 516 - graphics = config.sprites; 517 - } 518 - this.x = <number>xOrConfig; 519 - this.y = y; 520 - this.width = width; 521 - this.height = height; 522 - this.index = index; 523 - this.solid = solid; 524 - this.graphics = graphics; 525 - this._bounds = new BoundingBox(this.x, this.y, this.x + this.width, this.y + this.height); 526 - } 527 - 528 - public get bounds() { 529 - return this._bounds; 530 - } 531 - 532 - public get center(): Vector { 533 - return new Vector(this.x + this.width / 2, this.y + this.height / 2); 534 - } 535 - 536 - /** 537 - * Add another [[Sprite]] to this cell 538 - * @deprecated Use addSprite, will be removed in v0.26.0 539 - */ 540 - @obsolete({ message: 'Will be removed in v0.26.0', alternateMethod: 'addSprite' }) 541 - public pushSprite(sprite: Graphics.Sprite | LegacySprite) { 542 - this.addGraphic(sprite); 543 - } 544 - 545 - /** 546 - * Add another [[Graphic]] to this TileMap cell 547 - * @param graphic 548 - */ 549 - public addGraphic(graphic: Graphics.Graphic | LegacySprite) { 550 - if (graphic instanceof LegacySprite) { 551 - this.graphics.push(Graphics.Sprite.fromLegacySprite(graphic)); 552 - } else { 553 - this.graphics.push(graphic); 554 - } 555 - } 556 - 557 - /** 558 - * Remove an instance of a [[Graphic]] from this cell 559 - */ 560 - public removeGraphic(graphic: Graphics.Graphic | LegacySprite) { 561 - removeItemFromArray(graphic, this.graphics); 562 - } 563 - 564 - /** 565 - * Clear all graphics from this cell 566 - */ 567 - public clearGraphics() { 568 - this.graphics.length = 0; 569 - } 570 - } 571 - 572 - export interface CellArgs extends Partial<CellImpl> { 573 - x: number; 574 - y: number; 575 - width: number; 576 - height: number; 577 - index: number; 578 - solid?: boolean; 579 - sprites?: Graphics.Sprite[]; 580 - } 581 - 582 - /** 583 - * TileMap Cell 584 - * 585 - * A light-weight object that occupies a space in a collision map. Generally 586 - * created by a [[TileMap]]. 587 - * 588 - * Cells can draw multiple sprites. Note that the order of drawing is the order 589 - * of the sprites in the array so the last one will be drawn on top. You can 590 - * use transparency to create layers this way. 591 - */ 592 - export class Cell extends Configurable(CellImpl) { 593 - constructor(config: CellArgs); 594 - constructor(x: number, y: number, width: number, height: number, index: number, solid?: boolean, sprites?: Graphics.Sprite[]); 595 - constructor( 596 - xOrConfig: number | CellArgs, 597 - y?: number, 598 - width?: number, 599 - height?: number, 600 - index?: number, 601 - solid?: boolean, 602 - sprites?: Graphics.Sprite[] 603 - ) { 604 - super(xOrConfig, y, width, height, index, solid, sprites); 605 - } 606 - }
+3 -1
src/engine/index.ts
··· 27 27 export { Particle, ParticleEmitter, ParticleArgs, ParticleEmitterArgs, EmitterType } from './Particles'; 28 28 export * from './Collision/Physics'; 29 29 export * from './Scene'; 30 - export { TileMap, Cell, TileMapArgs, CellArgs } from './TileMap'; 30 + 31 + export * from './TileMap/index'; 32 + 31 33 export * from './Timer'; 32 34 export * from './Trigger'; 33 35 export * from './ScreenElement';
+1
src/spec/ActorSpec.ts
··· 519 519 expect(childActor.pos.y).toBe(50); 520 520 521 521 actor.addChild(childActor); 522 + actionSystem.notify(new ex.AddedEntity(actor)); 522 523 523 524 actor.actions.moveTo(10, 15, 1000); 524 525 actionSystem.update([actor], 1000);
+13
src/spec/BoundingBoxSpec.ts
··· 32 32 expect(zero.hasZeroDimensions()).withContext('zero width/height bb should have zero dimensions').toBe(true); 33 33 }); 34 34 35 + it('can be cloned', () => { 36 + const bb = new ex.BoundingBox({ 37 + left: 12, 38 + right: 34, 39 + top: 0, 40 + bottom: 100 41 + }); 42 + 43 + const sut = bb.clone(); 44 + expect(sut).toEqual(bb); 45 + expect(sut).not.toBe(bb); 46 + }); 47 + 35 48 /** 36 49 * 37 50 */
+6 -7
src/spec/DebugSystemSpec.ts
··· 199 199 engine.graphicsContext.clear(); 200 200 201 201 const tilemap = new ex.TileMap({ 202 - x: 0, 203 - y: 0, 204 - cellWidth: 50, 205 - cellHeight: 50, 206 - rows: 10, 207 - cols: 10 202 + pos: ex.vec(0, 0), 203 + tileWidth: 50, 204 + tileHeight: 50, 205 + height: 10, 206 + width: 10 208 207 }); 209 - tilemap.data[0].solid = true; 208 + tilemap.tiles[0].solid = true; 210 209 tilemap.update(engine, 1); 211 210 debugSystem.update([tilemap], 100); 212 211
+9 -42
src/spec/GraphicsSystemSpec.ts
··· 31 31 engine.currentScene._initialize(engine); 32 32 sut.initialize(engine.currentScene); 33 33 const es = [...entities]; 34 - es.sort(sut.sort); 35 - expect(es).toEqual(entities.reverse()); 36 - }); 37 - 38 - it('decorates offscreen entities with "offscreen" tag', () => { 39 - const sut = new ex.GraphicsSystem(); 40 - engine.currentScene.camera.update(engine, 1); 41 - engine.currentScene._initialize(engine); 42 - sut.initialize(engine.currentScene); 43 - 44 - const rect = new ex.Rectangle({ 45 - width: 25, 46 - height: 25, 47 - color: ex.Color.Yellow 48 - }); 49 - 50 - const offscreen = new ex.Entity([new TransformComponent(), new GraphicsComponent()]); 51 - 52 - offscreen.get(GraphicsComponent).show(rect); 53 - offscreen.get(TransformComponent).pos = ex.vec(112.5, 112.5); 54 - 55 - const offscreenSpy = jasmine.createSpy('offscreenSpy'); 56 - const onscreenSpy = jasmine.createSpy('onscreenSpy'); 57 - 58 - offscreen.eventDispatcher.on('enterviewport', onscreenSpy); 59 - offscreen.eventDispatcher.on('exitviewport', offscreenSpy); 60 - 61 - // Should be offscreen 62 - sut.update([offscreen], 1); 63 - expect(offscreenSpy).toHaveBeenCalled(); 64 - expect(onscreenSpy).not.toHaveBeenCalled(); 65 - expect(offscreen.hasTag('offscreen')).toBeTrue(); 66 - offscreenSpy.calls.reset(); 67 - onscreenSpy.calls.reset(); 68 - 69 - // Should be onscreen 70 - offscreen.get(TransformComponent).pos = ex.vec(80, 80); 71 - sut.update([offscreen], 1); 72 - offscreen.processComponentRemoval(); 73 - expect(offscreenSpy).not.toHaveBeenCalled(); 74 - expect(onscreenSpy).toHaveBeenCalled(); 75 - expect(offscreen.hasTag('offscreen')).toBeFalse(); 34 + es.forEach(e => sut.notify(new ex.AddedEntity(e))); 35 + sut.preupdate(); 36 + expect(sut.sortedTransforms.map(t => t.owner)).toEqual(entities.reverse()); 76 37 }); 77 38 78 39 it('draws entities with transform and graphics components', async () => { 79 40 const sut = new ex.GraphicsSystem(); 41 + const offscreenSystem = new ex.OffscreenSystem(); 80 42 engine.currentScene.camera.update(engine, 1); 81 43 engine.currentScene._initialize(engine); 44 + offscreenSystem.initialize(engine.currentScene); 82 45 sut.initialize(engine.currentScene); 83 46 84 47 const rect = new ex.Rectangle({ ··· 121 84 122 85 entities.push(offscreen); 123 86 engine.graphicsContext.clear(); 87 + entities.forEach(e => sut.notify(new ex.AddedEntity(e))); 124 88 89 + offscreenSystem.update(entities); 90 + 91 + sut.preupdate(); 125 92 sut.update(entities, 1); 126 93 127 94 expect(rect.draw).toHaveBeenCalled();
+302
src/spec/IsometricMapSpec.ts
··· 1 + import * as ex from '@excalibur'; 2 + import { ExcaliburAsyncMatchers, ExcaliburMatchers } from 'excalibur-jasmine'; 3 + import { TestUtils } from './util/TestUtils'; 4 + 5 + describe('A IsometricMap', () => { 6 + beforeAll(() => { 7 + jasmine.addAsyncMatchers(ExcaliburAsyncMatchers); 8 + jasmine.addMatchers(ExcaliburMatchers); 9 + }); 10 + it('exists', () => { 11 + expect(ex.IsometricMap).toBeDefined(); 12 + }); 13 + 14 + it('can be constructed', () => { 15 + const sut = new ex.IsometricMap({ 16 + pos: ex.vec(10, 10), 17 + tileWidth: 10, 18 + tileHeight: 10, 19 + width: 20, 20 + height: 10 21 + }); 22 + 23 + expect(sut).toBeDefined(); 24 + }); 25 + 26 + it('can be drawn', async () => { 27 + const engine = TestUtils.engine({}, ['suppress-obsolete-message']); 28 + // engine.toggleDebug(); 29 + const clock = engine.clock as ex.TestClock; 30 + const image = new ex.ImageSource('src/spec/images/IsometricMapSpec/tile.png'); 31 + await image.load(); 32 + const sprite = image.toSprite(); 33 + await TestUtils.runToReady(engine); 34 + 35 + const sut = new ex.IsometricMap({ 36 + pos: ex.vec(250, 10), 37 + tileWidth: 32, 38 + tileHeight: 16, 39 + width: 15, 40 + height: 15 41 + }); 42 + 43 + sut.tiles.forEach(tile => tile.addGraphic(sprite)); 44 + 45 + engine.add(sut); 46 + clock.step(100); 47 + 48 + const canvas = TestUtils.flushWebGLCanvasTo2D(engine.canvas); 49 + 50 + await expectAsync(canvas).toEqualImage('src/spec/images/IsometricMapSpec/map.png'); 51 + }); 52 + 53 + it('can be drawn from the top', async () => { 54 + const engine = TestUtils.engine({}, ['suppress-obsolete-message']); 55 + // engine.toggleDebug(); 56 + const clock = engine.clock as ex.TestClock; 57 + const image = new ex.ImageSource('src/spec/images/IsometricMapSpec/cube.png'); 58 + await image.load(); 59 + const sprite = image.toSprite(); 60 + await TestUtils.runToReady(engine); 61 + 62 + const sut = new ex.IsometricMap({ 63 + pos: ex.vec(250, 10), 64 + renderFromTopOfGraphic: true, 65 + tileWidth: 32, 66 + tileHeight: 16, 67 + width: 15, 68 + height: 15 69 + }); 70 + 71 + sut.tiles.forEach(tile => tile.addGraphic(sprite)); 72 + 73 + engine.add(sut); 74 + clock.step(100); 75 + 76 + const canvas = TestUtils.flushWebGLCanvasTo2D(engine.canvas); 77 + 78 + await expectAsync(canvas).toEqualImage('src/spec/images/IsometricMapSpec/cube-map-top.png'); 79 + }); 80 + 81 + it('can be drawn from the bottom', async () => { 82 + const engine = TestUtils.engine({}, ['suppress-obsolete-message']); 83 + // engine.toggleDebug(); 84 + const clock = engine.clock as ex.TestClock; 85 + const image = new ex.ImageSource('src/spec/images/IsometricMapSpec/cube.png'); 86 + await image.load(); 87 + const sprite = image.toSprite(); 88 + await TestUtils.runToReady(engine); 89 + 90 + const sut = new ex.IsometricMap({ 91 + pos: ex.vec(250, 10), 92 + tileWidth: 32, 93 + tileHeight: 16, 94 + width: 15, 95 + height: 15 96 + }); 97 + 98 + sut.tiles.forEach(tile => tile.addGraphic(sprite)); 99 + 100 + engine.add(sut); 101 + clock.step(100); 102 + 103 + const canvas = TestUtils.flushWebGLCanvasTo2D(engine.canvas); 104 + 105 + await expectAsync(canvas).toEqualImage('src/spec/images/IsometricMapSpec/cube-map-bottom.png'); 106 + }); 107 + 108 + it('can be debug drawn', async () => { 109 + const engine = TestUtils.engine({}, ['suppress-obsolete-message']); 110 + engine.toggleDebug(); 111 + engine.debug.entity.showName = false; 112 + engine.debug.entity.showId = false; 113 + engine.debug.graphics.showBounds = false; 114 + const clock = engine.clock as ex.TestClock; 115 + const image = new ex.ImageSource('src/spec/images/IsometricMapSpec/cube.png'); 116 + await image.load(); 117 + const sprite = image.toSprite(); 118 + await TestUtils.runToReady(engine); 119 + 120 + const sut = new ex.IsometricMap({ 121 + pos: ex.vec(250, 10), 122 + tileWidth: 32, 123 + tileHeight: 16, 124 + width: 15, 125 + height: 15 126 + }); 127 + 128 + sut.tiles.forEach(tile => tile.addGraphic(sprite)); 129 + 130 + engine.add(sut); 131 + clock.step(100); 132 + const canvas = TestUtils.flushWebGLCanvasTo2D(engine.canvas); 133 + 134 + await expectAsync(canvas).toEqualImage('src/spec/images/IsometricMapSpec/cube-map-debug.png'); 135 + }); 136 + 137 + it('can find a tile coordinate from a world position', async () => { 138 + const engine = TestUtils.engine({}, ['suppress-obsolete-message']); 139 + await TestUtils.runToReady(engine); 140 + 141 + const sut = new ex.IsometricMap({ 142 + pos: ex.vec(250, 10), 143 + tileWidth: 32, 144 + tileHeight: 16, 145 + width: 15, 146 + height: 15 147 + }); 148 + 149 + const topLeft = sut.worldToTile(ex.vec(250, 10)); 150 + expect(topLeft).toBeVector(ex.vec(0, 0)); 151 + 152 + const aboveTopLeft = sut.worldToTile(ex.vec(250, -10)); 153 + expect(aboveTopLeft).toBeVector(ex.vec(-1, -1)); 154 + 155 + const topRight = sut.worldToTile(ex.vec(15 * 32, 15 * 8)); 156 + expect(topRight).toBeVector(ex.vec(14, 0)); 157 + 158 + const bottomRight = sut.worldToTile(ex.vec(15 * 16, 15 * 16)); 159 + expect(bottomRight).toBeVector(ex.vec(14, 14)); 160 + 161 + const bottomLeft = sut.worldToTile(ex.vec(0, 15 * 8)); 162 + expect(bottomLeft).toBeVector(ex.vec(0, 14)); 163 + }); 164 + 165 + it('can find a top left world coordinate from a tile coordinate', async () => { 166 + const engine = TestUtils.engine({}, ['suppress-obsolete-message']); 167 + await TestUtils.runToReady(engine); 168 + 169 + const sut = new ex.IsometricMap({ 170 + pos: ex.vec(250, 10), 171 + tileWidth: 32, 172 + tileHeight: 16, 173 + width: 15, 174 + height: 15 175 + }); 176 + 177 + expect(sut.tileToWorld(ex.vec(0, 0))).toBeVector(ex.vec(250, 10)); 178 + expect(sut.tiles[0].pos).toBeVector(ex.vec(250, 10)); 179 + 180 + expect(sut.tileToWorld(ex.vec(-1, -1))).toBeVector(ex.vec(250, -6)); 181 + 182 + expect(sut.tileToWorld(ex.vec(14, 0))).toBeVector(ex.vec(474, 122)); 183 + 184 + expect(sut.tileToWorld(ex.vec(14, 14))).toBeVector(ex.vec(250, 234)); 185 + expect(sut.tiles[sut.tiles.length-1].pos).toBeVector(ex.vec(250, 234)); 186 + 187 + expect(sut.tileToWorld(ex.vec(0, 14))).toBeVector(ex.vec(26, 122)); 188 + }); 189 + 190 + it('can find the center of an isometric tile', () => { 191 + const sut = new ex.IsometricMap({ 192 + pos: ex.vec(250, 10), 193 + tileWidth: 32, 194 + tileHeight: 16, 195 + width: 15, 196 + height: 15 197 + }); 198 + 199 + const tile = sut.getTile(0, 0); 200 + expect(tile.center).toBeVector(ex.vec(250, 10 + 8)); 201 + }); 202 + 203 + it('can update colliders', () => { 204 + const sut = new ex.IsometricMap({ 205 + pos: ex.vec(250, 10), 206 + tileWidth: 32, 207 + tileHeight: 16, 208 + width: 15, 209 + height: 15 210 + }); 211 + 212 + sut.update(); 213 + 214 + const collider = sut.collider.get() as ex.CompositeCollider; 215 + expect(collider.getColliders()).toEqual([]); 216 + sut.tiles[0].solid = true; 217 + sut.tiles[0].addCollider(ex.Shape.Box(10, 10)); 218 + 219 + sut.update(); 220 + 221 + expect(collider.getColliders().length).toBe(1); 222 + 223 + sut.tiles[0].clearColliders(); 224 + 225 + sut.update(); 226 + 227 + expect(collider.getColliders().length).toBe(0); 228 + 229 + sut.update(); 230 + 231 + const box = ex.Shape.Box(10, 10); 232 + sut.tiles[0].addCollider(box); 233 + 234 + sut.update(); 235 + 236 + expect(collider.getColliders().length).toBe(1); 237 + 238 + sut.tiles[0].removeCollider(box); 239 + 240 + sut.update(); 241 + 242 + expect(collider.getColliders().length).toBe(0); 243 + }); 244 + 245 + it('can update graphics', async () => { 246 + const image = new ex.ImageSource('src/spec/images/IsometricMapSpec/cube.png'); 247 + await image.load(); 248 + const sprite = image.toSprite(); 249 + 250 + const sut = new ex.IsometricMap({ 251 + pos: ex.vec(250, 10), 252 + tileWidth: 32, 253 + tileHeight: 16, 254 + width: 15, 255 + height: 15 256 + }); 257 + 258 + sut.tiles[0].addGraphic(sprite); 259 + sut.tiles[0].addGraphic(sprite.clone()); 260 + 261 + expect(sut.tiles[0].getGraphics().length).toBe(2); 262 + 263 + sut.tiles[0].removeGraphic(sprite); 264 + 265 + expect(sut.tiles[0].getGraphics().length).toBe(1); 266 + 267 + sut.tiles[0].clearGraphics(); 268 + 269 + expect(sut.tiles[0].getGraphics().length).toBe(0); 270 + }); 271 + 272 + it('can get a tile by coordinate', () => { 273 + const sut = new ex.IsometricMap({ 274 + pos: ex.vec(250, 10), 275 + tileWidth: 32, 276 + tileHeight: 16, 277 + width: 15, 278 + height: 15 279 + }); 280 + 281 + expect(sut.getTile(0, 0).pos).toBeVector(ex.vec(250, 10)); 282 + expect(sut.getTile(14, 0).pos).toBeVector(ex.vec(474, 122)); 283 + expect(sut.getTile(0, 14).pos).toBeVector(ex.vec(26, 122)); 284 + expect(sut.getTile(14, 14).pos).toBeVector(ex.vec(250, 234)); 285 + 286 + expect(sut.getTile(-1, -1)).toBeNull(); 287 + expect(sut.getTile(15, 15)).toBeNull(); 288 + 289 + expect(sut.getTileByPoint(ex.vec(250, 10)).x).toBe(0); 290 + expect(sut.getTileByPoint(ex.vec(250, 10)).y).toBe(0); 291 + 292 + expect(sut.getTileByPoint(ex.vec(474, 122)).x).toBe(14); 293 + expect(sut.getTileByPoint(ex.vec(474, 122)).y).toBe(0); 294 + 295 + expect(sut.getTileByPoint(ex.vec(26, 122)).x).toBe(0); 296 + expect(sut.getTileByPoint(ex.vec(26, 122)).y).toBe(14); 297 + 298 + expect(sut.getTileByPoint(ex.vec(250, 234)).x).toBe(14); 299 + expect(sut.getTileByPoint(ex.vec(250, 234)).y).toBe(14); 300 + }); 301 + 302 + });
+67
src/spec/OffscreenSystemSpec.ts
··· 1 + import * as ex from '@excalibur'; 2 + import { GraphicsComponent, TransformComponent } from '@excalibur'; 3 + import { ExcaliburAsyncMatchers, ExcaliburMatchers } from 'excalibur-jasmine'; 4 + import { TestUtils } from './util/TestUtils'; 5 + 6 + describe('The OffscreenSystem', () => { 7 + let entities: ex.Entity[]; 8 + let engine: ex.Engine; 9 + beforeEach(() => { 10 + jasmine.addMatchers(ExcaliburMatchers); 11 + jasmine.addAsyncMatchers(ExcaliburAsyncMatchers); 12 + 13 + engine = TestUtils.engine({ width: 100, height: 100 }); 14 + entities = [ 15 + new ex.Entity().addComponent(new ex.TransformComponent()).addComponent(new ex.GraphicsComponent()), 16 + new ex.Entity().addComponent(new ex.TransformComponent()).addComponent(new ex.GraphicsComponent()), 17 + new ex.Entity().addComponent(new ex.TransformComponent()).addComponent(new ex.GraphicsComponent()) 18 + ]; 19 + entities[0].get(TransformComponent).z = 10; 20 + entities[1].get(TransformComponent).z = 5; 21 + entities[2].get(TransformComponent).z = 1; 22 + }); 23 + 24 + it('exists', () => { 25 + expect(ex.OffscreenSystem).toBeDefined(); 26 + }); 27 + 28 + it('decorates offscreen entities with "offscreen" tag', () => { 29 + const sut = new ex.OffscreenSystem(); 30 + engine.currentScene.camera.update(engine, 1); 31 + engine.currentScene._initialize(engine); 32 + sut.initialize(engine.currentScene); 33 + 34 + const rect = new ex.Rectangle({ 35 + width: 25, 36 + height: 25, 37 + color: ex.Color.Yellow 38 + }); 39 + 40 + const offscreen = new ex.Entity([new TransformComponent(), new GraphicsComponent()]); 41 + 42 + offscreen.get(GraphicsComponent).show(rect); 43 + offscreen.get(TransformComponent).pos = ex.vec(112.5, 112.5); 44 + 45 + const offscreenSpy = jasmine.createSpy('offscreenSpy'); 46 + const onscreenSpy = jasmine.createSpy('onscreenSpy'); 47 + 48 + offscreen.eventDispatcher.on('enterviewport', onscreenSpy); 49 + offscreen.eventDispatcher.on('exitviewport', offscreenSpy); 50 + 51 + // Should be offscreen 52 + sut.update([offscreen]); 53 + expect(offscreenSpy).toHaveBeenCalled(); 54 + expect(onscreenSpy).not.toHaveBeenCalled(); 55 + expect(offscreen.hasTag('ex.offscreen')).toBeTrue(); 56 + offscreenSpy.calls.reset(); 57 + onscreenSpy.calls.reset(); 58 + 59 + // Should be onscreen 60 + offscreen.get(TransformComponent).pos = ex.vec(80, 80); 61 + sut.update([offscreen]); 62 + offscreen.processComponentRemoval(); 63 + expect(offscreenSpy).not.toHaveBeenCalled(); 64 + expect(onscreenSpy).toHaveBeenCalled(); 65 + expect(offscreen.hasTag('ex.offscreen')).toBeFalse(); 66 + }); 67 + });
+2 -2
src/spec/SceneSpec.ts
··· 50 50 }); 51 51 52 52 it('cannot have the same TileMap added to it more than once', () => { 53 - const tileMap = new ex.TileMap(1, 1, 1, 1, 1, 1); 53 + const tileMap = new ex.TileMap({ pos: ex.vec(1, 1), tileWidth: 1, tileHeight: 1, width: 1, height: 1}); 54 54 scene.add(tileMap); 55 55 expect(scene.tileMaps.length).toBe(1); 56 56 scene.add(tileMap); ··· 624 624 625 625 it('will update TileMaps that were added in a Timer callback', () => { 626 626 let updated = false; 627 - const tilemap = new ex.TileMap(0, 0, 1, 1, 1, 1); 627 + const tilemap = new ex.TileMap({ pos: ex.vec(0, 0), tileWidth: 1, tileHeight: 1, width: 1, height: 1}); 628 628 tilemap.on('postupdate', () => { 629 629 updated = true; 630 630 });
+183 -65
src/spec/TileMapSpec.ts
··· 43 43 44 44 it('should have props set by the constructor', () => { 45 45 const tm = new ex.TileMap({ 46 - x: 0, 47 - y: 0, 48 - cellWidth: 64, 49 - cellHeight: 48, 50 - rows: 4, 51 - cols: 20 46 + pos: ex.vec(0, 0), 47 + tileWidth: 64, 48 + tileHeight: 48, 49 + height: 4, 50 + width: 20 52 51 }); 53 52 54 53 expect(tm.pos.x).toBe(0); 55 54 expect(tm.pos.y).toBe(0); 56 - expect(tm.cellWidth).toBe(64); 57 - expect(tm.cellHeight).toBe(48); 58 - expect(tm.rows).toBe(4); 59 - expect(tm.cols).toBe(20); 55 + expect(tm.tileWidth).toBe(64); 56 + expect(tm.tileHeight).toBe(48); 57 + expect(tm.height).toBe(4); 58 + expect(tm.width).toBe(20); 60 59 }); 61 60 62 61 it('can set the z-index convenience prop', () => { 63 62 const tm = new ex.TileMap({ 64 - x: 0, 65 - y: 0, 66 - cellWidth: 32, 67 - cellHeight: 32, 68 - rows: 3, 69 - cols: 5 63 + pos: ex.vec(0, 0), 64 + tileWidth: 32, 65 + tileHeight: 32, 66 + height: 3, 67 + width: 5 70 68 }); 71 69 72 70 tm.z = 99; ··· 77 75 78 76 it('can iterate over rows and cols', () => { 79 77 const tm = new ex.TileMap({ 80 - x: 0, 81 - y: 0, 82 - cellWidth: 32, 83 - cellHeight: 32, 84 - rows: 3, 85 - cols: 5 78 + pos: ex.vec(0, 0), 79 + tileWidth: 32, 80 + tileHeight: 32, 81 + height: 3, 82 + width: 5 86 83 }); 87 84 88 85 expect(tm.getRows().length).toBe(3); 89 86 expect(tm.getRows()[0].length).toBe(5); 90 - expect(tm.getRows()[0][4].x).toBe(4 * 32); 87 + expect(tm.getRows()[0][4].pos.x).toBe(4 * 32); 88 + expect(tm.getRows()[0][4].x).toBe(4); 91 89 expect(tm.getRows()[0][4].y).toBe(0); 92 - expect(tm.getRows()[2][4].x).toBe(4 * 32); 93 - expect(tm.getRows()[2][4].y).toBe(2 * 32); 90 + expect(tm.getRows()[2][4].pos.x).toBe(4 * 32); 91 + expect(tm.getRows()[2][4].x).toBe(4); 92 + expect(tm.getRows()[2][4].pos.y).toBe(2 * 32); 93 + expect(tm.getRows()[2][4].y).toBe(2); 94 94 95 95 expect(tm.getColumns().length).toBe(5); 96 96 expect(tm.getColumns()[0].length).toBe(3); 97 - expect(tm.getColumns()[4][0].x).toBe(4 * 32); 97 + expect(tm.getColumns()[4][0].pos.x).toBe(4 * 32); 98 + expect(tm.getColumns()[4][0].x).toBe(4); 98 99 expect(tm.getColumns()[4][0].y).toBe(0); 99 - expect(tm.getColumns()[4][2].x).toBe(4 * 32); 100 - expect(tm.getColumns()[4][2].y).toBe(2 * 32); 100 + expect(tm.getColumns()[4][2].pos.x).toBe(4 * 32); 101 + expect(tm.getColumns()[4][2].pos.y).toBe(2 * 32); 102 + expect(tm.getColumns()[4][2].x).toBe(4); 103 + expect(tm.getColumns()[4][2].y).toBe(2); 101 104 }); 102 105 103 106 it('can store arbitrary data in cells', () => { 104 107 const tm = new ex.TileMap({ 105 - x: 0, 106 - y: 0, 107 - cellWidth: 32, 108 - cellHeight: 32, 109 - rows: 3, 110 - cols: 5 108 + pos: ex.vec(0, 0), 109 + tileWidth: 32, 110 + tileHeight: 32, 111 + height: 3, 112 + width: 5 111 113 }); 112 114 113 - const cell = tm.getCell(4, 2); 115 + const cell = tm.getTile(4, 2); 114 116 cell.data.set('some_value', 'anything'); 115 117 116 - const otherCell = tm.getCell(4, 2); 118 + const otherCell = tm.getTile(4, 2); 117 119 118 120 expect(otherCell.data.get('some_value')).toBe('anything'); 119 121 120 - const otherCell2 = tm.getCell(0, 0); 122 + const otherCell2 = tm.getTile(0, 0); 121 123 122 124 expect(otherCell2.data.get('some_vale')).not.toBeDefined(); 123 125 }); 124 126 125 127 it('can use arbitrary graphics', async () => { 126 128 const tm = new ex.TileMap({ 127 - x: 0, 128 - y: 0, 129 - cellWidth: 32, 130 - cellHeight: 32, 131 - rows: 3, 132 - cols: 5 129 + pos: ex.vec(0, 0), 130 + tileWidth: 32, 131 + tileHeight: 32, 132 + height: 3, 133 + width: 5 133 134 }); 134 135 tm._initialize(engine); 135 136 136 - const cell = tm.getCell(0, 0); 137 + const cell = tm.getTile(0, 0); 137 138 const rectangle = new ex.Rectangle({ 138 139 width: 32, 139 140 height: 32, ··· 166 167 it('should draw the correct proportions', async () => { 167 168 await texture.load(); 168 169 const tm = new ex.TileMap({ 169 - x: 30, 170 - y: 30, 171 - cellWidth: 64, 172 - cellHeight: 48, 173 - rows: 3, 174 - cols: 7 170 + pos: ex.vec(30, 30), 171 + tileWidth: 64, 172 + tileHeight: 48, 173 + height: 3, 174 + width: 7 175 175 }); 176 176 const spriteTiles = new ex.LegacyDrawing.SpriteSheet(texture, 1, 1, 64, 48); 177 - tm.data.forEach(function (cell: ex.Cell) { 177 + tm.tiles.forEach(function (cell: ex.Tile) { 178 178 cell.solid = true; 179 179 cell.addGraphic(spriteTiles.sprites[0]); 180 180 }); ··· 188 188 it('should handle offscreen culling correctly with negative coords', async () => { 189 189 await texture.load(); 190 190 const tm = new ex.TileMap({ 191 - x: -100, 192 - y: -100, 193 - cellWidth: 64, 194 - cellHeight: 48, 195 - rows: 20, 196 - cols: 20 191 + pos: ex.vec(-100, -100), 192 + tileWidth: 64, 193 + tileHeight: 48, 194 + height: 20, 195 + width: 20 197 196 }); 198 197 const spriteTiles = new ex.LegacyDrawing.SpriteSheet(texture, 1, 1, 64, 48); 199 - tm.data.forEach(function (cell: ex.Cell) { 198 + tm.tiles.forEach(function (cell: ex.Tile) { 200 199 cell.solid = true; 201 200 cell.addGraphic(spriteTiles.sprites[0]); 202 201 }); ··· 209 208 await expectAsync(engine.canvas).toEqualImage('src/spec/images/TileMapSpec/TileMapCulling.png'); 210 209 }); 211 210 211 + it('can return a tile by xy coord', () => { 212 + const sut = new ex.TileMap({ 213 + pos: ex.vec(-100, -100), 214 + tileWidth: 64, 215 + tileHeight: 48, 216 + height: 20, 217 + width: 20 218 + }); 219 + 220 + expect(sut.pos).toBeVector(ex.vec(-100, -100)); 221 + const tile = sut.getTile(19, 19); 222 + expect(tile.x).toBe(19); 223 + expect(tile.y).toBe(19); 224 + expect(tile.pos).toBeVector(ex.vec(19 * 64 - 100, 19 * 48 - 100)); 225 + 226 + const nullTile = sut.getTile(20, 20); 227 + expect(nullTile).toBeNull(); 228 + 229 + expect(sut.getTileByPoint(ex.vec(19 * 64 - 100, 19 * 48 - 100)).x).toBe(19); 230 + expect(sut.getTileByPoint(ex.vec(19 * 64 - 100, 19 * 48 - 100)).y).toBe(19); 231 + expect(sut.getTileByPoint(ex.vec(19 * 64, 19 * 48))).toBeNull(); 232 + 233 + expect(sut.getTileByIndex(20 * 20 - 1)).toBe(tile); 234 + }); 235 + 236 + it('can add and remove graphics on a tile', async () => { 237 + const image = new ex.ImageSource('src/spec/images/TileMapSpec/Blocks.png'); 238 + await image.load(); 239 + const sprite = image.toSprite(); 240 + const sut = new ex.TileMap({ 241 + pos: ex.vec(0, 0), 242 + tileWidth: 64, 243 + tileHeight: 48, 244 + height: 20, 245 + width: 20 246 + }); 247 + const tile = sut.getTile(0, 0); 248 + 249 + tile.addGraphic(sprite); 250 + tile.addGraphic(sprite.clone()); 251 + 252 + expect(tile.getGraphics().length).toBe(2); 253 + 254 + tile.removeGraphic(sprite); 255 + 256 + expect(tile.getGraphics().length).toBe(1); 257 + 258 + tile.clearGraphics(); 259 + 260 + expect(tile.getGraphics().length).toBe(0); 261 + }); 262 + 263 + it('can add and remove colliders on a tile', () => { 264 + const engine = TestUtils.engine(); 265 + const sut = new ex.TileMap({ 266 + pos: ex.vec(0, 0), 267 + tileWidth: 64, 268 + tileHeight: 48, 269 + height: 20, 270 + width: 20 271 + }); 272 + 273 + 274 + const tile = sut.getTile(0, 0); 275 + tile.solid = true; 276 + 277 + const box = ex.Shape.Box(10, 10); 278 + 279 + tile.addCollider(box); 280 + tile.addCollider(box.clone()); 281 + sut.update(engine, 0); 282 + const tileMapCollider = sut.get(ColliderComponent).get() as ex.CompositeCollider; 283 + 284 + expect(tile.getColliders().length).toBe(2); 285 + expect(tileMapCollider.getColliders().length).toBe(2); 286 + 287 + tile.removeCollider(box); 288 + 289 + expect(tile.getColliders().length).toBe(1); 290 + 291 + tile.clearColliders(); 292 + tile.solid = false; 293 + sut.update(engine, 0); 294 + 295 + expect(tile.getColliders().length).toBe(0); 296 + const tileMapCollider2 = sut.get(ColliderComponent).get() as ex.CompositeCollider; 297 + expect(tileMapCollider2.getColliders().length).toBe(0); 298 + }); 299 + 300 + it('can get the bounds of a tile', () => { 301 + const sut = new ex.TileMap({ 302 + pos: ex.vec(100, 100), 303 + tileWidth: 64, 304 + tileHeight: 48, 305 + height: 20, 306 + width: 20 307 + }); 308 + 309 + const tile = sut.getTile(0, 0); 310 + expect(tile.bounds).toEqual(new ex.BoundingBox({ 311 + left: 100, 312 + top: 100, 313 + right: 164, 314 + bottom: 148 315 + })); 316 + }); 317 + 318 + it('can get the center of a tile', () => { 319 + const sut = new ex.TileMap({ 320 + pos: ex.vec(100, 100), 321 + tileWidth: 64, 322 + tileHeight: 48, 323 + height: 20, 324 + width: 20 325 + }); 326 + 327 + const tile = sut.getTile(0, 0); 328 + expect(tile.center).toBeVector(ex.vec(132, 124)); 329 + }); 330 + 212 331 describe('with an actor', () => { 213 332 let tm: ex.TileMap; 214 333 beforeEach(() => { 215 334 tm = new ex.TileMap({ 216 - x: 0, 217 - y: 0, 218 - cellWidth: 64, 219 - cellHeight: 48, 220 - rows: 10, 221 - cols: 10 335 + pos: ex.vec(0, 0), 336 + tileWidth: 64, 337 + tileHeight: 48, 338 + height: 10, 339 + width: 10 222 340 }); 223 - tm.data.forEach(function (cell: ex.Cell) { 341 + tm.tiles.forEach(function (cell: ex.Tile) { 224 342 cell.solid = true; 225 343 }); 226 344 });
+22
src/spec/TransformComponentSpec.ts
··· 156 156 expect(zSpy).toHaveBeenCalledWith(19); 157 157 expect(tx.z).toBe(19); 158 158 }); 159 + 160 + it('can observe position change', () => { 161 + const tx = new ex.TransformComponent(); 162 + const posChanged = jasmine.createSpy('posChanged'); 163 + tx.posChanged$.subscribe(posChanged); 164 + 165 + tx.pos = ex.vec(10, 10); 166 + tx.pos = ex.vec(10, 10); // second vec is the same 167 + expect(posChanged).toHaveBeenCalledTimes(1); 168 + 169 + tx.globalPos = ex.vec(1, 1); 170 + tx.globalPos = ex.vec(1, 1); // second vec is the same 171 + expect(posChanged).toHaveBeenCalledTimes(2); 172 + 173 + tx.pos.x = 20; 174 + tx.pos.x = 20; 175 + expect(posChanged).toHaveBeenCalledTimes(3); 176 + 177 + tx.globalPos.x = 40; 178 + tx.globalPos.x = 40; 179 + expect(posChanged).toHaveBeenCalledTimes(4); 180 + }); 159 181 });
+13
sandbox/tests/arcadecollider/index.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8"> 5 + <meta http-equiv="X-UA-Compatible" content="IE=edge"> 6 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 7 + <title>Arcade Collision Solver</title> 8 + </head> 9 + <body> 10 + <script src="../../lib/excalibur.js"></script> 11 + <script src="./index.js"></script> 12 + </body> 13 + </html>
+74
sandbox/tests/arcadecollider/index.ts
··· 1 + 2 + // 0,0 3.0625,0 3,6.0625 -0.0625,6 3 + 4 + var game = new ex.Engine({ 5 + width: 600, 6 + height: 800 7 + }); 8 + game.toggleDebug(); 9 + 10 + var player = new ex.Actor({ 11 + pos: ex.vec(100, 100), 12 + width: 16, 13 + height: 16, 14 + color: ex.Color.Blue, 15 + collisionType: ex.CollisionType.Active 16 + }); 17 + 18 + player.onPostUpdate = () => { 19 + player.vel.setTo(0, 0); 20 + const speed = 64; 21 + if (game.input.keyboard.isHeld(ex.Input.Keys.Right)) { 22 + player.vel.x = speed; 23 + } 24 + if (game.input.keyboard.isHeld(ex.Input.Keys.Left)) { 25 + player.vel.x = -speed; 26 + } 27 + if (game.input.keyboard.isHeld(ex.Input.Keys.Up)) { 28 + player.vel.y = -speed; 29 + } 30 + if (game.input.keyboard.isHeld(ex.Input.Keys.Down)) { 31 + player.vel.y = speed; 32 + } 33 + } 34 + game.add(player); 35 + 36 + 37 + // {_x: 6.0625, _y: 8.875} 38 + // {_x: 9.125, _y: 8.875} 39 + // {_x: 9.0625, _y: 14.9375} 40 + // {_x: 6, _y: 14.875} 41 + 42 + // {_x: 358.0625, _y: 168.875} 43 + // {_x: 361.125, _y: 168.875} 44 + // {_x: 361.0625, _y: 174.9375} 45 + // {_x: 358, _y: 174.875} 46 + 47 + // offset 48 + // _x: 352 49 + // _y: 160 50 + var offset = ex.vec(352, 160); 51 + // var offset = ex.vec(10, 10); 52 + // var offset = ex.vec(0, 0); 53 + var collider = new ex.Actor({ 54 + pos: ex.vec(150, 100), 55 + collisionType: ex.CollisionType.Fixed 56 + }); 57 + var points = [ 58 + ex.vec(6.0625, 8.875), 59 + ex.vec(9.125, 8.875), 60 + ex.vec(9.0625, 14.9375), 61 + ex.vec(6, 14.875) 62 + ]; 63 + collider.graphics.use(new ex.Polygon({points, color: ex.Color.Red}), { offset }); 64 + const polygon = ex.Shape.Polygon(points); 65 + polygon.offset = offset; 66 + var composite = collider.collider.useCompositeCollider([polygon]); 67 + // collider.collider.set(polygon); 68 + game.add(collider); 69 + 70 + 71 + game.currentScene.camera.strategy.elasticToActor(player, .8, .9); 72 + game.currentScene.camera.zoom = 2; 73 + 74 + game.start();
sandbox/tests/isometric/cube.png

This is a binary file and will not be displayed.

sandbox/tests/isometric/flat.png

This is a binary file and will not be displayed.

sandbox/tests/isometric/flat2.png

This is a binary file and will not be displayed.

+13
sandbox/tests/isometric/index.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8"> 5 + <meta http-equiv="X-UA-Compatible" content="IE=edge"> 6 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 7 + <title>Isometric Map</title> 8 + </head> 9 + <body> 10 + <script src="../../lib/excalibur.js"></script> 11 + <script src="index.js"></script> 12 + </body> 13 + </html>
+54
sandbox/tests/isometric/index.ts
··· 1 + /// <reference path="../../lib/excalibur.d.ts" /> 2 + 3 + var game = new ex.Engine({ 4 + width: 1200, 5 + height: 1200, 6 + antialiasing: true 7 + }); 8 + 9 + game.toggleDebug(); 10 + game.debug.entity.showName = false; 11 + game.debug.entity.showId = false; 12 + game.debug.transform.showZIndex = true; 13 + 14 + var isoBlockImage = new ex.ImageSource('./cube.png', true, ex.ImageFiltering.Blended); 15 + var isoSprite = isoBlockImage.toSprite(); 16 + 17 + var isoTileImage = new ex.ImageSource('./flat.png', true, ex.ImageFiltering.Blended); 18 + var isoTileSprite = isoTileImage.toSprite(); 19 + var loader = new ex.Loader([isoBlockImage, isoTileImage]); 20 + 21 + var isoMap = new ex.IsometricMap({ 22 + name: 'Isometric Tile Map', 23 + pos: ex.vec(300, 100), 24 + renderFromTopOfGraphic: true, 25 + tileWidth: 111, 26 + tileHeight: 64, 27 + width: 3, 28 + height: 3 29 + }); 30 + isoMap.tiles.forEach(t => t.addGraphic(isoSprite)); 31 + game.currentScene.add(isoMap); 32 + 33 + var isoMap2 = new ex.IsometricMap({ 34 + name: 'Isometric Tile Map', 35 + pos: ex.vec(700, 100), 36 + tileWidth: 256, 37 + tileHeight: 128, 38 + width: 3, 39 + height: 3 40 + }); 41 + isoMap2.tiles.forEach(t => t.addGraphic(isoTileSprite)); 42 + game.currentScene.add(isoMap2); 43 + 44 + var tileCoord = ex.vec(0, 0); 45 + game.input.pointers.on('move', evt => { 46 + tileCoord = isoMap2.worldToTile(evt.worldPos); 47 + }); 48 + 49 + game.currentScene.on('postdraw', () => { 50 + game.graphicsContext.debug.drawText(`Current Coord: ${ex.vec(~~tileCoord.x, ~~tileCoord.y).toString()}`, ex.vec(700, 40)); 51 + }); 52 + 53 + 54 + game.start(loader);
+7 -8
sandbox/tests/tilemap/tilemap.ts
··· 22 22 }); 23 23 24 24 var tm = new ex.TileMap({ 25 - x: -100, 26 - y: -100, 27 - cellWidth: 16, 28 - cellHeight: 16, 29 - cols: 40, 30 - rows: 40 25 + pos: ex.vec(-100, -100), 26 + tileWidth: 16, 27 + tileHeight: 16, 28 + width: 40, 29 + height: 40 31 30 }); 32 31 33 32 var tilesprite = ss.sprites[0]; 34 33 35 - for (var i = 0; i < tm.rows * tm.cols; i++) { 36 - tm.getCellByIndex(i).addGraphic(tilesprite); 34 + for (var i = 0; i < tm.width * tm.height; i++) { 35 + tm.getTileByIndex(i).addGraphic(tilesprite); 37 36 } 38 37 39 38 game.add(tm);
+17 -5
src/engine/Actions/ActionsSystem.ts
··· 1 1 import { Entity } from '../EntityComponentSystem'; 2 - import { System, SystemType } from '../EntityComponentSystem/System'; 2 + import { AddedEntity, isAddedSystemEntity, RemovedEntity, System, SystemType } from '../EntityComponentSystem/System'; 3 3 import { ActionsComponent } from './ActionsComponent'; 4 4 5 5 ··· 8 8 systemType = SystemType.Update; 9 9 priority = -1; 10 10 11 - update(entities: Entity[], delta: number): void { 12 - let actions: ActionsComponent; 13 - for (const entity of entities) { 14 - actions = entity.get(ActionsComponent); 11 + private _actions: ActionsComponent[] = []; 12 + public notify(entityAddedOrRemoved: AddedEntity | RemovedEntity): void { 13 + if (isAddedSystemEntity(entityAddedOrRemoved)) { 14 + const action = entityAddedOrRemoved.data.get(ActionsComponent); 15 + this._actions.push(action); 16 + } else { 17 + const action = entityAddedOrRemoved.data.get(ActionsComponent); 18 + const index = this._actions.indexOf(action); 19 + if (index > -1) { 20 + this._actions.splice(index, 1); 21 + } 22 + } 23 + } 24 + 25 + update(_entities: Entity[], delta: number): void { 26 + for (const actions of this._actions) { 15 27 actions.update(delta); 16 28 } 17 29 }
+7
src/engine/Collision/BoundingBox.ts
··· 45 45 } 46 46 47 47 /** 48 + * Returns a new instance of [[BoundingBox]] that is a copy of the current instance 49 + */ 50 + public clone(): BoundingBox { 51 + return new BoundingBox(this.left, this.top, this.right, this.bottom); 52 + } 53 + 54 + /** 48 55 * Given bounding box A & B, returns the side relative to A when intersection is performed. 49 56 * @param intersection Intersection vector between 2 bounding boxes 50 57 */
+2
src/engine/Debug/Debug.ts
··· 260 260 showPositionLabel: false, 261 261 positionColor: Color.Yellow, 262 262 263 + showZIndex: false, 264 + 263 265 showScale: false, 264 266 scaleColor: Color.Green, 265 267
+11
src/engine/Debug/DebugSystem.ts
··· 100 100 this._graphicsContext.debug.drawText(`pos${tx.pos.toString(2)}`, cursor); 101 101 cursor = cursor.add(lineHeight); 102 102 } 103 + if (txSettings.showAll || txSettings.showZIndex) { 104 + this._graphicsContext.debug.drawText(`z(${tx.z.toFixed(1)})`, cursor); 105 + cursor = cursor.add(lineHeight); 106 + } 103 107 104 108 if (entitySettings.showAll || entitySettings.showId) { 105 109 this._graphicsContext.debug.drawText(`id(${id}) ${tx.parent ? 'child of id(' + tx.parent?.owner?.id + ')' : ''}`, cursor); ··· 137 141 138 142 debugDraw = entity.get(DebugGraphicsComponent); 139 143 if (debugDraw) { 144 + if (!debugDraw.useTransform) { 145 + this._graphicsContext.restore(); 146 + } 140 147 debugDraw.draw(this._graphicsContext); 148 + if (!debugDraw.useTransform) { 149 + this._graphicsContext.save(); 150 + this._applyTransform(entity); 151 + } 141 152 } 142 153 143 154 body = entity.get(BodyComponent);
+1 -1
src/engine/Graphics/DebugGraphicsComponent.ts
··· 10 10 */ 11 11 export class DebugGraphicsComponent extends Component<'ex.debuggraphics'> { 12 12 readonly type = 'ex.debuggraphics'; 13 - constructor(public draw: (ctx: ExcaliburGraphicsContext) => void) { 13 + constructor(public draw: (ctx: ExcaliburGraphicsContext) => void, public useTransform = true) { 14 14 super(); 15 15 } 16 16 }
+19 -9
src/engine/Graphics/GraphicsComponent.ts
··· 99 99 gfx = this._graphics.getGraphic(nameOrGraphic); 100 100 } 101 101 this.graphics = this.graphics.filter((g) => g.graphic !== gfx); 102 + this._graphics.recalculateBounds(); 102 103 } 103 104 } 104 105 ··· 125 126 } 126 127 if (gfx) { 127 128 this.graphics.push({ graphic: gfx, options }); 129 + this._graphics.recalculateBounds(); 128 130 return gfx as T; 129 131 } else { 130 132 return null; ··· 356 358 * Show a graphic by name on the **default** layer, returns the new [[Graphic]] 357 359 */ 358 360 public show<T extends Graphic = Graphic>(nameOrGraphic: string | T, options?: GraphicsShowOptions): T { 359 - return this.layers.default.show<T>(nameOrGraphic, options); 361 + const result = this.layers.default.show<T>(nameOrGraphic, options); 362 + this.recalculateBounds(); 363 + return result; 360 364 } 361 365 362 366 /** ··· 365 369 * @param options 366 370 */ 367 371 public use<T extends Graphic = Graphic>(nameOrGraphic: string | T, options?: GraphicsShowOptions): T { 368 - return this.layers.default.use<T>(nameOrGraphic, options); 372 + const result = this.layers.default.use<T>(nameOrGraphic, options); 373 + this.recalculateBounds(); 374 + return result; 369 375 } 370 376 371 377 /** ··· 380 386 this.layers.default.hide(nameOrGraphic); 381 387 } 382 388 383 - private _bounds: BoundingBox = null; 389 + private _localBounds: BoundingBox = null; 384 390 public set localBounds(bounds: BoundingBox) { 385 - this._bounds = bounds; 391 + this._localBounds = bounds; 386 392 } 387 393 388 - public get localBounds(): BoundingBox { 389 - if (this._bounds) { 390 - return this._bounds; 391 - } 394 + public recalculateBounds() { 392 395 let bb = new BoundingBox(); 393 396 for (const layer of this.layers.get()) { 394 397 for (const { graphic, options } of layer.graphics) { ··· 406 409 bb = graphic?.localBounds.translate(vec(offsetX + layer.offset.x, offsetY + layer.offset.y)).combine(bb); 407 410 } 408 411 } 409 - return bb; 412 + this._localBounds = bb; 413 + } 414 + 415 + public get localBounds(): BoundingBox { 416 + if (!this._localBounds) { 417 + this.recalculateBounds(); 418 + } 419 + return this._localBounds; 410 420 } 411 421 412 422 /**
+62 -60
src/engine/Graphics/GraphicsSystem.ts
··· 5 5 import { CoordPlane, TransformComponent } from '../EntityComponentSystem/Components/TransformComponent'; 6 6 import { Entity } from '../EntityComponentSystem/Entity'; 7 7 import { Camera } from '../Camera'; 8 - import { System, SystemType, TagComponent } from '../EntityComponentSystem'; 8 + import { AddedEntity, isAddedSystemEntity, RemovedEntity, System, SystemType } from '../EntityComponentSystem'; 9 9 import { Engine } from '../Engine'; 10 - import { EnterViewPortEvent, ExitViewPortEvent } from '../Events'; 11 10 import { GraphicsGroup } from '.'; 12 11 import { Particle } from '../Particles'; 13 12 ··· 20 19 private _camera: Camera; 21 20 private _engine: Engine; 22 21 22 + private _sortedTransforms: TransformComponent[] = []; 23 + public get sortedTransforms() { 24 + return this._sortedTransforms; 25 + } 26 + 23 27 public initialize(scene: Scene): void { 24 28 this._graphicsContext = scene.engine.graphicsContext; 25 29 this._camera = scene.camera; 26 30 this._engine = scene.engine; 27 31 } 28 32 29 - public sort(a: Entity, b: Entity) { 30 - return a.get(TransformComponent).z - b.get(TransformComponent).z; 33 + private _zHasChanged = false; 34 + private _zIndexUpdate = () => { 35 + this._zHasChanged = true; 36 + }; 37 + 38 + public preupdate(): void { 39 + if (this._zHasChanged) { 40 + this._sortedTransforms.sort((a, b) => { 41 + return a.z - b.z; 42 + }); 43 + this._zHasChanged = false; 44 + } 31 45 } 32 46 33 - public update(entities: Entity[], delta: number): void { 47 + public notify(entityAddedOrRemoved: AddedEntity | RemovedEntity): void { 48 + if (isAddedSystemEntity(entityAddedOrRemoved)) { 49 + const tx = entityAddedOrRemoved.data.get(TransformComponent); 50 + this._sortedTransforms.push(tx); 51 + tx.zIndexChanged$.subscribe(this._zIndexUpdate); 52 + this._zHasChanged = true; 53 + } else { 54 + const tx = entityAddedOrRemoved.data.get(TransformComponent); 55 + tx.zIndexChanged$.unsubscribe(this._zIndexUpdate); 56 + const index = this._sortedTransforms.indexOf(tx); 57 + if (index > -1) { 58 + this._sortedTransforms.splice(index, 1); 59 + } 60 + } 61 + } 62 + 63 + public update(_entities: Entity[], delta: number): void { 34 64 this._token++; 35 - let transform: TransformComponent; 36 65 let graphics: GraphicsComponent; 37 66 38 - for (const entity of entities) { 39 - transform = entity.get(TransformComponent); 67 + // This is a performance enhancement, most things are in world space 68 + // so if we can only do this once saves a ton of transform updates 69 + this._graphicsContext.save(); 70 + if (this._camera) { 71 + this._camera.draw(this._graphicsContext); 72 + } 73 + for (const transform of this._sortedTransforms) { 74 + const entity = transform.owner as Entity; 75 + 76 + // If the entity is offscreen skip 77 + if (entity.hasTag('ex.offscreen')) { 78 + continue; 79 + } 80 + 40 81 graphics = entity.get(GraphicsComponent); 41 - 42 - // Figure out if entities are offscreen 43 - const entityOffscreen = this._isOffscreen(transform, graphics); 44 - if (entityOffscreen && !entity.hasTag('offscreen')) { 45 - entity.eventDispatcher.emit('exitviewport', new ExitViewPortEvent(entity)); 46 - entity.addComponent(new TagComponent('offscreen')); 47 - } 48 - 49 - if (!entityOffscreen && entity.hasTag('offscreen')) { 50 - entity.eventDispatcher.emit('enterviewport', new EnterViewPortEvent(entity)); 51 - entity.removeComponent('offscreen'); 52 - } 53 - // Skip entities that have graphics offscreen 54 - if (entityOffscreen) { 82 + // Exit if graphics set to not visible 83 + if (!graphics.visible) { 55 84 continue; 56 85 } 57 86 58 87 // This optionally sets our camera based on the entity coord plan (world vs. screen) 59 - this._pushCameraTransform(transform); 88 + if (transform.coordPlane === CoordPlane.Screen) { 89 + this._graphicsContext.restore(); 90 + } 60 91 61 92 this._graphicsContext.save(); 62 93 ··· 85 116 86 117 this._graphicsContext.restore(); 87 118 88 - // Reset the transform back to the original 89 - this._popCameraTransform(transform); 119 + // Reset the transform back to the original world space 120 + if (transform.coordPlane === CoordPlane.Screen) { 121 + this._graphicsContext.save(); 122 + if (this._camera) { 123 + this._camera.draw(this._graphicsContext); 124 + } 125 + } 90 126 } 91 - } 92 - 93 - private _isOffscreen(transform: TransformComponent, graphics: GraphicsComponent) { 94 - if (transform.coordPlane === CoordPlane.World) { 95 - const graphicsOffscreen = !this._camera.viewport.intersect(graphics.localBounds.transform(transform.getGlobalMatrix())); 96 - return graphicsOffscreen; 97 - } else { 98 - // TODO screen coordinates 99 - return false; 100 - } 127 + this._graphicsContext.restore(); 101 128 } 102 129 103 130 private _drawGraphicsComponent(graphicsComponent: GraphicsComponent) { ··· 148 175 this._graphicsContext.scale(transform.scale.x, transform.scale.y); 149 176 this._graphicsContext.rotate(transform.rotation); 150 177 } 151 - } 152 - } 153 - 154 - /** 155 - * Applies the current camera transform if in world coordinates 156 - * @param transform 157 - */ 158 - private _pushCameraTransform(transform: TransformComponent) { 159 - // Establish camera offset per entity 160 - if (transform.coordPlane === CoordPlane.World) { 161 - this._graphicsContext.save(); 162 - if (this._camera) { 163 - this._camera.draw(this._graphicsContext); 164 - } 165 - } 166 - } 167 - 168 - /** 169 - * Resets the current camera transform if in world coordinates 170 - * @param transform 171 - */ 172 - private _popCameraTransform(transform: TransformComponent) { 173 - if (transform.coordPlane === CoordPlane.World) { 174 - // Apply camera world offset 175 - this._graphicsContext.restore(); 176 178 } 177 179 } 178 180 }
+52
src/engine/Graphics/OffscreenSystem.ts
··· 1 + import { GraphicsComponent } from './GraphicsComponent'; 2 + import { EnterViewPortEvent, ExitViewPortEvent } from '../Events'; 3 + import { Scene } from '../Scene'; 4 + import { Entity } from '../EntityComponentSystem/Entity'; 5 + import { TransformComponent, CoordPlane } from '../EntityComponentSystem/Components/TransformComponent'; 6 + import { Camera } from '../Camera'; 7 + import { System, SystemType } from '../EntityComponentSystem/System'; 8 + 9 + export class OffscreenSystem extends System<TransformComponent | GraphicsComponent> { 10 + public readonly types = ['ex.transform', 'ex.graphics'] as const; 11 + public systemType = SystemType.Draw; 12 + priority: number = -1; 13 + private _camera: Camera; 14 + 15 + public initialize(scene: Scene): void { 16 + this._camera = scene.camera; 17 + } 18 + 19 + update(entities: Entity[]): void { 20 + let transform: TransformComponent; 21 + let graphics: GraphicsComponent; 22 + 23 + for (const entity of entities) { 24 + graphics = entity.get(GraphicsComponent); 25 + transform = entity.get(TransformComponent); 26 + 27 + // Figure out if entities are offscreen 28 + const entityOffscreen = this._isOffscreen(transform, graphics); 29 + if (entityOffscreen && !entity.hasTag('ex.offscreen')) { 30 + entity.eventDispatcher.emit('exitviewport', new ExitViewPortEvent(entity)); 31 + entity.addTag('ex.offscreen'); 32 + } 33 + 34 + if (!entityOffscreen && entity.hasTag('ex.offscreen')) { 35 + entity.eventDispatcher.emit('enterviewport', new EnterViewPortEvent(entity)); 36 + entity.removeTag('ex.offscreen'); 37 + } 38 + } 39 + } 40 + 41 + private _isOffscreen(transform: TransformComponent, graphics: GraphicsComponent) { 42 + if (transform.coordPlane === CoordPlane.World) { 43 + const transformedBounds = graphics.localBounds.transform(transform.getGlobalMatrix()); 44 + const graphicsOffscreen = !this._camera.viewport.overlaps(transformedBounds); 45 + return graphicsOffscreen; 46 + } else { 47 + // TODO screen coordinates 48 + return false; 49 + } 50 + } 51 + 52 + }
+2
src/engine/Graphics/index.ts
··· 10 10 export * from './GraphicsComponent'; 11 11 export * from './DebugGraphicsComponent'; 12 12 export * from './GraphicsSystem'; 13 + export * from './OffscreenSystem'; 14 + 13 15 14 16 // Raster graphics 15 17 export * from './Raster';
+21
src/engine/TileMap/IsometricEntityComponent.ts
··· 1 + import { Component } from '../EntityComponentSystem/Component'; 2 + import { IsometricMap } from './IsometricMap'; 3 + 4 + export class IsometricEntityComponent extends Component<'ex.isometricentity'> { 5 + public readonly type = 'ex.isometricentity'; 6 + /** 7 + * Vertical "height" in the isometric world 8 + */ 9 + public elevation: number = 0; 10 + 11 + public map: IsometricMap; 12 + 13 + /** 14 + * Specify the isometric map to use to position this entity's z-index 15 + * @param map 16 + */ 17 + constructor(map: IsometricMap) { 18 + super(); 19 + this.map = map; 20 + } 21 + }
+24
src/engine/TileMap/IsometricEntitySystem.ts
··· 1 + import { System, SystemType } from '../EntityComponentSystem/System'; 2 + import { Entity } from '../EntityComponentSystem/Entity'; 3 + import { TransformComponent } from '../EntityComponentSystem/Components/TransformComponent'; 4 + import { IsometricEntityComponent } from './IsometricEntityComponent'; 5 + 6 + 7 + export class IsometricEntitySystem extends System<TransformComponent | IsometricEntityComponent> { 8 + public readonly types = ['ex.transform', 'ex.isometricentity'] as const; 9 + public readonly systemType = SystemType.Update; 10 + priority: number = 99; 11 + update(entities: Entity[], _delta: number): void { 12 + let transform: TransformComponent; 13 + let iso: IsometricEntityComponent; 14 + for (const entity of entities) { 15 + transform = entity.get(TransformComponent); 16 + iso = entity.get(IsometricEntityComponent); 17 + 18 + const maxZindexPerElevation = Math.max(iso.map.width * iso.map.tileWidth, iso.map.height * iso.map.tileHeight); 19 + 20 + const newZ = maxZindexPerElevation * iso.elevation + transform.pos.y; 21 + transform.z = newZ; 22 + } 23 + } 24 + }
+423
src/engine/TileMap/IsometricMap.ts
··· 1 + import { BodyComponent, BoundingBox, Collider, ColliderComponent, CollisionType, Color, CompositeCollider, vec, Vector } from '..'; 2 + import { TransformComponent } from '../EntityComponentSystem/Components/TransformComponent'; 3 + import { Entity } from '../EntityComponentSystem/Entity'; 4 + import { DebugGraphicsComponent, ExcaliburGraphicsContext, Graphic, GraphicsComponent } from '../Graphics'; 5 + import { IsometricEntityComponent } from './IsometricEntityComponent'; 6 + 7 + export class IsometricTile extends Entity { 8 + /** 9 + * Indicates whether this tile is solid 10 + */ 11 + public solid: boolean = false; 12 + 13 + private _gfx: GraphicsComponent; 14 + private _tileBounds = new BoundingBox(); 15 + private _graphics: Graphic[] = []; 16 + public getGraphics(): readonly Graphic[] { 17 + return this._graphics; 18 + } 19 + /** 20 + * Tile graphics 21 + */ 22 + public addGraphic(graphic: Graphic) { 23 + this._graphics.push(graphic); 24 + this._gfx.visible = true; 25 + this._gfx.localBounds = this._recalculateBounds(); 26 + } 27 + 28 + private _recalculateBounds(): BoundingBox { 29 + let bounds = this._tileBounds.clone(); 30 + for (const graphic of this._graphics) { 31 + const offset = vec( 32 + this.map.graphicsOffset.x - this.map.tileWidth / 2, 33 + this.map.graphicsOffset.y - (this.map.renderFromTopOfGraphic ? 0 : (graphic.height - this.map.tileHeight))); 34 + bounds = bounds.combine(graphic.localBounds.translate(offset)); 35 + } 36 + return bounds; 37 + } 38 + 39 + public removeGraphic(graphic: Graphic) { 40 + const index = this._graphics.indexOf(graphic); 41 + if (index > -1) { 42 + this._graphics.splice(index, 1); 43 + } 44 + this._gfx.localBounds = this._recalculateBounds(); 45 + } 46 + 47 + public clearGraphics() { 48 + this._graphics.length = 0; 49 + this._gfx.visible = false; 50 + this._gfx.localBounds = this._recalculateBounds(); 51 + } 52 + 53 + /** 54 + * Tile colliders 55 + */ 56 + private _colliders: Collider[] = []; 57 + public getColliders(): readonly Collider[] { 58 + return this._colliders; 59 + } 60 + 61 + /** 62 + * Adds a collider to the IsometricTile 63 + * 64 + * **Note!** the [[Tile.solid]] must be set to true for it to act as a "fixed" collider 65 + * @param collider 66 + */ 67 + public addCollider(collider: Collider) { 68 + this._colliders.push(collider); 69 + this.map.flagCollidersDirty(); 70 + } 71 + 72 + /** 73 + * Removes a collider from the IsometricTile 74 + * @param collider 75 + */ 76 + public removeCollider(collider: Collider) { 77 + const index = this._colliders.indexOf(collider); 78 + if (index > -1) { 79 + this._colliders.splice(index, 1); 80 + } 81 + this.map.flagCollidersDirty(); 82 + } 83 + 84 + /** 85 + * Clears all colliders from the IsometricTile 86 + */ 87 + public clearColliders(): void { 88 + this._colliders.length = 0; 89 + this.map.flagCollidersDirty(); 90 + } 91 + 92 + /** 93 + * Integer tile x coordinate 94 + */ 95 + public readonly x: number; 96 + /** 97 + * Integer tile y coordinate 98 + */ 99 + public readonly y: number; 100 + /** 101 + * Reference to the [[IsometricMap]] this tile is part of 102 + */ 103 + public readonly map: IsometricMap; 104 + 105 + private _transform: TransformComponent; 106 + private _isometricEntityComponent: IsometricEntityComponent; 107 + 108 + /** 109 + * Returns the top left corner of the [[IsometricTile]] in world space 110 + */ 111 + public get pos(): Vector { 112 + return this.map.tileToWorld(vec(this.x, this.y)); 113 + } 114 + 115 + /** 116 + * Returns the center of the [[IsometricTile]] 117 + */ 118 + public get center(): Vector { 119 + return this.pos.add(vec(0, this.map.tileHeight / 2)); 120 + } 121 + 122 + /** 123 + * Construct a new IsometricTile 124 + * @param x tile coordinate in x (not world position) 125 + * @param y tile coordinate in y (not world position) 126 + * @param graphicsOffset offset that tile should be shifted by (default (0, 0)) 127 + * @param map reference to owning IsometricMap 128 + */ 129 + constructor(x: number, y: number, graphicsOffset: Vector | null, map: IsometricMap) { 130 + super([ 131 + new TransformComponent(), 132 + new GraphicsComponent({ 133 + offset: graphicsOffset ?? Vector.Zero, 134 + onPostDraw: (gfx, elapsed) => this.draw(gfx, elapsed) 135 + }), 136 + new IsometricEntityComponent(map) 137 + ]); 138 + this.x = x; 139 + this.y = y; 140 + this.map = map; 141 + this._transform = this.get(TransformComponent); 142 + this._isometricEntityComponent = this.get(IsometricEntityComponent); 143 + 144 + const halfTileWidth = this.map.tileWidth / 2; 145 + const halfTileHeight = this.map.tileHeight / 2; 146 + // See https://clintbellanger.net/articles/isometric_math/ for formula 147 + // The x position shifts left with every y step 148 + const xPos = (this.x - this.y) * halfTileWidth; 149 + // The y position needs to go down with every x step 150 + const yPos = (this.x + this.y) * halfTileHeight; 151 + this._transform.pos = vec(xPos, yPos); 152 + this._isometricEntityComponent.elevation = 0; 153 + 154 + this._gfx = this.get(GraphicsComponent); 155 + this._gfx.visible = false; // start not visible 156 + const totalWidth = this.map.tileWidth; 157 + const totalHeight = this.map.tileHeight; 158 + 159 + // initial guess at gfx bounds based on the tile 160 + const offset = vec(0, (this.map.renderFromTopOfGraphic ? totalHeight : 0)); 161 + this._gfx.localBounds = this._tileBounds = new BoundingBox({ 162 + left: -totalWidth / 2, 163 + top: -totalHeight, 164 + right: totalWidth / 2, 165 + bottom: totalHeight 166 + }).translate(offset); 167 + } 168 + 169 + draw(gfx: ExcaliburGraphicsContext, _elapsed: number) { 170 + const halfTileWidth = this.map.tileWidth / 2; 171 + gfx.save(); 172 + // shift left origin to corner of map, not the left corner of the first sprite 173 + gfx.translate(-halfTileWidth, 0); 174 + for (const graphic of this._graphics) { 175 + graphic.draw( 176 + gfx, 177 + this.map.graphicsOffset.x, 178 + this.map.graphicsOffset.y - (this.map.renderFromTopOfGraphic ? 0 : (graphic.height - this.map.tileHeight))); 179 + } 180 + gfx.restore(); 181 + } 182 + } 183 + 184 + export interface IsometricMapOptions { 185 + /** 186 + * Optionally name the isometric tile map 187 + */ 188 + name?: string; 189 + /** 190 + * Optionally specify the position of the isometric tile map 191 + */ 192 + pos?: Vector; 193 + /** 194 + * Optionally render from the top of the graphic, by default tiles are rendered from the bottom 195 + */ 196 + renderFromTopOfGraphic?: boolean; 197 + /** 198 + * Optionally present a graphics offset, this can be useful depending on your tile graphics 199 + */ 200 + graphicsOffset?: Vector; 201 + /** 202 + * Width of an individual tile in pixels, this should be the width of the parallelogram of the base of the tile art asset. 203 + */ 204 + tileWidth: number; 205 + /** 206 + * Height of an individual tile in pixels, this should be the height of the parallelogram of the base of the tile art asset. 207 + */ 208 + tileHeight: number; 209 + /** 210 + * Number of tiles wide 211 + */ 212 + width: number; 213 + /** 214 + * Number of tiles high 215 + */ 216 + height: number; 217 + } 218 + 219 + /** 220 + * The IsometricMap is a special tile map that provides isometric rendering support to Excalibur 221 + * 222 + * The tileWidth and tileHeight should be the height and width in pixels of the parallelogram of the base of the tile art asset. 223 + * The tileWidth and tileHeight is not necessarily the same as your graphic pixel width and height. 224 + * 225 + * Please refer to the docs https://excaliburjs.com for more details calculating what your tile width and height should be given 226 + * your art assets. 227 + */ 228 + export class IsometricMap extends Entity { 229 + /** 230 + * Width of individual tile in pixels 231 + */ 232 + public readonly tileWidth: number; 233 + /** 234 + * Height of individual tile in pixels 235 + */ 236 + public readonly tileHeight: number; 237 + /** 238 + * Number of tiles wide 239 + */ 240 + public readonly width: number; 241 + /** 242 + * Number of tiles high 243 + */ 244 + public readonly height: number; 245 + /** 246 + * List containing all of the tiles in IsometricMap 247 + */ 248 + public readonly tiles: IsometricTile[]; 249 + 250 + /** 251 + * Render the tile graphic from the top instead of the bottom 252 + * 253 + * default is `false` meaning rendering from the bottom 254 + */ 255 + public renderFromTopOfGraphic: boolean = false; 256 + public graphicsOffset: Vector = vec(0, 0); 257 + 258 + /** 259 + * Isometric map [[TransformComponent]] 260 + */ 261 + public transform: TransformComponent; 262 + 263 + /** 264 + * Isometric map [[ColliderComponent]] 265 + */ 266 + public collider: ColliderComponent; 267 + 268 + private _composite: CompositeCollider; 269 + 270 + constructor(options: IsometricMapOptions) { 271 + super([ 272 + new TransformComponent(), 273 + new BodyComponent({ 274 + type: CollisionType.Fixed 275 + }), 276 + new ColliderComponent(), 277 + new DebugGraphicsComponent((ctx) => this.debug(ctx), false) 278 + ], options.name); 279 + const { pos, tileWidth, tileHeight, width, height, renderFromTopOfGraphic, graphicsOffset } = options; 280 + 281 + this.transform = this.get(TransformComponent); 282 + if (pos) { 283 + this.transform.pos = pos; 284 + } 285 + 286 + this.collider = this.get(ColliderComponent); 287 + if (this.collider) { 288 + this.collider.set(this._composite = new CompositeCollider([])); 289 + } 290 + 291 + 292 + this.renderFromTopOfGraphic = renderFromTopOfGraphic ?? this.renderFromTopOfGraphic; 293 + this.graphicsOffset = graphicsOffset ?? this.graphicsOffset; 294 + 295 + this.tileWidth = tileWidth; 296 + this.tileHeight = tileHeight; 297 + this.width = width; 298 + this.height = height; 299 + 300 + this.tiles = new Array(width * height); 301 + 302 + // build up tile representation 303 + for (let y = 0; y < height; y++) { 304 + for (let x = 0; x < width; x++) { 305 + const tile = new IsometricTile(x, y, this.graphicsOffset, this); 306 + this.tiles[x + y * width] = tile; 307 + this.addChild(tile); 308 + // TODO row/columns helpers 309 + } 310 + } 311 + } 312 + 313 + public update(): void { 314 + if (this._collidersDirty) { 315 + this.updateColliders(); 316 + this._collidersDirty = false; 317 + } 318 + } 319 + 320 + private _collidersDirty = false; 321 + public flagCollidersDirty() { 322 + this._collidersDirty = true; 323 + } 324 + 325 + private _originalOffsets = new WeakMap<Collider, Vector>(); 326 + private _getOrSetColliderOriginalOffset(collider: Collider): Vector { 327 + if (!this._originalOffsets.has(collider)) { 328 + const originalOffset = collider.offset; 329 + this._originalOffsets.set(collider, originalOffset); 330 + return originalOffset; 331 + } else { 332 + return this._originalOffsets.get(collider); 333 + } 334 + } 335 + public updateColliders() { 336 + this._composite.clearColliders(); 337 + for (const tile of this.tiles) { 338 + if (tile.solid) { 339 + for (const collider of tile.getColliders()) { 340 + const originalOffset = this._getOrSetColliderOriginalOffset(collider); 341 + collider.offset = this.tileToWorld(vec(tile.x, tile.y)) 342 + .add(originalOffset) 343 + .sub(vec(this.tileWidth / 2, this.tileHeight)); // We need to unshift height based on drawing 344 + collider.owner = this; 345 + this._composite.addCollider(collider); 346 + } 347 + } 348 + } 349 + this.collider.update(); 350 + } 351 + 352 + /** 353 + * Convert world space coordinates to the tile x, y coordinate 354 + * @param worldCoordinate 355 + */ 356 + public worldToTile(worldCoordinate: Vector): Vector { 357 + worldCoordinate = worldCoordinate.sub(this.transform.globalPos); 358 + 359 + const halfTileWidth = this.tileWidth / 2; 360 + const halfTileHeight = this.tileHeight / 2; 361 + // See https://clintbellanger.net/articles/isometric_math/ for formula 362 + return vec( 363 + ~~((worldCoordinate.x / halfTileWidth + (worldCoordinate.y / halfTileHeight)) / 2), 364 + ~~((worldCoordinate.y / halfTileHeight - (worldCoordinate.x / halfTileWidth)) / 2)); 365 + } 366 + 367 + /** 368 + * Given a tile coordinate, return the top left corner in world space 369 + * @param tileCoordinate 370 + */ 371 + public tileToWorld(tileCoordinate: Vector): Vector { 372 + const halfTileWidth = this.tileWidth / 2; 373 + const halfTileHeight = this.tileHeight / 2; 374 + // The x position shifts left with every y step 375 + const xPos = (tileCoordinate.x - tileCoordinate.y) * halfTileWidth; 376 + // The y position needs to go down with every x step 377 + const yPos = (tileCoordinate.x + tileCoordinate.y) * halfTileHeight; 378 + return vec(xPos, yPos).add(this.transform.pos); 379 + } 380 + 381 + /** 382 + * Returns the [[IsometricTile]] by its x and y coordinates 383 + */ 384 + public getTile(x: number, y: number): IsometricTile | null { 385 + if (x < 0 || y < 0 || x >= this.width || y >= this.height) { 386 + return null; 387 + } 388 + return this.tiles[x + y * this.width]; 389 + } 390 + 391 + /** 392 + * Returns the [[IsometricTile]] by testing a point in world coordinates, 393 + * returns `null` if no Tile was found. 394 + */ 395 + public getTileByPoint(point: Vector): IsometricTile | null { 396 + const tileCoord = this.worldToTile(point); 397 + const tile = this.getTile(tileCoord.x, tileCoord.y); 398 + return tile; 399 + } 400 + 401 + /** 402 + * Debug draw for IsometricMap, called internally by excalibur when debug mode is toggled on 403 + * @param gfx 404 + */ 405 + public debug(gfx: ExcaliburGraphicsContext) { 406 + 407 + for (let y = 0; y < this.height + 1; y++) { 408 + const left = this.tileToWorld(vec(0, y)); 409 + const right = this.tileToWorld(vec(this.width, y)); 410 + gfx.drawLine(left, right, Color.Red, 2); 411 + } 412 + 413 + for (let x = 0; x < this.width + 1; x++) { 414 + const top = this.tileToWorld(vec(x, 0)); 415 + const bottom = this.tileToWorld(vec(x, this.height)); 416 + gfx.drawLine(top, bottom, Color.Red, 2); 417 + } 418 + 419 + for (const tile of this.tiles) { 420 + gfx.drawCircle(this.tileToWorld(vec(tile.x, tile.y)), 3, Color.Yellow); 421 + } 422 + } 423 + }
+658
src/engine/TileMap/TileMap.ts
··· 1 + import { BoundingBox } from '../Collision/BoundingBox'; 2 + import { Engine } from '../Engine'; 3 + import { Vector, vec } from '../Math/vector'; 4 + import { Logger } from '../Util/Log'; 5 + import { SpriteSheet } from '../Drawing/SpriteSheet'; 6 + import * as Events from '../Events'; 7 + import { Entity } from '../EntityComponentSystem/Entity'; 8 + import { TransformComponent } from '../EntityComponentSystem/Components/TransformComponent'; 9 + import { BodyComponent } from '../Collision/BodyComponent'; 10 + import { CollisionType } from '../Collision/CollisionType'; 11 + import { Shape } from '../Collision/Colliders/Shape'; 12 + import { ExcaliburGraphicsContext, GraphicsComponent, hasGraphicsTick } from '../Graphics'; 13 + import * as Graphics from '../Graphics'; 14 + import { CanvasDrawComponent, Sprite } from '../Drawing/Index'; 15 + import { Sprite as LegacySprite } from '../Drawing/Index'; 16 + import { removeItemFromArray } from '../Util/Util'; 17 + import { obsolete } from '../Util/Decorators'; 18 + import { MotionComponent } from '../EntityComponentSystem/Components/MotionComponent'; 19 + import { ColliderComponent } from '../Collision/ColliderComponent'; 20 + import { CompositeCollider } from '../Collision/Colliders/CompositeCollider'; 21 + import { Color } from '../Color'; 22 + import { DebugGraphicsComponent } from '../Graphics/DebugGraphicsComponent'; 23 + import { Collider } from '../Collision/Colliders/Collider'; 24 + 25 + export interface TileMapOptions { 26 + /** 27 + * Optionally name the isometric tile map 28 + */ 29 + name?: string; 30 + /** 31 + * Optionally specify the position of the isometric tile map 32 + */ 33 + pos?: Vector; 34 + /** 35 + * Width of an individual tile in pixels 36 + */ 37 + tileWidth: number; 38 + /** 39 + * Height of an individual tile in pixels 40 + */ 41 + tileHeight: number; 42 + /** 43 + * Number of tiles wide 44 + */ 45 + width: number; 46 + /** 47 + * Number of tiles high 48 + */ 49 + height: number; 50 + } 51 + 52 + /** 53 + * The TileMap provides a mechanism for doing flat 2D tiles rendered in a grid. 54 + * 55 + * TileMaps are useful for top down or side scrolling grid oriented games. 56 + */ 57 + export class TileMap extends Entity { 58 + private _token = 0; 59 + private _onScreenXStart: number = 0; 60 + private _onScreenXEnd: number = Number.MAX_VALUE; 61 + private _onScreenYStart: number = 0; 62 + private _onScreenYEnd: number = Number.MAX_VALUE; 63 + private _spriteSheets: { [key: string]: Graphics.SpriteSheet } = {}; 64 + 65 + private _legacySpriteMap = new Map<Graphics.Sprite, Sprite>(); 66 + public logger: Logger = Logger.getInstance(); 67 + public readonly tiles: Tile[] = []; 68 + private _rows: Tile[][] = []; 69 + private _cols: Tile[][] = []; 70 + 71 + public readonly tileWidth: number; 72 + public readonly tileHeight: number; 73 + public readonly height: number; 74 + public readonly width: number; 75 + 76 + private _collidersDirty = true; 77 + public flagCollidersDirty() { 78 + this._collidersDirty = true; 79 + } 80 + private _transform: TransformComponent; 81 + private _motion: MotionComponent; 82 + private _collider: ColliderComponent; 83 + private _composite: CompositeCollider; 84 + 85 + public get x(): number { 86 + return this._transform.pos.x ?? 0; 87 + } 88 + 89 + public set x(val: number) { 90 + if (this._transform?.pos) { 91 + this.get(TransformComponent).pos = vec(val, this.y); 92 + } 93 + } 94 + 95 + public get y(): number { 96 + return this._transform?.pos.y ?? 0; 97 + } 98 + 99 + public set y(val: number) { 100 + if (this._transform?.pos) { 101 + this._transform.pos = vec(this.x, val); 102 + } 103 + } 104 + 105 + public get z(): number { 106 + return this._transform.z ?? 0; 107 + } 108 + 109 + public set z(val: number) { 110 + if (this._transform) { 111 + this._transform.z = val; 112 + } 113 + } 114 + 115 + public get rotation(): number { 116 + return this._transform?.rotation ?? 0; 117 + } 118 + 119 + public set rotation(val: number) { 120 + if (this._transform?.rotation) { 121 + this._transform.rotation = val; 122 + } 123 + } 124 + 125 + public get scale(): Vector { 126 + return this._transform?.scale ?? Vector.One; 127 + } 128 + 129 + public set scale(val: Vector) { 130 + if (this._transform?.scale) { 131 + this._transform.scale = val; 132 + } 133 + } 134 + 135 + public get pos(): Vector { 136 + return this._transform.pos; 137 + } 138 + 139 + public set pos(val: Vector) { 140 + this._transform.pos = val; 141 + } 142 + 143 + public get vel(): Vector { 144 + return this._motion.vel; 145 + } 146 + 147 + public set vel(val: Vector) { 148 + this._motion.vel = val; 149 + } 150 + 151 + public on(eventName: Events.preupdate, handler: (event: Events.PreUpdateEvent<TileMap>) => void): void; 152 + public on(eventName: Events.postupdate, handler: (event: Events.PostUpdateEvent<TileMap>) => void): void; 153 + public on(eventName: Events.predraw, handler: (event: Events.PreDrawEvent) => void): void; 154 + public on(eventName: Events.postdraw, handler: (event: Events.PostDrawEvent) => void): void; 155 + public on(eventName: string, handler: (event: Events.GameEvent<any>) => void): void; 156 + public on(eventName: string, handler: (event: any) => void): void { 157 + super.on(eventName, handler); 158 + } 159 + 160 + 161 + /** 162 + * @param options 163 + */ 164 + constructor(options: TileMapOptions) { 165 + super(null, options.name); 166 + this.addComponent(new TransformComponent()); 167 + this.addComponent(new MotionComponent()); 168 + this.addComponent( 169 + new BodyComponent({ 170 + type: CollisionType.Fixed 171 + }) 172 + ); 173 + this.addComponent(new CanvasDrawComponent((ctx, delta) => this.draw(ctx, delta))); 174 + this.addComponent( 175 + new GraphicsComponent({ 176 + onPostDraw: (ctx, delta) => this.draw(ctx, delta) 177 + }) 178 + ); 179 + this.addComponent(new DebugGraphicsComponent((ctx) => this.debug(ctx))); 180 + this.addComponent(new ColliderComponent()); 181 + this._transform = this.get(TransformComponent); 182 + this._motion = this.get(MotionComponent); 183 + this._collider = this.get(ColliderComponent); 184 + this._composite = this._collider.useCompositeCollider([]); 185 + 186 + this._transform.pos = options.pos ?? Vector.Zero; 187 + this._transform.posChanged$.subscribe(() => this.flagCollidersDirty()); 188 + this.tileWidth = options.tileWidth; 189 + this.tileHeight = options.tileHeight; 190 + this.height = options.height; 191 + this.width = options.width; 192 + this.tiles = new Array<Tile>(this.height * this.width); 193 + this._rows = new Array(this.height); 194 + this._cols = new Array(this.width); 195 + let currentCol: Tile[] = []; 196 + for (let i = 0; i < this.width; i++) { 197 + for (let j = 0; j < this.height; j++) { 198 + const cd = new Tile({ 199 + x: i, 200 + y: j, 201 + map: this 202 + }); 203 + cd.map = this; 204 + this.tiles[i + j * this.width] = cd; 205 + currentCol.push(cd); 206 + if (!this._rows[j]) { 207 + this._rows[j] = []; 208 + } 209 + this._rows[j].push(cd); 210 + } 211 + this._cols[i] = currentCol; 212 + currentCol = []; 213 + } 214 + 215 + this.get(GraphicsComponent).localBounds = new BoundingBox({ 216 + left: 0, 217 + top: 0, 218 + right: this.width * this.tileWidth, 219 + bottom: this.height * this.tileHeight 220 + }); 221 + } 222 + 223 + public _initialize(engine: Engine) { 224 + super._initialize(engine); 225 + } 226 + 227 + /** 228 + * 229 + * @param key 230 + * @param spriteSheet 231 + * @deprecated No longer used, will be removed in v0.26.0 232 + */ 233 + public registerSpriteSheet(key: string, spriteSheet: SpriteSheet): void; 234 + public registerSpriteSheet(key: string, spriteSheet: Graphics.SpriteSheet): void; 235 + @obsolete({ message: 'No longer used, will be removed in v0.26.0' }) 236 + public registerSpriteSheet(key: string, spriteSheet: SpriteSheet | Graphics.SpriteSheet): void { 237 + if (spriteSheet instanceof Graphics.SpriteSheet) { 238 + this._spriteSheets[key] = spriteSheet; 239 + } else { 240 + this._spriteSheets[key] = Graphics.SpriteSheet.fromLegacySpriteSheet(spriteSheet); 241 + } 242 + } 243 + 244 + private _originalOffsets = new WeakMap<Collider, Vector>(); 245 + private _getOrSetColliderOriginalOffset(collider: Collider): Vector { 246 + if (!this._originalOffsets.has(collider)) { 247 + const originalOffset = collider.offset; 248 + this._originalOffsets.set(collider, originalOffset); 249 + return originalOffset; 250 + } else { 251 + return this._originalOffsets.get(collider); 252 + } 253 + } 254 + /** 255 + * Tiles colliders based on the solid tiles in the tilemap. 256 + */ 257 + private _updateColliders(): void { 258 + this._composite.clearColliders(); 259 + const colliders: BoundingBox[] = []; 260 + this._composite = this._collider.useCompositeCollider([]); 261 + let current: BoundingBox; 262 + // Bad square tesselation algo 263 + for (let i = 0; i < this.width; i++) { 264 + // Scan column for colliders 265 + for (let j = 0; j < this.height; j++) { 266 + // Columns start with a new collider 267 + if (j === 0) { 268 + current = null; 269 + } 270 + const tile = this.tiles[i + j * this.width]; 271 + // Current tile in column is solid build up current collider 272 + if (tile.solid) { 273 + // Use custom collider otherwise bounding box 274 + if (tile.getColliders().length > 0) { 275 + for (const collider of tile.getColliders()) { 276 + const originalOffset = this._getOrSetColliderOriginalOffset(collider); 277 + collider.offset = vec(tile.x * this.tileWidth, tile.y * this.tileHeight).add(originalOffset); 278 + collider.owner = this; 279 + this._composite.addCollider(collider); 280 + } 281 + current = null; 282 + } else { 283 + if (!current) { 284 + current = tile.bounds; 285 + } else { 286 + current = current.combine(tile.bounds); 287 + } 288 + } 289 + } else { 290 + // Not solid skip and cut off the current collider 291 + if (current) { 292 + colliders.push(current); 293 + } 294 + current = null; 295 + } 296 + } 297 + // After a column is complete check to see if it can be merged into the last one 298 + if (current) { 299 + // if previous is the same combine it 300 + const prev = colliders[colliders.length - 1]; 301 + if (prev && prev.top === current.top && prev.bottom === current.bottom) { 302 + colliders[colliders.length - 1] = prev.combine(current); 303 + } else { 304 + // else new collider 305 + colliders.push(current); 306 + } 307 + } 308 + } 309 + 310 + for (const c of colliders) { 311 + const collider = Shape.Box(c.width, c.height, Vector.Zero, vec(c.left - this.pos.x, c.top - this.pos.y)); 312 + collider.owner = this; 313 + this._composite.addCollider(collider); 314 + } 315 + this._collider.update(); 316 + } 317 + 318 + /** 319 + * Returns the [[Tile]] by index (row major order) 320 + */ 321 + public getTileByIndex(index: number): Tile { 322 + return this.tiles[index]; 323 + } 324 + /** 325 + * Returns the [[Tile]] by its x and y integer coordinates 326 + */ 327 + public getTile(x: number, y: number): Tile { 328 + if (x < 0 || y < 0 || x >= this.width || y >= this.height) { 329 + return null; 330 + } 331 + return this.tiles[x + y * this.width]; 332 + } 333 + /** 334 + * Returns the [[Tile]] by testing a point in world coordinates, 335 + * returns `null` if no Tile was found. 336 + */ 337 + public getTileByPoint(point: Vector): Tile { 338 + const x = Math.floor((point.x - this.pos.x) / this.tileWidth); 339 + const y = Math.floor((point.y - this.pos.y) / this.tileHeight); 340 + const tile = this.getTile(x, y); 341 + if (x >= 0 && y >= 0 && x < this.width && y < this.height && tile) { 342 + return tile; 343 + } 344 + return null; 345 + } 346 + 347 + public getRows(): readonly Tile[][] { 348 + return this._rows; 349 + } 350 + 351 + public getColumns(): readonly Tile[][] { 352 + return this._cols; 353 + } 354 + 355 + public update(engine: Engine, delta: number) { 356 + this.onPreUpdate(engine, delta); 357 + this.emit('preupdate', new Events.PreUpdateEvent(engine, delta, this)); 358 + if (this._collidersDirty) { 359 + this._collidersDirty = false; 360 + this._updateColliders(); 361 + } 362 + 363 + this._token++; 364 + const worldBounds = engine.getWorldBounds(); 365 + const worldCoordsUpperLeft = vec(worldBounds.left, worldBounds.top); 366 + const worldCoordsLowerRight = vec(worldBounds.right, worldBounds.bottom); 367 + 368 + this._onScreenXStart = Math.max(Math.floor((worldCoordsUpperLeft.x - this.x) / this.tileWidth) - 2, 0); 369 + this._onScreenYStart = Math.max(Math.floor((worldCoordsUpperLeft.y - this.y) / this.tileHeight) - 2, 0); 370 + this._onScreenXEnd = Math.max(Math.floor((worldCoordsLowerRight.x - this.x) / this.tileWidth) + 2, 0); 371 + this._onScreenYEnd = Math.max(Math.floor((worldCoordsLowerRight.y - this.y) / this.tileHeight) + 2, 0); 372 + this._transform.pos = vec(this.x, this.y); 373 + 374 + this.onPostUpdate(engine, delta); 375 + this.emit('postupdate', new Events.PostUpdateEvent(engine, delta, this)); 376 + } 377 + 378 + /** 379 + * Draws the tile map to the screen. Called by the [[Scene]]. 380 + * @param ctx CanvasRenderingContext2D or ExcaliburGraphicsContext 381 + * @param delta The number of milliseconds since the last draw 382 + */ 383 + public draw(ctx: CanvasRenderingContext2D | ExcaliburGraphicsContext, delta: number): void { 384 + this.emit('predraw', new Events.PreDrawEvent(ctx as any, delta, this)); // TODO fix event 385 + 386 + let x = this._onScreenXStart; 387 + const xEnd = Math.min(this._onScreenXEnd, this.width); 388 + let y = this._onScreenYStart; 389 + const yEnd = Math.min(this._onScreenYEnd, this.height); 390 + 391 + let graphics: readonly Graphics.Graphic[], graphicsIndex: number, graphicsLen: number; 392 + 393 + for (x; x < xEnd; x++) { 394 + for (y; y < yEnd; y++) { 395 + // get non-negative tile sprites 396 + graphics = this.getTile(x, y).getGraphics(); 397 + 398 + for (graphicsIndex = 0, graphicsLen = graphics.length; graphicsIndex < graphicsLen; graphicsIndex++) { 399 + // draw sprite, warning if sprite doesn't exist 400 + const graphic = graphics[graphicsIndex]; 401 + if (graphic) { 402 + if (!(ctx instanceof CanvasRenderingContext2D)) { 403 + if (hasGraphicsTick(graphic)) { 404 + graphic?.tick(delta, this._token); 405 + } 406 + graphic.draw(ctx, x * this.tileWidth, y * this.tileHeight); 407 + } else if (graphic instanceof Graphics.Sprite) { 408 + // TODO legacy drawing mode 409 + if (!this._legacySpriteMap.has(graphic)) { 410 + this._legacySpriteMap.set(graphic, Graphics.Sprite.toLegacySprite(graphic)); 411 + } 412 + this._legacySpriteMap.get(graphic).draw(ctx, x * this.tileWidth, y * this.tileHeight); 413 + } 414 + } 415 + } 416 + } 417 + y = this._onScreenYStart; 418 + } 419 + 420 + this.emit('postdraw', new Events.PostDrawEvent(ctx as any, delta, this)); 421 + } 422 + 423 + public debug(gfx: ExcaliburGraphicsContext) { 424 + const width = this.tileWidth * this.width; 425 + const height = this.tileHeight * this.height; 426 + const pos = Vector.Zero; 427 + for (let r = 0; r < this.height + 1; r++) { 428 + const yOffset = vec(0, r * this.tileHeight); 429 + gfx.drawLine(pos.add(yOffset), pos.add(vec(width, yOffset.y)), Color.Red, 2); 430 + } 431 + 432 + for (let c = 0; c < this.width + 1; c++) { 433 + const xOffset = vec(c * this.tileWidth, 0); 434 + gfx.drawLine(pos.add(xOffset), pos.add(vec(xOffset.x, height)), Color.Red, 2); 435 + } 436 + 437 + const colliders = this._composite.getColliders(); 438 + for (const collider of colliders) { 439 + const grayish = Color.Gray; 440 + grayish.a = 0.5; 441 + const bounds = collider.localBounds; 442 + const pos = collider.worldPos.sub(this.pos); 443 + gfx.drawRectangle(pos, bounds.width, bounds.height, grayish); 444 + } 445 + } 446 + } 447 + 448 + export interface TileOptions { 449 + /** 450 + * Integer tile x coordinate 451 + */ 452 + x: number; 453 + /** 454 + * Integer tile y coordinate 455 + */ 456 + y: number; 457 + map: TileMap; 458 + solid?: boolean; 459 + graphics?: Graphics.Graphic[]; 460 + } 461 + 462 + /** 463 + * TileMap Tile 464 + * 465 + * A light-weight object that occupies a space in a collision map. Generally 466 + * created by a [[TileMap]]. 467 + * 468 + * Tiles can draw multiple sprites. Note that the order of drawing is the order 469 + * of the sprites in the array so the last one will be drawn on top. You can 470 + * use transparency to create layers this way. 471 + */ 472 + export class Tile extends Entity { 473 + private _bounds: BoundingBox; 474 + private _pos: Vector; 475 + private _posDirty = false; 476 + private _transform: TransformComponent; 477 + 478 + /** 479 + * Return the world position of the top left corner of the tile 480 + */ 481 + public get pos() { 482 + if (this._posDirty) { 483 + this._recalculate(); 484 + this._posDirty = false; 485 + } 486 + return this._pos; 487 + } 488 + 489 + /** 490 + * Integer x coordinate of the tile 491 + */ 492 + public readonly x: number; 493 + 494 + /** 495 + * Integer y coordinate of the tile 496 + */ 497 + public readonly y: number; 498 + 499 + /** 500 + * Width of the tile in pixels 501 + */ 502 + public readonly width: number; 503 + 504 + /** 505 + * Height of the tile in pixels 506 + */ 507 + public readonly height: number; 508 + 509 + /** 510 + * Reference to the TileMap this tile is associated with 511 + */ 512 + public map: TileMap; 513 + 514 + private _solid = false; 515 + /** 516 + * Wether this tile should be treated as solid by the tilemap 517 + */ 518 + public get solid(): boolean { 519 + return this._solid; 520 + } 521 + /** 522 + * Wether this tile should be treated as solid by the tilemap 523 + */ 524 + public set solid(val: boolean) { 525 + this.map?.flagCollidersDirty(); 526 + this._solid = val; 527 + } 528 + 529 + private _graphics: Graphics.Graphic[] = []; 530 + 531 + /** 532 + * Current list of graphics for this tile 533 + */ 534 + public getGraphics(): readonly Graphics.Graphic[] { 535 + return this._graphics; 536 + } 537 + 538 + /** 539 + * Add another [[Graphic]] to this TileMap tile 540 + * @param graphic 541 + */ 542 + public addGraphic(graphic: Graphics.Graphic | LegacySprite) { 543 + if (graphic instanceof LegacySprite) { 544 + this._graphics.push(Graphics.Sprite.fromLegacySprite(graphic)); 545 + } else { 546 + this._graphics.push(graphic); 547 + } 548 + } 549 + 550 + /** 551 + * Remove an instance of a [[Graphic]] from this tile 552 + */ 553 + public removeGraphic(graphic: Graphics.Graphic | LegacySprite) { 554 + removeItemFromArray(graphic, this._graphics); 555 + } 556 + 557 + /** 558 + * Clear all graphics from this tile 559 + */ 560 + public clearGraphics() { 561 + this._graphics.length = 0; 562 + } 563 + 564 + /** 565 + * Current list of colliders for this tile 566 + */ 567 + private _colliders: Collider[] = []; 568 + 569 + /** 570 + * Returns the list of colliders 571 + */ 572 + public getColliders(): readonly Collider[] { 573 + return this._colliders; 574 + } 575 + 576 + /** 577 + * Adds a custom collider to the [[Tile]] to use instead of it's bounds 578 + * 579 + * If no collider is set but [[Tile.solid]] is set, the tile bounds are used as a collider. 580 + * 581 + * **Note!** the [[Tile.solid]] must be set to true for it to act as a "fixed" collider 582 + * @param collider 583 + */ 584 + public addCollider(collider: Collider) { 585 + this._colliders.push(collider); 586 + this.map.flagCollidersDirty(); 587 + } 588 + 589 + /** 590 + * Removes a collider from the [[Tile]] 591 + * @param collider 592 + */ 593 + public removeCollider(collider: Collider) { 594 + const index = this._colliders.indexOf(collider); 595 + if (index > -1) { 596 + this._colliders.splice(index, 1); 597 + } 598 + this.map.flagCollidersDirty(); 599 + } 600 + 601 + /** 602 + * Clears all colliders from the [[Tile]] 603 + */ 604 + public clearColliders() { 605 + this._colliders.length = 0; 606 + this.map.flagCollidersDirty(); 607 + } 608 + 609 + /** 610 + * Arbitrary data storage per tile, useful for any game specific data 611 + */ 612 + public data = new Map<string, any>(); 613 + 614 + constructor(options: TileOptions) { 615 + super(); 616 + this.x = options.x; 617 + this.y = options.y; 618 + this.map = options.map; 619 + this.width = options.map.tileWidth; 620 + this.height = options.map.tileHeight; 621 + this.solid = options.solid ?? this.solid; 622 + this._graphics = options.graphics ?? []; 623 + this._recalculate(); 624 + this._transform = options.map.get(TransformComponent); 625 + this._transform.posChanged$.subscribe(() => { 626 + this._posDirty = true; 627 + }); 628 + } 629 + 630 + private _recalculate() { 631 + this._pos = this.map.pos.add( 632 + vec( 633 + this.x * this.map.tileWidth, 634 + this.y * this.map.tileHeight)); 635 + this._bounds = new BoundingBox(this._pos.x, this._pos.y, this._pos.x + this.width, this._pos.y + this.height); 636 + } 637 + 638 + public get bounds() { 639 + if (this._posDirty) { 640 + this._recalculate(); 641 + this._posDirty = false; 642 + } 643 + return this._bounds; 644 + } 645 + 646 + public get center(): Vector { 647 + return new Vector(this._pos.x + this.width / 2, this._pos.y + this.height / 2); 648 + } 649 + 650 + /** 651 + * Add another [[Sprite]] to this tile 652 + * @deprecated Use addSprite, will be removed in v0.26.0 653 + */ 654 + @obsolete({ message: 'Will be removed in v0.26.0', alternateMethod: 'addSprite' }) 655 + public pushSprite(sprite: Graphics.Sprite | LegacySprite) { 656 + this.addGraphic(sprite); 657 + } 658 + }
+4
src/engine/TileMap/index.ts
··· 1 + export * from './TileMap'; 2 + export * from './IsometricMap'; 3 + export * from './IsometricEntityComponent'; 4 + export * from './IsometricEntitySystem';
+12
src/spec/util/TestUtils.ts
··· 51 51 await engine.isReady(); 52 52 } 53 53 } 54 + 55 + /** 56 + * 57 + */ 58 + export function flushWebGLCanvasTo2D(source: HTMLCanvasElement): HTMLCanvasElement { 59 + const canvas = document.createElement('canvas'); 60 + canvas.width = source.width; 61 + canvas.height = source.height; 62 + const ctx = canvas.getContext('2d'); 63 + ctx.drawImage(source, 0, 0); 64 + return canvas; 65 + } 54 66 }
+2 -4
src/engine/Collision/Colliders/PolygonCollider.ts
··· 143 143 throw Error('Invalid polygon'); 144 144 } 145 145 146 - // Helper to get a vertex in the list 147 146 /** 148 - * 147 + * Helper to get a vertex in the list 149 148 */ 150 149 function getItem<T>(index: number, list: T[]) { 151 150 if (index >= list.length) { ··· 157 156 } 158 157 } 159 158 160 - // Quick test for point in triangle 161 159 /** 162 - * 160 + * Quick test for point in triangle 163 161 */ 164 162 function isPointInTriangle(point: Vector, a: Vector, b: Vector, c: Vector) { 165 163 const ab = b.sub(a);
+24 -1
src/engine/EntityComponentSystem/Components/TransformComponent.ts
··· 3 3 import { Vector, vec } from '../../Math/vector'; 4 4 import { Component } from '../Component'; 5 5 import { Observable } from '../../Util/Observable'; 6 + import { watch } from '../../Util/Watch'; 6 7 7 8 export interface Transform { 8 9 /** ··· 94 95 private _dirty = false; 95 96 96 97 public readonly matrix = Matrix.identity().translate(0, 0).rotate(0).scale(1, 1); 97 - private _position = createPosView(this.matrix); 98 + private _position = watch(createPosView(this.matrix), (v) => { 99 + this.posChanged$.notifyAll(v); 100 + }); 98 101 private _rotation = 0; 99 102 private _scale = createScaleView(this.matrix); 100 103 ··· 131 134 public coordPlane = CoordPlane.World; 132 135 133 136 /** 137 + * Observable that notifies when the position changes 138 + */ 139 + public posChanged$ = new Observable<Vector>(); 140 + /** 134 141 * The current position of the entity in world space or in screen space depending on the the [[CoordPlane|coordinate plane]]. 135 142 * 136 143 * If a parent entity exists coordinates are local to the parent. ··· 143 150 } 144 151 145 152 public set pos(val: Vector) { 153 + const oldPos = this.matrix.getPosition(); 146 154 this.matrix.setPosition(val.x, val.y); 147 155 this._dirty = true; 156 + if (!oldPos.equals(val)) { 157 + this.posChanged$.notifyAll(this._position); 158 + } 148 159 } 149 160 150 161 // Dirty flag check up the chain ··· 165 176 getX: () => source.data[MatrixLocations.X], 166 177 getY: () => source.data[MatrixLocations.Y], 167 178 setX: (x) => { 179 + const oldX = this.matrix.data[MatrixLocations.X]; 168 180 if (this.parent) { 169 181 const { x: newX } = this.parent?.getGlobalMatrix().getAffineInverse().multiply(vec(x, source.data[MatrixLocations.Y])); 170 182 this.matrix.data[MatrixLocations.X] = newX; 171 183 } else { 172 184 this.matrix.data[MatrixLocations.X] = x; 173 185 } 186 + if (oldX !== this.matrix.data[MatrixLocations.X]) { 187 + this.posChanged$.notifyAll(this._position); 188 + } 174 189 }, 175 190 setY: (y) => { 191 + const oldY = this.matrix.data[MatrixLocations.Y]; 176 192 if (this.parent) { 177 193 const { y: newY } = this.parent?.getGlobalMatrix().getAffineInverse().multiply(vec(source.data[MatrixLocations.X], y)); 178 194 this.matrix.data[MatrixLocations.Y] = newY; 179 195 } else { 180 196 this.matrix.data[MatrixLocations.Y] = y; 181 197 } 198 + if (oldY !== this.matrix.data[MatrixLocations.Y]) { 199 + this.posChanged$.notifyAll(this._position); 200 + } 182 201 } 183 202 }); 184 203 } 185 204 186 205 public set globalPos(val: Vector) { 206 + const oldPos = this.pos; 187 207 const parentTransform = this.parent; 188 208 if (!parentTransform) { 189 209 this.pos = val; 190 210 } else { 191 211 this.pos = parentTransform.getGlobalMatrix().getAffineInverse().multiply(val); 212 + } 213 + if (!oldPos.equals(val)) { 214 + this.posChanged$.notifyAll(this.pos); 192 215 } 193 216 } 194 217
src/spec/images/DebugSystemSpec/transform.png

This is a binary file and will not be displayed.

src/spec/images/IsometricMapSpec/cube-map-bottom.png

This is a binary file and will not be displayed.

src/spec/images/IsometricMapSpec/cube-map-debug.png

This is a binary file and will not be displayed.

src/spec/images/IsometricMapSpec/cube-map-top.png

This is a binary file and will not be displayed.

src/spec/images/IsometricMapSpec/cube.png

This is a binary file and will not be displayed.

src/spec/images/IsometricMapSpec/map.png

This is a binary file and will not be displayed.

src/spec/images/IsometricMapSpec/tile.png

This is a binary file and will not be displayed.