From f76cb0bbacf77524b2e1e89cdbbf7b495b9cb642 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Fri, 23 Oct 2026 15:53:12 +0800 Subject: [PATCH 1/3] Tiled: tile classes, resolved properties and a TMXObject type A tile object takes what Tiled shows it inheriting from its tile, without changing how existing maps load: - a tile object with no class of its own is built by the class registered under its tile's class (Tiled's effective class: object, then tile). It comes after the object's own class and registered name, and only for a name the game registered itself: a tile classed like a structural type ("shape", "text", "tile") or an engine built-in ("Sprite", "Trigger"...) stays a plain tile. settings.class is left as it was - TMXObject#resolvedProperty(name) and resolvedProperties() return the object's custom properties merged over its tile's, as the methods of the same names in Tiled's scripting API do. The tile's properties are NOT set on the object by name: the built-in tile factory hands the whole object to the sprite as its settings, so a tile property named like a sprite setting (flipX...) would have changed how existing maps draw. Own properties are parsed once into a private bag and set on the object as before (no eval: property is evaluated twice) - TMXTileset#getTileClass(gid) returns a tile's class, read from `type` (Tiled 1.10 and older) or `class` (1.9) Typings: TMXObject is exported as a type and documented (it was internal and stripped), with tile: Tile | undefined and gid: number | null. Both registration functions are generic over the settings type, defaulting to TMXObject: an unannotated factory gets TMXObject, and a class or factory typed with its own settings still compiles. A non-generic signature broke exactly that (mutation T2). Tests: tile classes in both spellings, flipped gids, precedence (own class, registered name), built-ins left alone, resolved properties (precedence, copies, fields excluded, non-tile objects), a TMX file end to end, and two backward-compatibility guards (no tile property on the object, none reaching a plain tile sprite). A type-level spec checks the generic registrations, including the legacy annotations. 18 of 19 mutations caught; the one missed (dropping the JSDoc type on `tile`) is equivalent, as tsc infers the same type. Full suite 8,334 passed; the examples' registration calls type-check unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SeWCkETjW35ZbXDRswV1hx --- packages/melonjs/CHANGELOG.md | 5 + .../melonjs/skills/melonjs-tilemaps/SKILL.md | 52 +++- packages/melonjs/src/index.ts | 1 + packages/melonjs/src/level/tiled/TMXObject.js | 124 ++++++++- .../src/level/tiled/TMXObjectFactory.js | 51 +++- .../melonjs/src/level/tiled/TMXTileset.js | 24 ++ .../melonjs/tests/tiled-object-types.spec.ts | 108 +++++++ packages/melonjs/tests/tmxtilemap.spec.js | 263 ++++++++++++++++++ packages/melonjs/tests/tmxtileset.spec.js | 58 ++++ 9 files changed, 670 insertions(+), 16 deletions(-) create mode 100644 packages/melonjs/tests/tiled-object-types.spec.ts diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 53584dd9a9..5c81368ead 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -6,6 +6,11 @@ - 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 +- Skills: the tilemaps skill covers classes and properties set on tiles, `resolvedProperty()` and the `TMXObject` type ### 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 3c68cc1507..df44c1011c 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,48 @@ 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. + 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 +214,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 +325,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 b97a3af12e..87e55667cc 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 7cb490140a..035c7252bf 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 8d58399d86..a213eb9be5 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 42060e9689..e739dbb2e3 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 0000000000..203db5fa1c --- /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 af80fa16e4..8efe4088ba 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 e5f6608472..d3e457cd20 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 // ============================================================== From 203bca43eb890311f40653cb5b4ccf2ed3b5f031 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Fri, 23 Oct 2026 16:55:18 +0800 Subject: [PATCH 2/3] Skills: Tile#getRenderable() for a sprite of a Tiled tile A class that is not a Sprite itself (a Container holding the tile and something else) gets the tile's sprite from settings.tile.getRenderable(), as a plain tile object does, instead of building region names by hand. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SeWCkETjW35ZbXDRswV1hx --- packages/melonjs/CHANGELOG.md | 2 +- packages/melonjs/skills/melonjs-tilemaps/SKILL.md | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 5c81368ead..714c8240d4 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -10,7 +10,7 @@ - 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 -- Skills: the tilemaps skill covers classes and properties set on tiles, `resolvedProperty()` and the `TMXObject` type +- Skills: the tilemaps skill covers classes and properties set on tiles, `resolvedProperty()`, the `TMXObject` type and `Tile#getRenderable()` for a sprite of a tile ### 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 df44c1011c..eed9e7451c 100644 --- a/packages/melonjs/skills/melonjs-tilemaps/SKILL.md +++ b/packages/melonjs/skills/melonjs-tilemaps/SKILL.md @@ -165,6 +165,11 @@ registerTiledObjectClass("Door", Door); // and give the door tile the class "D 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 From 9dd1cd29fea1f83d3910393ea326fb78f8c76b68 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sat, 24 Oct 2026 10:26:43 +0800 Subject: [PATCH 3/3] Changelog: no entry for the skill update Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SeWCkETjW35ZbXDRswV1hx --- packages/melonjs/CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 714c8240d4..1cebb5b7c2 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -10,7 +10,6 @@ - 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 -- Skills: the tilemaps skill covers classes and properties set on tiles, `resolvedProperty()`, the `TMXObject` type and `Tile#getRenderable()` for a sprite of a tile ### 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