diff --git a/CHANGELOG.md b/CHANGELOG.md index 3601b2a2..c2b9a2ab 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 00000000..bffc4b9e --- /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 00000000..d142fb0e --- /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 22af7a57..c2c930e9 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 40f49f46..3a4062d4 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 f2b289df..5201e8ce 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 05fdcede..a92ae978 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 8c163d1e..238d88ba 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 b197f585..2d1862f8 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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%0Ab?f=3z0ds( zije5v559x!Vs9^se7jDs@!meqCd>VTx%jb{%pd4y{?OZ)*f~2HQfBF6g>kwV(Qy{Iu zule_ZON8cwGXGk3Td{n7VO(p&RQtmil<@eM?*9SqG3bjGf0KUqs^{|R^DB$2{=!+FrBQzOrv<_(hyCQM2H#xRXoX99g*%0vF4L5wQ8OAf zgLQKM*XsrKb#*mC_6h4*R6vbR2c~?En&05%M~>g~yYi0}{nGwrq5Acib?u&qHBk0$ zPz9K8R{TYKcH7tA^Y8cnI}`s?xwb~v8Z-rzaCx20iz7~l&Vh>Ec4da7cU%otE3O=8 zkP`sawtPG)Ye9n#RlAz%&c%M6=_mEoaN+gypoYTx_sV%-DVyf13$a&Wi%dZ^*820x z`e3Pj&1IKjw|1WU1zsizlFDD7d;vUuc~Ia~V0kE;)tbx41rjVlQ-6DH`aHp-73$tE z((Z;%f4;?6KDg=bA5g9G&de6NIJD{1+FCS&HSl_K`+Z42|WO2XHiJ-F}ZI?8pOhMZ6brxNIxDGTJ`YU7Nn=c@3 z?{gMi+5{T^JEfwf`fgRz#;`^GQm4Ewg0!7du~G$Td!MuD;-ht-q1Rt8Hs+XsdJg$I zi>_=0X}hGsW%_Ov!^Vh({Yqz?u7b23Qiu`-Y0F=mbno%HXFRice`Q>nQx4KL|NX*i z8$jA_=^S%?m({Z&YC*r&xx$+uZ69T-SV7v}zfHRJWZg2B-IcW^m43HDf$V=h;0$>D z(xNAE>TW3}o#S5v*{s%p$1i1^jX|@rU!EDI#lFjeEuzf_HEMIXuYLiA|LV&UQ+KY0 ztq?v03jf8LC5OS53Y!E4wNwrZ3Ty=B31RM&R(C^T z3zI;`oi}S(0!lf3J}O$J;euv5OZa(mL%;)=vlxUyoe-b zDU1PWAd4<|G)0w*Ixs8$VtB}Q6=YE}W57g^MZwxT-fZsbcG$xn<$okKB0w_28$yx!@*fw)`J(pWs1& z-s-zoRSkX=*B)rle+>%1_*3fk;9<|5Q+DT-mmcr_7bS1P~Fz!$bDh*!zCGVh6>4{K|BDP_Mvh_te>C<)zNu|HI^M9?y;ktvIPmmiq_p7JP`% zdv*6J>l*g?uO2f#>7EakRGjw@+%fnPb?*1wtCp+9<6k{yefs%6Sn{3h{~}PR{D|qR zKM*Usxa!kFyLkzA|0aUmbNtp%evo_4y?^>e{LWQG_Zd8Rnf3QzIVkzopr077Fyv;Y7A literal 13466 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 F1OPviG4ucc