Files
GDevelop/Extensions/TileMap/tilemapcollisionmaskruntimeobject.ts
2025-07-15 13:38:23 +02:00

659 lines
18 KiB
TypeScript

/// <reference path="helper/TileMapHelper.d.ts" />
namespace gdjs {
export type TilemapCollisionMaskObjectDataType = {
content: {
tilemapJsonFile: string;
tilesetJsonFile: string;
collisionMaskTag: string;
debugMode: boolean;
fillColor: string;
outlineColor: string;
};
fillOpacity: float;
outlineOpacity: float;
outlineSize: float;
};
export type TilemapCollisionMaskObjectData = ObjectData &
TilemapCollisionMaskObjectDataType;
export type TilemapCollisionMaskNetworkSyncDataType = {
tmjf: string;
tsjf: string;
dm: boolean;
oc: integer;
fc: integer;
os: float;
fo: float;
oo: float;
};
export type TilemapCollisionMaskNetworkSyncData = ObjectNetworkSyncData &
TilemapCollisionMaskNetworkSyncDataType;
/**
* An object that handle hitboxes for a tile map.
* @extends gdjs.RuntimeObject
*/
export class TileMapCollisionMaskRuntimeObject
extends gdjs.RuntimeObject
implements gdjs.Resizable, gdjs.Scalable
{
private _tilemapJsonFile: string;
private _tilesetJsonFile: string;
private _renderer: gdjs.TileMap.TileMapCollisionMaskRenderer;
_collisionTileMap: gdjs.TileMap.TransformedCollisionTileMap;
/**
* The tiles are filtered according to this tag.
*
* This allows have multiple objects with different usage
* for the same tile map.
* For instance, platforms, jumpthru, ladder, spike, water...
*/
private _collisionMaskTag: string;
private _layerIndex: integer | null;
private _tileMapManager: gdjs.TileMap.TileMapRuntimeManager;
/**
* When set to true, the hitboxes will be shown.
*/
_debugMode: boolean;
_fillColor: integer;
_outlineColor: integer;
_fillOpacity: float;
_outlineOpacity: float;
_outlineSize: float;
_width: float;
_height: float;
_scaleX: float;
_scaleY: float;
/**
* If the owner moves, the hitboxes vertices
* will have to be transformed again.
*/
private _transformationIsUpToDate: boolean = false;
constructor(instanceContainer: gdjs.RuntimeInstanceContainer, objectData) {
super(instanceContainer, objectData);
this._tilemapJsonFile = objectData.content.tilemapJsonFile;
this._tilesetJsonFile = objectData.content.tilesetJsonFile;
this._collisionMaskTag = objectData.content.collisionMaskTag;
this._layerIndex =
objectData.content.useAllLayers ||
objectData.content.useAllLayers === undefined
? null
: Number.isFinite(objectData.content.layerIndex)
? objectData.content.layerIndex
: 1;
this._debugMode = objectData.content.debugMode;
this._fillColor = gdjs.rgbOrHexStringToNumber(
objectData.content.fillColor
);
this._outlineColor = gdjs.rgbOrHexStringToNumber(
objectData.content.outlineColor
);
this._fillOpacity = objectData.content.fillOpacity;
this._outlineOpacity = objectData.content.outlineOpacity;
this._outlineSize = objectData.content.outlineSize;
this._tileMapManager =
gdjs.TileMap.TileMapRuntimeManager.getManager(instanceContainer);
// The actual size is set when the tile map file is loaded.
this._width = 0;
this._height = 0;
this._scaleX = 1;
this._scaleY = 1;
const editableTileMap = new TileMapHelper.EditableTileMap(
1,
1,
0,
0,
new Map()
);
this._collisionTileMap = new gdjs.TileMap.TransformedCollisionTileMap(
editableTileMap,
this._collisionMaskTag,
this._layerIndex
);
this._renderer = new gdjs.TileMap.TileMapCollisionMaskRenderer(
this,
instanceContainer
);
this._updateTileMap();
// *ALWAYS* call `this.onCreated()` at the very end of your object constructor.
this.onCreated();
}
updatePreRender(instanceContainer: gdjs.RuntimeInstanceContainer) {
super.updatePreRender(instanceContainer);
if (this._debugMode && this.hitBoxesDirty) {
this.updateHitBoxes();
}
}
getRendererObject() {
return this._renderer.getRendererObject();
}
getVisibilityAABB() {
return null;
}
updateFromObjectData(
oldObjectData: TilemapCollisionMaskObjectData,
newObjectData: TilemapCollisionMaskObjectData
): boolean {
if (
oldObjectData.content.tilemapJsonFile !==
newObjectData.content.tilemapJsonFile
) {
this.setTilemapJsonFile(newObjectData.content.tilemapJsonFile);
}
if (
oldObjectData.content.tilesetJsonFile !==
newObjectData.content.tilesetJsonFile
) {
this.setTilesetJsonFile(newObjectData.content.tilesetJsonFile);
}
if (oldObjectData.content.debugMode !== newObjectData.content.debugMode) {
this.setDebugMode(newObjectData.content.debugMode);
}
if (oldObjectData.content.fillColor !== newObjectData.content.fillColor) {
this.setFillColor(
gdjs.rgbOrHexStringToNumber(newObjectData.content.fillColor)
);
}
if (
oldObjectData.content.outlineColor !==
newObjectData.content.outlineColor
) {
this.setOutlineColor(
gdjs.rgbOrHexStringToNumber(newObjectData.content.outlineColor)
);
}
if (oldObjectData.fillOpacity !== newObjectData.fillOpacity) {
this.setFillOpacity(newObjectData.fillOpacity);
}
if (oldObjectData.outlineOpacity !== newObjectData.outlineOpacity) {
this.setOutlineOpacity(newObjectData.outlineOpacity);
}
if (oldObjectData.outlineSize !== newObjectData.outlineSize) {
this.setOutlineSize(newObjectData.outlineSize);
}
// TODO Handle changes to collisionMaskTag, useAllLayers and layerIndex.
return true;
}
getNetworkSyncData(): TilemapCollisionMaskNetworkSyncData {
return {
...super.getNetworkSyncData(),
tmjf: this.getTilemapJsonFile(),
tsjf: this.getTilesetJsonFile(),
dm: this.getDebugMode(),
oc: this.getOutlineColor(),
fc: this.getFillColor(),
os: this.getOutlineSize(),
fo: this.getFillOpacity(),
oo: this.getOutlineOpacity(),
};
}
updateFromNetworkSyncData(
networkSyncData: TilemapCollisionMaskNetworkSyncData
): void {
super.updateFromNetworkSyncData(networkSyncData);
if (networkSyncData.tmjf !== undefined) {
this.setTilemapJsonFile(networkSyncData.tmjf);
}
if (networkSyncData.tsjf !== undefined) {
this.setTilesetJsonFile(networkSyncData.tsjf);
}
if (networkSyncData.dm !== undefined) {
this.setDebugMode(networkSyncData.dm);
}
if (networkSyncData.oc !== undefined) {
this.setOutlineColor(networkSyncData.oc);
}
if (networkSyncData.fc !== undefined) {
this.setFillColor(networkSyncData.fc);
}
if (networkSyncData.os !== undefined) {
this.setOutlineSize(networkSyncData.os);
}
if (networkSyncData.fo !== undefined) {
this.setFillOpacity(networkSyncData.fo);
}
if (networkSyncData.oo !== undefined) {
this.setOutlineOpacity(networkSyncData.oo);
}
}
extraInitializationFromInitialInstance(initialInstanceData): void {
if (initialInstanceData.customSize) {
this.setWidth(initialInstanceData.width);
this.setHeight(initialInstanceData.height);
}
}
private _updateTileMap(): void {
this._tileMapManager.getOrLoadTileMap(
this._tilemapJsonFile,
this._tilesetJsonFile,
0, // levelIndex
(tileMap: TileMapHelper.EditableTileMap | null) => {
if (!tileMap) {
// getOrLoadTileMap already log errors.
return;
}
this._collisionTileMap = new gdjs.TileMap.TransformedCollisionTileMap(
tileMap,
this._collisionMaskTag,
this._layerIndex
);
// The tile map polygons always keep the same references.
// It works because the tilemap is never modified.
this.hitBoxes = Array.from(
this._collisionTileMap.getAllHitboxes(this._collisionMaskTag)
);
this._renderer.redrawCollisionMask();
this._width = this._collisionTileMap.getWidth() * this._scaleX;
this._height = this._collisionTileMap.getHeight() * this._scaleY;
}
);
}
updateHitBoxes(): void {
this.updateTransformation();
// Update the RuntimeObject hitboxes attribute.
for (const _ of this._collisionTileMap.getAllHitboxes(
this._collisionMaskTag
)) {
// RuntimeObject.hitBoxes contains the same polygons instances as the
// hitboxes from the tiles.
//
// When hitboxes for a tile is asked to the model, they are updated
// according to the new object location if needed.
// Iterating over all the tiles forces them to update their hitboxes.
//
// The hitboxes array is built by _updateTileMap().
}
this.hitBoxesDirty = false;
this._renderer.redrawCollisionMask();
this.updateAABB();
}
/**
* Update the affine transformation according to the object position, size
* and angle.
*/
updateTransformation(): void {
if (this._transformationIsUpToDate) {
return;
}
const transformation = this._collisionTileMap.getTransformation();
const absScaleX = Math.abs(this._scaleX);
const absScaleY = Math.abs(this._scaleY);
transformation.setToIdentity();
// Translation
transformation.translate(this.x, this.y);
// Rotation
const angleInRadians = (this.angle * Math.PI) / 180;
transformation.rotateAround(
angleInRadians,
this.getCenterX(),
this.getCenterY()
);
// Scale
transformation.scale(absScaleX, absScaleY);
this._collisionTileMap.setTransformation(transformation);
this._transformationIsUpToDate = true;
}
/**
* This method is expensive and should not be called.
* Prefer using {@link getHitBoxesAround} rather than getHitBoxes.
*/
getHitBoxes(): gdjs.Polygon[] {
if (this.hitBoxesDirty) {
this.updateHitBoxes();
this.updateAABB();
this.hitBoxesDirty = false;
}
return this.hitBoxes;
}
getHitBoxesAround(left: float, top: float, right: float, bottom: float) {
// 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();
return this._collisionTileMap.getHitboxesAround(
this._collisionMaskTag,
left,
top,
right,
bottom
);
}
/**
* insideObject usually use the AABB of the object.
* But, in case of a tile map, it makes more sense to look each tile individually.
* It returns true when there is an hitbox in the tile.
*/
insideObject(x: float, y: float): boolean {
this.updateTransformation();
// This is more precise than the default implementation.
return this._collisionTileMap.pointIsInsideTile(
x,
y,
this._collisionMaskTag
);
}
// This implementation doesn't use updateHitBoxes.
// It's important for good performances.
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 {
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
);
}
}
/**
* Set the Tilemap json file to display.
*/
setTilemapJsonFile(tilemapJsonFile: string): void {
this._tilemapJsonFile = tilemapJsonFile;
this._updateTileMap();
}
getTilemapJsonFile(): string {
return this._tilemapJsonFile;
}
isTilemapJsonFile(selectedTilemapJsonFile: string): boolean {
return this._tilemapJsonFile === selectedTilemapJsonFile;
}
setTilesetJsonFile(tilesetJsonFile: string) {
this._tilesetJsonFile = tilesetJsonFile;
this._updateTileMap();
}
getTilesetJsonFile(): string {
return this._tilesetJsonFile;
}
isTilesetJsonFile(selectedTilesetJsonFile: string): boolean {
return this._tilesetJsonFile === selectedTilesetJsonFile;
}
/**
* @returns true if the hitboxes are shown.
*/
getDebugMode(): boolean {
return this._debugMode;
}
/**
* @returns true if the hitboxes are shown.
*/
setDebugMode(debugMode: boolean): void {
this._debugMode = debugMode;
this._renderer.redrawCollisionMask();
}
getFillColor(): integer {
return this._fillColor;
}
getOutlineColor(): integer {
return this._outlineColor;
}
setFillColor(fillColor: integer): void {
this._fillColor = fillColor;
}
setOutlineColor(outlineColor: integer): void {
this._outlineColor = outlineColor;
}
setOutlineSize(size: float): void {
this._outlineSize = size;
}
getOutlineSize(): float {
return this._outlineSize;
}
/**
*
* @param opacity from 0 to 255
*/
setFillOpacity(opacity: float): void {
this._fillOpacity = opacity;
}
/**
*
* @returns an opacity value from 0 to 255.
*/
getFillOpacity(): float {
return this._fillOpacity;
}
/**
*
* @param opacity from 0 to 255
*/
setOutlineOpacity(opacity: float): void {
this._outlineOpacity = opacity;
}
/**
*
* @returns an opacity value from 0 to 255.
*/
getOutlineOpacity(): float {
return this._outlineOpacity;
}
setX(x: float): void {
super.setX(x);
this._transformationIsUpToDate = false;
}
setY(y: float): void {
super.setY(y);
this._transformationIsUpToDate = false;
}
setAngle(angle: float): void {
super.setAngle(angle);
this._transformationIsUpToDate = false;
}
setWidth(width: float): void {
if (this._width === width) {
return;
}
this._scaleX = width / this._collisionTileMap.getWidth();
this._width = width;
this._transformationIsUpToDate = false;
this.invalidateHitboxes();
}
setHeight(height: float): void {
if (this._height === height) {
return;
}
this._scaleY = height / this._collisionTileMap.getHeight();
this._height = height;
this._transformationIsUpToDate = false;
this.invalidateHitboxes();
}
setSize(newWidth: float, newHeight: float): void {
this.setWidth(newWidth);
this.setHeight(newHeight);
}
/**
* 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);
}
/**
* 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._scaleX === scaleX) {
return;
}
this._scaleX = scaleX;
this._width = scaleX * this._collisionTileMap.getWidth();
this._transformationIsUpToDate = false;
this.invalidateHitboxes();
}
/**
* 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._scaleY === scaleY) {
return;
}
this._scaleY = scaleY;
this._height = scaleY * this._collisionTileMap.getHeight();
this._transformationIsUpToDate = false;
this.invalidateHitboxes();
}
getWidth(): float {
return this._width;
}
getHeight(): float {
return this._height;
}
getScaleX(): float {
return this._scaleX;
}
getScaleY(): float {
return this._scaleY;
}
}
gdjs.registerObject(
'TileMap::CollisionMask',
gdjs.TileMapCollisionMaskRuntimeObject
);
TileMapCollisionMaskRuntimeObject.supportsReinitialization = false;
}