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
zcmeAS@N?(olHy`uVBq!ia0y~yVEh8Y9Bd2>45zQ%?_ywJU@Q)DcVbv~PUa;8g93x6
zi(^Pd+}jz3o0gpnQ_0WV%z9y0c*7AZfk^H)i@V(wRuWRtD@7CDJW0BGqbF)+;i1cU
zvc*@vyZ(uNH?eHhk8eMJe*F3Hzo!F}(1ZpSl@H#+KfZnZ`SGX!|L^v7FYS-i|9fp;
zU-SFNj~{Q(zu*65{^R}sp32+R{XhDj$DZZK^B+;hSK943nBH|Qm{tDo!$ZgOKd(-<
zn_zV*aG{;sk5`GcS0?sxeKkB3&R>1uwMTlZ-2}<~Wl{3Ei#FTUp82u$@~;fxxnC
z=eb`tFO2KHZsA|jR}!+KUvOVlgna3e#ZI+7AFdvj`(kw|av^`pvGsGWxzB#F#$VpJ
z?(G)4TQ2Ffa$h9(SKVR{a<|gDa-1XHc1=Irt^8H8FV|dPH+p>C@AYEel55NR;cn&s
z)e~>~mU*SJtmv!cmi2Pi+~ICz-`iIb_JVol$L!@_FREF+zI+_+R`%Ls>*sxIga^Uv
z#W1(tXpVEY(*Dv24}zD;Ft?s>wtF1z|Jwl`1YZMTZhhmpUU{zQF9&!Kd@+Q%^||Bx
zkE@s0D!_x_*9w?hzb&{v@f=sJ0z3$Q&49V}^Mdm~u3jFEE=bZEjjqw$MRM-?_n&?5
zy+1oXe181+@$KWskKh0M`@Y@3^Y#B9KaPL@cMkuO1R6{*KSv(BklVuBj8Qx
zpVABV_V#smod5enN?nD<_!$Sb@Bh7hf9=0h|5yDz`0x0ApMU?v|6l+4|9A19f6u?i
zSNQ$^J^$VPJNu_O)xKy}c(9IRRps;e|4#qk_P^i%Xa5D+FgqrtmHbUv_g41Lk)3#j
z-O=>@ohOHz=e=Jr?~aMie|z4ql}B3NX}Pttd~HnV^)~!71ytwX}$TfTNSFCKDq+*p=ad7-m4
zW$rtz$8Jl%I8HoU1u`yO+97Cm+fzq(HKD1yR&z*;U1D!D)4KQMaI>6m181((H1@eS
zJubakCzKWACNFb)NhQdlO$-{FK^A4GIIi8zwV+Y3wxOW;709B43@c86ELu7B&i7rb
ztJ6}P{nyR|AgP^m@0jgg9i8;%
z`>%wb_qOGKdg!=+D}UX7kX;2D)33gJB_#1@r(ME<>0f8pR9w)1I(dFR$b~VdVz=+!
z_1Lw3E&r?!vH#WN<-S;de{mh`ujNId@7Ap=cqspx*}di~Gf3b1E1TuP`tF|c-MxF)
z!lU)8`R9E+tDgh1&yc@kG8~86fX(Apida$UVt#|FnbL^Za|!FYb4*
zrd*l*{>#U<&o9@5wQl|QV7Kq#PYdiN&OG$rvD!JH-|q5p-+t9s2S{xz@(H_KP#f
zul-Z@|IE0&!RAH2S@9?BcduG5?au%5vG40i{oGFv9qkSOy_gNQSnBM-^E*~Ed+^&|
zJnnph-F_>`Vuv5A{lOMzncDxCE&p2VroaEfaqnC9|JQ<|&Hnq}H{D>}v(Nh1pNRb{
za`7_%myaUf{p)ik^Yj1mFWvt;0c7|4vd^32i^A=dzkI8|emwe~{qL<{{hR-OsJ{<#
z`r(Ivo-Ds}^?KpQ$NOF6e<^VMxiOV}u7Y&guM5rv0Y35qH#UUHD_UqQarAZl
zbkNuox!%qwFT_h;4OpB4nC9FCLsfO-3sGR%#(FBC1bo-r?kdAk=6Cl+?)N($xvUloUc!q}ixe?RBh
zi*PgE*Wc~;%m1CZ|C9T#A5mpr7V$pBx62w|91AM#z1sVEejY_bOOJ4OCLb&3BIj
zOVzNyU9vk%cR-Cf<-721vh~@o?2a0MyrD&zh!6JE6R(OFiU8d$vvjIb>_(nQpK{?mMqO7`xS@
ztm@0fhc~irg0w}sE=&h$YjvEWbtjZFWu=?E>V&1UK-!iDnq+~r-HUKpemJ(&Vq4Ug
zi%K_Ng0rN&;p()oflW%6+
z0@)VfvN9Q@ZE2uL)}2tsq>WDUt}|L!fwZ+cZqWj3i#odJcG&=UFHVma3MmoWJ|jO+n9EfsoJVK(7DE_v$3buItYQ#docikQKVZ-ejr=
zN~v<^7s%fMnO85Lxp=qNNkhw8gP621HQWJJw2h4|Q{QR9GCHUo(5|eI1j-ZY
zVv|aDt%j9dAmircGI)Ygj_gd2kXP%Jaw0tBC2p;#0Hrq*Mzt-V8ZS-RbMUmkw?KkW4Iyc^b-Tbur_C8Q8
zbefO$Q{O?w~j6>yrK{4|F
z&f<7bD?oPcsnDIfcO7c2U&Al+@hU$klkE>WU$+OOuT0zg?YmcPV*l6LRU9+lzX4?5
zF`GYcLH0e|7;|O!YVHVryQ{~UPl(%Z1W7vC{Cx?Me7SYb_uZ?D!}RxEInI6R`F^nE
z9qWHjL6RSL%KUHGZRPg$iF2*Z!J0qHAouid{VbmcYR%j~{ZfC&>foUKeV30*pIg3v
z6Uf{vznb5J66?PNgLeCe>-ZdPfBDGwJIMb#14?+#FZJzigDTSQqjUbY?Y4US<&}2r
z9;MoE)*zG5&$0iR1xm91YQFY
zYa*HcasTCBdGm|8>;Kk&rEO2w?d$87|F8P%0=wgb~X{P&izaRhBufOnj_M_Pw=ltuk^Yh&M
z(@#F%Vm_3e2kPO>J}dM0)Uw3C|DJzeZ2vj@pND+>={~zJjsiEfX!BcboUox7RO~+F
zXqd5^X#t&tka4bmb_G^hRph~*dprzyvPTIOCpJ|)3?|H*dty}Nq@Z-Xi%_uv(6cSA*=@9>dNZn@e7s==>j
zo;d}oaO`e78NXdO%j9|NuNlTUW}wwmEXa-3yCuxQZnSBSYTUhQ;Tn;bi<>rvfvUH?X5Ez+
zK@NFdong9Twb)6&emg6fXkC!D=+zoua-^oQ?=|QMYTd2XsB`tjVwO!Ipq}wwlPMsMaHrSZ&=y|qm(0%6qM#JquXdmXloZe9
zsAQIgbDHWc=I_hBvEetEO+Uez`H
zR$aTHCjT}l&)&bqz8};lu(FtPI;*^tljmQIyupLhzd+$P{nN?wpq%Na_jKLu*t;JW
z?Z3wE`(s@_C`RU1e|Zh^*W%!vTIJ#4J3jP(ZB*X(s}ZCx{MF5VkiK(yPnX?|y?f!&
z{;TZge>@WhC4l=g+V_Ln{#G{Ax~s}d8DAe<{;PuF=OcBHWPHbRd$8o*X`k!LOV7PJ
zI{jCLAIlenA73%AW<|gHjQ;u*kb5?k{W}bD&*y5(zpUk@
zi0(63YgGM*yP)(V&-3hq`kkwaE8OS5d@T6l@O)6;txn_b(R5Ih*KeG$`F=ro`kc7h
zrTyVc&ex@bOrH5!Jsy<7|DH_gt2+`an^^U2qTRfOcE1+-+U={YtE-XwyYjCGZ1hy$
z`#}A|dPURO|CYVje_{W*4Wr{fqvJoLLvvb7hrrO>Xw#JRrsd^Cg#7^mFd5D+4v!>g5X`g9`n}l2@AFt%A*WgNJx(ef6Wkj^9)ptmTx!A0<{f(WpG{HuzS@bndvW+dp1OYMppJ(^+w(VS(YB1vFvW>
z_;k$KR2Hc^)G#qroK+REozT_xybM&QDRb~0rC&
zaA_Y%+jO_U6@}rf9Y?upMH0nUfwYOUU0ihoG?tew`dO~rH2p@-1^3C%_GE*q+Zz6@
zZ6Iye(+u^B!joIBGS!9@_MHZ4>wkVh8>}r~{IXiPsp^fhuNR*#z6EZM?rKY_1GRpR
zCi1Mlb5$V0a$UdCg~Yp{*3UzUSsy_rnol=;T@(&qdjM*krAMz=25PCq%{{i_Olbm8^$uNI##xwHe+_TAJ{R0gu_X(7+6J69Rx1g^3(
zlm>trmbC^gWhEe~x1~>N-)W`0t9*3~IBN|`BG0W?sDtgA?;i7FT~dihfIP>w9VMWk
zvT3d>0BQPLyQKG>mg?~*Uk(=dMuRjh*JdaJHBH#rgu-^MhE0`#1~BFN8ahDDlDQEc
z*IurJ&Hj{wjN8Vj0!le0DN2DWcXw?_RIE+NSo{c--Y&AtxByCuGkaCmzSCNGEcMI5
zjlQ!$7O8VL7=bKmcN4m@Yc+$E#6@-v^A(`MemS0oE>I_6Zq&)z^}BbayQzI~ytMZ{
zNUH9=yf;WncJ|5Ajk|ZbwXuC^e6&~Z{*%Mab!+EygQVu(KAF3D_b%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