diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3601b2a2c..c2b9a2abd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,6 +15,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
### Added
+- Ability to configure TileMap debug drawing with the `ex.Engine.debug.tilemap` property.
- Materials have a new convenience method for updating uniforms
```typescript
game.input.pointers.primary.on('move', evt => {
@@ -28,6 +29,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
### Fixed
+- Fixed issue where TileMap solid tiles tile packing algorithm would incorrectly merge tiles in certain situations.
- Sprite tint was not respected when supplied in the constructor, this has been fixed!
- Adjusted the `FontCache` font timeout to 400 ms and makes it configurable as a static `FontCache.FONT_TIMEOUT`. This is to help prevent a downward spiral on mobile devices that might take a long while to render a few starting frames causing the cache to repeatedly clear and never recover.
@@ -43,7 +45,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
### Changed
--
+- TileMap debug draw is now less verbose by default to save draw cycles when toggling to debug
diff --git a/sandbox/tests/tilemap-pack/index.html b/sandbox/tests/tilemap-pack/index.html
new file mode 100644
index 000000000..bffc4b9ef
--- /dev/null
+++ b/sandbox/tests/tilemap-pack/index.html
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+ TileMap Packing
+
+
+ TileMap Packing
+ Should collapse bounds into minimum geometry to represent colliders
+
+
+
+
\ No newline at end of file
diff --git a/sandbox/tests/tilemap-pack/index.ts b/sandbox/tests/tilemap-pack/index.ts
new file mode 100644
index 000000000..d142fb0e2
--- /dev/null
+++ b/sandbox/tests/tilemap-pack/index.ts
@@ -0,0 +1,78 @@
+///
+
+var game = new ex.Engine({
+ width: 600,
+ height: 600
+});
+
+game.toggleDebug();
+game.debug.entity.showId = false;
+game.debug.tilemap.showSolidBounds = true;
+game.debug.tilemap.showGrid = true;
+
+var tm = new ex.TileMap({
+ pos: ex.vec(200, 200),
+ tileWidth: 16,
+ tileHeight: 16,
+ columns: 6,
+ rows: 4
+});
+
+tm.getTile(0, 0).solid = true;
+tm.getTile(0, 1).solid = true;
+tm.getTile(0, 2).solid = true;
+tm.getTile(0, 3).solid = true;
+
+tm.getTile(1, 0).solid = false;
+tm.getTile(1, 1).solid = false;
+tm.getTile(1, 2).solid = false;
+tm.getTile(1, 3).solid = false;
+
+tm.getTile(2, 0).solid = false;
+tm.getTile(2, 1).solid = false;
+tm.getTile(2, 2).solid = false;
+tm.getTile(2, 3).solid = false;
+
+tm.getTile(3, 0).solid = true;
+tm.getTile(3, 1).solid = true;
+tm.getTile(3, 2).solid = true;
+tm.getTile(3, 3).solid = true;
+
+tm.getTile(4, 0).solid = true;
+tm.getTile(4, 1).solid = true;
+tm.getTile(4, 2).solid = true;
+tm.getTile(4, 3).solid = true;
+
+game.add(tm);
+game.input.pointers.primary.on('down', (evt: ex.PointerEvent) => {
+ const tile = tm.getTileByPoint(evt.worldPos);
+ if (tile) {
+ tile.solid = !tile.solid;
+ }
+});
+
+let currentPointer!: ex.Vector;
+game.input.pointers.primary.on('down', (moveEvent) => {
+ if (moveEvent.button === ex.PointerButton.Right) {
+ currentPointer = moveEvent.worldPos;
+ game.currentScene.camera.move(currentPointer, 300, ex.EasingFunctions.EaseInOutCubic);
+ }
+});
+
+document.oncontextmenu = () => false;
+
+game.input.pointers.primary.on('wheel', (wheelEvent) => {
+ // wheel up
+ game.currentScene.camera.pos = currentPointer;
+ if (wheelEvent.deltaY < 0) {
+ game.currentScene.camera.zoom *= 1.2;
+ } else {
+ game.currentScene.camera.zoom /= 1.2;
+ }
+});
+
+game.start().then(() => {
+ game.currentScene.camera.pos = ex.vec(250, 225);
+ game.currentScene.camera.zoom = 3.5;
+ currentPointer = game.currentScene.camera.pos;
+});
diff --git a/src/engine/Debug/Debug.ts b/src/engine/Debug/Debug.ts
index 22af7a575..c2c930e9d 100644
--- a/src/engine/Debug/Debug.ts
+++ b/src/engine/Debug/Debug.ts
@@ -346,6 +346,19 @@ export class Debug implements DebugFlags {
showZoom: false
};
+
+ public tilemap = {
+ showAll: false,
+
+ showGrid: false,
+ gridColor: Color.Red,
+ gridWidth: .5,
+ showSolidBounds: false,
+ solidBoundsColor: Color.fromHex('#8080807F'), // grayish
+ showColliderGeometry: true,
+ colliderGeometryColor: Color.Green,
+ showQuadTree: false
+ };
}
/**
diff --git a/src/engine/Debug/DebugSystem.ts b/src/engine/Debug/DebugSystem.ts
index 40f49f466..3a4062d43 100644
--- a/src/engine/Debug/DebugSystem.ts
+++ b/src/engine/Debug/DebugSystem.ts
@@ -145,7 +145,7 @@ export class DebugSystem extends System {
if (!debugDraw.useTransform) {
this._graphicsContext.restore();
}
- debugDraw.draw(this._graphicsContext);
+ debugDraw.draw(this._graphicsContext, this._engine.debug);
if (!debugDraw.useTransform) {
this._graphicsContext.save();
this._applyTransform(entity);
diff --git a/src/engine/Graphics/DebugGraphicsComponent.ts b/src/engine/Graphics/DebugGraphicsComponent.ts
index f2b289df1..5201e8ce9 100644
--- a/src/engine/Graphics/DebugGraphicsComponent.ts
+++ b/src/engine/Graphics/DebugGraphicsComponent.ts
@@ -1,4 +1,5 @@
import { ExcaliburGraphicsContext } from '.';
+import { Debug } from '../Debug';
import { Component } from '../EntityComponentSystem/Component';
@@ -10,7 +11,7 @@ import { Component } from '../EntityComponentSystem/Component';
*/
export class DebugGraphicsComponent extends Component<'ex.debuggraphics'> {
readonly type = 'ex.debuggraphics';
- constructor(public draw: (ctx: ExcaliburGraphicsContext) => void, public useTransform = true) {
+ constructor(public draw: (ctx: ExcaliburGraphicsContext, debugFlags: Debug) => void, public useTransform = true) {
super();
}
}
\ No newline at end of file
diff --git a/src/engine/TileMap/TileMap.ts b/src/engine/TileMap/TileMap.ts
index 05fdcede6..a92ae978c 100644
--- a/src/engine/TileMap/TileMap.ts
+++ b/src/engine/TileMap/TileMap.ts
@@ -12,13 +12,13 @@ import { removeItemFromArray } from '../Util/Util';
import { MotionComponent } from '../EntityComponentSystem/Components/MotionComponent';
import { ColliderComponent } from '../Collision/ColliderComponent';
import { CompositeCollider } from '../Collision/Colliders/CompositeCollider';
-import { Color } from '../Color';
import { DebugGraphicsComponent } from '../Graphics/DebugGraphicsComponent';
import { Collider } from '../Collision/Colliders/Collider';
import { PostDrawEvent, PostUpdateEvent, PreDrawEvent, PreUpdateEvent } from '../Events';
import { EventEmitter, EventKey, Handler, Subscription } from '../EventEmitter';
import { CoordPlane } from '../Math/coord-plane';
import { QuadTree } from '../Collision/Detection/QuadTree';
+import { Debug } from '../Debug';
export interface TileMapOptions {
/**
@@ -220,7 +220,7 @@ export class TileMap extends Entity {
onPostDraw: (ctx, delta) => this.draw(ctx, delta)
})
);
- this.addComponent(new DebugGraphicsComponent((ctx) => this.debug(ctx), false));
+ this.addComponent(new DebugGraphicsComponent((ctx, debugFlags) => this.debug(ctx, debugFlags), false));
this.addComponent(new ColliderComponent());
this._graphics = this.get(GraphicsComponent);
this._transform = this.get(TransformComponent);
@@ -314,14 +314,55 @@ export class TileMap extends Entity {
this._composite = this._collider.useCompositeCollider([]);
let current: BoundingBox;
- // Bad square tesselation algo
+ /**
+ * Returns wether or not the 2 boxes share an edge and are the same height
+ * @param prev
+ * @param next
+ * @returns true if they share and edge, false if not
+ */
+ const shareEdges = (prev: BoundingBox, next: BoundingBox) => {
+ if (prev && next) {
+ // same top/bottom
+ return prev.top === next.top &&
+ prev.bottom === next.bottom &&
+ // Shared right/left edge
+ prev.right === next.left;
+ }
+ return false;
+ };
+
+ /**
+ * Potentially merges the current collider into a list of previous ones, mutating the list
+ * If checkAndCombine returns true, the collider was successfully merged and should be thrown away
+ * @param current current collider to test
+ * @param colliders List of colliders to consider merging with
+ * @param maxLookBack The amount of colliders to look back for combindation
+ * @returns false when no combination found, true when successfully combined
+ */
+ const checkAndCombine = (current: BoundingBox, colliders: BoundingBox[], maxLookBack = 10) => {
+ if (!current) {
+ return false;
+ }
+ // walk backwards through the list of colliders and combine with the first that shares an edge
+ for (let i = colliders.length - 1; i >= 0; i--) {
+ if (maxLookBack-- < 0) {
+ // blunt the O(n^2) algorithm a bit
+ return false;
+ }
+ const prev = colliders[i];
+ if (shareEdges(prev, current)) {
+ colliders[i] = prev.combine(current);
+ return true;
+ }
+ }
+ return false;
+ };
+
+ // ? configurable bias perhaps, horizontal strips vs. vertical ones
+ // Bad tile collider packing algorithm
for (let i = 0; i < this.columns; i++) {
// Scan column for colliders
for (let j = 0; j < this.rows; j++) {
- // Columns start with a new collider
- if (j === 0) {
- current = null;
- }
const tile = this.tiles[i + j * this.columns];
// Current tile in column is solid build up current collider
if (tile.solid) {
@@ -335,11 +376,11 @@ export class TileMap extends Entity {
this._composite.addCollider(collider);
}
//we push any current collider before nulling the current run
- if (current) {
+ if (current && !checkAndCombine(current, colliders)) {
colliders.push(current);
}
current = null;
- // Use the bounding box
+ // Use the bounding box
} else {
if (!current) {
// no current run, start one
@@ -351,23 +392,20 @@ export class TileMap extends Entity {
}
} else {
// Not solid skip and cut off the current collider
- if (current) {
+ // End of run check and combine
+ if (current && !checkAndCombine(current, colliders)) {
colliders.push(current);
}
current = null;
}
}
// After a column is complete check to see if it can be merged into the last one
- if (current) {
- // if previous is the same combine it
- const prev = colliders[colliders.length - 1];
- if (prev && prev.top === current.top && prev.bottom === current.bottom) {
- colliders[colliders.length - 1] = prev.combine(current);
- } else {
- // else new collider
- colliders.push(current);
- }
+ // Eno of run check and combine
+ if (current && !checkAndCombine(current, colliders)) {
+ // else new collider if no combination
+ colliders.push(current);
}
+ current = null;
}
for (const c of colliders) {
@@ -421,7 +459,7 @@ export class TileMap extends Entity {
this.onPreUpdate(engine, delta);
this.emit('preupdate', new PreUpdateEvent(engine, delta, this));
if (!this._oldPos.equals(this.pos) ||
- this._oldRotation !== this.rotation ||
+ this._oldRotation !== this.rotation ||
!this._oldScale.equals(this.scale)) {
this.flagCollidersDirty();
this.flagTilesDirty();
@@ -487,39 +525,67 @@ export class TileMap extends Entity {
this.emit('postdraw', new PostDrawEvent(ctx as any, delta, this));
}
- public debug(gfx: ExcaliburGraphicsContext) {
+ public debug(gfx: ExcaliburGraphicsContext, debugFlags: Debug) {
+ const {
+ showAll,
+ showGrid,
+ gridColor,
+ gridWidth,
+ showSolidBounds: showColliderBounds,
+ solidBoundsColor: colliderBoundsColor,
+ showColliderGeometry,
+ colliderGeometryColor,
+ showQuadTree
+ } = debugFlags.tilemap;
const width = this.tileWidth * this.columns * this.scale.x;
const height = this.tileHeight * this.rows * this.scale.y;
const pos = this.pos;
- for (let r = 0; r < this.rows + 1; r++) {
- const yOffset = vec(0, r * this.tileHeight * this.scale.y);
- gfx.drawLine(pos.add(yOffset), pos.add(vec(width, yOffset.y)), Color.Red, 2);
- }
+ if (showGrid || showAll) {
+ for (let r = 0; r < this.rows + 1; r++) {
+ const yOffset = vec(0, r * this.tileHeight * this.scale.y);
+ gfx.drawLine(pos.add(yOffset), pos.add(vec(width, yOffset.y)), gridColor, gridWidth);
+ }
- for (let c = 0; c < this.columns + 1; c++) {
- const xOffset = vec(c * this.tileWidth * this.scale.x, 0);
- gfx.drawLine(pos.add(xOffset), pos.add(vec(xOffset.x, height)), Color.Red, 2);
+ for (let c = 0; c < this.columns + 1; c++) {
+ const xOffset = vec(c * this.tileWidth * this.scale.x, 0);
+ gfx.drawLine(pos.add(xOffset), pos.add(vec(xOffset.x, height)), gridColor, gridWidth);
+ }
}
- const colliders = this._composite.getColliders();
- gfx.save();
- gfx.translate(this.pos.x, this.pos.y);
- gfx.scale(this.scale.x, this.scale.y);
- for (const collider of colliders) {
- const grayish = Color.Gray;
- grayish.a = 0.5;
- const bounds = collider.localBounds;
- const pos = collider.worldPos.sub(this.pos);
- gfx.drawRectangle(pos, bounds.width, bounds.height, grayish);
+ if (showAll || showColliderBounds || showColliderGeometry) {
+ const colliders = this._composite.getColliders();
+ gfx.save();
+ gfx.translate(this.pos.x, this.pos.y);
+ gfx.scale(this.scale.x, this.scale.y);
+ for (const collider of colliders) {
+ const bounds = collider.localBounds;
+ const pos = collider.worldPos.sub(this.pos);
+ if (showColliderBounds) {
+ gfx.drawRectangle(pos, bounds.width, bounds.height, colliderBoundsColor);
+ }
+ }
+ gfx.restore();
+ if (showColliderGeometry) {
+ for (const collider of colliders) {
+ collider.debug(gfx, colliderGeometryColor);
+ }
+ }
}
- gfx.restore();
- gfx.save();
- gfx.z = 999;
- this._quadTree.debug(gfx);
- for (let i = 0; i < this.tiles.length; i++) {
- this.tiles[i].bounds.draw(gfx);
+
+ if (showAll || showQuadTree || showColliderBounds) {
+ gfx.save();
+ gfx.z = 999;
+ if (showQuadTree) {
+ this._quadTree.debug(gfx);
+ }
+
+ if (showColliderBounds) {
+ for (let i = 0; i < this.tiles.length; i++) {
+ this.tiles[i].bounds.draw(gfx);
+ }
+ }
+ gfx.restore();
}
- gfx.restore();
}
}
diff --git a/src/spec/DebugSystemSpec.ts b/src/spec/DebugSystemSpec.ts
index 8c163d1e9..238d88baa 100644
--- a/src/spec/DebugSystemSpec.ts
+++ b/src/spec/DebugSystemSpec.ts
@@ -198,6 +198,9 @@ describe('DebugSystem', () => {
debugSystem.initialize(engine.currentScene);
engine.graphicsContext.clear();
+ engine.debug.tilemap.showGrid = true;
+ engine.debug.tilemap.showSolidBounds = true;
+ engine.debug.tilemap.showColliderGeometry = true;
const tilemap = new ex.TileMap({
pos: ex.vec(0, 0),
diff --git a/src/spec/TileMapSpec.ts b/src/spec/TileMapSpec.ts
index b197f585a..2d1862f89 100644
--- a/src/spec/TileMapSpec.ts
+++ b/src/spec/TileMapSpec.ts
@@ -103,6 +103,61 @@ describe('A TileMap', () => {
expect(tm.getColumns()[4][2].y).toBe(2);
});
+ it('can pack tile colliders', () => {
+ const tm = new ex.TileMap({
+ pos: ex.vec(200, 200),
+ tileWidth: 16,
+ tileHeight: 16,
+ columns: 6,
+ rows: 4
+ });
+ tm._initialize(engine);
+
+ tm.getTile(0, 0).solid = true;
+ tm.getTile(0, 1).solid = true;
+ tm.getTile(0, 2).solid = true;
+ tm.getTile(0, 3).solid = true;
+
+ tm.getTile(1, 0).solid = false;
+ tm.getTile(1, 1).solid = false;
+ tm.getTile(1, 2).solid = false;
+ tm.getTile(1, 3).solid = false;
+
+ tm.getTile(2, 0).solid = false;
+ tm.getTile(2, 1).solid = false;
+ tm.getTile(2, 2).solid = false;
+ tm.getTile(2, 3).solid = false;
+
+ tm.getTile(3, 0).solid = true;
+ tm.getTile(3, 1).solid = true;
+ tm.getTile(3, 2).solid = true;
+ tm.getTile(3, 3).solid = true;
+
+ tm.getTile(4, 0).solid = true;
+ tm.getTile(4, 1).solid = true;
+ tm.getTile(4, 2).solid = true;
+ tm.getTile(4, 3).solid = true;
+
+ tm.flagCollidersDirty();
+
+ tm.update(engine, 1);
+
+ const collider = tm.get(ex.ColliderComponent);
+ const composite = collider.get() as ex.CompositeCollider;
+ const colliders = composite.getColliders();
+
+ expect(colliders.length).toBe(2);
+ expect(colliders[0].bounds.top).toBe(200);
+ expect(colliders[0].bounds.left).toBe(200);
+ expect(colliders[0].bounds.right).toBe(216);
+ expect(colliders[0].bounds.bottom).toBe(264);
+
+ expect(colliders[1].bounds.left).toBe(248);
+ expect(colliders[1].bounds.right).toBe(280);
+ expect(colliders[1].bounds.top).toBe(200);
+ expect(colliders[1].bounds.bottom).toBe(264);
+ });
+
it('can store arbitrary data in cells', () => {
const tm = new ex.TileMap({
pos: ex.vec(0, 0),
diff --git a/src/spec/images/DebugSystemSpec/tilemap-debug.png b/src/spec/images/DebugSystemSpec/tilemap-debug.png
index 8cc57ece0..f27787fa2 100644
Binary files a/src/spec/images/DebugSystemSpec/tilemap-debug.png and b/src/spec/images/DebugSystemSpec/tilemap-debug.png differ