diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md
index 53584dd9a..1cebb5b7c 100644
--- a/packages/melonjs/CHANGELOG.md
+++ b/packages/melonjs/CHANGELOG.md
@@ -6,6 +6,10 @@
- Effects: colors take an alpha. Every color option and `setColor` takes `[r, g, b]` or `[r, g, b, a]`, the alpha being the color's opacity (`[r, g, b]` stays opaque), `RadialGradientEffect` honours the alpha of its `Color`, and `DropShadowEffect` gains the `setColor` the others have
- Effects: `SwayEffect` leans a sprite or a layer from side to side, as if in a breeze, its top swaying most and everything from its anchor row down staying put. It is a `WaveEffect`, which now takes an `anchor` for the row its wave fades out at, and it wraps its own time so it never stutters on a phone's medium-precision floats
- Skills: the effects skill covers colors with an alpha and `SwayEffect`, with an example of each
+- Tiled: a tile object with no class of its own is built by the class registered under its tile's class, as Tiled shows it, so a class set once on a tile in the tileset applies to every object placed from it. The object's own class or registered name still comes first, and a tile classed like a built-in (`Sprite`, `Trigger`, `shape`...) stays a plain tile
+- Tiled: `TMXObject#resolvedProperty()` and `resolvedProperties()` return an object's custom properties together with those it inherits from its tile, the object's own winning, as the methods of the same names in Tiled's scripting API do. Only the object's own properties are set on it by name, as before
+- Tiled: `TMXTileset#getTileClass()` returns the class set on a tile in Tiled's tileset editor
+- Typings: `TMXObject`, the settings a class registered with `registerTiledObjectClass` (or a factory registered with `registerTiledObjectFactory`) receives, is exported as a type, and both functions are typed with it. A class or factory typed with its own settings still compiles
### Fixed
- Effects: a color given four values no longer breaks the effect. WebGL rejected it, and on WebGPU the fourth value overwrote the next setting, such as an outline's width; a color of any other length now throws a `TypeError` saying so
diff --git a/packages/melonjs/skills/melonjs-tilemaps/SKILL.md b/packages/melonjs/skills/melonjs-tilemaps/SKILL.md
index 3c68cc150..eed9e7451 100644
--- a/packages/melonjs/skills/melonjs-tilemaps/SKILL.md
+++ b/packages/melonjs/skills/melonjs-tilemaps/SKILL.md
@@ -1,6 +1,6 @@
---
name: melonjs-tilemaps
-description: "Use this skill for Tiled maps in melonJS — loading TMX/TSX levels, spawning entities from Tiled objects, collision shapes authored in Tiled, isometric and hexagonal maps, and image layers. Covers registerTiledObjectClass and the pool.register name contract, camera bounds, compressed maps needing the inflate plugin, and the level director API. Triggers on: Tiled, TMX, TSX, tilemap, level.load, level.load async, await level.load, tileset, ImageLayer, isometric, hexagonal, staggered, pool.register, Collectable, Trigger, object layer, collision layer, parallax, registerTiledObjectClass, registerTiledObjectFactory."
+description: "Use this skill for Tiled maps in melonJS — loading TMX/TSX levels, spawning entities from Tiled objects, collision shapes authored in Tiled, isometric and hexagonal maps, and image layers. Covers registerTiledObjectClass and the pool.register name contract, camera bounds, compressed maps needing the inflate plugin, and the level director API. Triggers on: Tiled, TMX, TSX, tilemap, level.load, level.load async, await level.load, tileset, ImageLayer, isometric, hexagonal, staggered, pool.register, Collectable, Trigger, object layer, collision layer, parallax, registerTiledObjectClass, registerTiledObjectFactory, TMXObject, resolvedProperty, resolvedProperties, tile class, tile properties."
license: MIT
---
@@ -72,7 +72,8 @@ object group in its own `Container` named after the group.
## Spawning entities from Tiled objects
Objects are matched against a factory registry, in this order: the object's
-Tiled **class**, then its **name**, then the structural fallbacks `"text"`,
+Tiled **class**, then its **name**, then, for a tile object with no class of
+its own, its **tile's class**, then the structural fallbacks `"text"`,
`"tile"` and `"shape"`.
```js
@@ -122,6 +123,53 @@ registerTiledObjectClass("Trigger", MyTrigger); // whenever you like
The throw is for a clash between two of *your* classes under one name. For
that case `registerTiledObjectFactory` overwrites and only warns.
+### Classes and properties set on tiles
+
+In Tiled, a tile can carry a class and custom properties (tileset editor), and
+every object placed from that tile shows them as its own. melonJS follows:
+
+- **The tile's class.** A tile object with no class is built by the class
+ registered under its tile's class, so a designer sets "Chest" once on the
+ chest tile and every chest dropped in the map is a `Chest`. The object's
+ own class or registered name still comes first, and a tile classed like a
+ built-in (`Sprite`, `Trigger`, `shape`...) stays a plain tile.
+- **The tile's properties.** Read them with `settings.resolvedProperty(name)`
+ or `settings.resolvedProperties()`, which merge the tile's properties under
+ the object's own, as Tiled's scripting API of the same names does. They are
+ **not** set on `settings` by name: only the object's own properties are,
+ as before, so a tile property named like a sprite setting never reaches a
+ plain tile sprite.
+
+```ts
+import { registerTiledObjectClass, Sprite, type TMXObject } from "melonjs";
+
+// the custom properties set on the object itself, by name
+interface DoorSettings extends TMXObject {
+ locked?: boolean;
+}
+
+class Door extends Sprite {
+ locked: boolean;
+ facing: string;
+ constructor(x: number, y: number, settings: DoorSettings) {
+ super(x, y, { image: "door" });
+ this.locked = settings.locked ?? false;
+ // on the object, or else on the door tile in the tileset
+ this.facing = settings.resolvedProperty("facing") ?? "N";
+ }
+}
+registerTiledObjectClass("Door", Door); // and give the door tile the class "Door"
+```
+
+`TMXObject` is the type of the `settings` a registered class (or factory)
+receives: the object as read from the map, with `tile` (a `Tile`) on a tile
+object. Extend it with the properties your class reads.
+
+For a class that is not a `Sprite` itself (a `Container` holding the tile and
+something else, say), `settings.tile.getRenderable()` returns the tile's sprite
+as a plain tile object would get it: from a tileset image, an image collection
+or an animated tile alike, with no region names to build by hand.
+
melonJS pre-registers `Renderable`, `Sprite`, `NineSliceSprite`, `Text`,
`BitmapText`, `ImageLayer`, `ColorLayer`, `Light2d`, `Collectable`, `Trigger`
and `Entity` as Tiled classes, so `Collectable` and `Trigger` work out of the
@@ -171,7 +219,8 @@ An object that matches no factory and carries no text or tile data falls to the
geometry, which the world auto-registers with the active adapter on `addChild`.
For per-object physics, set the object's custom properties in Tiled; they arrive
-on the settings object passed to your entity's constructor.
+on the settings object passed to your entity's constructor. Properties set on
+the object's tile are read with `settings.resolvedProperty(name)`.
## Map orientations
@@ -281,6 +330,8 @@ unanimated layer into the offscreen-bake path instead.
| a character is always in front of (or behind) every wall | the walls are on a tile layer, or on another object layer than the character: each layer has its own `z`, compared before `y` |
| a small prop draws over a character standing in front of it | images of different heights: the y-sort uses `pos.y`, the top of a Tiled object's image |
| a character is stopped short of walls and props placed as tile objects | it collides with their default bodies (static, `ENEMY_OBJECT`, the whole image box); mask it to `collision.types.WORLD_SHAPE` when the collision layer holds the real shapes |
+| a property set on a tile is undefined on the object's `settings` | tile properties are not copied onto `settings`: read them with `settings.resolvedProperty(name)` |
+| objects placed from a classed tile come out as plain sprites | that class is not registered, it is a built-in name, or the object has a class or a registered name of its own, which come first |
| missing tiles or a blank map | the `.tsx` or tileset image was not preloaded |
| tilemap slow on some machines | layer fell back off the shader path — check the console warning for the reason |
diff --git a/packages/melonjs/src/index.ts b/packages/melonjs/src/index.ts
index b97a3af12..87e55667c 100644
--- a/packages/melonjs/src/index.ts
+++ b/packages/melonjs/src/index.ts
@@ -143,6 +143,7 @@ export * as input from "./input/input.ts";
export * from "./lang/deprecated.js";
export type { LevelLoadOptions } from "./level/level.js";
export { level } from "./level/level.js";
+export type { default as TMXObject } from "./level/tiled/TMXObject.js";
export {
registerTiledObjectClass,
registerTiledObjectFactory,
diff --git a/packages/melonjs/src/level/tiled/TMXObject.js b/packages/melonjs/src/level/tiled/TMXObject.js
index 7cb490140..035c7252b 100644
--- a/packages/melonjs/src/level/tiled/TMXObject.js
+++ b/packages/melonjs/src/level/tiled/TMXObject.js
@@ -8,6 +8,10 @@ import { vector2dPool } from "../../math/vector2d.ts";
import Tile from "./TMXTile.js";
import { applyTMXProperties } from "./TMXUtils.js";
+/**
+ * @import TMXTileMap from "./TMXTileMap.js";
+ */
+
/**
* Detect the shape type from the TMX object settings.
* Shape markers are mutually exclusive; the first match wins.
@@ -36,12 +40,55 @@ function detectShape(settings) {
}
/**
- * a TMX Object defintion, as defined in Tiled
- * (Object definition is translated into the virtual `app.world` using `me.Renderable`)
- * @ignore
- * @internal
+ * An object of a Tiled map, as read from the map. It is the `settings` that
+ * a class registered with {@link registerTiledObjectClass} receives (and
+ * that a factory registered with {@link registerTiledObjectFactory} is
+ * given), before the object is turned into a renderable.
+ *
+ * The object's own custom properties are set on it by name.
+ * {@link TMXObject#resolvedProperties} and {@link TMXObject#resolvedProperty}
+ * also include those a tile object inherits from its tile, as Tiled shows
+ * them. A tile object with no class of its own is matched by its tile's class.
+ *
+ * In TypeScript, declare the custom properties a class reads by extending
+ * this type.
+ * @category Tilemap
+ * @example
+ * import { registerTiledObjectClass, Sprite, type TMXObject } from "melonjs";
+ *
+ * // the custom properties the class reads, as set on the object in Tiled
+ * interface ChestSettings extends TMXObject {
+ * locked?: boolean;
+ * }
+ *
+ * class Chest extends Sprite {
+ * locked: boolean;
+ * gold: number;
+ * constructor(x: number, y: number, settings: ChestSettings) {
+ * super(x, y, { image: "chest" });
+ * this.locked = settings.locked ?? false;
+ * // set on the object, or else on the chest tile in the tileset
+ * this.gold = settings.resolvedProperty("gold") ?? 10;
+ * }
+ * }
+ * // in Tiled, give the chest tile the class "Chest": every chest placed
+ * // in the map is then built by this class
+ * registerTiledObjectClass("Chest", Chest);
*/
export default class TMXObject {
+ /**
+ * the custom properties set on the object itself
+ * @type {Object}
+ */
+ #properties = Object.create(null);
+
+ /**
+ * @param {TMXTileMap} map - the map the object belongs to
+ * @param {object} settings - the object as parsed from the map
+ * @param {number} z - the depth of the object's layer
+ * @ignore
+ * @internal
+ */
constructor(map, settings, z) {
/**
* point list in JSON format
@@ -88,7 +135,7 @@ export default class TMXObject {
/**
* object gid value
* when defined the object is a tiled object
- * @type {number}
+ * @type {number|null}
*/
this.gid = +settings.gid || null;
@@ -193,9 +240,11 @@ export default class TMXObject {
this.text.textBaseline = settings.text.valign || "top";
this.text.width = this.width;
this.text.height = this.height;
- applyTMXProperties(this.text, settings);
+ applyTMXProperties(this.#properties, settings);
+ Object.assign(this.text, this.#properties);
} else {
- applyTMXProperties(this, settings);
+ applyTMXProperties(this.#properties, settings);
+ Object.assign(this, this.#properties);
if (!this.shapes) {
this.shapes = this.parseTMXShapes();
}
@@ -216,7 +265,17 @@ export default class TMXObject {
const tileset = tilesets.getTilesetByGid(this.gid);
if (tileset.isCollection === false) {
+ /**
+ * width of the object's tile in its tileset image, for a tile
+ * object from a tileset image (undefined otherwise)
+ * @type {number|undefined}
+ */
this.framewidth = tileset.tilewidth;
+ /**
+ * height of the object's tile in its tileset image, for a tile
+ * object from a tileset image (undefined otherwise)
+ * @type {number|undefined}
+ */
this.frameheight = tileset.tileheight;
// an object resized in Tiled keeps its size, and its tile is
// stretched to fill it; one saved without a size is the tile's
@@ -224,9 +283,60 @@ export default class TMXObject {
this.height ||= tileset.tileheight;
}
+ /**
+ * the tile of a tile object (one with a `gid`), undefined otherwise
+ * @type {Tile|undefined}
+ */
this.tile = new Tile(this.x, this.y, this.gid, tileset);
}
+ /**
+ * Return the object's custom properties, including those it inherits
+ * from its tile (for a tile object), as Tiled resolves them: a property
+ * the object sets itself wins over its tile's. Each call returns a new
+ * object; the values themselves are not copied.
+ * @returns {Object} the resolved custom properties
+ * @example
+ * // a door tile has a "facing" property in the tileset: every door
+ * // object placed from it has it, unless the object sets its own
+ * const { facing = "N" } = settings.resolvedProperties();
+ */
+ resolvedProperties() {
+ return { ...this.inheritedProperties(), ...this.#properties };
+ }
+
+ /**
+ * Return the value of one custom property of the object, or else of its
+ * tile (for a tile object), as Tiled resolves it.
+ * @param {string} name - name of the custom property
+ * @returns {*} the property's value, or undefined if neither the object
+ * nor its tile sets it
+ * @example
+ * const opens = settings.resolvedProperty("opens");
+ */
+ resolvedProperty(name) {
+ if (Object.hasOwn(this.#properties, name)) {
+ return this.#properties[name];
+ }
+ const inherited = this.inheritedProperties();
+ return inherited !== undefined && Object.hasOwn(inherited, name)
+ ? inherited[name]
+ : undefined;
+ }
+
+ /**
+ * the custom properties of the object's tile, if it is a tile object
+ * @returns {object|undefined}
+ * @ignore
+ * @internal
+ */
+ inheritedProperties() {
+ const tile = this.tile;
+ return tile !== undefined
+ ? tile.tileset.getTileProperties(tile.tileId)
+ : undefined;
+ }
+
/**
* parses the TMX shape definition and returns a corresponding array of shape objects
* @private
diff --git a/packages/melonjs/src/level/tiled/TMXObjectFactory.js b/packages/melonjs/src/level/tiled/TMXObjectFactory.js
index 8d58399d8..a213eb9be 100644
--- a/packages/melonjs/src/level/tiled/TMXObjectFactory.js
+++ b/packages/melonjs/src/level/tiled/TMXObjectFactory.js
@@ -6,6 +6,12 @@ import { createTextObject } from "./factories/text.js";
import { createTileObject } from "./factories/tile.js";
import TMXLayer from "./TMXLayer.js";
+/**
+ * @import Renderable from "../../renderable/renderable.js";
+ * @import TMXObject from "./TMXObject.js";
+ * @import TMXTileMap from "./TMXTileMap.js";
+ */
+
/**
* registry of Tiled object factory functions
* @ignore
@@ -41,6 +47,13 @@ let factoriesInitialized = false;
*/
const builtinNames = new Set();
+/**
+ * the factories for an object's shape rather than its class
+ * @ignore
+ * @internal
+ */
+const STRUCTURAL_TYPES = new Set(["text", "tile", "shape"]);
+
/**
* Return a default shape (polygon) for the given dimensions,
* or the existing shapes if already defined in settings.
@@ -70,9 +83,11 @@ export function getDefaultShape(settings) {
* 1. TMXLayer instances → "layer" (passthrough)
* 2. Object class matching a registered factory → class name
* 3. Object name matching a registered factory → name
- * 4. Object with text data → "text"
- * 5. Object with tile/gid data → "tile"
- * 6. Everything else → "shape"
+ * 4. A tile object with no class, whose tile's class matches a factory or
+ * class the game registered (never a built-in) → the tile's class
+ * 5. Object with text data → "text"
+ * 6. Object with tile/gid data → "tile"
+ * 7. Everything else → "shape"
* @param {object} settings - TMX object settings
* @returns {string} the factory type key
* @ignore
@@ -90,6 +105,20 @@ export function detectObjectType(settings) {
if (typeof settings.name === "string" && factories.has(settings.name)) {
return settings.name;
}
+ // a tile object with no class of its own takes its tile's class, as in
+ // Tiled. Only for a name the game registered itself, so a tile classed
+ // like a built-in ("shape", "Trigger"...) stays a tile
+ if (!settings.class && typeof settings.tile === "object") {
+ const tileClass = settings.tile.tileset.getTileClass(settings.tile.tileId);
+ if (
+ tileClass !== undefined &&
+ factories.has(tileClass) &&
+ !STRUCTURAL_TYPES.has(tileClass) &&
+ !builtinNames.has(tileClass)
+ ) {
+ return tileClass;
+ }
+ }
if (typeof settings.text === "object") {
return "text";
}
@@ -106,12 +135,14 @@ export function detectObjectType(settings) {
*
* Built-in structural types are: `"text"`, `"tile"`, `"shape"`.
* Custom types are matched against the object's Tiled `class` or `name` property.
+ * A tile object with no class of its own is matched by its tile's class.
*
* For simple cases where you just want to map a Tiled class to a constructor,
* use {@link registerTiledObjectClass} instead.
* @category Tilemap
+ * @template [S=TMXObject]
* @param {string} type - the object type key (built-in type or Tiled class/name)
- * @param {Function} factory - factory function with signature `(settings, map) => Renderable`
+ * @param {(settings: S, map: TMXTileMap) => Renderable} factory - factory function, given the Tiled object (a {@link TMXObject}) and its map
* @example
* // register a custom factory for "Spine" objects in Tiled
* // (set the object class to "Spine" in Tiled, and add atlasFile/jsonFile custom properties)
@@ -147,7 +178,14 @@ export function registerTiledObjectFactory(type, factory) {
/**
* Register a class constructor as a Tiled object factory.
* When an object with a matching `class` or `name` is found in a Tiled map,
- * an instance of the given constructor will be created with `new Constructor(x, y, settings)`.
+ * an instance of the given constructor will be created with `new Constructor(x, y, settings)`,
+ * `settings` being the {@link TMXObject} as read from the map.
+ *
+ * A tile object with no class of its own is matched by its tile's class, as in
+ * Tiled: give a tile a class in Tiled's tileset editor, and every object placed
+ * from that tile is built by the class registered under that name (an object's
+ * own class or name, when registered, still comes first). The tile's custom
+ * properties are read with {@link TMXObject#resolvedProperty}.
*
* This is a convenience wrapper around {@link registerTiledObjectFactory}.
* If the same class is registered twice with the same constructor, the call is silently ignored.
@@ -156,8 +194,9 @@ export function registerTiledObjectFactory(type, factory) {
* Note: classes registered via {@link pool.register} are also automatically registered
* as Tiled object factories (unless {@link pool.autoRegisterTiled} is set to `false`).
* @category Tilemap
+ * @template [S=TMXObject]
* @param {string} name - the Tiled class or object name to match
- * @param {Function} Constructor - class constructor with signature `(x, y, settings)`
+ * @param {new (x: number, y: number, settings: S) => Renderable} Constructor - class constructor, given the object's position and the object (a {@link TMXObject})
* @example
* // register a custom enemy class for use in Tiled maps
* // In Tiled: set the object class to "Enemy" and add custom properties
diff --git a/packages/melonjs/src/level/tiled/TMXTileset.js b/packages/melonjs/src/level/tiled/TMXTileset.js
index 42060e968..e739dbb2e 100644
--- a/packages/melonjs/src/level/tiled/TMXTileset.js
+++ b/packages/melonjs/src/level/tiled/TMXTileset.js
@@ -33,6 +33,14 @@ export default class TMXTileset {
*/
this.tileProperties = new Map();
+ /**
+ * per-tile class indexed by gid
+ * @type {Map}
+ * @ignore
+ * @internal
+ */
+ this.tileClasses = new Map();
+
/**
* per-tile images for "Collection of Image" tilesets, indexed by gid
* @type {Map}
@@ -382,6 +390,12 @@ export default class TMXTileset {
for (const tile of tileEntries) {
const tileId = +tile.id;
+ // the tile's class: `class` in Tiled 1.9, `type` before and since 1.10
+ const tileClass = tile.class ?? tile.type;
+ if (typeof tileClass === "string" && tileClass !== "") {
+ this.tileClasses.set(tileId + this.firstgid, tileClass);
+ }
+
// parse animation frames
if (tile.animation) {
this.isAnimated = true;
@@ -618,6 +632,16 @@ export default class TMXTileset {
return this.tileProperties.get(tileId);
}
+ /**
+ * return the class of the specified tile, as set on the tile in Tiled's
+ * tileset editor. A tile object with no class of its own is matched by it.
+ * @param {number} tileId - global tile ID
+ * @returns {string|undefined} the tile's class, or undefined if it has none
+ */
+ getTileClass(tileId) {
+ return this.tileClasses.get(tileId);
+ }
+
/**
* update tile animations
* @param {number} dt - time delta in milliseconds
diff --git a/packages/melonjs/tests/tiled-object-types.spec.ts b/packages/melonjs/tests/tiled-object-types.spec.ts
new file mode 100644
index 000000000..203db5fa1
--- /dev/null
+++ b/packages/melonjs/tests/tiled-object-types.spec.ts
@@ -0,0 +1,108 @@
+import { describe, expectTypeOf, it } from "vitest";
+import {
+ Renderable,
+ registerTiledObjectClass,
+ registerTiledObjectFactory,
+ type Tile,
+ type TMXObject,
+ type TMXTileMap,
+} from "../src/index.js";
+
+// type-level only, checked by `tsc` (pnpm test:types): the registrations sit
+// in functions that are never called, so a call that stops compiling fails
+// the type check without registering anything at runtime
+
+describe("TMXObject", () => {
+ it("types the object a registered class receives", () => {
+ expectTypeOf().toEqualTypeOf();
+ expectTypeOf().toEqualTypeOf();
+ expectTypeOf().toEqualTypeOf();
+ expectTypeOf()
+ .parameter(0)
+ .toEqualTypeOf();
+ expectTypeOf>().toEqualTypeOf<
+ Record
+ >();
+ });
+});
+
+describe("registerTiledObjectClass", () => {
+ it("accepts a class taking a TMXObject", () => {
+ class Chest extends Renderable {
+ constructor(x: number, y: number, settings: TMXObject) {
+ super(x, y, settings.width, settings.height);
+ }
+ }
+ const register = () => {
+ registerTiledObjectClass("Chest", Chest);
+ };
+ expectTypeOf(register).toBeFunction();
+ });
+
+ it("accepts a class whose settings extend TMXObject with custom properties", () => {
+ interface DoorSettings extends TMXObject {
+ facing?: "N" | "W";
+ }
+ class Door extends Renderable {
+ constructor(x: number, y: number, settings: DoorSettings) {
+ super(x, y, settings.width, settings.height);
+ }
+ }
+ const register = () => {
+ registerTiledObjectClass("Door", Door);
+ };
+ expectTypeOf(register).toBeFunction();
+ });
+
+ it("still accepts a class typed with its own settings, as before", () => {
+ // written before TMXObject was public: it must keep compiling
+ class Legacy extends Renderable {
+ constructor(
+ x: number,
+ y: number,
+ settings: { width: number; height: number; image: string },
+ ) {
+ super(x, y, settings.width, settings.height);
+ }
+ }
+ const register = () => {
+ registerTiledObjectClass("Legacy", Legacy);
+ };
+ expectTypeOf(register).toBeFunction();
+ });
+
+ it("refuses what is not a class", () => {
+ const register = () => {
+ // @ts-expect-error a function is not a constructor
+ registerTiledObjectClass("NotAClass", () => {
+ return new Renderable(0, 0, 1, 1);
+ });
+ };
+ expectTypeOf(register).toBeFunction();
+ });
+});
+
+describe("registerTiledObjectFactory", () => {
+ it("types an unannotated factory's arguments", () => {
+ const register = () => {
+ registerTiledObjectFactory("Typed", (settings, map) => {
+ expectTypeOf(settings).toEqualTypeOf();
+ expectTypeOf(map).toEqualTypeOf();
+ return new Renderable(settings.x, settings.y, 1, 1);
+ });
+ };
+ expectTypeOf(register).toBeFunction();
+ });
+
+ it("still accepts a factory typed with its own settings, as before", () => {
+ const register = () => {
+ registerTiledObjectFactory(
+ "Spine",
+ (settings: { atlasFile: string; jsonFile: string }) => {
+ return new Renderable(0, 0, settings.atlasFile.length, 1);
+ },
+ );
+ };
+ expectTypeOf(register).toBeFunction();
+ });
+});
diff --git a/packages/melonjs/tests/tmxtilemap.spec.js b/packages/melonjs/tests/tmxtilemap.spec.js
index af80fa16e..8efe4088b 100644
--- a/packages/melonjs/tests/tmxtilemap.spec.js
+++ b/packages/melonjs/tests/tmxtilemap.spec.js
@@ -9,6 +9,7 @@ import {
Renderable,
registerTiledObjectClass,
registerTiledObjectFactory,
+ Sprite,
Text,
TMXLayer,
TMXTileMap,
@@ -16,6 +17,7 @@ import {
} from "../src/index.js";
import {
applyObjectOpacity,
+ parse,
parseTintColor,
propagateBlendMode,
tiledBlendMode,
@@ -1606,6 +1608,267 @@ describe("TMXTileMap", () => {
});
});
+ // ---------------------------------------------------------------
+ // a tile object and its tile's class and properties, as in Tiled
+ // ---------------------------------------------------------------
+ describe("getObjects: a tile object's tile class and properties, as in Tiled", () => {
+ // the settings each registered class was built with
+ const built = [];
+ class TileClassChest extends Renderable {
+ constructor(x, y, settings) {
+ super(x, y, settings.width, settings.height);
+ built.push(settings);
+ }
+ }
+ class TileClassOwn extends Renderable {
+ constructor(x, y, settings) {
+ super(x, y, settings.width, settings.height);
+ }
+ }
+ class TileClassNamed extends Renderable {
+ constructor(x, y, settings) {
+ super(x, y, settings.width, settings.height);
+ }
+ }
+
+ beforeAll(() => {
+ registerTiledObjectClass("TileClassChest", TileClassChest);
+ registerTiledObjectClass("TileClassOwn", TileClassOwn);
+ registerTiledObjectClass("TileClassNamed", TileClassNamed);
+ });
+
+ // tile 0 of the 32x32 test tiles carries `tile`: a class, properties
+ function objectsOf(objects, tile = {}) {
+ const map = new TMXTileMap("test", {
+ ...tileObjectMap,
+ tilesets: [{ ...tilesetData, tiles: [{ id: 0, ...tile }] }],
+ layers: [
+ {
+ type: "objectgroup",
+ name: "TileObjects",
+ opacity: 1,
+ visible: true,
+ objects: objects.map((object, i) => {
+ return {
+ id: 400 + i,
+ name: "",
+ type: "",
+ gid: 1,
+ x: 64,
+ y: 96,
+ width: 32,
+ height: 32,
+ ...object,
+ };
+ }),
+ },
+ ],
+ });
+ built.length = 0;
+ return map.getObjects(true);
+ }
+
+ describe("class", () => {
+ it("builds a tile object with no class by its tile's registered class", () => {
+ const [obj] = objectsOf([{}], { type: "TileClassChest" });
+ expect(obj).toBeInstanceOf(TileClassChest);
+ });
+
+ it("reads the tile's class in its Tiled 1.9 spelling too", () => {
+ const [obj] = objectsOf([{}], { class: "TileClassChest" });
+ expect(obj).toBeInstanceOf(TileClassChest);
+ });
+
+ it("matches a flipped tile object by its tile's class", () => {
+ // the flip flags live in the high bits of the gid
+ const [obj] = objectsOf([{ gid: 1 | 0x80000000 }], {
+ type: "TileClassChest",
+ });
+ expect(obj).toBeInstanceOf(TileClassChest);
+ });
+
+ it("leaves the object's own class alone", () => {
+ objectsOf([{}], { type: "TileClassChest" });
+ expect(built.length).toBe(1);
+ // the JSON format writes an empty class for an object with none
+ expect(built[0].class).toBe("");
+ });
+
+ it("keeps an object's own registered class first", () => {
+ const [obj] = objectsOf([{ type: "TileClassOwn" }], {
+ type: "TileClassChest",
+ });
+ expect(obj).toBeInstanceOf(TileClassOwn);
+ });
+
+ it("keeps an object's registered name first", () => {
+ const [obj] = objectsOf([{ name: "TileClassNamed" }], {
+ type: "TileClassChest",
+ });
+ expect(obj).toBeInstanceOf(TileClassNamed);
+ });
+
+ it("makes a plain tile sprite when the tile's class is not registered", () => {
+ const [obj] = objectsOf([{}], { type: "NothingRegisteredUnderThis" });
+ expect(obj).toBeInstanceOf(Sprite);
+ expect(obj).not.toBeInstanceOf(TileClassChest);
+ });
+
+ it("never turns a tile into a built-in by its class", () => {
+ // structural types and the engine's own classes stay tiles: a map
+ // whose tiles were classed like them loads as it always did
+ for (const type of [
+ "shape",
+ "text",
+ "tile",
+ "Trigger",
+ "Collectable",
+ ]) {
+ const [obj] = objectsOf([{}], { type });
+ expect(obj.constructor, type).toBe(Sprite);
+ }
+ });
+ });
+
+ describe("properties", () => {
+ const tile = {
+ type: "TileClassChest",
+ properties: [
+ { name: "opens", type: "int", value: 3 },
+ { name: "gold", type: "int", value: 5 },
+ { name: "loot", type: "class", value: { coins: 2 } },
+ ],
+ };
+
+ it("resolves a property from the tile when the object has none", () => {
+ objectsOf([{}], tile);
+ expect(built[0].resolvedProperty("opens")).toBe(3);
+ });
+
+ it("resolves the object's own property over its tile's", () => {
+ objectsOf(
+ [{ properties: [{ name: "gold", type: "int", value: 9 }] }],
+ tile,
+ );
+ expect(built[0].resolvedProperty("gold")).toBe(9);
+ expect(built[0].resolvedProperties()).toEqual({
+ opens: 3,
+ gold: 9,
+ loot: { coins: 2 },
+ });
+ });
+
+ it("resolves undefined for a property neither sets", () => {
+ objectsOf([{}], tile);
+ expect(built[0].resolvedProperty("missing")).toBeUndefined();
+ });
+
+ it("resolves only custom properties, not the object's fields", () => {
+ objectsOf([{ name: "chest" }], tile);
+ expect(built[0].name).toBe("chest");
+ expect(built[0].resolvedProperty("name")).toBeUndefined();
+ expect(Object.keys(built[0].resolvedProperties()).sort()).toEqual([
+ "gold",
+ "loot",
+ "opens",
+ ]);
+ });
+
+ it("returns a new object every time, leaving the tileset as it was", () => {
+ objectsOf([{}], tile);
+ const resolved = built[0].resolvedProperties();
+ resolved.opens = 99;
+ expect(built[0].resolvedProperties()).not.toBe(resolved);
+ expect(built[0].resolvedProperty("opens")).toBe(3);
+ });
+
+ it("resolves the properties of an object that is not a tile", () => {
+ const map = new TMXTileMap("test", {
+ ...tileObjectMap,
+ layers: [
+ {
+ type: "objectgroup",
+ name: "Shapes",
+ opacity: 1,
+ visible: true,
+ objects: [
+ {
+ id: 500,
+ name: "zone",
+ type: "TileClassChest",
+ x: 0,
+ y: 0,
+ width: 16,
+ height: 16,
+ properties: [{ name: "depth", type: "int", value: 4 }],
+ },
+ ],
+ },
+ ],
+ });
+ built.length = 0;
+ map.getObjects(true);
+ expect(built[0].resolvedProperty("depth")).toBe(4);
+ expect(built[0].resolvedProperties()).toEqual({ depth: 4 });
+ });
+
+ // what existing maps rely on
+ it("still sets the object's own properties on it by name", () => {
+ objectsOf(
+ [{ properties: [{ name: "gold", type: "int", value: 9 }] }],
+ tile,
+ );
+ expect(built[0].gold).toBe(9);
+ });
+
+ it("sets none of its tile's properties on the object", () => {
+ objectsOf([{}], tile);
+ expect("opens" in built[0]).toBe(false);
+ expect(built[0].gold).toBeUndefined();
+ });
+
+ it("does not hand a tile's properties to a plain tile sprite", () => {
+ // a tile property named like a sprite setting must not reach the
+ // sprite, or existing maps would draw differently
+ const [obj] = objectsOf([{}], {
+ properties: [
+ { name: "flipX", type: "bool", value: true },
+ { name: "anchorPoint", type: "string", value: "0.5" },
+ ],
+ });
+ expect(obj).toBeInstanceOf(Sprite);
+ expect(obj.isFlippedX).toBe(false);
+ expect([obj.anchorPoint.x, obj.anchorPoint.y]).toEqual([0, 0]);
+ });
+ });
+
+ it("reads a tile's class and properties from a TMX file", () => {
+ const tmx = new DOMParser().parseFromString(
+ ``,
+ "text/xml",
+ ).documentElement;
+ const map = new TMXTileMap("test", parse(tmx));
+ built.length = 0;
+ const [obj] = map.getObjects(true);
+ expect(obj).toBeInstanceOf(TileClassChest);
+ expect(built[0].resolvedProperty("opens")).toBe(3);
+ expect(built[0].resolvedProperty("facing")).toBe("W");
+ });
+ });
+
// ---------------------------------------------------------------
// TMXLayer passthrough
// ---------------------------------------------------------------
diff --git a/packages/melonjs/tests/tmxtileset.spec.js b/packages/melonjs/tests/tmxtileset.spec.js
index e5f660847..d3e457cd2 100644
--- a/packages/melonjs/tests/tmxtileset.spec.js
+++ b/packages/melonjs/tests/tmxtileset.spec.js
@@ -328,6 +328,64 @@ describe("TMXTileset", () => {
});
});
+ // ==============================================================
+ // Tile classes
+ // ==============================================================
+ describe("tile classes", () => {
+ const base = {
+ firstgid: 10,
+ name: "ground",
+ tilewidth: 32,
+ tileheight: 32,
+ spacing: 0,
+ margin: 0,
+ tilecount: 16,
+ columns: 4,
+ image: "ground.png",
+ };
+
+ it("reads a tile's class, as Tiled 1.10 and older write it (type)", () => {
+ const ts = new TMXTileset({ ...base, tiles: [{ id: 2, type: "Chest" }] });
+ expect(ts.getTileClass(12)).toBe("Chest");
+ });
+
+ it("reads a tile's class as Tiled 1.9 writes it (class)", () => {
+ const ts = new TMXTileset({ ...base, tiles: [{ id: 3, class: "Door" }] });
+ expect(ts.getTileClass(13)).toBe("Door");
+ });
+
+ it("reads it from tiles keyed by id (TMX and older JSON)", () => {
+ const ts = new TMXTileset({ ...base, tiles: { 4: { type: "Torch" } } });
+ expect(ts.getTileClass(14)).toBe("Torch");
+ });
+
+ it("returns undefined for a tile with no class, or an empty one", () => {
+ const ts = new TMXTileset({
+ ...base,
+ tiles: [{ id: 0 }, { id: 1, type: "" }],
+ });
+ expect(ts.getTileClass(10)).toBeUndefined();
+ expect(ts.getTileClass(11)).toBeUndefined();
+ // and for a tile with no entry at all
+ expect(ts.getTileClass(15)).toBeUndefined();
+ });
+
+ it("keeps a tile's class and its properties side by side", () => {
+ const ts = new TMXTileset({
+ ...base,
+ tiles: [
+ {
+ id: 5,
+ type: "Chest",
+ properties: [{ name: "gold", type: "int", value: 5 }],
+ },
+ ],
+ });
+ expect(ts.getTileClass(15)).toBe("Chest");
+ expect(ts.getTileProperties(15)).toEqual({ gold: 5 });
+ });
+ });
+
// ==============================================================
// Collection with properties on tiles
// ==============================================================