mirror of
https://github.com/lscharen/iigs-game-engine.git
synced 2024-12-01 08:49:41 +00:00
537 lines
19 KiB
JavaScript
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;
|
|
} |