iigs-game-engine/tools/tiled2iigs.js

537 lines
19 KiB
JavaScript

/**
* Read an exported Tiled project in JSON format and produce Merlin32 output files with
* GTE-compatible setup code wrapped around it.
*/
const fs = require('fs');
const path = require('path');
const process = require('process');
const StringBuilder = require('string-builder');
const parser = require('xml2json');
const png2iigs = require('./png2iigs');
// Global constants
const GTE_PRIORITY_BIT = 0x4000;
const GTE_MASK_BIT = 0x1000;
const GTE_DYN_BIT = 0x0800;
const GTE_VFLIP_BIT = 0x0400;
const GTE_HFLIP_BIT = 0x0200;
const TILED_VFLIP_BIT = 0x40000000;
const TILED_HFLIP_BIT = 0x80000000;
const TILED_DFLIP_BIT = 0x20000000;
main(process.argv.slice(2)).then(
() => process.exit(0),
(e) => {
console.error(e);
process.exit(1);
}
);
function toHex(h, width=4) {
return h.toString(16).padStart(width, '0');
}
function hexToRbg(hex) {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result ? [parseInt(result[1], 16), parseInt(result[2], 16), parseInt(result[3], 16)] : null;
}
async function readTileSet(workdir, tileset) {
// Load up the PNG image
const imageSource = GLOBALS.options.tilesetImage || tileset.image.source;
const pngfile = path.resolve(path.join(workdir, imageSource));
console.log(`Reading PNG file from ${pngfile}`);
const png = await png2iigs.readPNG(pngfile);
// Find the index of the transparent color (if defined)
console.log(`Looking for transparency...`);
let transparentIndex = -1;
if (tileset.image.trans) {
const color = hexToRbg(tileset.image.trans);
console.log(`Found color ${color} as transparent marker`);
[transparentIndex] = png2iigs.findColorIndex(GLOBALS.options, png, color);
if (typeof transparentIndex !== 'number') {
console.log('Could not find color in palette');
console.log(png.palette);
transparentIndex = -1;
} else {
console.log(`Transparent color palette index is ${transparentIndex}`);
}
}
GLOBALS.options.transparentIndex = transparentIndex
console.log(`Converting PNG to IIgs bitmap format...`);
const [buff, mask] = png2iigs.pngToIIgsBuff(GLOBALS.options, png);
console.log(`Mapping source and target palettes`);
const { paletteMap } = png2iigs.getPaletteMap(GLOBALS.options, png);
GLOBALS.options.paletteMap = paletteMap;
console.log(`Building tiles...`);
const tiles = png2iigs.buildTiles(GLOBALS.options, buff, mask, png.width / 2, transparentIndex);
// Return the tiles
return tiles;
}
function emitHeader() {
const sb = new StringBuilder();
sb.appendLine('; Tiled Map Export');
sb.appendLine(';');
sb.appendLine('; This is a generated file. Do not modify.');
return sb.toString();
}
async function loadTileset(workdir, tileset) {
const source = tileset.source;
const filename = path.isAbsolute(source) ? source : path.join(workdir, source);
const contents = fs.readFileSync(filename);
return JSON.parse(parser.toJson(contents));
}
function getArg(argv, arg, fn, defaultValue) {
for (let i = 0; i < argv.length; i += 1) {
if (argv[i] === arg) {
if (fn) {
return fn(argv[i+1]);
}
return true; // Return true if the argument was found
}
}
return defaultValue;
}
function writeTileAnimations(filename, animations) {
const init = new StringBuilder();
const scripts = new StringBuilder();
// First step is some initialization code that copies the first animated
// tile data into the dynamic tile space
const initLabel = 'TileAnimInit';
init.appendLine(`${initLabel}`);
init.appendLine();
for (const animation of animations) {
// Get the first tile of the animation
const firstTileId = animation.frames[0].tileId;
// Create code to copy it into the dynamic tile index location
init.appendLine(' pea ' + firstTileId);
init.appendLine(' pea ' + animation.dynTileId);
init.appendLine(' _GTECopyTileToDynamic');
}
// Next, create the scripts to change the tile data based on the configured ticks delays.
for (const animation of animations) {
// Get the animation frames
const frames = animation.frames;
// Look at the frames and get the number of ticks. We only support a uniform animation period.
const numTicks = frames.map(f => f.ticks).reduce((x, y) => Math.min(x, y),Infinity);
if (frames.some(f => f.ticks !== numTicks)) {
console.warn(`Animated tiles must have a uniform animation delay. Setting ticks to ${numTicks}`);
}
const label = `TileAnim_${animation.tileId}`;
init.appendLine(` pea ${numTicks}`);
init.appendLine(` pea ^${label}`);
init.appendLine(` pea ${label}`);
init.appendLine(` _GTEStartScript`);
// bit 15 = 1 if the end of a sequence
// bit 14 = 0 proceed to next action, 1 jump
// bit 13 = 0 (Reserved)
// bit 12 = 0 (Reserved)
// bit 11 - 8 = signed jump displacement F = -1, E = -2, D = -3, C = -4, B = -5, A = -6, 9 = -7, 8 = -8, 7 = 7, 6 = 6, ....
// bit 8 - 0 = command number
const YIELD = 0x8000;
const JUMP = 0x4000;
const SET_DYN_TILE = 0x0006; // Command number
scripts.appendLine(label);
const lastValidIndex = frames.length - 1;
for (let i = 0; i < frames.length ; i += 1) {
const isLast = (i === lastValidIndex);
let command = YIELD | SET_DYN_TILE;
if (isLast) {
command |= JUMP;
const offset = ((0x0010 - lastValidIndex) & 0x000F) * 256;
command |= offset;
}
command = '$' + toHex(command, 4);
// scripts.appendLine(` ScriptStep #${command};#${frames[i].tileId};#${animation.dynTileId};#0`);
scripts.appendLine(` dw ${command},${frames[i].tileId},${animation.dynTileId},0`);
}
}
init.appendLine(' rts');
fs.writeFileSync(filename, init.toString() + scripts.toString());
}
function writeTiles(filename, tiles) {
const tileSource = png2iigs.buildMerlinCodeForTiles(GLOBALS.options, tiles);
fs.writeFileSync(filename, tileSource);
}
function findAnimatedTiles(tileset) {
const animations = [];
let dynTileId = 0;
if (tileset.tile) {
for (const tile of tileset.tile) {
if (!tile.animation) {
continue;
}
console.log(tile);
const tileId = parseInt(tile.id, 10);
let frame = tile.animation.frame;
if (!frame.length) {
frame = [frame];
}
const frames = frame.map(f => {
const millis = parseInt(f.duration, 10);
const ticksPerMillis = 60. / 1000.;
return {
tileId: parseInt(f.tileid, 10) + 1, // The IDs in the XML file appear to be zero-based. The JSON files appear to be one-based
ticks: Math.round(millis * ticksPerMillis),
millis
};
});
animations.push({
tileId,
dynTileId,
frames
});
dynTileId += 1;
if (dynTileId > 31) {
console.warn('Only 32 animated tiles are supported');
break;
}
}
}
return animations;
}
// Global reference object
let GLOBALS = {
options: {
startIndex: 0,
asTileData: true,
maxTiles: 360,
transparentColor: 'FF00FF',
backgroundColor: '6B8CFF'
}
};
/**
* Command line arguments
*
* --output-dir : sets the output folder to write all assets into
* --force-masked : sets the masked flag on the BG0 map data, event if a BG1 layer is not present. Useful if manually locaing a second background.
* --empty-tile : designates a specific tile as the empty (background) tile
* --no-gen-tiles : do not try and create the tile set
*/
async function main(argv) {
// Read in the JSON data
const fullpath = path.resolve(argv[0]);
const workdir = path.dirname(fullpath);
const outdir = getArg(argv, '--output-dir', x => x, workdir);
const forceMasked = getArg(argv, '--force-masked', x => true, false);
const noGenTiles = getArg(argv, '--no-gen-tiles', x => true, false);
const emptyTile = getArg(argv, '--empty-tile', x => parseInt(x, 10), -1);
const tileSet = getArg(argv, '--tile-set', x => x, null);
console.log(`Reading Tiled JSON file from ${fullpath}`);
const raw = fs.readFileSync(fullpath);
console.log(`Parsing JSON file...`);
const doc = JSON.parse(raw);
// Make sure it's a map format we can handle
if (doc.infinite) {
throw new Error('Cannot import infinite maps.');
}
// Require 8x8 tiles
if (doc.tileheight !== 8 || doc.tilewidth !== 8) {
throw new Error('Only 8x8 tiles are supported');
}
// The total map size must be less than 32768 tiles because we limit the map to one data bank
// and the tiles are stored in GTE as 16-bit values.
if (doc.height * doc.width >= 32768) {
throw new Error('The tile map must have less than 32,768 tiles');
}
// Look at the tile layers. We support a maximum of two tile layers.
const tileLayers = doc.layers.filter(l => l.type === 'tilelayer');
if (tileLayers.length === 0) {
throw new Error('There must be at least one tile layer defined for the map');
}
if (tileLayers.length > 2) {
throw new Error('The map cannot have more than two tile layers');
}
// Sort the tile layers by ID. The lower ID is considered to be the "front" layer
tileLayers.sort((first, second) => first.id - second.id);
// Look for an object layer where miscellaneous flags are stored
const objectLayers = doc.layers.filter(l => l.type === 'objectgroup');
if (objectLayers.length > 1) {
throw new Error('Only one object layer is supported');
}
// Load up any/all tilesets
const tileSets = await Promise.all(doc.tilesets.map(tileset => loadTileset(workdir, tileset)));
if (tileSets.length === 1 && tileSet !== null) {
tileSets[0].tileset.image.source = tileSet;
}
// Create a global reference object
GLOBALS = {
...GLOBALS,
outdir,
forceMasked,
noGenTiles,
emptyTile,
tileSets,
tileLayers
};
// Save all of the tilesets
let bg0TileSet = null;
for (const record of tileSets) {
console.log('Looking for animated tiles...');
const animations = findAnimatedTiles(record.tileset);
console.log(`Importing tileset "${record.tileset.name}"`);
const tiles = await readTileSet(workdir, record.tileset);
if (!GLOBALS.noGenTiles) {
const outputFilename = path.resolve(path.join(outdir, record.tileset.name + '.s'));
console.log(`Writing tiles to ${outputFilename}`);
writeTiles(outputFilename, tiles);
console.log(`Writing complete`);
}
// Look for tiles with animation sequences. If found, this information need to be propagated
// to the tilemap export to mark those tile IDs as Dynamic Tiles.
//
// Exporting the "animations" actually creates two code stubs; one to copy the first
// tile of the animation into the dynamic tile space during initialization and a second
// that created the timer callbacks that replace the tile data based on the time animation
// rate. We only have a VBL timer, so the animation time is rounded to the nearest
// 1/60 of a second.
if (animations.length > 0) {
console.log('Writing tile animation ');
const animationFilename = path.resolve(path.join(outdir, record.tileset.name + 'Anim.s'));
writeTileAnimations(animationFilename, animations);
console.log(`Writing complete`);
// Modify the entries in the tileset that are animated
for (const animation of animations) {
tiles[animation.tileId].animation = animation;
}
}
bg0TileSet = tiles;
}
// Convert the Tiled data to a plain 2D array
const bg0layer = tileLayers[0];
const bg0data = buildLayerData(bg0layer, bg0TileSet);
// If there is an object layer, apply it to BG0
if (objectLayers.length > 0) {
applyObjectLayerToBG0(objectLayers[0], bg0data);
}
// Write out the source code for the layers
console.log('Generating data for front layer (BG0): ' + bg0layer.name);
const header = emitHeader();
const bg0 = emitBG0Layer(bg0layer, bg0data);
const bg0OutputFilename = path.resolve(path.join(outdir, bg0layer.name + '.s'));
console.log(`Writing BG0 data to ${bg0OutputFilename}`);
fs.writeFileSync(bg0OutputFilename, header + '\n' + bg0);
console.log(`Writing complete`);
if (tileLayers.length > 1) {
console.log('Generating data for back layer (BG1): ' + tileLayers[1].name);
const bg1 = emitBG1Layer(tileLayers[1], bg0TileSet);
const bg1OutputFilename = path.resolve(path.join(outdir, tileLayers[1].name + '.s'));
console.log(`Writing BG1 data to ${bg1OutputFilename}`);
fs.writeFileSync(bg1OutputFilename, header + '\n' + bg1);
console.log(`Writing complete`);
}
}
function isPriorityObject(obj) {
return obj && obj.properties && obj.properties.some(p => p.name === "Priority" && p.value === true);
}
function applyObjectLayerToBG0(objectLayer, bg0data) {
// Find any objects that mark priority tile areas
const priorityObjects = objectLayer.objects.filter(o => isPriorityObject(o));
console.log(`Found ${priorityObjects.length} priority objects`);
for (const region of priorityObjects) {
// Convert coordinates to tile indices
const [x, y, w, h] = [region.x, region.y, region.width, region.height].map(x => Math.floor(x / 8));
console.log(`Marking tiles (${x}, ${y}) to (${x+w-1}, ${y+h-1}) as priorty tiles`);
// Mark each tile as priority
for (let j = y; j < (y + h); j += 1) {
for (let i = x; i < (x + w); i += 1) {
bg0data[j][i] |= GTE_PRIORITY_BIT;
}
}
}
}
function emitBG1Layer(layer, tileset) {
const sb = new StringBuilder();
const label = layer.name.split(' ').join('_').split('.').join('_');
const initCode = `
BG1SetUp
lda #${layer.width}
sta BG1TileMapWidth
lda #${layer.height}
sta BG1TileMapHeight
lda #${label}
sta BG1TileMapPtr
lda #^${label}
sta BG1TileMapPtr+2
rts
`;
sb.appendLine(initCode);
sb.appendLine(`${label}`);
emitLayerData(sb, layer, tileset);
return sb.toString();
}
function emitBG0Layer(layer, data) {
const sb = new StringBuilder();
const label = layer.name.split(' ').join('_').split('.').join('_');
const initCode = `
BG0SetUp
pea ${layer.width}
pea ${layer.height}
pea ^${label}
pea ${label}
_GTESetBG0TileMapInfo
rts
`;
sb.appendLine(initCode);
sb.appendLine(`${label}`);
emitLayerData(sb, layer, data);
return sb.toString();
}
// Return the raw 2D array of tile data
function buildLayerData(layer, tileset) {
const rows = [];
const tileIDs = layer.data;
for (let j = 0; j < tileIDs.length; j += layer.width) {
const src = tileIDs.slice(j, j + layer.width);
const row = src.map(tID => convertTileID(tID, tileset));
rows.push(row);
}
return rows;
}
function emitLayerData(sb, layer, rows) {
// Print out the data in groups of N
//
// Merlin32 errors out with errno 3221226505 is the line is too long (>1047 characters)
const N = 64;
// Tiled starts numbering its tiles at 1. This is OK since Tile 0 is reserved in
// GTE, also
for (const row of rows) {
for (let i = 0; i < row.length; i += N) {
const chunk = row.slice(i, i + N);
sb.appendLine(' dw ' + chunk.map(id => '$' + toHex(id, 4)).join(','));
}
sb.appendLine('');
}
return sb;
}
/**
* Map the bit flags used in Tiled to compatible values in GTE
*
* tileID is a value from the exported TileD data. It starts at index 1.
*/
function convertTileID(tileId, tileset) {
// We don't support the flipped diagonally flag or tile values greater than 511
if ((tileId & TILED_DFLIP_BIT) !== 0) {
throw new Error('Diagonally flipped bits are not supported: tileId = ' + tileId.toString(16));
}
const hflip = (tileId & TILED_HFLIP_BIT) !== 0;
const vflip = (tileId & TILED_VFLIP_BIT) !== 0;
// Mask out the flip bits
const tileIndex = tileId & 0x1FFFFFFF;
if (tileIndex >= 512) {
throw new Error('A maximum of 511 tiles are supported');
}
if (tileIndex === 0) {
// This should be a warning
return 0;
}
// The tileId starts at one, but the tile set starts at zero. It's ok when we export,
// because a special zero tile is inserted, but we have to manually adjust here
if (!tileset[tileIndex - 1]) {
throw new Error(`Tileset for tileId ${tileIndex} is underinfed`);
}
const mask_bit = (!tileset[tileIndex - 1].isSolid || tileIndex === GLOBALS.emptyTile) && ((GLOBALS.tileLayers.length !== 1) || GLOBALS.forceMasked);
/*
if (tileIndex === 48) {
console.warn('isSolid: ', tileset[tileIndex - 1].isSolid);
console.warn('GLOBALS.emptyTile: ', GLOBALS.emptyTile);
console.warn('GLOBALS.tileLayers.length: ', GLOBALS.tileLayers.length);
console.warn('GLOBALS.forceMasked: ', GLOBALS.forceMasked);
console.warn('mask_bit: ', mask_bit);
}
*/
// Build up a partial set of control bits
let control_bits = (mask_bit ? GTE_MASK_BIT : 0) + (hflip ? GTE_HFLIP_BIT : 0) + (vflip ? GTE_VFLIP_BIT : 0);
// Check if this is an animated tile. If so, substitute the index of the animation slot for
// the tile ID
if (tileset[tileIndex - 1].animation) {
const animation = tileset[tileIndex - 1].animation;
tileId = animation.dynTileId * 4; // pre-map the ID -> byte offset
control_bits = GTE_DYN_BIT;
console.warn('Dyanmic animation tile found!');
console.warn('isSolid: ', tileset[tileIndex - 1].isSolid);
console.warn('dynTileId: ', animation.dynTileId);
console.warn('mask_bit: ', mask_bit);
}
return (tileId & 0x1FFFFFFF) + control_bits;
}