/// namespace gdjs { export namespace TileMap { /** * A tile map transformed with an affine transformation. * * @see {@link getHitboxesAround} It gives a fast access to hitboxes for collision handling. */ export class TransformedCollisionTileMap { /** * The model that describes the tile map. */ private _source: TileMapHelper.EditableTileMap; tag: string; private _layerIndex: integer | null; private _layers: Map; // TODO Tiled allows to offset the layers /** * The transformation from the time map coordinate (in pixels) * to the scene coordinate (in pixels). */ private _transformation: gdjs.AffineTransformation = new gdjs.AffineTransformation(); /** * The transformation from the scene coordinate (in pixels) * to the time map coordinate (in pixels). */ private _inverseTransformation: gdjs.AffineTransformation = new gdjs.AffineTransformation(); /** * This allows tiles to know if their hitboxes must be updated. * @see {@link TransformedCollisionTile.affineTransformationUpToDateCount} */ _transformationUpToDateCount: integer = 1; /** * A reusable Point to avoid allocations. */ private static readonly workingPoint: FloatPoint = [0, 0]; /** * @param source The model that describes the tile map. */ constructor( source: TileMapHelper.EditableTileMap, tag: string, layerIndex: number | null = null ) { this._source = source; this.tag = tag; this._layerIndex = layerIndex; this._layers = new Map(); this._buildLayersFromTileMap(source, this._layers); } updateFromTileMap(tileMap: TileMapHelper.EditableTileMap) { this._source = tileMap; this._layers = new Map(); this._buildLayersFromTileMap(tileMap, this._layers); } _buildLayersFromTileMap( tileMap: TileMapHelper.EditableTileMap, layers: Map ) { if (this._layerIndex) { const tileLayer = tileMap.getTileLayer(this._layerIndex); if (!tileLayer) { return; } layers.set( tileLayer.id, new TransformedCollisionTileMapLayer(this, tileLayer) ); } else { for (const sourceLayer of tileMap.getLayers()) { // TODO A visitor could be used to avoid a cast. if (!(sourceLayer instanceof TileMapHelper.EditableTileMapLayer)) { // TODO Collision mask for object layers is not handled. continue; } const tileLayer = sourceLayer as TileMapHelper.EditableTileMapLayer; layers.set( tileLayer.id, new TransformedCollisionTileMapLayer(this, tileLayer) ); } } } /** * @returns The transformation from the time map coordinate (in pixels) * to the scene coordinate (in pixels). */ getTransformation(): gdjs.AffineTransformation { return this._transformation; } /** * @param transformation the transformation from the tile map coordinate * (in pixels) to the scene coordinate (in pixels). */ setTransformation(transformation: gdjs.AffineTransformation) { this._transformation = transformation; const inverseTransformation = this._inverseTransformation; inverseTransformation.copyFrom(transformation); inverseTransformation.invert(); this._invalidate(); } private _invalidate() { this._transformationUpToDateCount = (this._transformationUpToDateCount + 1) % Number.MAX_SAFE_INTEGER; } invalidateTile(layerIndex: integer, x: integer, y: integer) { const layer = this.getLayer(layerIndex); if (layer) { const tile = layer.get(x, y); if (tile) { tile.invalidate(); } } } /** * @returns The tile map width in pixels. */ getWidth() { return this._source.getWidth(); } /** * @returns The tile map height in pixels. */ getHeight() { return this._source.getHeight(); } /** * @returns The tile width in pixels. */ getTileHeight() { return this._source.getTileHeight(); } /** * @returns The tile height in pixels. */ getTileWidth() { return this._source.getTileWidth(); } /** * @returns The number of tile columns in the map. */ getDimensionX() { return this._source.getDimensionX(); } /** * @returns The number of tile rows in the map. */ getDimensionY() { return this._source.getDimensionY(); } /** * @param tileId The tile identifier * @returns The tile definition form the tile set. */ getTileDefinition(tileId: integer) { return this._source.getTileDefinition(tileId); } /** * @param layerId The layer identifier. * @returns the layer */ getLayer(layerId: integer): TransformedCollisionTileMapLayer | undefined { return this._layers.get(layerId); } /** * @returns All the layers of the tile map. */ getLayers(): Iterable { return this._layers.values(); } /** * Check if a point is inside a tile with a given tag. * * It doesn't use the tile hitboxes. * It only check the point is inside the tile square. * * @param x The X coordinate of the point to check. * @param y The Y coordinate of the point to check. * @param tag The tile tag * @returns true when the point is inside a tile with a given tag. */ pointIsInsideTile(x: float, y: float, tag: string): boolean { const workingPoint: FloatPoint = TransformedCollisionTileMap.workingPoint; workingPoint[0] = x; workingPoint[1] = y; this._inverseTransformation.transform(workingPoint, workingPoint); return this._source.pointIsInsideTile( workingPoint[0], workingPoint[1], tag ); } /** * @param tag The tile tag. * @param left The left border of the area in the scene. * @param top The top border of the area in the scene. * @param right The right border of the area in the scene. * @param bottom The left border of the area in the scene. * @returns At least all the hitboxes from the given area * where tiles have the right tag. * * @see {@link gdjs.RuntimeObject.getHitboxesAround} */ getHitboxesAround( tag: string, left: float, top: float, right: float, bottom: float ): Iterable { // Return the hitboxes from the tiles that overlap // the AABB of the area in the tile map basis. // Some of these tiles are not event in the given area // but this is a good trade of between the number of // useless returned hitboxes and the time to find them. // Transform the vertices of the area // from the scene basis to the tile map basis. const inverseTransformation = this._inverseTransformation; const workingPoint: FloatPoint = TransformedCollisionTileMap.workingPoint; workingPoint[0] = left; workingPoint[1] = top; inverseTransformation.transform(workingPoint, workingPoint); const topLeftX = workingPoint[0]; const topLeftY = workingPoint[1]; workingPoint[0] = right; workingPoint[1] = top; inverseTransformation.transform(workingPoint, workingPoint); const topRightX = workingPoint[0]; const topRightY = workingPoint[1]; workingPoint[0] = right; workingPoint[1] = bottom; inverseTransformation.transform(workingPoint, workingPoint); const bottomRightX = workingPoint[0]; const bottomRightY = workingPoint[1]; workingPoint[0] = left; workingPoint[1] = bottom; inverseTransformation.transform(workingPoint, workingPoint); const bottomLeftX = workingPoint[0]; const bottomLeftY = workingPoint[1]; // Calculate the AABB of the area in the tile map basis. const xMin = Math.max( 0, Math.floor( Math.min(topLeftX, topRightX, bottomRightX, bottomLeftX) / this._source.getTileWidth() ) ); const xMax = Math.min( this.getDimensionX() - 1, Math.floor( Math.max(topLeftX, topRightX, bottomRightX, bottomLeftX) / this._source.getTileWidth() ) ); const yMin = Math.max( 0, Math.floor( Math.min(topLeftY, topRightY, bottomRightY, bottomLeftY) / this._source.getTileHeight() ) ); const yMax = Math.min( this.getDimensionY() - 1, Math.floor( Math.max(topLeftY, topRightY, bottomRightY, bottomLeftY) / this._source.getTileHeight() ) ); return this.getHitboxes(tag, xMin, yMin, xMax, yMax); } /** * @param tag The tile tag. * @param xMin The fist column to include. * @param yMin The fist row to include. * @param xMax The last column to include. * @param yMax The last row to include. * @returns All the hitboxes from the tiles overlapping * the given area where tiles have the right tag. */ getHitboxes( tag: string, xMin: integer, yMin: integer, xMax: integer, yMax: integer ): Iterable { return new MapCollisionMaskIterable(this, tag, xMin, yMin, xMax, yMax); } /** * @param tag The tile tag. * @returns All the hitboxes from the tiles having the right tag. */ getAllHitboxes(tag: string): Iterable { return this.getHitboxes( tag, 0, 0, this._source.getDimensionX() - 1, this._source.getDimensionY() - 1 ); } } /** * Iterable over the tile hitboxes of a given area and tag. */ class MapCollisionMaskIterable implements Iterable { map: TransformedCollisionTileMap; tag: string; xMin: integer; yMin: integer; xMax: integer; yMax: integer; /** * Avoid to allocate an empty iterator each time * the iterable is initialized. */ static emptyItr: Iterator = { next: () => ({ value: undefined, done: true }), }; /** * @param map The tile map. * @param tag The tile tag. * @param xMin The fist column to include. * @param yMin The fist row to include. * @param xMax The last column to include. * @param yMax The last row to include. */ constructor( map: TransformedCollisionTileMap, tag: string, xMin: integer, yMin: integer, xMax: integer, yMax: integer ) { this.map = map; this.tag = tag; this.xMin = xMin; this.yMin = yMin; this.xMax = xMax; this.yMax = yMax; } [Symbol.iterator]() { // Flatten the iterable of each layers into one. let layerItr = this.map.getLayers()[Symbol.iterator](); let listItr: Iterator = MapCollisionMaskIterable.emptyItr; return { next: () => { let listNext = listItr.next(); while (listNext.done) { const layerNext = layerItr.next(); if (layerNext.done) { return listNext; } listItr = layerNext.value .getHitboxes( this.tag, this.xMin, this.yMin, this.xMax, this.yMax ) [Symbol.iterator](); listNext = listItr.next(); } return listNext; }, }; } } /** * A tile map layer transformed with an affine transformation. */ export class TransformedCollisionTileMapLayer { /** * The time map that contains this layer. */ readonly tileMap: TransformedCollisionTileMap; /** * The model that describes the tile map. */ readonly _source: TileMapHelper.EditableTileMapLayer; private readonly _tiles: TransformedCollisionTile[][]; /** * @param tileMap The time map that contains this layer. * @param source The model that describes the tile map. */ constructor( tileMap: TransformedCollisionTileMap, source: TileMapHelper.EditableTileMapLayer ) { this.tileMap = tileMap; this._source = source; this._tiles = []; const dimX = this._source.getDimensionX(); const dimY = this._source.getDimensionY(); this._tiles.length = dimY; for (let y = 0; y < dimY; y++) { this._tiles[y] = []; this._tiles[y].length = dimX; for (let x = 0; x < dimX; x++) { this._tiles[y][x] = new TransformedCollisionTile(this, x, y); } } } /** * @param x The layer column. * @param y The layer row. * @return The tile from the tile set. */ get(x: integer, y: integer): TransformedCollisionTile | undefined { const row = this._tiles[y]; return row ? row[x] : undefined; } /** * The number of tile columns in the layer. */ getDimensionX() { return this._tiles.length === 0 ? 0 : this._tiles[0].length; } /** * The number of tile rows in the layer. */ getDimensionY() { return this._tiles.length; } /** * @returns The layer width in pixels. */ getWidth() { return this._source.getWidth(); } /** * @returns The layer height in pixels. */ getHeight() { return this._source.getHeight(); } /** * @param x The layer column. * @param y The layer row. * @returns true if the tile is flipped diagonally. */ isFlippedDiagonally(x: integer, y: integer) { return this._source.isFlippedDiagonally(x, y); } /** * @param x The layer column. * @param y The layer row. * @returns true if the tile is flipped vertically. */ isFlippedVertically(x: integer, y: integer) { return this._source.isFlippedVertically(x, y); } /** * @param x The layer column. * @param y The layer row. * @returns true if the tile is flipped horizontally. */ isFlippedHorizontally(x: integer, y: integer) { return this._source.isFlippedHorizontally(x, y); } /** * @param tag The tile tag. * @param xMin The fist column to include. * @param yMin The fist row to include. * @param xMax The last column to include. * @param yMax The last row to include. * @returns All the hitboxes from the tiles overlapping * the given area where tiles have the right tag. */ getHitboxes( tag: string, xMin: integer, yMin: integer, xMax: integer, yMax: integer ): Iterable { return new LayerCollisionMaskIterable( this, tag, xMin, yMin, xMax, yMax ); } /** * @param tag The tile tag. * @returns All the hitboxes from the tiles having the right tag. */ getAllHitboxes(tag: string): Iterable { return this.getHitboxes( tag, 0, 0, this.getDimensionX() - 1, this.getDimensionY() - 1 ); } } /** * Iterable over the tile hitboxes of a given area and tag. */ class LayerCollisionMaskIterable implements Iterable { layer: TransformedCollisionTileMapLayer; tag: string; xMin: integer; yMin: integer; xMax: integer; yMax: integer; /** * Avoid to allocate an empty iterator each time * the iterable is initialized. */ static emptyItr: Iterator = { next: () => ({ value: undefined, done: true }), }; /** * @param map The tile map. * @param tag The tile tag. * @param xMin The fist column to include. * @param yMin The fist row to include. * @param xMax The last column to include. * @param yMax The last row to include. */ constructor( layer: TransformedCollisionTileMapLayer, tag: string, xMin: integer, yMin: integer, xMax: integer, yMax: integer ) { this.layer = layer; this.tag = tag; this.xMin = xMin; this.yMin = yMin; this.xMax = xMax; this.yMax = yMax; } [Symbol.iterator]() { // Flatten the iterable of each tile into one. // xMin and yMin next increment let x = this.xMax; let y = this.yMin - 1; let polygonItr: Iterator = LayerCollisionMaskIterable.emptyItr; return { next: () => { let listNext = polygonItr.next(); while (listNext.done) { x++; if (x > this.xMax) { y++; x = this.xMin; } if (y > this.yMax) { // done return listNext; } const tile = this.layer.get(x, y); if (!tile) { continue; } const definition = tile.getDefinition(); if (!definition) { continue; } if (definition.hasTaggedHitBox(this.tag)) { polygonItr = tile.getHitboxes()[Symbol.iterator](); listNext = polygonItr.next(); } } return listNext; }, }; } } /** * A tile transformed with an affine transformation. */ class TransformedCollisionTile { /** * The layer that contains this tile. */ readonly layer: TransformedCollisionTileMapLayer; /** * The column index in the layer. */ readonly x: integer; /** * The row index in the layer. */ readonly y: integer; private readonly hitBoxes: gdjs.Polygon[]; private affineTransformationUpToDateCount: integer = -1; /** * An reusable AffineTransformation to avoid allocations. */ private static readonly workingTransformation: gdjs.AffineTransformation = new gdjs.AffineTransformation(); /** * * @param layer The layer that contains this tile. * @param x The column index in the layer. * @param y The row index in the layer. */ constructor( layer: TransformedCollisionTileMapLayer, x: integer, y: integer ) { this.layer = layer; this.x = x; this.y = y; const definition = this.getDefinition(); this.hitBoxes = []; if (definition) { const tag = this.layer.tileMap.tag; // Full hit boxes are built on demand. if (!definition.hasFullHitBox(tag)) { const definitionHitboxes = definition.getHitBoxes(tag); if (definitionHitboxes && definitionHitboxes.length) { this.hitBoxes.length = definitionHitboxes.length; for ( let polygonIndex = 0; polygonIndex < this.hitBoxes.length; polygonIndex++ ) { const polygon = new gdjs.Polygon(); this.hitBoxes[polygonIndex] = polygon; polygon.vertices.length = definitionHitboxes[polygonIndex].length; for ( let vertexIndex = 0; vertexIndex < polygon.vertices.length; vertexIndex++ ) { polygon.vertices[vertexIndex] = [0, 0]; } } } } } } /** * @returns The tile definition from the tile set. */ getDefinition(): TileMapHelper.TileDefinition { return this.layer.tileMap.getTileDefinition( this.layer._source.getTileId(this.x, this.y)! )!; } private _isHitboxesUpToDate() { return ( this.affineTransformationUpToDateCount === this.layer.tileMap._transformationUpToDateCount ); } private _setHitboxesUpToDate() { this.affineTransformationUpToDateCount = this.layer.tileMap._transformationUpToDateCount; } invalidate() { this.affineTransformationUpToDateCount = -1; // Also invalidate neighbors because their hit boxes may need to be // extended differently. let neighbor = this.layer.get(this.x - 1, this.y); if (neighbor) { neighbor.affineTransformationUpToDateCount = -1; } neighbor = this.layer.get(this.x + 1, this.y); if (neighbor) { neighbor.affineTransformationUpToDateCount = -1; } neighbor = this.layer.get(this.x, this.y - 1); if (neighbor) { neighbor.affineTransformationUpToDateCount = -1; } neighbor = this.layer.get(this.x, this.y + 1); if (neighbor) { neighbor.affineTransformationUpToDateCount = -1; } } /** * @returns The hitboxes of this tile in the scene basis. */ getHitboxes(): Polygon[] { if (this._isHitboxesUpToDate()) { return this.hitBoxes; } const definition = this.getDefinition(); if (!definition) { this._setHitboxesUpToDate(); this.hitBoxes.length = 0; return this.hitBoxes; } const tag = this.layer.tileMap.tag; const definitionHitboxes = definition.getHitBoxes(tag); if (!definitionHitboxes) { this._setHitboxesUpToDate(); this.hitBoxes.length = 0; return this.hitBoxes; } const tileMap = this.layer.tileMap; const width = tileMap.getTileWidth(); const height = tileMap.getTileHeight(); // Extend the hit boxes. // It avoids small objects to be pushed side way into a wall when they // should be pop out of the wall. const hasFullHitBox = definitionHitboxes.length === 1 && definition.hasFullHitBox(tag); if (hasFullHitBox) { const isLeftFull = this._hasNeighborFullHitBox(-1, 0); const isRightFull = this._hasNeighborFullHitBox(1, 0); const isTopFull = this._hasNeighborFullHitBox(0, -1); const isBottomFull = this._hasNeighborFullHitBox(0, 1); let hitBoxesCount = 0; if (isLeftFull || isRightFull) { let minX = isLeftFull ? -width : 0; let maxX = isRightFull ? 2 * width : width; if (hitBoxesCount >= this.hitBoxes.length) { this.hitBoxes[hitBoxesCount] = gdjs.Polygon.createRectangle(0, 0); } TransformedCollisionTile.setRectangle( this.hitBoxes[hitBoxesCount], minX, 0, maxX, height ); hitBoxesCount++; } if (isTopFull || isBottomFull) { let minY = isTopFull ? -height : 0; let maxY = isBottomFull ? 2 * height : height; if (hitBoxesCount >= this.hitBoxes.length) { this.hitBoxes[hitBoxesCount] = gdjs.Polygon.createRectangle(0, 0); } TransformedCollisionTile.setRectangle( this.hitBoxes[hitBoxesCount], 0, minY, width, maxY ); hitBoxesCount++; } if (hitBoxesCount === 0) { if (this.hitBoxes.length === 0) { this.hitBoxes[0] = gdjs.Polygon.createRectangle(0, 0); } TransformedCollisionTile.setRectangle( this.hitBoxes[0], 0, 0, width, height ); hitBoxesCount++; } this.hitBoxes.length = hitBoxesCount; } else { for ( let polygonIndex = 0; polygonIndex < definitionHitboxes.length; polygonIndex++ ) { const defPolygon = definitionHitboxes[polygonIndex]; if (polygonIndex >= this.hitBoxes.length) { // This can't happen in practice as only the simple tile map can be // modify and it only contains full hit boxes. this.hitBoxes[polygonIndex] = gdjs.Polygon.createRectangle(0, 0); } const polygon = this.hitBoxes[polygonIndex]; for ( let vertexIndex = 0; vertexIndex < polygon.vertices.length; vertexIndex++ ) { const defVertex = defPolygon[vertexIndex]; const vertex = polygon.vertices[vertexIndex]; vertex[0] = defVertex[0]; vertex[1] = defVertex[1]; } } } // Transform the hit boxes. // First compute the affine transformation relative to the tile. // Then the transformation is applied to each polygon in the hitboxes. // TODO: If the tile contains hitboxes that are both extended and not full, // this code should be adapted so that the transformation considers the // actual width and height of the hitbox (and not the tile). // At the moment, extended hitboxes can only be full and since flipping // transformations don't change a full hitbox, those transformations // are not applied. const tileTransformation = TransformedCollisionTile.workingTransformation; tileTransformation.setToTranslation(width * this.x, height * this.y); if (!hasFullHitBox) { if (this.layer.isFlippedHorizontally(this.x, this.y)) { tileTransformation.flipX(width / 2); } if (this.layer.isFlippedVertically(this.x, this.y)) { tileTransformation.flipY(height / 2); } if (this.layer.isFlippedDiagonally(this.x, this.y)) { tileTransformation.flipX(width / 2); tileTransformation.rotateAround(Math.PI / 2, width / 2, height / 2); } } tileTransformation.preConcatenate(tileMap.getTransformation()); for ( let polygonIndex = 0; polygonIndex < this.hitBoxes.length; polygonIndex++ ) { const polygon = this.hitBoxes[polygonIndex]; for ( let vertexIndex = 0; vertexIndex < polygon.vertices.length; vertexIndex++ ) { const vertex = polygon.vertices[vertexIndex]; tileTransformation.transform(vertex, vertex); } } this._setHitboxesUpToDate(); return this.hitBoxes; } private _hasNeighborFullHitBox(deltaX: integer, deltaY: integer) { const sourceLayer = this.layer._source; const tileId = sourceLayer.getTileId(this.x + deltaX, this.y + deltaY); const tileDefinition = tileId && this.layer.tileMap.getTileDefinition(tileId); return ( tileDefinition && tileDefinition.hasFullHitBox(this.layer.tileMap.tag) ); } private static setRectangle( polygon: gdjs.Polygon, minX: float, minY: float, maxX: float, maxY: float ) { const vertices = polygon.vertices; vertices[0][0] = minX; vertices[0][1] = minY; vertices[1][0] = maxX; vertices[1][1] = minY; vertices[2][0] = maxX; vertices[2][1] = maxY; vertices[3][0] = minX; vertices[3][1] = maxY; } } } }