Blood on the Clocktower Icon Generation

This is a quick passion project of mine, two simple scripts that generate icons for a custom role list for a game called Blood on the Clocktower. Blood on the Clocktower is a fun board game with an online version available for free. My homebrew script Seeing Double required me to make icons of two normal characters and combine them. I decided to do this by taking two opposite halves of the two icons at a 45 degree angle and putting them together. Originally, I did this manually in Photoshop, but decided that writing a script for it might be faster. And so, I decided to look up popular image manipulation libraries, the majority of which were written for JavaScript.
Eventually, I came across JIMP, Javascript Image Manipulation Program, and decided that it’d be the right thing for the job. In around an hour of trial and error, I had made a working script… but forgotten that one hundred fourty four squared is a very large number, and I had fed my script that many images to make every possible variation. Not because I needed them but I was already downloading around 60 images anyways, might as well download all of them. And so from 20 megabytes of input, I received 3 gigabytes in return.

Maybe this wasn’t such a great idea. (Also there’s a bit of variation from having to fix some issues)
I used two single color images to test the tool.

And finally, I had a great big variety of possible seeing double tokens.

But this wasn’t enough. After this I realized I needed varieties of each of these being good and evil. If I did all possible options I’d be dangerously close to six gigabytes of icons, so I decided to just do what I needed this time which made this iteration much faster. I thought hard about how to make the correct look for each token. I could detect white, grey out the rest and then multiply it with a color, but that seemed to be a lot of work. Then I thought about making colored noise fill it, which I tried but didn’t look right. The example below shows the original good versus the new evil.

Sometimes though, the simplest solution is the best. And thankfully someone had already made a good noise fill for a majority of things with their own token maker. So I just borrowed a square of that for evil and good and used that as the fill image instead, below are the results of good and evil of the same image.

While obviously not an exact match, the soft (what is probably Perlin) noise has a pretty good color match and looks similar enough at a distance to look accurate, which is really what matters.

Scripts

Token maker

import jimp from "jimp"
import path from "node:path"
import fs from "node:fs"

const offset = 10;
const inputFolder = 'icons/';
const outputFolder = 'out/';


async function main( ) 
{
    //Making a list of every image in the input folder
    let list = [];
    fs.readdirSync(inputFolder).forEach(file => {list[list.length] = file;})

    // For every image, make a new image with every other image.
    for (let i =0;i<list.length;i++) 
    {
        //await combine("organgrinder.png",list[i]); This is a lazy way of fixing individual variations
        //await combine(list[i],"organgrinder.png");  When fixing individual ones, I'd just comment out the second for loop.

        for (let x = 0;x<list.length;x++) 
        {
            await combine(list[i], list[x]);
        }
    }
    
}

async function combine(top, bottom) {
    // We start by reading the first / the "top" image
    const image = await jimp.read(inputFolder + top);

    const topimg = image.scanQuiet(
        0,
        0,
        image.bitmap.width,
        image.bitmap.height,
        function (x, y, idx) {
                if (x +offset >=  (image.bitmap.width - y)) // When X + the offset is greater than the width minus y we will take the pixels.  
                {
                    image.bitmap.data[idx] =0;
                    image.bitmap.data[idx+1] =0;
                    image.bitmap.data[idx+2] =0;
                    image.bitmap.data[idx+3] =0;
                }
              }
      );
      // We start by reading the first / the "top" image
    const image2 = await jimp.read(inputFolder + bottom);
    const BOTimg = image2.scanQuiet(
        0,
        0,
        image2.bitmap.width,
        image2.bitmap.height,
        function (x, y, idx) {
                if (x <=  (image2.bitmap.width - y + offset))  // When X is less than width minus y plus the offset we take the second half of the image data.
                {
                    image2.bitmap.data[idx] =0;
                    image2.bitmap.data[idx+1] =0;
                    image2.bitmap.data[idx+2] =0;
                    image2.bitmap.data[idx+3] =0;
                }
              }
      );
      //After saving both halves of the image we'll combine them like this quickly.
    const Combined = topimg.scanQuiet(0,0,topimg.bitmap.width, topimg.bitmap.height,
        function (x,y,idx) 
        {
            topimg.bitmap.data[idx] += BOTimg.bitmap.data[idx];
            topimg.bitmap.data[idx+1] += BOTimg.bitmap.data[idx+1];
            topimg.bitmap.data[idx+2] += BOTimg.bitmap.data[idx+2];
            topimg.bitmap.data[idx+3] += BOTimg.bitmap.data[idx+3];
        }
        );
    await Combined.writeAsync('out/'+ top + '_and_' + bottom);


}
await main();
console.log("test");

Recolor

import jimp from "jimp"
import path from "node:path"
import fs from "node:fs"

const offset = 10; // unused
const folder = 'Copiloted/'; // Input folder
const folder_out = 'copiloted_out/'; // Output folder
const noise = 'noise.png'; // unused
const noise_blue = 'noise_blue3.png' // Image for good player background
const noise_red = 'noise_red3.png' // Image for evil player background
const white_min = 160; // The minimum for colors to be considered in the white/grey zone for 
let total = 0; // total iterations/generations for console writing.  


async function main( ) 
{
    let list = [];
    
    fs.readdirSync(folder).forEach(file => {list[list.length] = path.basename(file, path.extname(file));})

    //await recolor('slayight_fixed');  Recoloring single 
    
    for (let i =0;i<list.length;i++) 
    {
        await recolor(list[i]);
        total+=2;
    }
    
}

async function recolor(top) {
    
    const read_red = await jimp.read(noise_red);
    const read_blue = await jimp.read(noise_blue); 
    const image = await jimp.read(folder + top + '.png');
    image.resize(read_red.bitmap.height,read_red.bitmap.width,jimp.RESIZE_BICUBIC);

    const blue_image = image.scanQuiet(
        0,
        0,
        image.bitmap.width,
        image.bitmap.height,
        function (x, y, idx) {
            if (image.bitmap.data[idx] > white_min && image.bitmap.data[idx+1] >white_min && image.bitmap.data[idx+2] > white_min)
            {
                
                image.bitmap.data[idx] = image.bitmap.data[idx];// red channel
                image.bitmap.data[idx+1] = image.bitmap.data[idx+1]; // blue channel
                image.bitmap.data[idx+2] = image.bitmap.data[idx+2]; // green channel
            }
            else if (image.bitmap.data[idx+3] > 50)
            {
                
                image.bitmap.data[idx] = read_blue.bitmap.data[idx];
                image.bitmap.data[idx+1] = read_blue.bitmap.data[idx+1];
                image.bitmap.data[idx+2] = read_blue.bitmap.data[idx+2];
                image.bitmap.data[idx+3] = image.bitmap.data[idx+3]; // alpha
            }
            }
      );
      console.log(`Writing ${folder_out + top + '_good.png'}`);
      await blue_image.writeAsync(folder_out + top + '_good.png');

      const red_image = image.scanQuiet(
        0,
        0,
        image.bitmap.width,
        image.bitmap.height,
        function (x, y, idx) {
            if (image.bitmap.data[idx] > white_min && image.bitmap.data[idx+1] >white_min && image.bitmap.data[idx+2] > white_min)
            {
                
                image.bitmap.data[idx] = image.bitmap.data[idx];
                image.bitmap.data[idx+1] = image.bitmap.data[idx+1];
                image.bitmap.data[idx+2] = image.bitmap.data[idx+2];
            }
            else if (image.bitmap.data[idx+3] > 50)
            {
                
                image.bitmap.data[idx] = read_red.bitmap.data[idx];
                image.bitmap.data[idx+1] = read_red.bitmap.data[idx+1];
                image.bitmap.data[idx+2] = read_red.bitmap.data[idx+2];
                image.bitmap.data[idx+3] = image.bitmap.data[idx+3];
            }
            }
      );
      console.log(`Writing ${folder_out + top + '_evil.png'}`);
      await red_image.writeAsync(folder_out + top + '_evil.png');
    


}
await main();
console.log("Test complete.");
console.log(`Images Generated: ${total}.`);