UNPKG

jrpg-stellar-engine

Version:
640 lines (548 loc) 22.9 kB
/** * Loads .tmx Tiled Editor level file * https://doc.mapeditor.org/en/stable/reference/tmx-map-format/ */ class LoaderTMX { /** * TMX loader constructor * @param args.tilesets - optional tilesets dict */ constructor(args = {}) { // Tilesets this.tilesets = 'tilesets' in args ? args.tilesets : null; // Loaders this.loader = { tsx: new LoaderTSX() }; } /** * Load and parse level * @param args.url: string - fetch url * @param args.scale: int - scale for this level (default 1) * @param args.view: Object - view reference * @param args.prefetch: bool - prefetch .acx & .tsx resources */ async loadLevel(args) { const { url = null, scale = 1, prefetch = true } = args; const file = await fetch(url); const text = await file.text(); const level = await this.parseLevel({ xml: text, url, scale, prefetch, view: args.view }); return level; } /** * Parse .tmx xml * @param args.xml: string - xml to parse * @param args.url: string - map's file url * @param args.scale: int - scale for this level (default 1) * @param args.view: Object - view reference * @param args.prefetch: bool - prefetch .acx & .tsx resources */ async parseLevel(args) { const { xml = null, url = null, scale = 1, prefetch = true } = args; // Parse XML const parser = new DOMParser(); const doc = parser.parseFromString(xml, 'application/xml'); // Create Level instance to return to const level = new Level({ view: args.view }); // Scale level.scale = scale; // Parse global level properties level.properties = parseProperties(doc.querySelector('map > properties')); // Root <map> const root = doc.querySelector('map'); // Tiles size level.tile.w = parseInt(root.getAttribute('tilewidth')); level.tile.h = parseInt(root.getAttribute('tileheight')); // Resources list const resources = await this.fetchResources(root, url, scale, prefetch); // Parse for (const node of root.childNodes) { if (node.nodeType === Node.ELEMENT_NODE) { switch (node.nodeName) { // Tileset case 'tileset': this.parseTileSet(level, resources, node); break; // Image case 'imagelayer': this.parseImageLayer(level, url, node); break; // Layer case 'layer': this.parseLayer(level, node); break; // Objects case 'objectgroup': this.parseObjectGroup(level, resources, node); break; } } } return level; } /** * Init and fetch resoruces if necesssary */ async fetchResources(selector, url, scale, prefetch) { const resources = { // {'name': { url: '/url/of/file.tsx', buffer: <fetched buffer>, tileset: <TileSet object>}, ...} tsx: {}, // {'/url/of/file.acx': <fetched buffer>, ...} acx: {} }; // Pre-parse for fetching resources if (prefetch) { for (const node of selector.childNodes) { if (node.nodeType === Node.ELEMENT_NODE) { // Tileset if (node.nodeName == 'tileset') { const tilesetName = node.getAttribute('source'); resources.tsx[tilesetName] = {url: resolvePath(url, tilesetName), buffer: null, tileset: null}; } // Objects layer else if (node.nodeName == 'objectgroup') { node.querySelectorAll('object').forEach(obj => { const name = obj.getAttribute('name')?.toLowerCase(); const type = obj.getAttribute('type')?.toLowerCase(); const properties = parseProperties(obj.querySelector('properties')); // ACX from spawn/respawn points if (type == 'spawn' || type == 'respawn') { if ('actor' in properties) { resources.acx[properties.actor] = null; } else if (name.search(':') != -1) { const [kind, uri] = name.split(':'); if (kind && uri) resources.acx[uri] = null; } } }); } } } // Fetch tsx resources const tsxPromises = Object.keys(resources.tsx).map(async name => { const tsxFile = await fetch(resources.tsx[name].url); const tsxText = await tsxFile.text(); resources.tsx[name].buffer = tsxText; resources.tsx[name].tileset = await this.loader.tsx.parseTileSet({ xml: resources.tsx[name].buffer, url: resources.tsx[name].url, preload: true, scale }); }); await Promise.all(tsxPromises); // Fetch acx resources const acxPromises = Object.keys(resources.acx).map(async url => { const acxFile = await fetch(url); const acxText = await acxFile.text(); resources.acx[url] = acxText; }); await Promise.all(acxPromises); } return resources; } /** * Parse <tileset firstgid="1" source="foo.tsx"/> */ parseTileSet(level, resources, node) { const tilesetName = node.getAttribute('source'); const tilesetFirst = parseInt(node.getAttribute('firstgid')); if (tilesetName && tilesetFirst) { // Tilesets passed in contructor if (this.tilesets) { level.tilesets[tilesetName] = {ref: this.tilesets[tilesetName], first: tilesetFirst}; } // Tilesets prefetched in resources else { level.tilesets[tilesetName] = { ref: resources.tsx[tilesetName].tileset, first: tilesetFirst }; } } } /** * Parse <imagelayer id="1" name="foo" locked="1" offsetx="-512" offsety="-512" repeatx="1" repeaty="1"> */ parseImageLayer(level, url, node) { const imageName = node.getAttribute('name'); const imageOffsetX = node.hasAttribute('offsetx') ? parseInt(node.getAttribute('offsetx')) : 0; const imageOffsetY = node.hasAttribute('offsety') ? parseInt(node.getAttribute('offsety')) : 0; const imageRepeatX = node.hasAttribute('repeatx') ? parseInt(node.getAttribute('repeatx')) : 0; const imageRepeatY = node.hasAttribute('repeaty') ? parseInt(node.getAttribute('repeaty')) : 0; const imageParallaxX = node.hasAttribute('parallaxx') ? parseFloat(node.getAttribute('parallaxx')) : 0; const imageParallaxY = node.hasAttribute('parallaxy') ? parseFloat(node.getAttribute('parallaxy')) : 0; const nodeImage = node.querySelector('image'); const imageSource = nodeImage.getAttribute('source'); const imageWidth = parseInt(nodeImage.getAttribute('width')); const imageHeight = parseInt(nodeImage.getAttribute('height')); if (imageName && imageSource) { const layer = { 'name': imageName, 'class': 'image', 'src': null, 'x': imageOffsetX, 'y': imageOffsetY, 'w': imageWidth, 'h': imageHeight, 'repeat': { 'x': imageRepeatX, 'y': imageRepeatY }, 'parallax': { 'x': imageParallaxX, 'y': imageParallaxY }, 'coordinates': 'world' }; // Load from html resource if (imageSource.startsWith('#')) { layer.src = document.querySelector(imageSource); } // Load from file else { const img = new Image(); img.src = url ? resolvePath(url, imageSource) : imageSource; layer.src = img; } if (layer.src) level.layers.push(layer); } } /** * Parse <layer id="1" name="foo" width="10" height="20" locked="1"> */ parseLayer(level, node) { const name = node.getAttribute('name').toLowerCase(); const cl = node.hasAttribute('class') ? node.getAttribute('class').toLowerCase() : 'tiles'; const data = node.querySelector('data'); const offsetX = node.hasAttribute('offsetx') ? node.getAttribute('offsetx').toLowerCase() : null; const offsetY = node.hasAttribute('offsety') ? node.getAttribute('offsety').toLowerCase() : null; if (data && !name.trim().startsWith('.')) { const arrayContent = data.textContent.split(',').map(Number); const indexes = create2DArray(arrayContent, parseInt(node.getAttribute('width'))); const layer = { 'name': name, 'class': cl, 'offset': {x: 0, y: 0}, 'map': indexes }; if (offsetX !== null) layer.offset.x = offsetX; if (offsetY !== null) layer.offset.y = offsetY; level.layers.push(layer); // Update map boundaries const layerBounds = this.calculateBounds(indexes, level.tile.w * level.scale, layer.offset.x, layer.offset.y); if (level.bounds.left > layerBounds.left) level.bounds.left = layerBounds.left; if (level.bounds.right < layerBounds.right) level.bounds.right = layerBounds.right; if (level.bounds.top > layerBounds.top) level.bounds.top = layerBounds.top; if (level.bounds.bottom < layerBounds.bottom) level.bounds.bottom = layerBounds.bottom; } } /** * Parse <objectgroup id="1" name="Objects" class="objects" locked="1"> */ parseObjectGroup(level, resources, node) { const layerName = node.getAttribute('name').toLowerCase().trim(); const layerClass = node.hasAttribute('class') ? node.getAttribute('class').toLowerCase().trim() : 'objects'; const layer = { 'name': layerName, 'class': layerClass === '' ? 'objects' : layerClass, 'actors': [] } level.layers.push(layer); node.querySelectorAll('object').forEach(obj => { // Parse attributes const name = obj.getAttribute('name')?.toLowerCase(); const type = obj.getAttribute('type')?.toLowerCase(); const x = parseFloat(obj.getAttribute('x')) * level.scale; const y = parseFloat(obj.getAttribute('y')) * level.scale; const w = obj.hasAttribute('width') ? parseFloat(obj.getAttribute('width')) * level.scale : 0; const h = obj.hasAttribute('height') ? parseFloat(obj.getAttribute('height')) * level.scale : 0; const visible = obj.hasAttribute('visible') ? parseInt(obj.getAttribute('visible')) : 1; // Parse properties const properties = parseProperties(obj.querySelector('properties')); if (visible == 1) { // Spawn point (direct) if (type == 'spawn') { this.parseObjectSpawn(level, layer, resources, name, x, y, w, h, properties); } // Respawn point (random repeatable spawn) else if (type == 'respawn') { this.parseObjectRespawn(level, layer, resources, name, x, y, w, h, properties); } // Stairs else if (type == 'stairs') { this.parseObjectStairs(level, obj, x, y); } // Portals else if (type == 'portal') { this.parseObjectPortal(level, obj, properties); } // Masks else if (type == 'mask') { this.parseObjectMask(level, obj); } // Shapes else if (type == 'shape') { this.parseObjectShape(level, obj, properties); } // Texts else if (type == 'text') { this.parseObjectText(level, obj); } // Custom else { layer.actors.push(this.parseObjectCustom(level, obj, properties)); } } }); } /** * Parse <object id="1" name="foo" type="Spawn" x="10" y="20"> */ async parseObjectSpawn(level, layer, resources, name, x, y, w, h, properties) { // Add point to list of spawnpoints if (!(name in level.spawnpoints)) level.spawnpoints[name] = []; level.spawnpoints[name].push(new SpawnPoint({x, y, w, h})); // Determine URI let kindName = ''; let uri = null; if ('actor' in properties) { kindName = ('type' in properties) ? properties.type : 'actor'; uri = properties.actor; } else if (name.search(':') != -1) { const twin = name.split(':'); kindName = twin[0].toLowerCase(); uri = twin[1]; } // Actual spawn if (uri) { for (let n = 0; n < (properties.number || 1); n++) { await level.spawn({ x, y, w, h, type: kindName, layer: layer.name, actor: { xml: (uri in resources.acx) ? resources.acx[uri] : document.getElementById(uri).innerText, properties, scale: level.scale } }); } } } /** * Parse <object id="1" name="foo" type="Respawn" x="10" y="20"> */ parseObjectRespawn(level, layer, resources, name, x, y, w, h, properties) { // Add point to list of spawnpoints if (!(name in level.spawnpoints)) level.spawnpoints[name] = []; // Determine URI let kindName = ''; let uri = null; if ('actor' in properties) { kindName = ('type' in properties) ? properties.type : 'actor'; uri = properties.actor; } else if (name.search(':') != -1) { const twin = name.split(':'); kindName = twin[0].toLowerCase(); uri = twin[1]; } // Add respawn info if (uri) { level.respawnpoints.push(new RespawnPoint({ x, y, w, h, type: kindName, layer: layer.name, range: parseIntRange(properties._number), delay: parseIntRange(properties._delay), actor: { xml: (uri in resources.acx) ? resources.acx[uri] : document.getElementById(uri).innerText, properties, scale: level.scale } })); } } /** * Parse <object id="1" name="foo" type="Stairs" x="10" y="20"> */ parseObjectStairs(level, node, x, y) { const points = node.querySelector('polygon')?.getAttribute('points')?.split(' '); const [x1, y1] = points[0].split(','); const [x2, y2] = points[1].split(','); const [x3, y3] = points[2].split(','); const [x4, y4] = points[3].split(','); level.stairs.push(new Stairs({ x1: x + (parseFloat(x1) * level.scale), y1: y + (parseFloat(y1) * level.scale), x2: x + (parseFloat(x2) * level.scale), y2: y + (parseFloat(y2) * level.scale), x3: x + (parseFloat(x3) * level.scale), y3: y + (parseFloat(y3) * level.scale), x4: x + (parseFloat(x4) * level.scale), y4: y + (parseFloat(y4) * level.scale) })); } /** * Parse <object id="1" name="foo" type="Portal" x="10" y="20"> */ parseObjectPortal(level, node, properties) { const name = node.getAttribute('name'); const [mapOld, spawnOld] = name.split(':'); let map = ('map' in properties) ? properties.map : mapOld; let spawn = ('spawn' in properties) ? properties.spawn : spawnOld; const x = parseFloat(node.getAttribute('x')) * level.scale; const y = parseFloat(node.getAttribute('y')) * level.scale; const w = parseFloat(node.getAttribute('width')) * level.scale; const h = parseFloat(node.getAttribute('height')) * level.scale; level.portals.push(new Portal({ map: map.trim(), spawn: spawn.trim(), left: x, top: y, right: x + w, bottom: y + h })); } /** * Parse <object id="1" name="foo" type="Mask" x="10" y="20"> */ parseObjectMask(level, node) { const name = node.getAttribute('name'); const x = parseFloat(node.getAttribute('x')) * level.scale; const y = parseFloat(node.getAttribute('y')) * level.scale; const w = parseFloat(node.getAttribute('width')) * level.scale; const h = parseFloat(node.getAttribute('height')) * level.scale; level.masks.push({ name: name.trim(), x: x + (w / 2), y: y + (h / 2), radius: w / 2, left: x, top: y, right: x + w, bottom: y + h }); } /** * Parse <object id="1" name="foo" type="Shape" x="10" y="20"> <polyline points="1.0,2.0 3.0,4.0"/> </object> */ parseObjectShape(level, node, properties) { const name = node.getAttribute('name'); const x = parseFloat(node.getAttribute('x')) * level.scale; const y = parseFloat(node.getAttribute('y')) * level.scale; const points = node.querySelector('polyline')?.getAttribute('points')?.split(' ').map(pair => { const [x, y] = pair.split(','); return {x: parseFloat(x), y: parseFloat(y)}; }); level.shapes.push({ name: name.trim(), x, y, points, properties }); } /** * Parse <object id="1" name="foo" type="Text" x="10" y="20"> <text wrap="1" halign="center">Bar</text> </object> */ parseObjectText(level, node) { const name = node.getAttribute('name'); const x = parseFloat(node.getAttribute('x')) * level.scale; const y = parseFloat(node.getAttribute('y')) * level.scale; const w = parseFloat(node.getAttribute('width')) * level.scale; const h = parseFloat(node.getAttribute('height')) * level.scale; const cx = x + (w / 2); const cy = y + (h / 2); const textNode = node.querySelector('text'); const text = textNode.textContent.trim(); const align = textNode.getAttribute('halign'); level.texts.push({ name: name.trim(), x: cx, y: cy, align, text }); } /** * Parse <object id="1" name="foo" type="" x="10" y="20"> <point/> </object> */ parseObjectCustom(level, node, properties) { const name = node.getAttribute('name'); const x = parseFloat(node.getAttribute('x')) * level.scale; const y = parseFloat(node.getAttribute('y')) * level.scale; const w = node.hasAttribute('width') ? parseFloat(node.getAttribute('width')) * level.scale : null; const h = node.hasAttribute('height') ? parseFloat(node.getAttribute('height')) * level.scale : null; const cx = w ? x + (w / 2) : x; const cy = h ? y + (h / 2) : y; // Point const childPoint = node.querySelector('point'); if (childPoint) return { type: 'point', name, x, y, properties }; // Ellipse const childEllipse = node.querySelector('ellipse'); if (childEllipse) return { type: 'ellipse', name, x, y, w, h, cx, cy, properties }; // Unknown return {}; } /** * Calculates tile bounds in world coordinates * @param {number[][]} indexes - 2D array with tile indexes (0 means no tile) * @param {number} tileSize - Size of a tile in world units * @param {number} offsetX - X-axis offset * @param {number} offsetY - Y-axis offset * @returns {Object} Object containing tile bounds: left, right, top, bottom */ calculateBounds(indexes, tileSize, offsetX, offsetY) { // Initialize boundary values let minRow = Infinity; let maxRow = -Infinity; let minColumn = Infinity; let maxColumn = -Infinity; // Check if the array is empty if (!indexes || indexes.length === 0 || indexes[0].length === 0) { return { left: offsetX, right: offsetX, top: offsetY, bottom: offsetY }; } // Search the array for tiles (values different from 0) for (let row = 0; row < indexes.length; row++) { for (let column = 0; column < indexes[row].length; column++) { if (indexes[row][column] !== 0) { minRow = Math.min(minRow, row); maxRow = Math.max(maxRow, row); minColumn = Math.min(minColumn, column); maxColumn = Math.max(maxColumn, column); } } } // If no tiles found if (minRow === Infinity) { return { left: offsetX, right: offsetX, top: offsetY, bottom: offsetY }; } // Convert to world coordinates const left = minColumn * tileSize + offsetX; const right = (maxColumn + 1) * tileSize + offsetX; // Y coordinates (where Y grows downward) const top = minRow * tileSize + offsetY; const bottom = (maxRow + 1) * tileSize + offsetY; return { left, right, top, bottom }; } }