/// namespace gdjs { /** * @category Objects > Tile Map */ export type SimpleTileMapObjectDataType = { content: { atlasImage: string; rowCount: number; columnCount: number; tileSize: number; tilesWithHitBox: string; }; }; /** * @category Objects > Tile Map */ export type SimpleTileMapObjectData = ObjectData & SimpleTileMapObjectDataType; /** * @category Objects > Tile Map */ export type SimpleTileMapNetworkSyncDataType = { op: number; tm?: TileMapHelper.EditableTileMapAsJsObject; }; /** * @category Objects > Tile Map */ export type SimpleTileMapNetworkSyncData = ObjectNetworkSyncData & SimpleTileMapNetworkSyncDataType; /** * Displays a SimpleTileMap object. * @category Objects > Tile Map */ export class SimpleTileMapRuntimeObject extends gdjs.RuntimeObject implements gdjs.Resizable, gdjs.Scalable, gdjs.OpacityHandler { /** * A reusable Point to avoid allocations. */ private static readonly workingPoint: FloatPoint = [0, 0]; _opacity: float = 255; _atlasImage: string; _tileMapManager: gdjs.TileMap.TileMapRuntimeManager; _tileMap: TileMapHelper.EditableTileMap | null = null; _renderer: gdjs.TileMapRuntimeObjectPixiRenderer; readonly _rowCount: number; readonly _columnCount: number; readonly _tileSize: number; _displayMode = 'all'; _layerIndex = 0; _initialTileMapAsJsObject: TileMapHelper.EditableTileMapAsJsObject; readonly _initialTilesWithHitBox: number[]; _isTileMapDirty: boolean = false; _sceneToTileMapTransformation: gdjs.AffineTransformation = new gdjs.AffineTransformation(); _tileMapToSceneTransformation: gdjs.AffineTransformation = new gdjs.AffineTransformation(); _collisionTileMap: gdjs.TileMap.TransformedCollisionTileMap | null = null; _hitBoxTag: string = 'collision'; private _transformationIsUpToDate: boolean = false; // TODO: Add a debug mode like for TileMapCollisionMaskRuntimeObject to draw? constructor( instanceContainer: gdjs.RuntimeInstanceContainer, objectData: ObjectData & SimpleTileMapObjectDataType, instanceData?: InstanceData ) { super(instanceContainer, objectData, instanceData); this._atlasImage = objectData.content.atlasImage; this._rowCount = objectData.content.rowCount; this._columnCount = objectData.content.columnCount; this._tileSize = objectData.content.tileSize; this._initialTileMapAsJsObject = { tileWidth: this._tileSize, tileHeight: this._tileSize, dimX: 1, dimY: 1, layers: [{ id: 0, alpha: this._opacity / 255, tiles: [] }], }; this._initialTilesWithHitBox = ( objectData.content.tilesWithHitBox as string ) .split(',') .filter((id) => !!id) .map((idAsString) => parseInt(idAsString, 10)); this._tileMapManager = gdjs.TileMap.TileMapRuntimeManager.getManager(instanceContainer); this._renderer = new gdjs.TileMapRuntimeObjectRenderer( this, instanceContainer ); this._loadTileMap( this._initialTileMapAsJsObject, (tileMap: TileMapHelper.EditableTileMap) => { this._renderer.updatePosition(); this._collisionTileMap = new gdjs.TileMap.TransformedCollisionTileMap( tileMap, this._hitBoxTag ); this.updateTransformation(); } ); // *ALWAYS* call `this.onCreated()` at the very end of your object constructor. this.onCreated(); } getRendererObject() { return this._renderer.getRendererObject(); } updateTileMap(forceUpdate: boolean): void { this._tileMapManager.getOrLoadSimpleTileMapTextureCache( (textureName) => { return this.getInstanceContainer() .getGame() .getImageManager() .getPIXITexture( textureName ) as unknown as PIXI.BaseTexture; }, this._atlasImage, this._tileSize, this._columnCount, this._rowCount, (textureCache: TileMapHelper.TileTextureCache | null) => { if (!textureCache) { // getOrLoadTextureCache already log warns and errors. return; } this._renderer.refreshPixiTileMap(textureCache, forceUpdate); }, (error) => { console.error( `Could not load texture cache for atlas ${this._atlasImage} during prerender. The tilemap might be badly configured or an issues happened with the loaded atlas image:`, error ); } ); } updatePreRender(instanceContainer: gdjs.RuntimeInstanceContainer): void { if (!this.isHidden()) { this.updateTileMap(this._isTileMapDirty); this._isTileMapDirty = false; } } updateFromObjectData( oldObjectData: SimpleTileMapObjectData, newObjectData: SimpleTileMapObjectData ): boolean { if ( oldObjectData.content.atlasImage !== newObjectData.content.atlasImage ) { // TODO: support changing the atlas texture return false; } // Map content is updated at hot-reload by extraInitializationFromInitialInstance. return true; } getNetworkSyncData( syncOptions: GetNetworkSyncDataOptions ): SimpleTileMapNetworkSyncData { const syncData: SimpleTileMapNetworkSyncData = { ...super.getNetworkSyncData(syncOptions), op: this._opacity, }; if (this._tileMap && syncOptions.syncFullTileMaps) { const currentTileMapAsJsObject = this._tileMap.toJSObject(); syncData.tm = currentTileMapAsJsObject; } return syncData; } updateFromNetworkSyncData( networkSyncData: SimpleTileMapNetworkSyncData, options: UpdateFromNetworkSyncDataOptions ): void { super.updateFromNetworkSyncData(networkSyncData, options); if (networkSyncData.tm !== undefined) { this._loadTileMap( networkSyncData.tm, (tileMap: TileMapHelper.EditableTileMap) => { if (networkSyncData.w !== undefined) { this.setWidth(networkSyncData.w); } if (networkSyncData.h !== undefined) { this.setHeight(networkSyncData.h); } if (networkSyncData.op !== undefined) { this.setOpacity(networkSyncData.op); } // 4. Update position (calculations based on renderer's dimensions). this._renderer.updatePosition(); if (this._collisionTileMap) { // If collision tile map is already defined, only update it. this._collisionTileMap.updateFromTileMap(tileMap); } else { this._collisionTileMap = new gdjs.TileMap.TransformedCollisionTileMap( tileMap, this._hitBoxTag ); } this.updateTransformation(); } ); } } extraInitializationFromInitialInstance( initialInstanceData: InstanceData ): void { // 1. load the tilemap from the instance. for (const property of initialInstanceData.stringProperties) { if (property.name === 'tilemap') { this._initialTileMapAsJsObject = JSON.parse(property.value); } } // 2. Update the renderer so that it updates the tilemap object // (used for width and position calculations). this._loadTileMap( this._initialTileMapAsJsObject, (tileMap: TileMapHelper.EditableTileMap) => { // 3. Set custom dimensions & opacity if applicable. if (initialInstanceData.customSize) { this.setWidth(initialInstanceData.width); this.setHeight(initialInstanceData.height); } this.setOpacity( initialInstanceData.opacity === undefined ? 255 : initialInstanceData.opacity ); // 4. Update position (calculations based on renderer's dimensions). this._renderer.updatePosition(); if (this._collisionTileMap) { // If collision tile map is already defined, there's a good chance it means // extraInitializationFromInitialInstance is called when hot reloading the // scene so the collision is tile map is updated instead of being re-created. this._collisionTileMap.updateFromTileMap(tileMap); } else { this._collisionTileMap = new gdjs.TileMap.TransformedCollisionTileMap( tileMap, this._hitBoxTag ); } this._transformationIsUpToDate = false; this.updateTransformation(); this.invalidateHitboxes(); } ); } private _loadTileMap( tileMapAsJsObject: TileMapHelper.EditableTileMapAsJsObject, tileMapLoadingCallback: (tileMap: TileMapHelper.EditableTileMap) => void ): void { if (this._columnCount <= 0 || this._rowCount <= 0) { console.error( `Tilemap object ${this.name} is not configured properly.` ); return; } this._tileMapManager.getOrLoadSimpleTileMap( tileMapAsJsObject, this.name, this._tileSize, this._columnCount, this._rowCount, (tileMap: TileMapHelper.EditableTileMap) => { this._initialTilesWithHitBox.forEach((tileId) => { const tileDefinition = tileMap.getTileDefinition(tileId); if (!tileDefinition) { console.warn( `Could not set hit box for tile with id ${tileId}. Continuing.` ); return; } tileDefinition.addHitBox( this._hitBoxTag, [ [0, 0], [0, tileMap.getTileHeight()], [tileMap.getTileWidth(), tileMap.getTileHeight()], [tileMap.getTileWidth(), 0], ], true ); }); this._tileMapManager.getOrLoadSimpleTileMapTextureCache( (textureName) => { return this.getInstanceContainer() .getGame() .getImageManager() .getPIXITexture( textureName ) as unknown as PIXI.BaseTexture; }, this._atlasImage, this._tileSize, this._columnCount, this._rowCount, (textureCache: TileMapHelper.TileTextureCache | null) => { if (!textureCache) { // getOrLoadTextureCache already log warns and errors. return; } this._tileMap = tileMap; this._renderer.refreshPixiTileMap(textureCache, true); tileMapLoadingCallback(tileMap); }, (error) => { console.error( `Could not load texture cache for atlas ${this._atlasImage} during initial loading. The tilemap might be badly configured or an issues happened with the loaded atlas image:`, error ); } ); } ); } onDestroyed(): void { super.onDestroyed(); this._renderer.destroy(); } setWidth(width: float): void { if (this.getWidth() === width) return; this._transformationIsUpToDate = false; this._renderer.setWidth(width); this.invalidateHitboxes(); } setHeight(height: float): void { if (this.getHeight() === height) return; this._transformationIsUpToDate = false; this._renderer.setHeight(height); this.invalidateHitboxes(); } setSize(newWidth: float, newHeight: float): void { this.setWidth(newWidth); this.setHeight(newHeight); this._transformationIsUpToDate = false; } /** * Get the scale of the object (or the geometric mean of the X and Y scale in case they are different). * * @return the scale of the object (or the geometric mean of the X and Y scale in case they are different). */ getScale(): float { const scaleX = this.getScaleX(); const scaleY = this.getScaleY(); return scaleX === scaleY ? scaleX : Math.sqrt(scaleX * scaleY); } /** * Change the scale on X and Y axis of the object. * * @param scale The new scale (must be greater than 0). */ setScale(scale: float): void { this.setScaleX(scale); this.setScaleY(scale); this._transformationIsUpToDate = false; } /** * Change the scale on X axis of the object (changing its width). * * @param scaleX The new scale (must be greater than 0). */ setScaleX(scaleX: float): void { if (scaleX < 0) { scaleX = 0; } if (this.getScaleX() === scaleX) return; this._renderer.setScaleX(scaleX); this.invalidateHitboxes(); this._transformationIsUpToDate = false; } /** * Change the scale on Y axis of the object (changing its width). * * @param scaleY The new scale (must be greater than 0). */ setScaleY(scaleY: float): void { if (scaleY < 0) { scaleY = 0; } if (this.getScaleY() === scaleY) return; this._renderer.setScaleY(scaleY); this.invalidateHitboxes(); this._transformationIsUpToDate = false; } setX(x: float): void { super.setX(x); this._renderer.updatePosition(); this._transformationIsUpToDate = false; } setY(y: float): void { super.setY(y); this._renderer.updatePosition(); this._transformationIsUpToDate = false; } setAngle(angle: float): void { super.setAngle(angle); this._renderer.updateAngle(); this._transformationIsUpToDate = false; } setOpacity(opacity: float): void { this._opacity = opacity; this._renderer.updateOpacity(); this._isTileMapDirty = true; } getOpacity(): float { return this._opacity; } getWidth(): float { return this._renderer.getWidth(); } getHeight(): float { return this._renderer.getHeight(); } override getOriginalWidth(): float { return this.getTileMapWidth(); } override getOriginalHeight(): float { return this.getTileMapHeight(); } getScaleX(): float { return this._renderer.getScaleX(); } getScaleY(): float { return this._renderer.getScaleY(); } /** * This method is expensive and should not be called. * Prefer using {@link getHitBoxesAround} rather than getHitBoxes. */ override getHitBoxes(): gdjs.Polygon[] { return super.getHitBoxes(); } override updateHitBoxes(): void { this.updateTransformation(); if (!this._collisionTileMap) return; this.hitBoxes = Array.from( this._collisionTileMap.getAllHitboxes(this._hitBoxTag) ); this.hitBoxesDirty = false; this.updateAABB(); } // This implementation doesn't use updateHitBoxes. // It's important for good performances. override getAABB(): AABB { // It's fine to compute it every time because tile maps are rarely rotated. // It avoids calling updateHitBoxes to rely on hitBoxesDirty to know when // to update. this.updateAABB(); return this.aabb; } // This implementation doesn't use updateHitBoxes. // It's important for good performances. override updateAABB(): void { if (this.getAngle() === 0) { // Fast computation of AABB for non rotated object this.aabb.min[0] = this.x; this.aabb.min[1] = this.y; this.aabb.max[0] = this.aabb.min[0] + this.getWidth(); this.aabb.max[1] = this.aabb.min[1] + this.getHeight(); } else { if (!this._collisionTileMap) return; const affineTransformation = this._collisionTileMap.getTransformation(); const left = 0; const right = this._collisionTileMap.getWidth(); const top = 0; const bottom = this._collisionTileMap.getHeight(); const workingPoint = this.aabb.min; workingPoint[0] = left; workingPoint[1] = top; affineTransformation.transform(workingPoint, workingPoint); const topLeftX = workingPoint[0]; const topLeftY = workingPoint[1]; workingPoint[0] = right; workingPoint[1] = top; affineTransformation.transform(workingPoint, workingPoint); const topRightX = workingPoint[0]; const topRightY = workingPoint[1]; workingPoint[0] = right; workingPoint[1] = bottom; affineTransformation.transform(workingPoint, workingPoint); const bottomRightX = workingPoint[0]; const bottomRightY = workingPoint[1]; workingPoint[0] = left; workingPoint[1] = bottom; affineTransformation.transform(workingPoint, workingPoint); const bottomLeftX = workingPoint[0]; const bottomLeftY = workingPoint[1]; this.aabb.min[0] = Math.min( topLeftX, topRightX, bottomRightX, bottomLeftX ); this.aabb.max[0] = Math.max( topLeftX, topRightX, bottomRightX, bottomLeftX ); this.aabb.min[1] = Math.min( topLeftY, topRightY, bottomRightY, bottomLeftY ); this.aabb.max[1] = Math.max( topLeftY, topRightY, bottomRightY, bottomLeftY ); } } getHitBoxesAround( left: float, top: float, right: float, bottom: float ): Iterable { // This implementation doesn't call updateHitBoxes. // It's important for good performances because there is no need to // update the whole collision mask where only a few hitboxes must be // checked. this.updateTransformation(); if (!this._collisionTileMap) return []; return this._collisionTileMap.getHitboxesAround( this._hitBoxTag, left, top, right, bottom ); } override isSpatiallyIndexed(): boolean { return true; } updateTransformation() { if (this._transformationIsUpToDate) { return; } const absScaleX = Math.abs(this._renderer.getScaleX()); const absScaleY = Math.abs(this._renderer.getScaleY()); this._tileMapToSceneTransformation.setToIdentity(); // Translation this._tileMapToSceneTransformation.translate(this.getX(), this.getY()); // Rotation const angleInRadians = (this.getAngle() * Math.PI) / 180; this._tileMapToSceneTransformation.rotateAround( angleInRadians, this.getCenterX(), this.getCenterY() ); // Scale this._tileMapToSceneTransformation.scale(absScaleX, absScaleY); if (this._collisionTileMap) { const collisionTileMapTransformation = this._collisionTileMap.getTransformation(); collisionTileMapTransformation.copyFrom( this._tileMapToSceneTransformation ); this._collisionTileMap.setTransformation( collisionTileMapTransformation ); } this._sceneToTileMapTransformation.copyFrom( this._tileMapToSceneTransformation ); this._sceneToTileMapTransformation.invert(); this._transformationIsUpToDate = true; } getSceneXCoordinateOfTileCenter( columnIndex: integer, rowIndex: integer ): float { const sceneCoordinates: FloatPoint = SimpleTileMapRuntimeObject.workingPoint; this._tileMapToSceneTransformation.transform( [ (columnIndex + 0.5) * this._tileSize, (rowIndex + 0.5) * this._tileSize, ], sceneCoordinates ); return sceneCoordinates[0]; } getSceneYCoordinateOfTileCenter( columnIndex: integer, rowIndex: integer ): float { const sceneCoordinates: FloatPoint = SimpleTileMapRuntimeObject.workingPoint; this._tileMapToSceneTransformation.transform( [ (columnIndex + 0.5) * this._tileSize, (rowIndex + 0.5) * this._tileSize, ], sceneCoordinates ); return sceneCoordinates[1]; } getGridCoordinatesFromSceneCoordinates( x: float, y: float ): [integer, integer] { this.updateTransformation(); const gridCoordinates: FloatPoint = SimpleTileMapRuntimeObject.workingPoint; this._sceneToTileMapTransformation.transform([x, y], gridCoordinates); const columnIndex = Math.floor(gridCoordinates[0] / this._tileSize); const rowIndex = Math.floor(gridCoordinates[1] / this._tileSize); return [columnIndex, rowIndex]; } getColumnIndexAtPosition(x: float, y: float): integer { return this.getGridCoordinatesFromSceneCoordinates(x, y)[0]; } getRowIndexAtPosition(x: float, y: float): integer { return this.getGridCoordinatesFromSceneCoordinates(x, y)[1]; } getTileAtPosition(x: float, y: float): integer { const [columnIndex, rowIndex] = this.getGridCoordinatesFromSceneCoordinates(x, y); return this.getTileAtGridCoordinates(columnIndex, rowIndex); } getTileAtGridCoordinates(columnIndex: integer, rowIndex: integer): integer { return this.getTileId(columnIndex, rowIndex, 0); } setTileAtPosition(tileId: number, x: float, y: float) { const [columnIndex, rowIndex] = this.getGridCoordinatesFromSceneCoordinates(x, y); this.setTileAtGridCoordinates(tileId, columnIndex, rowIndex); } setTileAtGridCoordinates( tileId: number, columnIndex: integer, rowIndex: integer ) { if (!this._tileMap) { return; } const layer = this._tileMap.getTileLayer(this._layerIndex); if (!layer) { return; } const oldTileId = layer.getTileId(columnIndex, rowIndex); if (tileId === oldTileId) { return; } layer.setTile(columnIndex, rowIndex, tileId); if (this._collisionTileMap) { const oldTileDefinition = oldTileId !== undefined && this._tileMap.getTileDefinition(oldTileId); const newTileDefinition = this._tileMap.getTileDefinition(tileId); const hadFullHitBox = !!oldTileDefinition && oldTileDefinition.hasFullHitBox(this._hitBoxTag); const haveFullHitBox = !!newTileDefinition && newTileDefinition.hasFullHitBox(this._hitBoxTag); if (hadFullHitBox !== haveFullHitBox) { this._collisionTileMap.invalidateTile( this._layerIndex, columnIndex, rowIndex ); } } this._isTileMapDirty = true; } flipTileOnYAtPosition(x: float, y: float, flip: boolean) { const [columnIndex, rowIndex] = this.getGridCoordinatesFromSceneCoordinates(x, y); this.flipTileOnYAtGridCoordinates(columnIndex, rowIndex, flip); } flipTileOnXAtPosition(x: float, y: float, flip: boolean) { const [columnIndex, rowIndex] = this.getGridCoordinatesFromSceneCoordinates(x, y); this.flipTileOnXAtGridCoordinates(columnIndex, rowIndex, flip); } flipTileOnYAtGridCoordinates( columnIndex: integer, rowIndex: integer, flip: boolean ) { this.flipTileOnY(columnIndex, rowIndex, 0, flip); this._isTileMapDirty = true; // No need to invalidate hit boxes since at the moment, collision mask // cannot be configured on each tile. } flipTileOnXAtGridCoordinates( columnIndex: integer, rowIndex: integer, flip: boolean ) { this.flipTileOnX(columnIndex, rowIndex, 0, flip); this._isTileMapDirty = true; // No need to invalidate hit boxes since at the moment, collision mask // cannot be configured on each tile. } isTileFlippedOnXAtPosition(x: float, y: float) { const [columnIndex, rowIndex] = this.getGridCoordinatesFromSceneCoordinates(x, y); return this.isTileFlippedOnX(columnIndex, rowIndex, 0); } isTileFlippedOnXAtGridCoordinates(columnIndex: integer, rowIndex: integer) { return this.isTileFlippedOnX(columnIndex, rowIndex, 0); } isTileFlippedOnYAtPosition(x: float, y: float) { const [columnIndex, rowIndex] = this.getGridCoordinatesFromSceneCoordinates(x, y); return this.isTileFlippedOnY(columnIndex, rowIndex, 0); } isTileFlippedOnYAtGridCoordinates(columnIndex: integer, rowIndex: integer) { return this.isTileFlippedOnY(columnIndex, rowIndex, 0); } removeTileAtPosition(x: float, y: float) { const [columnIndex, rowIndex] = this.getGridCoordinatesFromSceneCoordinates(x, y); this.removeTileAtGridCoordinates(columnIndex, rowIndex); } removeTileAtGridCoordinates(columnIndex: integer, rowIndex: integer) { if (!this._tileMap) { return; } const layer = this._tileMap.getTileLayer(this._layerIndex); if (!layer) { return; } const oldTileId = layer.getTileId(columnIndex, rowIndex); if (oldTileId === undefined) { return; } layer.removeTile(columnIndex, rowIndex); if (this._collisionTileMap) { const oldTileDefinition = oldTileId !== undefined && this._tileMap.getTileDefinition(oldTileId); const hadFullHitBox = !!oldTileDefinition && oldTileDefinition.hasFullHitBox(this._hitBoxTag); if (hadFullHitBox) { this._collisionTileMap.invalidateTile( this._layerIndex, columnIndex, rowIndex ); } } this._isTileMapDirty = true; } setGridRowCount(targetRowCount: integer) { if (targetRowCount <= 0) return; if (!this._tileMap) return; this._tileMap.setDimensionY(targetRowCount); this._isTileMapDirty = true; this.invalidateHitboxes(); } setGridColumnCount(targetColumnCount: integer) { if (targetColumnCount <= 0) return; if (!this._tileMap) return; this._tileMap.setDimensionX(targetColumnCount); this._isTileMapDirty = true; this.invalidateHitboxes(); } getGridRowCount(): integer { if (!this._tileMap) return 0; return this._tileMap.getDimensionY(); } getGridColumnCount(): integer { if (!this._tileMap) return 0; return this._tileMap.getDimensionX(); } getTilesetColumnCount(): integer { return this._columnCount; } getTilesetRowCount(): integer { return this._rowCount; } getTileMap(): TileMapHelper.EditableTileMap | null { return this._tileMap; } getTileMapWidth() { const tileMap = this._tileMap; return tileMap ? tileMap.getWidth() : 20; } getTileMapHeight() { const tileMap = this._tileMap; return tileMap ? tileMap.getHeight() : 20; } /** * @param x The layer column. * @param y The layer row. * @param layerIndex The layer index. * @returns The tile's id. */ getTileId(x: integer, y: integer, layerIndex: integer): integer { if (!this._tileMap) return -1; return this._tileMap.getTileId(x, y, layerIndex); } /** * @param x The layer column. * @param y The layer row. * @param layerIndex The layer index. * @param flip true if the tile should be flipped. */ flipTileOnY(x: integer, y: integer, layerIndex: integer, flip: boolean) { if (!this._tileMap) return; this._tileMap.flipTileOnY(x, y, layerIndex, flip); } /** * @param x The layer column. * @param y The layer row. * @param layerIndex The layer index. * @param flip true if the tile should be flipped. */ flipTileOnX(x: integer, y: integer, layerIndex: integer, flip: boolean) { if (!this._tileMap) return; this._tileMap.flipTileOnX(x, y, layerIndex, flip); } /** * @param x The layer column. * @param y The layer row. * @param layerIndex The layer index. */ isTileFlippedOnX(x: integer, y: integer, layerIndex: integer): boolean { if (!this._tileMap) return false; return this._tileMap.isTileFlippedOnX(x, y, layerIndex); } /** * @param x The layer column. * @param y The layer row. * @param layerIndex The layer index. */ isTileFlippedOnY(x: integer, y: integer, layerIndex: integer): boolean { if (!this._tileMap) return false; return this._tileMap.isTileFlippedOnY(x, y, layerIndex); } } gdjs.registerObject( 'TileMap::SimpleTileMap', gdjs.SimpleTileMapRuntimeObject ); }