Adding to the toolset

Having finished the graphics for the tileset I now have to think about getting the data inside the game. Luckily Multipaint offers a handy option of exporting an image as a text file with byte data.

; Multipaint machine = 10 (c64m)
; border and background 
    .byte 6 
    .byte 11 
; startrow and no of rows 
    .byte 0 
    .byte 25 
; bitmap
    .byte 127,151,169,170,170,170,170,170
    .byte 255,255,95,85,165,165,169,169
    .byte 85,149,165,154,165,170,175,191
    .byte 85,85,85,165,169,185,234,254
    .byte 170,154,170,102,106,85,253,255

...

And as I know which cells I use for the tiles I would just need to copy and paste the respective tiles from the byte data and reformat it a bit to fit my code. But that would be pretty tedious. Especially when I really get to creating the game with multiple different dungeon tilesets.

So I decided I would need a tool that can do that for me.

Getting Angular with it

As I am a frontend developer in my day job and angular is the weapon of choice in my team I started a little project using that language.

My goal was it to create a tool which automatically cuts and formats the exported data from Multipaint into a tileset dataset I can use in my games code.

So to start easy I just created a simple webform with two text areas, one for inputting the file and one for outputting the data. Going from here I wrote a simple parsing algorithm. Not very pretty but it gets the job done.

I attached the starting script to the input event of the text area in which the user is supposed to paste the data from the exported file.

I read the value from the event and split it at the ‘;’ character. Next I use that array to split the text into an array of lines. One for the bitmap data and two for the different colors for each cell.

Finally I arrange the bytes in a way that resembles the matrix of a strip.

updateText(event: Event) {
    const target = event.target as HTMLTextAreaElement;
    const value = target.value;
    const textArray = value.split(/\;/);
    const bitmapData = textArray
      .find((item) => item.includes('bitmap'))
      ?.split(/\r?\n\t?/)
      ?.filter((item) => item !== '');
    bitmapData?.shift();
    const colorData: ColorData | undefined = { colors1: [], colors2: [] };
    colorData.colors1 = this.getColorData(
      textArray.find((item) => item.includes('Colors 1'))?.toString()
    );
    colorData.colors2 = this.getColorData(
      textArray.find((item) => item.includes('Colors 2'))?.toString()
    );

    const tiles: string[][][] = [];
    for (var x = 0; x < 9; x++) {
      for (var i = 0; i <= 15; i++) {
        if (!tiles[x]) {
          tiles[x] = [];
        }
        if (!!bitmapData) {
          tiles[x].push(bitmapData.slice(i * 40 + x * 4, i * 40 + x * 4 + 4));
        }
      }
    }
    this._outputData$.next({ tiles, colorData });
  }

For the colors I use a similar method. First I split the string at the blank so I get an array with just the byte word and one with just the data. I filter the byte part so I just get the data and join them with a comma and split the data again so I get an array of just the numbers.

getColorData = (data: string | undefined) => {
  let colorData: Color[] = [];
  data
    ?.split(/\r?\n\t?/)
    .map((item) => {
      if (item.includes('byte')) {
        return item
          .split(' ')
          .filter((item) => !item.includes('byte'))
          .join(',')
          .split(',');
      }
      return false;
    })
    .filter((item) => !!item)
    .join(',')
    .split(',')
    .forEach((item, key) => {
      if (key < 16 * 40) {
        for (let i = 0; i < 9; i++) { if (key % 40 > i * 4 - 1 && key % 40 < (i + 1) * 4) { if (!colorData.find((item) => item.tile === i)) {
              colorData.push({ tile: i, colorData: [] });
            }
            colorData.find((item) => item.tile === i)?.colorData.push(item);
          }
        }
      }
    });
  return colorData;
};

To output the data I use an observable, so that any changes to the input data immediately updates the result.

I build the output string by iterating through the data arrays and inserting comments at appropriate positions. First the bitmap data followed by the appropriate two colors.

get outputData$() {
  return combineLatest([
    this._outputData$.asObservable(),
    this._refId.asObservable(),
  ]).pipe(
    map(([data, refId]) => {
      console.log(data);
      const refIdString = !!refId ? `_${refId}` : '';
      let output = '';
      if (data.tiles.length > 0) {
        output += '//bitmap_data';
        data.tiles.forEach((item_row, key_row) => {
          let title = '';
          switch (key_row) {
            case 0:
              title = 'view_left_0';
              break;
            case 1:
              title = 'view_left_1';
              break;
            case 2:
              title = 'view_left_2';
              break;
            case 3:
              title = 'view_left_3';
              break;
            case 4:
              title = 'view_center_0';
              break;
            case 5:
              title = 'view_right_3';
              break;
            case 6:
              title = 'view_right_2';
              break;
            case 7:
              title = 'view_right_1';
              break;
            case 8:
              title = 'view_right_0';
              break;
          }
          output += `\ntile_${title}${refIdString}:\n`;
          item_row.forEach((item_block, key_block) => {
            output += `tile_${key_row}_${key_block}:\n`;
            item_block.forEach((item) => {
              output += `${item}\n`;
            });
          });
        });
        output += `\n//color_data\n`;
        output += `\ncolors_1${refIdString}:\n`;
        data.colorData.colors1.forEach((item) => {
          let title = '';
          switch (item.tile) {
            case 0:
              title = 'view_left_0_colors_1';
              break;
            case 1:
              title = 'view_left_1_colors_1';
              break;
            case 2:
              title = 'view_left_2_colors_1';
              break;
            case 3:
              title = 'view_left_3_colors_1';
              break;
            case 4:
              title = 'view_center_0_colors_1';
              break;
            case 5:
              title = 'view_right_3_colors_1';
              break;
            case 6:
              title = 'view_right_2_colors_1';
              break;
            case 7:
              title = 'view_right_1_colors_1';
              break;
            case 8:
              title = 'view_right_0_colors_1';
              break;
          }
          output += `\n${title}${refIdString}:\n`;
          item.colorData.forEach((entry, entry_key) => {
            if (entry_key % 4 === 0) {
              output += `\n.byte `;
            }
            output += `${entry}`;
            if (entry_key % 4 !== 3) {
              output += ', ';
            }
          });
          output += `\n`;
        });
        output += `\ncolors_2${refIdString}:\n`;

        data.colorData.colors2.forEach((item) => {
          let title = '';
          switch (item.tile) {
            case 0:
              title = 'view_left_0_colors_2';
              break;
            case 1:
              title = 'view_left_1_colors_2';
              break;
            case 2:
              title = 'view_left_2_colors_2';
              break;
            case 3:
              title = 'view_left_3_colors_2';
              break;
            case 4:
              title = 'view_center_0_colors_2';
              break;
            case 5:
              title = 'view_right_3_colors_2';
              break;
            case 6:
              title = 'view_right_2_colors_2';
              break;
            case 7:
              title = 'view_right_1_colors_2';
              break;
            case 8:
              title = 'view_right_0_colors_2';
              break;
          }
          output += `\n${title}${refIdString}:\n`;
          item.colorData.forEach((entry, entry_key) => {
            if (entry_key % 4 === 0) {
              output += `\n.byte `;
            }
            output += `${entry}`;
            if (entry_key % 4 !== 3) {
              output += ', ';
            }
          });
          output += `\n`;
        });
      }
      return output;
    })
  );
}

Refining and preview

So far the app does what I need it to but there definitely is some room for improvement. For starters I want to try if I can show a preview of the data.

So I created a new component to which I input the tilesetdata observable. I map the tilestdata into three new observables for the pixel and color data.

For the pixel data I create a multidimensional array and convert the strings into numbers.

this.charData$ = this.tilesetData$.pipe(
  map((v) => {
    const charDataArray: number[][][][] = [];
    v.tiles.forEach((tilesData, tilesRow) => {
      charDataArray.push([]);
      tilesData.forEach((tileData, tileRow) => {
        charDataArray[tilesRow].push([]);
        tileData.forEach((charData) => {
          charDataArray[tilesRow][tileRow].push(
            charData.split('.byte ')[1].split(',').map(Number)
          );
        });
      });
    });
    // console.log(charDataArray);
    return charDataArray;
  })
);

The colors are a similar affair. I traverse the array and create a new cut for every four colors which is the tiles cell width.

this.colorData1$ = this.tilesetData$.pipe(
  map((v) => {
    const colorDataArray: number[][][] = [];
    v.colorData.colors1.forEach((color1Data) => {
      colorDataArray.push([]);
      color1Data.colorData.forEach((color, colorKey) => {
        if (colorKey % 4 === 0) {
          colorDataArray[color1Data.tile].push([]);
        }
        colorDataArray[color1Data.tile][Math.floor(colorKey / 4)].push(
          +color
        );
      });
    });
    // console.log(colorDataArray);
    return colorDataArray;
  })
);

Inside the component is another that creates the individual pixels of the image. I iterate over every pixel line and do a binary comparison to see if a pixel is set or not. And I check the corresponding colors to see which to pick.

Typescript
import { Component, Input, OnInit } from '@angular/core';

@Component({
  selector: 'app-pixelblock',
  templateUrl: './pixelblock.component.html',
  styleUrls: ['./pixelblock.component.scss'],
})
export class PixelblockComponent implements OnInit {
  @Input() pixelData: number;
  @Input() colorData1: number;
  @Input() colorData2: number;
  @Input() backgroundColor: number;
  pixelSplit: number[][];
  colorSplit1: number[][];
  colorSplit2: number[];
  color1 = [0, 0];
  color2 = 0;

  colorNames = [
    'black',
    'white',
    'red',
    'cyan',
    'purple',
    'green',
    'blue',
    'yellow',
    'orange',
    'brown',
    'lightred',
    'darkgray',
    'gray',
    'lightgreen',
    'lightblue',
    'lightgray',
  ];

  constructor() {}

  ngOnInit(): void {
    let pixelString = this.pixelData.toString(2);
    // console.log(this.pixelData, pixelString);
    while (pixelString.length < 8) { pixelString = '0' + pixelString; } this.pixelSplit = pixelString.match(/.{2}/g)?.map((v) => v.split('').map(Number)) ?? [];

    let colorString1 = this.colorData1.toString(2);
    while (colorString1.length < 8) { colorString1 = '0' + colorString1; } this.colorSplit1 = colorString1.match(/.{4}/g)?.map((v) => v.split('').map(Number)) ?? [];

    let colorString2 = this.colorData2.toString(2);
    while (colorString2.length < 8) { colorString2 = '0' + colorString2; } this.colorSplit2 = colorString2.split('').map(Number) ?? []; this.colorSplit1.forEach((v, colorKey) =>
      v.forEach((x, key) => {
        this.color1[colorKey] += x * Math.pow(2, 3 - key);
      })
    );

    this.colorSplit2.forEach(
      (v, colorKey) =>
        // v.forEach((x, key) => {
        (this.color2 += v * Math.pow(2, 7 - colorKey))
      // })
    );
    // console.log(this.colorSplit2);
  }
}

SCSS

$black: #000;
$white: #fff;
$red: #68372b;
$cyan: #70a4b2;
$purple: #6f3d86;
$green: #588d43;
$blue: #352879;
$yellow: #b8c76f;
$orange: #6f4f25;
$brown: #433900;
$lightred: #9a6759;
$darkgrey: #444;
$gray: #6c6c6c;
$lightgreen: #9ad284;
$lightblue: #6c5eb5;
$lightgray: #959595;

:host {
  display: flex;
  section[pixel] {
    height: 3px;
    width: 6px;
    outline: 1px solid black;
    background-color: $black;
    // background-color: transparent;
    overflow: hidden;

    &.color1active.color1black,
    &.color2active.color2black,
    &.color3active.color3black,
    &.backgroundColor.backgroundblack {
      background-color: $black;
    }

    &.color1active.color1white,
    &.color2active.color2white,
    &.color3active.color3white,
    &.backgroundColor.backgroundwhite {
      background-color: $white;
    }

    &.color1active.color1red,
    &.color2active.color2red,
    &.color3active.color3red,
    &.backgroundColor.backgroundred {
      background-color: $red;
    }

    &.color1active.color1cyan,
    &.color2active.color2cyan,
    &.color3active.color3cyan,
    &.backgroundColor.backgroundcyan {
      background-color: $cyan;
    }

    &.color1active.color1purple,
    &.color2active.color2purple,
    &.color3active.color3purple,
    &.backgroundColor.backgroundpurple {
      background-color: $purple;
    }

    &.color1active.color1green,
    &.color2active.color2green,
    &.color3active.color3green,
    &.backgroundColor.backgroundgreen {
      background-color: $green;
    }

    &.color1active.color1blue,
    &.color2active.color2blue,
    &.color3active.color3blue,
    &.backgroundColor.backgroundblue {
      background-color: $blue;
    }

    &.color1active.color1yellow,
    &.color2active.color2yellow,
    &.color3active.color3yellow,
    &.backgroundColor.backgroundyellow {
      background-color: $yellow;
    }

    &.color1active.color1orange,
    &.color2active.color2orange,
    &.color3active.color3orange,
    &.backgroundColor.backgroundorange {
      background-color: $orange;
    }

    &.color1active.color1brown,
    &.color2active.color2brown,
    &.color3active.color3brown,
    &.backgroundColor.backgroundbrown {
      background-color: $brown;
    }

    &.color1active.color1lightred,
    &.color2active.color2lightred,
    &.color3active.color3lightred,
    &.backgroundColor.backgroundlightred {
      background-color: $lightred;
    }

    &.color1active.color1darkgray,
    &.color2active.color2darkgray,
    &.color3active.color3darkgray,
    &.backgroundColor.backgrounddarkgray {
      background-color: $darkgrey;
    }

    &.color1active.color1gray,
    &.color2active.color2gray,
    &.color3active.color3gray,
    &.backgroundColor.backgroundgray {
      background-color: $gray;
    }

    &.color1active.color1lightgreen,
    &.color2active.color2lightgreen,
    &.color3active.color3lightgreen,
    &.backgroundColor.backgroundlightgreen {
      background-color: $lightgreen;
    }

    &.color1active.color1lightblue,
    &.color2active.color2lightblue,
    &.color3active.color3lightblue,
    &.backgroundColor.backgroundlightblue {
      background-color: $lightblue;
    }

    &.color1active.color1lightgray,
    &.color2active.color2lightgray,
    &.color3active.color3lightgray,
    &.backgroundColor.backgroundlightgray {
      background-color: $lightgray;
    }
  }
}

Style and substance

The preview area is a nice gimmick, but it could be far more useful. And the app could use some styling.

My idea was that it would be nice to be able to just click on the tiles I want to export in the preview. So I tried to make that happen.

I added a click event to every tile that emits its column and row number. That get’s picked up by the main component and added to an observable that collects the currently selected tiles.

@Output() selectedSet = new EventEmitter<number[]>();

selectCell(event: MouseEvent, column: number, row: number) {
  this.selectedSet.emit([column, row]);
}
updateSelectedCells(cellStrip: number, cellId: number) {
    let selectedCells = this._selectedCells$.value;
    selectedCells
      ? selectedCells[cellStrip]
        ? selectedCells[cellStrip].includes(cellId)
          ? (selectedCells[cellStrip] = selectedCells[cellStrip].filter(
              (cell) => cell !== cellId
            ))
          : selectedCells[cellStrip].push(cellId)
        : (selectedCells[cellStrip] = [cellId])
      : undefined;
    if (
      !!selectedCells &&
      !Object.keys(selectedCells).some(
        (key) => !!selectedCells && selectedCells[+key].length > 0
      )
    ) {
      selectedCells = undefined;
    }
    this._selectedCells$.next(selectedCells);
  }

Next I wanted to make the app more appealing to the eye so I worked on the styling of the app and restricted myself to the colors of the C64.

Final touches

The last thing I wanted to change in the app was to be able to drag a file over it or chose one with a file picker. So I changed the input textarea to a drop target and added a file picker button.