Files
D8H f76a834284 Optimize simple tile maps with huge dimensions (#8202)
- Huge tile maps are faster to create and modify at runtime
2026-02-10 11:08:02 +01:00

938 lines
28 KiB
TypeScript

/// <reference path="../helper/TileMapHelper.d.ts" />
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<integer, TransformedCollisionTileMapLayer>;
// 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<integer, TransformedCollisionTileMapLayer>();
this._buildLayersFromTileMap(source, this._layers);
}
updateFromTileMap(tileMap: TileMapHelper.EditableTileMap) {
this._source = tileMap;
this._layers = new Map<integer, TransformedCollisionTileMapLayer>();
this._buildLayersFromTileMap(tileMap, this._layers);
}
_buildLayersFromTileMap(
tileMap: TileMapHelper.EditableTileMap,
layers: Map<integer, TransformedCollisionTileMapLayer>
) {
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<TransformedCollisionTileMapLayer> {
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<gdjs.Polygon> {
// 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<gdjs.Polygon> {
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<gdjs.Polygon> {
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<gdjs.Polygon> {
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<gdjs.Polygon> = {
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<gdjs.Polygon> = 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<gdjs.Polygon> {
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<gdjs.Polygon> {
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<gdjs.Polygon> {
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<gdjs.Polygon> = {
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<gdjs.Polygon> =
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;
}
}
}
}