@flighthq/tilemap-formats 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/tiledColor.d.ts +3 -0
- package/dist/tiledColor.d.ts.map +1 -0
- package/dist/tiledColor.js +38 -0
- package/dist/tiledColor.js.map +1 -0
- package/dist/tiledGid.d.ts +4 -0
- package/dist/tiledGid.d.ts.map +1 -0
- package/dist/tiledGid.js +31 -0
- package/dist/tiledGid.js.map +1 -0
- package/dist/tiledJsonParse.d.ts +5 -0
- package/dist/tiledJsonParse.d.ts.map +1 -0
- package/dist/tiledJsonParse.js +238 -0
- package/dist/tiledJsonParse.js.map +1 -0
- package/dist/tiledLayerData.d.ts +4 -0
- package/dist/tiledLayerData.d.ts.map +1 -0
- package/dist/tiledLayerData.js +59 -0
- package/dist/tiledLayerData.js.map +1 -0
- package/dist/tiledOptions.d.ts +8 -0
- package/dist/tiledOptions.d.ts.map +1 -0
- package/dist/tiledOptions.js +2 -0
- package/dist/tiledOptions.js.map +1 -0
- package/dist/tiledProject.d.ts +4 -0
- package/dist/tiledProject.d.ts.map +1 -0
- package/dist/tiledProject.js +58 -0
- package/dist/tiledProject.js.map +1 -0
- package/dist/tiledTmxFormat.d.ts +3 -0
- package/dist/tiledTmxFormat.d.ts.map +1 -0
- package/dist/tiledTmxFormat.js +178 -0
- package/dist/tiledTmxFormat.js.map +1 -0
- package/dist/tiledXmlParse.d.ts +5 -0
- package/dist/tiledXmlParse.d.ts.map +1 -0
- package/dist/tiledXmlParse.js +247 -0
- package/dist/tiledXmlParse.js.map +1 -0
- package/package.json +40 -0
- package/src/tiledColor.test.ts +32 -0
- package/src/tiledGid.test.ts +54 -0
- package/src/tiledJsonParse.test.ts +137 -0
- package/src/tiledLayerData.test.ts +56 -0
- package/src/tiledProject.test.ts +71 -0
- package/src/tiledTmxFormat.test.ts +50 -0
- package/src/tiledXmlParse.test.ts +241 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { parseTiledTilesetJson, parseTiledTmj } from './tiledJsonParse';
|
|
4
|
+
import type { TiledParseOptions } from './tiledOptions';
|
|
5
|
+
|
|
6
|
+
function base64Layer(gids: readonly number[]): string {
|
|
7
|
+
const bytes = new Uint8Array(gids.length * 4);
|
|
8
|
+
const view = new DataView(bytes.buffer);
|
|
9
|
+
gids.forEach((gid, i) => view.setUint32(i * 4, gid, true));
|
|
10
|
+
const table = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
|
11
|
+
let out = '';
|
|
12
|
+
for (let i = 0; i < bytes.length; i += 3) {
|
|
13
|
+
const b0 = bytes[i];
|
|
14
|
+
const b1 = i + 1 < bytes.length ? bytes[i + 1] : 0;
|
|
15
|
+
const b2 = i + 2 < bytes.length ? bytes[i + 2] : 0;
|
|
16
|
+
out += table[b0 >> 2] + table[((b0 & 3) << 4) | (b1 >> 4)];
|
|
17
|
+
out += i + 1 < bytes.length ? table[((b1 & 15) << 2) | (b2 >> 6)] : '=';
|
|
18
|
+
out += i + 2 < bytes.length ? table[b2 & 63] : '=';
|
|
19
|
+
}
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function tmjWithData(data: unknown, extra: Record<string, unknown> = {}): string {
|
|
24
|
+
return JSON.stringify({
|
|
25
|
+
height: 2,
|
|
26
|
+
layers: [{ data, height: 2, id: 1, name: 'g', type: 'tilelayer', width: 2, ...extra }],
|
|
27
|
+
tilewidth: 32,
|
|
28
|
+
tileheight: 32,
|
|
29
|
+
type: 'map',
|
|
30
|
+
version: '1.10',
|
|
31
|
+
width: 2,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('parseTiledTilesetJson', () => {
|
|
36
|
+
it('parses a standalone TSJ tileset', () => {
|
|
37
|
+
const tsj = JSON.stringify({
|
|
38
|
+
columns: 3,
|
|
39
|
+
image: 'terrain.png',
|
|
40
|
+
imageheight: 50,
|
|
41
|
+
imagewidth: 50,
|
|
42
|
+
margin: 2,
|
|
43
|
+
name: 'terrain',
|
|
44
|
+
spacing: 1,
|
|
45
|
+
tilecount: 9,
|
|
46
|
+
tileheight: 16,
|
|
47
|
+
tiles: [{ animation: [{ duration: 100, tileid: 0 }], id: 0, type: 'water' }],
|
|
48
|
+
tilewidth: 16,
|
|
49
|
+
});
|
|
50
|
+
const tileset = parseTiledTilesetJson(tsj)!;
|
|
51
|
+
expect(tileset.name).toBe('terrain');
|
|
52
|
+
expect(tileset.image).toBe('terrain.png');
|
|
53
|
+
expect(tileset.columns).toBe(3);
|
|
54
|
+
expect(tileset.tiles[0].animation).toEqual([{ duration: 100, tileId: 0 }]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('returns null on malformed JSON', () => {
|
|
58
|
+
expect(parseTiledTilesetJson('{ not json')).toBeNull();
|
|
59
|
+
expect(parseTiledTilesetJson('[]')).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('parseTiledTmj', () => {
|
|
64
|
+
it('parses a polygon object and its points', () => {
|
|
65
|
+
const tmj = JSON.stringify({
|
|
66
|
+
height: 1,
|
|
67
|
+
layers: [
|
|
68
|
+
{
|
|
69
|
+
id: 1,
|
|
70
|
+
name: 'shapes',
|
|
71
|
+
objects: [
|
|
72
|
+
{
|
|
73
|
+
id: 1,
|
|
74
|
+
name: 'zone',
|
|
75
|
+
polygon: [
|
|
76
|
+
{ x: 0, y: 0 },
|
|
77
|
+
{ x: 32, y: 0 },
|
|
78
|
+
{ x: 32, y: 32 },
|
|
79
|
+
],
|
|
80
|
+
type: '',
|
|
81
|
+
x: 4,
|
|
82
|
+
y: 4,
|
|
83
|
+
},
|
|
84
|
+
],
|
|
85
|
+
type: 'objectgroup',
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
tileheight: 32,
|
|
89
|
+
tilewidth: 32,
|
|
90
|
+
type: 'map',
|
|
91
|
+
version: '1.10',
|
|
92
|
+
width: 1,
|
|
93
|
+
});
|
|
94
|
+
const map = parseTiledTmj(tmj)!;
|
|
95
|
+
const layer = map.layers[0];
|
|
96
|
+
if (layer.type !== 'objectgroup') throw new Error('expected an object group');
|
|
97
|
+
expect(layer.objects[0].polygon).toEqual([
|
|
98
|
+
{ x: 0, y: 0 },
|
|
99
|
+
{ x: 32, y: 0 },
|
|
100
|
+
{ x: 32, y: 32 },
|
|
101
|
+
]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('decodes an array-data tile layer', () => {
|
|
105
|
+
const map = parseTiledTmj(tmjWithData([1, 5, 2147483649, 6]))!;
|
|
106
|
+
const layer = map.layers[0];
|
|
107
|
+
if (layer.type !== 'tilelayer') throw new Error('expected a tile layer');
|
|
108
|
+
expect(Array.from(layer.data)).toEqual([1, 5, 2147483649, 6]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('decodes a base64 string tile layer', () => {
|
|
112
|
+
const map = parseTiledTmj(tmjWithData(base64Layer([1, 5, 2147483649, 6]), { encoding: 'base64' }))!;
|
|
113
|
+
const layer = map.layers[0];
|
|
114
|
+
if (layer.type !== 'tilelayer') throw new Error('expected a tile layer');
|
|
115
|
+
expect(Array.from(layer.data)).toEqual([1, 5, 2147483649, 6]);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('preserves a compressed layer as empty without an inflate seam, and decodes with one', () => {
|
|
119
|
+
const json = tmjWithData(base64Layer([1, 5, 2147483649, 6]), { compression: 'zlib', encoding: 'base64' });
|
|
120
|
+
|
|
121
|
+
const dropped = parseTiledTmj(json)!;
|
|
122
|
+
const droppedLayer = dropped.layers[0];
|
|
123
|
+
if (droppedLayer.type !== 'tilelayer') throw new Error('expected a tile layer');
|
|
124
|
+
expect(Array.from(droppedLayer.data)).toEqual([0, 0, 0, 0]);
|
|
125
|
+
|
|
126
|
+
const options: TiledParseOptions = { inflate: (bytes) => new Uint8Array(bytes) };
|
|
127
|
+
const decoded = parseTiledTmj(json, options)!;
|
|
128
|
+
const decodedLayer = decoded.layers[0];
|
|
129
|
+
if (decodedLayer.type !== 'tilelayer') throw new Error('expected a tile layer');
|
|
130
|
+
expect(Array.from(decodedLayer.data)).toEqual([1, 5, 2147483649, 6]);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('returns null on malformed JSON', () => {
|
|
134
|
+
expect(parseTiledTmj('{ not json')).toBeNull();
|
|
135
|
+
expect(parseTiledTmj('42')).toBeNull();
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { decodeTiledBase64Layer, decodeTiledCsvLayer } from './tiledLayerData';
|
|
4
|
+
|
|
5
|
+
// GIDs [1, 5, 0x80000001, 6] as little-endian 32-bit ints.
|
|
6
|
+
const gids = [1, 5, 0x80000001, 6];
|
|
7
|
+
const bytes = new Uint8Array(gids.length * 4);
|
|
8
|
+
const view = new DataView(bytes.buffer);
|
|
9
|
+
gids.forEach((gid, i) => view.setUint32(i * 4, gid, true));
|
|
10
|
+
const base64 = encodeBase64(bytes);
|
|
11
|
+
|
|
12
|
+
// Portable base64 encoder for fixtures (avoids depending on Node's Buffer in the type-check).
|
|
13
|
+
function encodeBase64(input: Readonly<Uint8Array>): string {
|
|
14
|
+
const table = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
|
15
|
+
let out = '';
|
|
16
|
+
for (let i = 0; i < input.length; i += 3) {
|
|
17
|
+
const b0 = input[i];
|
|
18
|
+
const b1 = i + 1 < input.length ? input[i + 1] : 0;
|
|
19
|
+
const b2 = i + 2 < input.length ? input[i + 2] : 0;
|
|
20
|
+
out += table[b0 >> 2] + table[((b0 & 3) << 4) | (b1 >> 4)];
|
|
21
|
+
out += i + 1 < input.length ? table[((b1 & 15) << 2) | (b2 >> 6)] : '=';
|
|
22
|
+
out += i + 2 < input.length ? table[b2 & 63] : '=';
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('decodeTiledBase64Layer', () => {
|
|
28
|
+
it('decodes an uncompressed base64 payload as little-endian GIDs', () => {
|
|
29
|
+
expect(Array.from(decodeTiledBase64Layer(base64, null)!)).toEqual(gids);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('agrees with the CSV decode of the same GIDs', () => {
|
|
33
|
+
expect(Array.from(decodeTiledBase64Layer(base64, null)!)).toEqual(
|
|
34
|
+
Array.from(decodeTiledCsvLayer('1,5,2147483649,6')),
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('returns null when compressed with no inflate seam', () => {
|
|
39
|
+
expect(decodeTiledBase64Layer(base64, 'gzip')).toBeNull();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('decodes through an inflate seam when supplied', () => {
|
|
43
|
+
const inflate = (input: Readonly<Uint8Array>) => new Uint8Array(input);
|
|
44
|
+
expect(Array.from(decodeTiledBase64Layer(base64, 'gzip', inflate)!)).toEqual(gids);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('returns null when the inflate seam fails', () => {
|
|
48
|
+
expect(decodeTiledBase64Layer(base64, 'zlib', () => null)).toBeNull();
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('decodeTiledCsvLayer', () => {
|
|
53
|
+
it('parses comma-separated GIDs and ignores whitespace', () => {
|
|
54
|
+
expect(Array.from(decodeTiledCsvLayer('\n1, 5,\n2147483649, 6\n'))).toEqual(gids);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { createTileset } from '@flighthq/tileset';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import type { TiledTilesetResolver } from './tiledOptions';
|
|
5
|
+
import { buildTilemapLayersFromTiled } from './tiledProject';
|
|
6
|
+
import { parseTiledTmx } from './tiledXmlParse';
|
|
7
|
+
|
|
8
|
+
function mapWithLayerData(data: string): ReturnType<typeof parseTiledTmx> {
|
|
9
|
+
return parseTiledTmx(
|
|
10
|
+
'<map version="1" width="2" height="2" tilewidth="16" tileheight="16">' +
|
|
11
|
+
'<tileset firstgid="1" source="a.tsx"/>' +
|
|
12
|
+
'<tileset firstgid="5" source="b.tsx"/>' +
|
|
13
|
+
`<layer id="1" name="g" width="2" height="2"><data encoding="csv">${data}</data></layer>` +
|
|
14
|
+
'<objectgroup id="2" name="o"><object id="1" x="0" y="0"/></objectgroup>' +
|
|
15
|
+
'</map>',
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const tilesetA = createTileset({ columns: 2, rows: 2, tileHeight: 16, tileWidth: 16 });
|
|
20
|
+
const tilesetB = createTileset({ columns: 2, rows: 2, tileHeight: 16, tileWidth: 16 });
|
|
21
|
+
|
|
22
|
+
const resolve: TiledTilesetResolver = (ref) => {
|
|
23
|
+
if (ref.firstGid === 1) return tilesetA;
|
|
24
|
+
if (ref.firstGid === 5) return tilesetB;
|
|
25
|
+
return null;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
describe('buildTilemapLayersFromTiled', () => {
|
|
29
|
+
it('splits a two-tileset layer into one TilemapData per tileset with local ids', () => {
|
|
30
|
+
const map = mapWithLayerData('1,5,2147483649,6')!;
|
|
31
|
+
const result = buildTilemapLayersFromTiled(map, 0, resolve)!;
|
|
32
|
+
expect(result).toHaveLength(2);
|
|
33
|
+
|
|
34
|
+
expect(result[0].tileset).toBe(tilesetA);
|
|
35
|
+
// Cell 2's GID has the flip bit set; its tile id (1) still resolves to tileset A as local id 0.
|
|
36
|
+
expect(Array.from(result[0].tiles)).toEqual([0, -1, 0, -1]);
|
|
37
|
+
|
|
38
|
+
expect(result[1].tileset).toBe(tilesetB);
|
|
39
|
+
expect(Array.from(result[1].tiles)).toEqual([-1, 0, -1, 1]);
|
|
40
|
+
expect(result[1].columns).toBe(2);
|
|
41
|
+
expect(result[1].rows).toBe(2);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('returns a one-element array for a single-tileset layer', () => {
|
|
45
|
+
const map = mapWithLayerData('1,2,1,2')!;
|
|
46
|
+
const result = buildTilemapLayersFromTiled(map, 0, resolve)!;
|
|
47
|
+
expect(result).toHaveLength(1);
|
|
48
|
+
expect(result[0].tileset).toBe(tilesetA);
|
|
49
|
+
expect(Array.from(result[0].tiles)).toEqual([0, 1, 0, 1]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('leaves an unresolved tileset empty while projecting the resolved one', () => {
|
|
53
|
+
const map = mapWithLayerData('1,5,1,5')!;
|
|
54
|
+
const onlyA: TiledTilesetResolver = (ref) => (ref.firstGid === 1 ? tilesetA : null);
|
|
55
|
+
const result = buildTilemapLayersFromTiled(map, 0, onlyA)!;
|
|
56
|
+
expect(result).toHaveLength(1);
|
|
57
|
+
expect(result[0].tileset).toBe(tilesetA);
|
|
58
|
+
expect(Array.from(result[0].tiles)).toEqual([0, -1, 0, -1]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('returns null when no referenced tileset resolves', () => {
|
|
62
|
+
const map = mapWithLayerData('1,5,2147483649,6')!;
|
|
63
|
+
expect(buildTilemapLayersFromTiled(map, 0, () => null)).toBeNull();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('returns null for a non-tile layer or an out-of-range index', () => {
|
|
67
|
+
const map = mapWithLayerData('1,5,2147483649,6')!;
|
|
68
|
+
expect(buildTilemapLayersFromTiled(map, 1, resolve)).toBeNull();
|
|
69
|
+
expect(buildTilemapLayersFromTiled(map, 9, resolve)).toBeNull();
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { formatTiledTmx } from './tiledTmxFormat';
|
|
4
|
+
import { parseTiledTmx } from './tiledXmlParse';
|
|
5
|
+
|
|
6
|
+
// A map exercising every modeled TMX construct: background + typed properties, an embedded tileset
|
|
7
|
+
// with an animated tile, an external tileset ref, a CSV tile layer (with a flipped GID), an object
|
|
8
|
+
// group (point / polygon-with-property), an image layer, and a nested group layer.
|
|
9
|
+
const richTmx = [
|
|
10
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
11
|
+
'<map version="1.10" tiledversion="1.10.2" orientation="orthogonal" renderorder="right-down" width="2" height="2" tilewidth="16" tileheight="16" infinite="0" backgroundcolor="#204060">',
|
|
12
|
+
' <properties><property name="name" value="demo"/><property name="score" type="int" value="10"/></properties>',
|
|
13
|
+
' <tileset firstgid="1" name="base" tilewidth="16" tileheight="16" tilecount="4" columns="2">',
|
|
14
|
+
' <image source="base.png" width="32" height="32"/>',
|
|
15
|
+
' <tile id="0" type="lava"><animation><frame tileid="0" duration="200"/></animation></tile>',
|
|
16
|
+
' </tileset>',
|
|
17
|
+
' <tileset firstgid="5" source="extra.tsx"/>',
|
|
18
|
+
' <layer id="1" name="ground" width="2" height="2"><data encoding="csv">1,2,5,2147483649</data></layer>',
|
|
19
|
+
' <objectgroup id="2" name="objs">',
|
|
20
|
+
' <object id="1" name="p" type="marker" x="1" y="2"><point/></object>',
|
|
21
|
+
' <object id="2" type="poly" x="0" y="0"><polygon points="0,0 8,0 8,8"/><properties><property name="tag" value="zone"/></properties></object>',
|
|
22
|
+
' </objectgroup>',
|
|
23
|
+
' <imagelayer id="3" name="bg"><image source="sky.png"/></imagelayer>',
|
|
24
|
+
' <group id="4" name="grp">',
|
|
25
|
+
' <layer id="5" name="over" width="2" height="2"><data encoding="csv">0,0,1,1</data></layer>',
|
|
26
|
+
' </group>',
|
|
27
|
+
'</map>',
|
|
28
|
+
].join('\n');
|
|
29
|
+
|
|
30
|
+
describe('formatTiledTmx', () => {
|
|
31
|
+
it('round-trips parse -> format -> parse for the modeled fields', () => {
|
|
32
|
+
const map = parseTiledTmx(richTmx)!;
|
|
33
|
+
const reparsed = parseTiledTmx(formatTiledTmx(map));
|
|
34
|
+
expect(reparsed).toEqual(map);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('writes tile layers as CSV data', () => {
|
|
38
|
+
const map = parseTiledTmx(richTmx)!;
|
|
39
|
+
const text = formatTiledTmx(map);
|
|
40
|
+
expect(text).toContain('<data encoding="csv">');
|
|
41
|
+
expect(text).toContain('1,2,5,2147483649');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('emits external tileset refs by source and embedded tilesets inline', () => {
|
|
45
|
+
const map = parseTiledTmx(richTmx)!;
|
|
46
|
+
const text = formatTiledTmx(map);
|
|
47
|
+
expect(text).toContain('<tileset firstgid="5" source="extra.tsx"/>');
|
|
48
|
+
expect(text).toContain('firstgid="1" name="base"');
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { decodeTiledGid } from './tiledGid';
|
|
4
|
+
import { parseTiledTilesetJson, parseTiledTmj } from './tiledJsonParse';
|
|
5
|
+
import type { TiledParseOptions } from './tiledOptions';
|
|
6
|
+
import { parseTiledTileset, parseTiledTmx } from './tiledXmlParse';
|
|
7
|
+
|
|
8
|
+
// A 2x2 orthogonal map with a CSV tile layer drawing from two tilesets (firstgid 1 and 5), one
|
|
9
|
+
// embedded tileset and one external `source="ext.tsx"` ref, an object group with a point and a
|
|
10
|
+
// rectangle, and a typed property block. Cell (0,1) sets the horizontal flip bit on tile id 1.
|
|
11
|
+
const orthogonalTmx = [
|
|
12
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
13
|
+
'<map version="1.10" tiledversion="1.10.2" orientation="orthogonal" renderorder="right-down" width="2" height="2" tilewidth="32" tileheight="32" infinite="0" backgroundcolor="#ff8800">',
|
|
14
|
+
' <properties>',
|
|
15
|
+
' <property name="author" value="flight"/>',
|
|
16
|
+
' <property name="level" type="int" value="3"/>',
|
|
17
|
+
' <property name="hard" type="bool" value="true"/>',
|
|
18
|
+
' </properties>',
|
|
19
|
+
' <tileset firstgid="1" name="A" tilewidth="32" tileheight="32" tilecount="4" columns="2">',
|
|
20
|
+
' <image source="a.png" width="64" height="64"/>',
|
|
21
|
+
' </tileset>',
|
|
22
|
+
' <tileset firstgid="5" source="ext.tsx"/>',
|
|
23
|
+
' <layer id="1" name="ground" width="2" height="2">',
|
|
24
|
+
' <data encoding="csv">1,5,2147483649,6</data>',
|
|
25
|
+
' </layer>',
|
|
26
|
+
' <objectgroup id="2" name="things">',
|
|
27
|
+
' <object id="1" name="spawn" type="marker" x="16" y="16"><point/></object>',
|
|
28
|
+
' <object id="2" name="wall" type="collider" x="0" y="0" width="32" height="32"/>',
|
|
29
|
+
' </objectgroup>',
|
|
30
|
+
'</map>',
|
|
31
|
+
].join('\n');
|
|
32
|
+
|
|
33
|
+
const orthogonalTmj = JSON.stringify({
|
|
34
|
+
backgroundcolor: '#ff8800',
|
|
35
|
+
height: 2,
|
|
36
|
+
infinite: false,
|
|
37
|
+
layers: [
|
|
38
|
+
{
|
|
39
|
+
data: [1, 5, 2147483649, 6],
|
|
40
|
+
height: 2,
|
|
41
|
+
id: 1,
|
|
42
|
+
name: 'ground',
|
|
43
|
+
opacity: 1,
|
|
44
|
+
type: 'tilelayer',
|
|
45
|
+
visible: true,
|
|
46
|
+
width: 2,
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
id: 2,
|
|
50
|
+
name: 'things',
|
|
51
|
+
objects: [
|
|
52
|
+
{ id: 1, name: 'spawn', point: true, type: 'marker', x: 16, y: 16 },
|
|
53
|
+
{ height: 32, id: 2, name: 'wall', type: 'collider', width: 32, x: 0, y: 0 },
|
|
54
|
+
],
|
|
55
|
+
opacity: 1,
|
|
56
|
+
type: 'objectgroup',
|
|
57
|
+
visible: true,
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
orientation: 'orthogonal',
|
|
61
|
+
properties: [
|
|
62
|
+
{ name: 'author', type: 'string', value: 'flight' },
|
|
63
|
+
{ name: 'level', type: 'int', value: 3 },
|
|
64
|
+
{ name: 'hard', type: 'bool', value: true },
|
|
65
|
+
],
|
|
66
|
+
renderorder: 'right-down',
|
|
67
|
+
tiledversion: '1.10.2',
|
|
68
|
+
tileheight: 32,
|
|
69
|
+
tilesets: [
|
|
70
|
+
{
|
|
71
|
+
columns: 2,
|
|
72
|
+
firstgid: 1,
|
|
73
|
+
image: 'a.png',
|
|
74
|
+
imageheight: 64,
|
|
75
|
+
imagewidth: 64,
|
|
76
|
+
name: 'A',
|
|
77
|
+
tilecount: 4,
|
|
78
|
+
tileheight: 32,
|
|
79
|
+
tiles: [],
|
|
80
|
+
tilewidth: 32,
|
|
81
|
+
},
|
|
82
|
+
{ firstgid: 5, source: 'ext.tsx' },
|
|
83
|
+
],
|
|
84
|
+
tilewidth: 32,
|
|
85
|
+
type: 'map',
|
|
86
|
+
version: '1.10',
|
|
87
|
+
width: 2,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
function base64Layer(gids: readonly number[]): string {
|
|
91
|
+
const bytes = new Uint8Array(gids.length * 4);
|
|
92
|
+
const view = new DataView(bytes.buffer);
|
|
93
|
+
gids.forEach((gid, i) => view.setUint32(i * 4, gid, true));
|
|
94
|
+
const table = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
|
95
|
+
let out = '';
|
|
96
|
+
for (let i = 0; i < bytes.length; i += 3) {
|
|
97
|
+
const b0 = bytes[i];
|
|
98
|
+
const b1 = i + 1 < bytes.length ? bytes[i + 1] : 0;
|
|
99
|
+
const b2 = i + 2 < bytes.length ? bytes[i + 2] : 0;
|
|
100
|
+
out += table[b0 >> 2] + table[((b0 & 3) << 4) | (b1 >> 4)];
|
|
101
|
+
out += i + 1 < bytes.length ? table[((b1 & 15) << 2) | (b2 >> 6)] : '=';
|
|
102
|
+
out += i + 2 < bytes.length ? table[b2 & 63] : '=';
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function tmxWithData(dataElement: string): string {
|
|
108
|
+
return `<map version="1" width="2" height="2" tilewidth="32" tileheight="32"><layer id="1" name="g" width="2" height="2">${dataElement}</layer></map>`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
describe('parseTiledTileset', () => {
|
|
112
|
+
it('parses a standalone TSX tileset with per-tile animation and properties', () => {
|
|
113
|
+
const tsx = [
|
|
114
|
+
'<tileset name="terrain" tilewidth="16" tileheight="16" tilecount="9" columns="3" spacing="1" margin="2">',
|
|
115
|
+
' <image source="terrain.png" width="50" height="50"/>',
|
|
116
|
+
' <properties><property name="solid" type="bool" value="true"/></properties>',
|
|
117
|
+
' <tile id="0" type="water">',
|
|
118
|
+
' <animation><frame tileid="0" duration="100"/><frame tileid="1" duration="120"/></animation>',
|
|
119
|
+
' </tile>',
|
|
120
|
+
'</tileset>',
|
|
121
|
+
].join('\n');
|
|
122
|
+
const tileset = parseTiledTileset(tsx)!;
|
|
123
|
+
expect(tileset.name).toBe('terrain');
|
|
124
|
+
expect(tileset.image).toBe('terrain.png');
|
|
125
|
+
expect(tileset.imageWidth).toBe(50);
|
|
126
|
+
expect(tileset.columns).toBe(3);
|
|
127
|
+
expect(tileset.spacing).toBe(1);
|
|
128
|
+
expect(tileset.margin).toBe(2);
|
|
129
|
+
expect(tileset.properties).toEqual([{ name: 'solid', type: 'bool', value: true }]);
|
|
130
|
+
expect(tileset.tiles).toHaveLength(1);
|
|
131
|
+
expect(tileset.tiles[0].type).toBe('water');
|
|
132
|
+
expect(tileset.tiles[0].animation).toEqual([
|
|
133
|
+
{ duration: 100, tileId: 0 },
|
|
134
|
+
{ duration: 120, tileId: 1 },
|
|
135
|
+
]);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('returns null when the root is not a tileset', () => {
|
|
139
|
+
expect(parseTiledTileset('<map/>')).toBeNull();
|
|
140
|
+
expect(parseTiledTileset('not xml')).toBeNull();
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe('parseTiledTmx', () => {
|
|
145
|
+
it('parses map metadata, orientation, and background color', () => {
|
|
146
|
+
const map = parseTiledTmx(orthogonalTmx)!;
|
|
147
|
+
expect(map.orientation).toBe('orthogonal');
|
|
148
|
+
expect(map.renderOrder).toBe('right-down');
|
|
149
|
+
expect(map.width).toBe(2);
|
|
150
|
+
expect(map.height).toBe(2);
|
|
151
|
+
expect(map.tileWidth).toBe(32);
|
|
152
|
+
expect(map.infinite).toBe(false);
|
|
153
|
+
expect(map.version).toBe('1.10');
|
|
154
|
+
expect(map.tiledVersion).toBe('1.10.2');
|
|
155
|
+
expect(map.backgroundColor).toBe(0xff8800ff);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('parses typed properties', () => {
|
|
159
|
+
const map = parseTiledTmx(orthogonalTmx)!;
|
|
160
|
+
expect(map.properties).toEqual([
|
|
161
|
+
{ name: 'author', type: 'string', value: 'flight' },
|
|
162
|
+
{ name: 'level', type: 'int', value: 3 },
|
|
163
|
+
{ name: 'hard', type: 'bool', value: true },
|
|
164
|
+
]);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('parses embedded and external tileset refs', () => {
|
|
168
|
+
const map = parseTiledTmx(orthogonalTmx)!;
|
|
169
|
+
expect(map.tilesets).toHaveLength(2);
|
|
170
|
+
expect(map.tilesets[0].firstGid).toBe(1);
|
|
171
|
+
expect(map.tilesets[0].source).toBeNull();
|
|
172
|
+
expect(map.tilesets[0].tileset?.name).toBe('A');
|
|
173
|
+
expect(map.tilesets[0].tileset?.image).toBe('a.png');
|
|
174
|
+
expect(map.tilesets[1].firstGid).toBe(5);
|
|
175
|
+
expect(map.tilesets[1].source).toBe('ext.tsx');
|
|
176
|
+
expect(map.tilesets[1].tileset).toBeNull();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('preserves raw GIDs including the flip bit', () => {
|
|
180
|
+
const map = parseTiledTmx(orthogonalTmx)!;
|
|
181
|
+
const layer = map.layers[0];
|
|
182
|
+
expect(layer.type).toBe('tilelayer');
|
|
183
|
+
if (layer.type !== 'tilelayer') return;
|
|
184
|
+
expect(Array.from(layer.data)).toEqual([1, 5, 2147483649, 6]);
|
|
185
|
+
expect(decodeTiledGid(layer.data[2])).toMatchObject({ flipHorizontal: true, tileId: 1 });
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('parses object groups with point and rectangle objects', () => {
|
|
189
|
+
const map = parseTiledTmx(orthogonalTmx)!;
|
|
190
|
+
const layer = map.layers[1];
|
|
191
|
+
expect(layer.type).toBe('objectgroup');
|
|
192
|
+
if (layer.type !== 'objectgroup') return;
|
|
193
|
+
expect(layer.objects).toHaveLength(2);
|
|
194
|
+
expect(layer.objects[0]).toMatchObject({ name: 'spawn', point: true, type: 'marker', x: 16, y: 16 });
|
|
195
|
+
expect(layer.objects[1]).toMatchObject({ ellipse: false, height: 32, name: 'wall', point: false, width: 32 });
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('decodes base64 and csv layer data to the same GIDs', () => {
|
|
199
|
+
const csv = parseTiledTmx(tmxWithData('<data encoding="csv">1,5,2147483649,6</data>'))!;
|
|
200
|
+
const b64 = parseTiledTmx(tmxWithData(`<data encoding="base64">${base64Layer([1, 5, 2147483649, 6])}</data>`))!;
|
|
201
|
+
const csvLayer = csv.layers[0];
|
|
202
|
+
const b64Layer = b64.layers[0];
|
|
203
|
+
if (csvLayer.type !== 'tilelayer' || b64Layer.type !== 'tilelayer') throw new Error('expected tile layers');
|
|
204
|
+
expect(Array.from(b64Layer.data)).toEqual(Array.from(csvLayer.data));
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('preserves a compressed layer as an empty grid without an inflate seam, and decodes with one', () => {
|
|
208
|
+
const payload = base64Layer([1, 5, 2147483649, 6]);
|
|
209
|
+
const tmx = tmxWithData(`<data encoding="base64" compression="gzip">${payload}</data>`);
|
|
210
|
+
|
|
211
|
+
const dropped = parseTiledTmx(tmx)!;
|
|
212
|
+
const droppedLayer = dropped.layers[0];
|
|
213
|
+
if (droppedLayer.type !== 'tilelayer') throw new Error('expected a tile layer');
|
|
214
|
+
expect(Array.from(droppedLayer.data)).toEqual([0, 0, 0, 0]);
|
|
215
|
+
|
|
216
|
+
const options: TiledParseOptions = { inflate: (bytes) => new Uint8Array(bytes) };
|
|
217
|
+
const decoded = parseTiledTmx(tmx, options)!;
|
|
218
|
+
const decodedLayer = decoded.layers[0];
|
|
219
|
+
if (decodedLayer.type !== 'tilelayer') throw new Error('expected a tile layer');
|
|
220
|
+
expect(Array.from(decodedLayer.data)).toEqual([1, 5, 2147483649, 6]);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('parses to a document equivalent to the TMJ form', () => {
|
|
224
|
+
expect(parseTiledTmx(orthogonalTmx)).toEqual(parseTiledTmj(orthogonalTmj));
|
|
225
|
+
// Cross-check that the TSJ/TSX embedded tileset shapes also match.
|
|
226
|
+
expect(
|
|
227
|
+
parseTiledTilesetJson(
|
|
228
|
+
'{"name":"A","tilewidth":32,"tileheight":32,"tilecount":4,"columns":2,"image":"a.png","imagewidth":64,"imageheight":64,"tiles":[]}',
|
|
229
|
+
),
|
|
230
|
+
).toEqual(
|
|
231
|
+
parseTiledTileset(
|
|
232
|
+
'<tileset name="A" tilewidth="32" tileheight="32" tilecount="4" columns="2"><image source="a.png" width="64" height="64"/></tileset>',
|
|
233
|
+
),
|
|
234
|
+
);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('returns null for malformed input', () => {
|
|
238
|
+
expect(parseTiledTmx('<foo/>')).toBeNull();
|
|
239
|
+
expect(parseTiledTmx('garbage')).toBeNull();
|
|
240
|
+
});
|
|
241
|
+
});
|