Skip to content

Commit 0c2d62e

Browse files
authored
Merge pull request #1323 from melonjs/feature/tilerendersize-fillmode
Support Tiled 1.9+ tilerendersize/fillmode, bump to 1.10
2 parents 03ceb56 + ee125d9 commit 0c2d62e

7 files changed

Lines changed: 554 additions & 14 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ UI
8181
- Text buttons with built-in styling
8282

8383
Level Editor
84-
- [Tiled](https://www.mapeditor.org) map format version +1.0 built-in support for easy level design
84+
- [Tiled](https://www.mapeditor.org) map format [up to 1.10+](https://doc.mapeditor.org/en/stable/reference/tmx-changelog/) built-in support for easy level design
8585
- Uncompressed and [compressed](https://github.com/melonjs/melonJS/tree/master/packages/tiled-inflate-plugin) Plain, Base64, CSV and JSON encoded XML tilemap loading
8686
- Orthogonal, Isometric and Hexagonal maps (both normal and staggered)
8787
- Multiple layers (multiple background/foreground, collision and Image layers)

packages/melonjs/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
- TMX: support Tiled 1.12+ per-object opacity and visibility
1313
- TMX: support Tiled 1.12+ layer blend modes (`mode` attribute on tile layers, image layers, and object groups)
1414
- TMX: support Tiled 1.9+ tile sub-rectangles (`x`, `y`, `width`, `height` on `<tile>` elements for defining sub-regions within a tileset image)
15+
- TMX: support Tiled 1.9+ `tilerendersize` and `fillmode` tileset attributes for controlling how oversize tiles render relative to the map grid
1516
- CanvasRenderer: add support for additional CSS blend modes (`overlay`, `darken`, `lighten`, `color-dodge`, `color-burn`, `hard-light`, `soft-light`, `difference`, `exclusion`) when supported by the browser
1617
- WebGLRenderer: add `darken` and `lighten` blend modes via `gl.MIN`/`gl.MAX` (WebGL2 only)
1718

packages/melonjs/src/level/tiled/TMXTileMap.js

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -100,8 +100,8 @@ function readImageLayer(map, data, z) {
100100
* read the tileset Data
101101
* @ignore
102102
*/
103-
function readTileset(data) {
104-
return new TMXTileset(data);
103+
function readTileset(data, mapTilewidth, mapTileheight) {
104+
return new TMXTileset(data, mapTilewidth, mapTileheight);
105105
}
106106

107107
/**
@@ -307,7 +307,9 @@ export default class TMXTileMap {
307307
if (typeof data.tilesets !== "undefined") {
308308
const tilesets = data.tilesets;
309309
for (let i = 0, len = tilesets.length; i < len; i++) {
310-
this.tilesets.add(readTileset(tilesets[i]));
310+
this.tilesets.add(
311+
readTileset(tilesets[i], this.tilewidth, this.tileheight),
312+
);
311313
}
312314
}
313315

packages/melonjs/src/level/tiled/TMXTileset.js

Lines changed: 106 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@ import { renderer } from "../../video/video.js";
1111
export default class TMXTileset {
1212
/**
1313
* @param {object} tileset - tileset data in JSON format ({@link http://docs.mapeditor.org/en/stable/reference/tmx-map-format/#tileset})
14+
* @param {number} [mapTilewidth] - the map's tile grid width in pixels
15+
* @param {number} [mapTileheight] - the map's tile grid height in pixels
1416
*/
15-
constructor(tileset) {
17+
constructor(tileset, mapTilewidth, mapTileheight) {
1618
/**
1719
* per-tile properties indexed by gid
1820
* @type {Map<number, object>}
@@ -103,10 +105,70 @@ export default class TMXTileset {
103105
this.isCollection = false;
104106

105107
/**
106-
* the tileset class (since Tiled 1.9)
108+
* the tileset class (since Tiled 1.9, renamed back to type on tile/object in 1.10)
107109
* @type {string}
108110
*/
109-
this.class = tileset.class;
111+
this.class = tileset.class ?? tileset.type;
112+
113+
/**
114+
* how tiles render relative to the grid (since Tiled 1.9)
115+
* @type {string}
116+
* @default "tile"
117+
*/
118+
this.tilerendersize = tileset.tilerendersize ?? "tile";
119+
120+
/**
121+
* fill mode when tiles are not rendered at native size (since Tiled 1.9)
122+
* @type {string}
123+
* @default "stretch"
124+
*/
125+
this.fillmode = tileset.fillmode ?? "stretch";
126+
127+
/**
128+
* the map's tile grid width (used for tilerendersize="grid")
129+
* @type {number}
130+
* @ignore
131+
*/
132+
this.mapTilewidth = mapTilewidth ?? this.tilewidth;
133+
134+
/**
135+
* the map's tile grid height (used for tilerendersize="grid")
136+
* @type {number}
137+
* @ignore
138+
*/
139+
this.mapTileheight = mapTileheight ?? this.tileheight;
140+
141+
/**
142+
* precomputed render scale for tilerendersize="grid" (spritesheet only)
143+
* @private
144+
*/
145+
this._renderScaleX = 1;
146+
this._renderScaleY = 1;
147+
this._renderDw = this.tilewidth;
148+
this._renderDh = this.tileheight;
149+
this._renderDyOffset = 0;
150+
this._renderDxCenter = 0;
151+
this._renderDyCenter = 0;
152+
153+
if (this.tilerendersize === "grid") {
154+
this._renderScaleX = this.mapTilewidth / this.tilewidth;
155+
this._renderScaleY = this.mapTileheight / this.tileheight;
156+
157+
if (this.fillmode === "preserve-aspect-fit") {
158+
const minScale = Math.min(this._renderScaleX, this._renderScaleY);
159+
this._renderScaleX = minScale;
160+
this._renderScaleY = minScale;
161+
}
162+
163+
this._renderDw = this.tilewidth * this._renderScaleX;
164+
this._renderDh = this.tileheight * this._renderScaleY;
165+
this._renderDyOffset = this.tileheight - this._renderDh;
166+
167+
if (this.fillmode === "preserve-aspect-fit") {
168+
this._renderDxCenter = (this.mapTilewidth - this._renderDw) / 2;
169+
this._renderDyCenter = -(this.mapTileheight - this._renderDh) / 2;
170+
}
171+
}
110172

111173
/**
112174
* per-tile sub-rectangles (Tiled 1.9+), indexed by local tile id
@@ -401,6 +463,43 @@ export default class TMXTileset {
401463
* @ignore
402464
*/
403465
drawTile(renderer, dx, dy, tmxTile) {
466+
let dw, dh;
467+
468+
if (this.isCollection) {
469+
// collection tiles can have varying sizes; compute scale per-tile
470+
if (this.tilerendersize === "grid") {
471+
let scaleX = this.mapTilewidth / tmxTile.width;
472+
let scaleY = this.mapTileheight / tmxTile.height;
473+
474+
if (this.fillmode === "preserve-aspect-fit") {
475+
const scale = Math.min(scaleX, scaleY);
476+
scaleX = scale;
477+
scaleY = scale;
478+
}
479+
480+
dw = tmxTile.width * scaleX;
481+
dh = tmxTile.height * scaleY;
482+
483+
// bottom-align against tileset baseline (renderer uses tileset.tileheight)
484+
dy += this.tileheight - dh;
485+
486+
if (this.fillmode === "preserve-aspect-fit") {
487+
dx += (this.mapTilewidth - dw) / 2;
488+
dy -= (this.mapTileheight - dh) / 2;
489+
}
490+
} else {
491+
dw = tmxTile.width;
492+
dh = tmxTile.height;
493+
}
494+
} else {
495+
// spritesheet: use precomputed values
496+
dw = this._renderDw;
497+
dh = this._renderDh;
498+
dy += this._renderDyOffset;
499+
dx += this._renderDxCenter;
500+
dy += this._renderDyCenter;
501+
}
502+
404503
// check if any transformation is required
405504
if (tmxTile.flipped) {
406505
renderer.save();
@@ -422,8 +521,8 @@ export default class TMXTileset {
422521
tmxTile.height,
423522
dx,
424523
dy,
425-
tmxTile.width,
426-
tmxTile.height,
524+
dw,
525+
dh,
427526
);
428527
} else {
429528
// use the tileset texture atlas
@@ -436,8 +535,8 @@ export default class TMXTileset {
436535
this.tileheight,
437536
dx,
438537
dy,
439-
this.tilewidth + renderer.uvOffset,
440-
this.tileheight + renderer.uvOffset,
538+
dw + renderer.uvOffset,
539+
dh + renderer.uvOffset,
441540
);
442541
}
443542

packages/melonjs/src/level/tiled/constants.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// latest supported Tiled map format version
2-
export const TILED_SUPPORTED_VERSION = "1.9";
2+
export const TILED_SUPPORTED_VERSION = "1.10";
33

44
// bitmask constants to check for flipped & rotated tiles
55
export const TMX_FLIP_H = 0x80000000;

packages/melonjs/tests/tmxrenderer.spec.js

Lines changed: 169 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,31 @@
11
import { beforeAll, describe, expect, it } from "vitest";
2-
import { boot } from "../src/index.js";
2+
import { boot, video } from "../src/index.js";
33
import TMXHexagonalRenderer from "../src/level/tiled/renderer/TMXHexagonalRenderer.js";
44
import TMXIsometricRenderer from "../src/level/tiled/renderer/TMXIsometricRenderer.js";
55
import TMXOrthogonalRenderer from "../src/level/tiled/renderer/TMXOrthogonalRenderer.js";
66
import TMXStaggeredRenderer from "../src/level/tiled/renderer/TMXStaggeredRenderer.js";
7+
import Tile from "../src/level/tiled/TMXTile.js";
8+
import TMXTileset from "../src/level/tiled/TMXTileset.js";
9+
import { imgList } from "../src/loader/cache.js";
10+
11+
// helper to create a fake image in the loader cache
12+
function fakeImage(name, w = 64, h = 64) {
13+
const canvas = document.createElement("canvas");
14+
canvas.width = w;
15+
canvas.height = h;
16+
imgList[name] = canvas;
17+
return canvas;
18+
}
719

820
describe("TMX Renderers", () => {
921
beforeAll(() => {
1022
boot();
23+
video.init(128, 128, {
24+
parent: "screen",
25+
scale: "auto",
26+
renderer: video.AUTO,
27+
});
28+
fakeImage("drawtest", 256, 256);
1129
});
1230

1331
// mock map object for constructors
@@ -203,4 +221,154 @@ describe("TMX Renderers", () => {
203221
expect(result1.y).toEqual(result2.y);
204222
});
205223
});
224+
225+
// ==============================================================
226+
// drawTile with tilerendersize/fillmode across renderers
227+
// ==============================================================
228+
describe("drawTile with tilerendersize/fillmode", () => {
229+
// spy renderer that captures drawImage calls
230+
function spyRenderer() {
231+
const calls = [];
232+
return {
233+
calls,
234+
drawImage(img, sx, sy, sw, sh, dx, dy, dw, dh) {
235+
calls.push({ sx, sy, sw, sh, dx, dy, dw, dh });
236+
},
237+
save() {},
238+
restore() {},
239+
translate() {},
240+
transform() {},
241+
uvOffset: 0,
242+
};
243+
}
244+
245+
// create a tileset with given overrides and map grid size
246+
function makeTileset(overrides, mapTw, mapTh) {
247+
return new TMXTileset(
248+
{
249+
firstgid: 1,
250+
name: "drawtest",
251+
tilewidth: 48,
252+
tileheight: 48,
253+
spacing: 0,
254+
margin: 0,
255+
tilecount: 16,
256+
columns: 4,
257+
image: "drawtest.png",
258+
...overrides,
259+
},
260+
mapTw,
261+
mapTh,
262+
);
263+
}
264+
265+
describe("TMXOrthogonalRenderer.drawTile", () => {
266+
it("should draw oversize tile at native size with tilerendersize='tile'", () => {
267+
const ts = makeTileset({}, 32, 32);
268+
const tile = new Tile(0, 0, 1, ts);
269+
const spy = spyRenderer();
270+
const ortho = new TMXOrthogonalRenderer(mockMap());
271+
ortho.drawTile(spy, 0, 0, tile);
272+
expect(spy.calls.length).toEqual(1);
273+
// native tile size: 48x48
274+
expect(spy.calls[0].dw).toEqual(48);
275+
expect(spy.calls[0].dh).toEqual(48);
276+
});
277+
278+
it("should scale tile to grid size with tilerendersize='grid' stretch", () => {
279+
const ts = makeTileset({ tilerendersize: "grid" }, 32, 32);
280+
const tile = new Tile(0, 0, 1, ts);
281+
const spy = spyRenderer();
282+
const ortho = new TMXOrthogonalRenderer(mockMap());
283+
ortho.drawTile(spy, 0, 0, tile);
284+
expect(spy.calls.length).toEqual(1);
285+
expect(spy.calls[0].dw).toBeCloseTo(32);
286+
expect(spy.calls[0].dh).toBeCloseTo(32);
287+
});
288+
289+
it("should use uniform scale with preserve-aspect-fit", () => {
290+
// 48x48 tile on 32x16 grid → min(32/48, 16/48) = 1/3
291+
const ts = makeTileset(
292+
{ tilerendersize: "grid", fillmode: "preserve-aspect-fit" },
293+
32,
294+
16,
295+
);
296+
const tile = new Tile(0, 0, 1, ts);
297+
const spy = spyRenderer();
298+
const ortho = new TMXOrthogonalRenderer(
299+
mockMap({ tilewidth: 32, tileheight: 16 }),
300+
);
301+
ortho.drawTile(spy, 0, 0, tile);
302+
expect(spy.calls.length).toEqual(1);
303+
expect(spy.calls[0].dw).toBeCloseTo(16);
304+
expect(spy.calls[0].dh).toBeCloseTo(16);
305+
});
306+
});
307+
308+
describe("TMXIsometricRenderer.drawTile", () => {
309+
it("should draw at native size with tilerendersize='tile'", () => {
310+
const ts = makeTileset({}, 32, 32);
311+
const tile = new Tile(0, 0, 1, ts);
312+
const spy = spyRenderer();
313+
const iso = new TMXIsometricRenderer(
314+
mockMap({ orientation: "isometric" }),
315+
);
316+
iso.drawTile(spy, 0, 0, tile);
317+
expect(spy.calls.length).toEqual(1);
318+
expect(spy.calls[0].dw).toEqual(48);
319+
expect(spy.calls[0].dh).toEqual(48);
320+
});
321+
322+
it("should scale tile to grid size with tilerendersize='grid'", () => {
323+
const ts = makeTileset({ tilerendersize: "grid" }, 32, 32);
324+
const tile = new Tile(0, 0, 1, ts);
325+
const spy = spyRenderer();
326+
const iso = new TMXIsometricRenderer(
327+
mockMap({ orientation: "isometric" }),
328+
);
329+
iso.drawTile(spy, 0, 0, tile);
330+
expect(spy.calls.length).toEqual(1);
331+
expect(spy.calls[0].dw).toBeCloseTo(32);
332+
expect(spy.calls[0].dh).toBeCloseTo(32);
333+
});
334+
});
335+
336+
describe("TMXHexagonalRenderer.drawTile", () => {
337+
it("should draw at native size with tilerendersize='tile'", () => {
338+
const ts = makeTileset({}, 32, 32);
339+
const tile = new Tile(0, 0, 1, ts);
340+
const spy = spyRenderer();
341+
const hex = new TMXHexagonalRenderer(
342+
mockMap({
343+
orientation: "hexagonal",
344+
hexsidelength: 16,
345+
staggeraxis: "y",
346+
staggerindex: "odd",
347+
}),
348+
);
349+
hex.drawTile(spy, 0, 0, tile);
350+
expect(spy.calls.length).toEqual(1);
351+
expect(spy.calls[0].dw).toEqual(48);
352+
expect(spy.calls[0].dh).toEqual(48);
353+
});
354+
355+
it("should scale tile to grid size with tilerendersize='grid'", () => {
356+
const ts = makeTileset({ tilerendersize: "grid" }, 32, 32);
357+
const tile = new Tile(0, 0, 1, ts);
358+
const spy = spyRenderer();
359+
const hex = new TMXHexagonalRenderer(
360+
mockMap({
361+
orientation: "hexagonal",
362+
hexsidelength: 16,
363+
staggeraxis: "y",
364+
staggerindex: "odd",
365+
}),
366+
);
367+
hex.drawTile(spy, 0, 0, tile);
368+
expect(spy.calls.length).toEqual(1);
369+
expect(spy.calls[0].dw).toBeCloseTo(32);
370+
expect(spy.calls[0].dh).toBeCloseTo(32);
371+
});
372+
});
373+
});
206374
});

0 commit comments

Comments
 (0)