From 109f0e6d2ebad4ca6092528c142fe9df2d753d8b Mon Sep 17 00:00:00 2001 From: Erik Onarheim Date: Thu, 21 Dec 2023 21:21:18 -0600 Subject: [PATCH] fix: TileMap packing + TileMap debug draw configurable (#2851) This PR makes TileMap debug draw configurable, and by default reduces the draw burden by toggling off the grid display by default. This PR also fixes a tilemap packing bug where some situation would result in incorrect colliders https://github.com/excaliburjs/Excalibur/assets/612071/104bfb9d-c8e3-431e-a454-e8bed89ad960 --- CHANGELOG.md | 4 +- sandbox/tests/tilemap-pack/index.html | 15 ++ sandbox/tests/tilemap-pack/index.ts | 78 +++++++++ src/engine/Debug/Debug.ts | 13 ++ src/engine/Debug/DebugSystem.ts | 2 +- src/engine/Graphics/DebugGraphicsComponent.ts | 3 +- src/engine/TileMap/TileMap.ts | 156 +++++++++++++----- src/spec/DebugSystemSpec.ts | 3 + src/spec/TileMapSpec.ts | 55 ++++++ .../images/DebugSystemSpec/tilemap-debug.png | Bin 13466 -> 13594 bytes 10 files changed, 281 insertions(+), 48 deletions(-) create mode 100644 sandbox/tests/tilemap-pack/index.html create mode 100644 sandbox/tests/tilemap-pack/index.ts 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 8cc57ece0cc6520e38d91d67b5ef11aa15a5ecaa..f27787fa2b1fe2766ae3910f75179c237ac1039d 100644 GIT binary patch literal 13594 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