diff --git a/tools/mksprite.js b/tools/mksprite.js new file mode 100644 index 0000000..fbf25d9 --- /dev/null +++ b/tools/mksprite.js @@ -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. + */ + diff --git a/tools/png2iigs.js b/tools/png2iigs.js index 65d63da..ddcc93e 100644 --- a/tools/png2iigs.js +++ b/tools/png2iigs.js @@ -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 +} \ No newline at end of file diff --git a/tools/tiled2iigs.js b/tools/tiled2iigs.js index 4b6964e..05008d7 100644 --- a/tools/tiled2iigs.js +++ b/tools/tiled2iigs.js @@ -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; } \ No newline at end of file