Improve export of Tiled projects. Does level data and tileset export in one command now

This commit is contained in:
Lucas Scharenbroich 2021-08-14 20:57:00 -05:00
parent 191094e7e6
commit 44ee61a3f3
3 changed files with 428 additions and 86 deletions

44
tools/mksprite.js Normal file
View File

@ -0,0 +1,44 @@
/**
* Basic sprite compiler
*
* GTE has some specific needs that makes existing tools (like MrSprite) inappropriate. GTE
* sprites need to reference some internal data structures and have slightly difference code
* in order to handle clipping to the playfield bounds.
*
* The core sprite drawing approach is the same (set up Bank 1 direct page and stack), but
* the setup and dispatch are a bit different.
*
* A Note on Clipping
*
* GTE supports two clipping buffers for sprites to use. The first one is a static buffer
* that is aligned with the playfield and is used to clip the sprite when crossing the
* left and right boundaries, but since it's a static image, mask data can be put anywhere
* that the sprites should not show through, so irregular borders and sprite punch-outs
* on the playfield are possible.
*
* The second buffer matches the current tiles in the playfield and can be used as a
* dynamic mask of the playfield. Since the sprite code itself must use this data,
* different variations of the same sprite can be created to stand in "front" and "behind"
* different screen elements.
*
* The sprite requires the X and Y registers for this. The most general code that
* should be used for each sprite word is this:
*
* Example: DATA = $5670, MASK = $000F, screen_mask = $FF00, field_mask = $F0FF
* lda DP ; A = $1234
* eor #DATA ; A = $4444
* and #~MASK ; A = $4440
* and screen_mask,y ; A = $4400
* and >field_mask,x ; A = $4000
* eor DP ; A = $5234 <-- Only the high nibble is set to the sprite data
* sta DP
*
* It is not *required* that sprites use this approach, any compiled sprites code can be used,
* so if a sprite does not need to be masked, than any of the fast sprite approaches can be
* used.
*
* For clipping vertically, we pass in the starting and finishing lines in a register and
* a sprite record is set up to allow the sprite to be entered in the middle and exited
* before the last line of the sprite.
*/

View File

@ -2,6 +2,7 @@ const fs = require('fs').promises;
const PNG = require("pngjs").PNG;
const process = require('process');
const { Buffer } = require('buffer');
const StringBuilder = require('string-builder');
// Starting color index
let startIndex = 0;
@ -17,7 +18,7 @@ main(process.argv.slice(2)).then(
function findColorIndex(png, pixel) {
for (let i = 0; i < png.palette.length; i += 1) {
const color = png.palette[i];
const color = png.palette[i].slice(0, pixel.length); // Handle RGB or RGBA
if (color.every((c, idx) => c === pixel[idx])) {
return i + startIndex;
}
@ -104,64 +105,84 @@ function getArg(argv, arg, fn, defaultValue) {
if (fn) {
return fn(argv[i+1]);
}
return true; // REturn true if the argument was found
return true; // Return true if the argument was found
}
}
return defaultValue;
}
async function main(argv) {
const data = await fs.readFile(argv[0]);
async function readPNG(filename) {
const data = await fs.readFile(filename);
const png = PNG.sync.read(data);
startIndex = getArg(argv, '--start-index', x => parseInt(x, 10), 0);
asTileData = getArg(argv, '--as-tile-data', null, 0);
transparentColor = getArg(argv, '--transparent-color-index', x => parseInt(x, 10), 0);
console.info(`; startIndex = ${startIndex}`);
if (png.colorType !== 3) {
console.warn('; PNG must be in palette color type');
return;
throw new Error('PNG must be in palette color type');
}
if (png.palette.length > 16) {
console.warn('; Too many colors. Must be 16 or less');
return;
throw new Error(`Too many colors. Must be 16 or less. Found ${png.palette.length}`);
}
// Dump the palette in IIgs hex format
console.log('; Palette:');
const hexCodes = png.palette.map(c => '$' + paletteToIIgs(c));
console.log(';', hexCodes.join(','));
return png;
}
// Just convert a paletted PNG to IIgs memory format. We make sute that only a few widths
// are supported
let buff = null;
async function main(argv) {
try {
const png = await readPNG(argv[0]);
startIndex = getArg(argv, '--start-index', x => parseInt(x, 10), 0);
asTileData = getArg(argv, '--as-tile-data', null, 0);
if (png.width === 512) {
console.log('; Converting to BG1 format...');
buff = pngToIIgsBuff(png);
}
transparentColor = getArg(argv, '--transparent-color-index', x => parseInt(x, 10), 0);
if (png.width === 256) {
console.log('; Converting to BG1 format w/repeat...');
buff = pngToIIgsBuffRepeat(png);
}
console.info(`; startIndex = ${startIndex}`);
if (png.width === 328 || png.width == 320) {
console.log('; Converting to BG0 format...');
buff = pngToIIgsBuff(png);
}
if (buff && argv[1]) {
if (asTileData) {
writeToTileDataSource(buff, png.width / 2);
if (png.colorType !== 3) {
console.warn('; PNG must be in palette color type');
return;
}
else {
console.log(`; Writing to output file ${argv[1]}`);
await writeBinayOutput(argv[1], buff);
if (png.palette.length > 16) {
console.warn('; Too many colors. Must be 16 or less');
return;
}
// Dump the palette in IIgs hex format
console.log('; Palette:');
const hexCodes = png.palette.map(c => '$' + paletteToIIgs(c));
console.log(';', hexCodes.join(','));
// Just convert a paletted PNG to IIgs memory format. We make sure that only a few widths
// are supported
let buff = null;
if (png.width === 512) {
console.log('; Converting to BG1 format...');
buff = pngToIIgsBuff(png);
}
if (png.width === 256) {
console.log('; Converting to BG1 format w/repeat...');
buff = pngToIIgsBuffRepeat(png);
}
if (png.width === 328 || png.width == 320) {
console.log('; Converting to BG0 format...');
buff = pngToIIgsBuff(png);
}
if (buff && argv[1]) {
if (asTileData) {
writeToTileDataSource(buff, png.width / 2);
}
else {
console.log(`; Writing to output file ${argv[1]}`);
await writeBinayOutput(argv[1], buff);
}
}
} catch (e) {
console.log(`; ${e}`);
process.exit(1);
}
}
@ -169,6 +190,161 @@ function reverse(str) {
return [...str].reverse().join(''); // use [...str] instead of split as it is unicode-aware.
}
function toHex(h) {
return h.toString(16).padStart(2, '0');
}
function swap(hex) {
const high = hex & 0xF0;
const low = hex & 0x0F;
return (high >> 4) | (low << 4);
}
function toMask(hex, transparentIndex) {
if (transparentIndex === -1) {
return 0;
}
const indexHigh = (transparentIndex & 0xF) << 4;
const indexLow = (transparentIndex & 0xF);
let mask = 0;
if ((hex & 0xF0) === indexHigh) {
mask = mask | 0xF0;
}
if ((hex & 0x0F) === indexLow) {
mask = mask | 0x0F;
}
return mask;
}
/**
* Return all four 32 byte chunks of data for a single 8x8 tile
*/
function buildTile(buff, width, x, y, transparentIndex = -1) {
const tile = {
normal: {
data: [],
mask: []
},
flipped: {
data: [],
mask: []
}
};
const offset = y * width + x;
for (dy = 0; dy < 8; dy += 1) {
const hex0 = buff[offset + dy * width + 0];
const hex1 = buff[offset + dy * width + 1];
const hex2 = buff[offset + dy * width + 2];
const hex3 = buff[offset + dy * width + 3];
const data = [hex0, hex1, hex2, hex3];
const mask = data.map(h => toMask(h, transparentIndex));
tile.normal.data.push(data);
tile.normal.mask.push(mask);
}
for (dy = 0; dy < 8; dy += 1) {
const hex0 = swap(buff[offset + dy * width + 0]);
const hex1 = swap(buff[offset + dy * width + 1]);
const hex2 = swap(buff[offset + dy * width + 2]);
const hex3 = swap(buff[offset + dy * width + 3]);
const data = [hex3, hex2, hex1, hex0];
const mask = data.map(h => toMask(h, transparentIndex));
tile.flipped.data.push(data);
tile.flipped.mask.push(mask);
}
return tile;
}
function buildTiles(buff, width, transparentIndex = -1) {
const tiles = [];
const MAX_TILES = 64;
let count = 0;
for (let y = 0; ; y += 8) {
for (let x = 0; x < width; x += 4, count += 1) {
if (count >= MAX_TILES) {
return tiles;
}
const tile = buildTile(buff, width, x, y, transparentIndex);
tiles.push(tile);
}
}
}
function writeTileToStream(stream, data) {
// Output the tile data
for (const row of data) {
const hex = row.map(d => toHex(d)).join('');
stream.write(' hex ' + hex + '\n');
}
}
function writeTilesToStream(stream, tiles, label='tiledata') {
stream.write(`${label} ENT\n`);
stream.write('');
stream.write('; Reserved space (tile 0 is special...)\n');
stream.write(' ds 128\n');
const MAX_TILES = 511;
let count = 0;
for (const tile of tiles.slice(0, MAX_TILES)) {
console.log(`Writing tile ${count + 1}`);
stream.write(`; Tile ID ${count + 1}\n`);
writeTileToStream(stream, tile.normal.data);
writeTileToStream(stream, tile.normal.mask);
writeTileToStream(stream, tile.flipped.data);
writeTileToStream(stream, tile.flipped.mask);
stream.write('');
count += 1;
}
}
function buildMerlinCodeForTile(data) {
const sb = new StringBuilder();
// Output the tile data
for (const row of data) {
const hex = row.map(d => toHex(d)).join('');
sb.appendLine(' hex ' + hex);
}
return sb.toString();
}
function buildMerlinCodeForTiles(tiles, label='tiledata') {
const sb = new StringBuilder();
sb.appendLine(`${label} ENT`);
sb.appendLine();
sb.appendLine('; Reserved space (tile 0 is special...)');
sb.appendLine(' ds 128');
const MAX_TILES = 511;
let count = 0;
for (const tile of tiles.slice(0, MAX_TILES)) {
console.log(`Writing tile ${count + 1}`);
sb.appendLine(`; Tile ID ${count + 1}`);
sb.append(buildMerlinCodeForTile(tile.normal.data));
sb.append(buildMerlinCodeForTile(tile.normal.mask));
sb.append(buildMerlinCodeForTile(tile.flipped.data));
sb.append(buildMerlinCodeForTile(tile.flipped.mask));
sb.appendLine();
count += 1;
}
return sb.toString();
}
function writeToTileDataSource(buff, width) {
console.log('tiledata ENT');
console.log();
@ -186,47 +362,35 @@ function writeToTileDataSource(buff, width) {
console.log('; Tile ID ' + (count + 1));
console.log('; From image coordinates ' + (x * 2) + ', ' + y);
const tile = buildTile(buff, width, x, y, transparentIndex);
// Output the tile data
const offset = y * width + x;
for (dy = 0; dy < 8; dy += 1) {
const hex0 = buff[offset + dy * width + 0].toString(16).padStart(2, '0');
const hex1 = buff[offset + dy * width + 1].toString(16).padStart(2, '0');
const hex2 = buff[offset + dy * width + 2].toString(16).padStart(2, '0');
const hex3 = buff[offset + dy * width + 3].toString(16).padStart(2, '0');
console.log(' hex ' + hex0 + hex1 + hex2 + hex3);
for (const row of tile.normal.data) {
const hex = row.map(d => toHex(d)).join('');
console.log(' hex ' + hex);
}
console.log();
// Output the tile mask
for (dy = 0; dy < 8; dy += 1) {
//const hex0 = buff[offset + dy * width + 0].toString(16).padStart(2, '0');
//const hex1 = buff[offset + dy * width + 1].toString(16).padStart(2, '0');
//const hex2 = buff[offset + dy * width + 2].toString(16).padStart(2, '0');
//const hex3 = buff[offset + dy * width + 3].toString(16).padStart(2, '0');
console.log(' hex 00000000');
for (const row of tile.normal.mask) {
const hex = row.map(d => toHex(d)).join('');
console.log(' hex ' + hex);
}
console.log();
// Output the flipped tile data
for (dy = 0; dy < 8; dy += 1) {
const hex0 = reverse(buff[offset + dy * width + 0].toString(16).padStart(2, '0'));
const hex1 = reverse(buff[offset + dy * width + 1].toString(16).padStart(2, '0'));
const hex2 = reverse(buff[offset + dy * width + 2].toString(16).padStart(2, '0'));
const hex3 = reverse(buff[offset + dy * width + 3].toString(16).padStart(2, '0'));
console.log(' hex ' + hex3 + hex2 + hex1 + hex0);
for (const row of tile.flipped.data) {
const hex = row.map(d => toHex(d)).join('');
console.log(' hex ' + hex);
}
console.log();
// Output the flipped tile mask
for (dy = 0; dy < 8; dy += 1) {
//const hex0 = buff[offset + dy * width + 0].toString(16).padStart(2, '0');
//const hex1 = buff[offset + dy * width + 1].toString(16).padStart(2, '0');
//const hex2 = buff[offset + dy * width + 2].toString(16).padStart(2, '0');
//const hex3 = buff[offset + dy * width + 3].toString(16).padStart(2, '0');
console.log(' hex 00000000');
// Output the flipped tile data
for (const row of tile.flipped.mask) {
const hex = row.map(d => toHex(d)).join('');
console.log(' hex ' + hex);
}
console.log();
}
}
}
@ -249,3 +413,17 @@ async function writeBinayOutput(filename, buff) {
await fs.writeFile(filename, Buffer.concat([header, buff]));
}
module.exports = {
buildTile,
buildTiles,
buildMerlinCodeForTiles,
buildMerlinCodeForTile,
findColorIndex,
paletteToIIgs,
pngToIIgsBuff,
readPNG,
toHex,
writeBinayOutput,
writeToTileDataSource,
writeTilesToStream
}

View File

@ -2,10 +2,14 @@
* 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').promises;
const fs = require('fs');
const path = require('path');
const { abort } = require('process');
const process = require('process');
const { Buffer } = require('buffer');
const StringBuilder = require('string-builder');
const parser = require('xml2json');
const png2iigs = require('./png2iigs');
main(process.argv.slice(2)).then(
() => process.exit(0),
(e) => {
@ -14,15 +18,92 @@
}
);
function emitHeader() {
console.log('; Tiled Map Export');
console.log(';');
console.log('; This is a generated file. Do not modify.');
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 pngfile = path.resolve(path.join(workdir, tileset.image.source));
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(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}`);
}
}
console.log(`Converting PNG to IIgs bitmap format...`);
const buff = png2iigs.pngToIIgsBuff(png);
console.log(`Building tiles...`);
const tiles = png2iigs.buildTiles(buff, 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 writeTiles(filename, tiles) {
const tileSource = png2iigs.buildMerlinCodeForTiles(tiles);
fs.writeFileSync(filename, tileSource);
}
/**
* Command line arguments
*
* --output-dir : sets the output folder to write all assets into
*/
async function main(argv) {
// Read in the JSON data
const doc = JSON.parse(await fs.readFile(argv[0]));
const fullpath = path.resolve(argv[0]);
const workdir = path.dirname(fullpath);
const outdir = getArg(argv, '--output-dir', x => x, workdir);
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) {
@ -53,16 +134,43 @@ async function main(argv) {
// Sort the tile layers by ID. The lower ID is considered to be the "front" layer
tileLayers.sort((first, second) => first.id <= second.id);
// Ok, looks good. Write out the source code
emitHeader();
emitBG0Layer(tileLayers[0]);
// Load up any/all tilesets
const tileSets = await Promise.all(doc.tilesets.map(tileset => loadTileset(workdir, tileset)));
for (const record of tileSets) {
console.log(`Importing tileset "${record.tileset.name}"`);
const tiles = await readTileSet(workdir, record.tileset);
const outputFilename = path.resolve(path.join(outdir, record.tileset.name + '.s'));
console.log(`Writing tiles to ${outputFilename}`);
writeTiles(outputFilename, tiles);
console.log(`Writing complete`);
}
// Ok, looks good. Write out the source code for the layers
console.log('Generating data for front layer (BG0): ' + tileLayers[0].name);
const header = emitHeader();
const bg0 = emitBG0Layer(tileLayers[0]);
const bg0OutputFilename = path.resolve(path.join(outdir, tileLayers[0].name + '.s'));
console.log(`Writing BG0 data to ${bg0OutputFilename}`);
fs.writeFileSync(bg0OutputFilename, header + '\n' + bg0);
console.log(`Writing complete`);
if (tileLayers.length > 1) {
emitBG1Layer(tileLayers[1]);
console.log('Generating data for front layer (BG0): ' + tileLayers[1].name);
const bg1 = emitBG1Layer(tileLayers[1]);
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 emitBG1Layer(layer) {
const label = layer.name.split(' ').join('_');
const sb = new StringBuilder();
const label = layer.name.split(' ').join('_').split('.').join('_');
const initCode = `
BG1SetUp
lda #${layer.width}
@ -75,12 +183,17 @@ BG1SetUp
sta BG1TileMapPtr+2
rts
`;
console.log(initCode);
console.log(`${label}`);
sb.appendLine(initCode);
sb.appendLine(`${label}`);
emitLayerData(sb, layer);
return sb.toString();
}
function emitBG0Layer(layer) {
const label = layer.name.split(' ').join('_');
const sb = new StringBuilder();
const label = layer.name.split(' ').join('_').split('.').join('_');
const initCode = `
BG0SetUp
lda #${layer.width}
@ -93,9 +206,14 @@ BG0SetUp
sta TileMapPtr+2
rts
`;
console.log(initCode);
console.log(`${label}`);
sb.appendLine(initCode);
sb.appendLine(`${label}`);
emitLayerData(sb, layer);
return sb.toString();
}
function emitLayerData(sb, layer) {
// Print out the data in groups of N
const N = 16;
const chunks = [];
@ -106,6 +224,8 @@ BG0SetUp
// Tiled starts numbering its tiles at 1. This is OK since Tile 0 is reserved in
// GTE, also
for (const chunk of chunks) {
console.log(' dw ' + chunk.join(','));
sb.appendLine(' dw ' + chunk.join(','));
}
return sb;
}