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