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.
![](https://trewest.dev/blog/wp-content/uploads/2024/05/image.png)
![](https://trewest.dev/blog/wp-content/uploads/2024/05/combined.png)
And finally, I had a great big variety of possible seeing double tokens.
![](https://trewest.dev/blog/wp-content/uploads/2024/05/image-1-1024x547.png)
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.
![](https://trewest.dev/blog/wp-content/uploads/2024/05/Cannimother-1.png)
![](https://trewest.dev/blog/wp-content/uploads/2024/05/Cannimother_evil-1.png)
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.
![](https://trewest.dev/blog/wp-content/uploads/2024/05/Cannimother_good_good-1.png)
![](https://trewest.dev/blog/wp-content/uploads/2024/05/Cannimother_good_evil.png)
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}.`);