sc4
Version:
A command line utility for automating SimCity 4 modding tasks & modifying savegames
320 lines (286 loc) • 8.04 kB
JavaScript
// ## loadImage(url)
// Loads an image object from the given url.
function loadImage(url) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.crossOrigin = true;
img.src = url;
});
}
// # img()
// Calculates the source offsets to use from the image based on the size of the
// image. In the future we might allow repositioning here.
function getSourceOffset(img) {
let { width, height } = img;
let sx, sy, sWidth, sHeight;
if (width > height) {
sx = (width-height)/2;
sy = 0;
sWidth = height;
sHeight = height;
} else {
sx = 0;
sy = (height-width)/2;
sWidth = width;
sHeight = width;
}
return [sx, sy, sWidth, sHeight];
}
// # clear()
// Clears the canvas
function clear(canvas) {
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
}
// # draw(canvas, img, overlay, templated)
// The function that actually draws the image on the canvas. Note that by
// default we apply the icon template to it - meaning drawing the image four
// times and applying the grayscale, but you can also choose to just rawdog it
// onto the canvas - useful for existing icons!
async function draw(canvas, { image, overlay, templated, withIcon, color }) {
// Rawdog if possible.
const ctx = canvas.getContext('2d');
clear(canvas);
if (!templated) {
ctx.drawImage(image, 0, 0);
if (withIcon) {
for (let i = 0; i < 4; i++) {
drawMenuIcon(ctx, 44*i, color);
}
}
return;
}
// Draw the image four times. Note that the first time we have to apply the
// grayscaling.
const offsets = getSourceOffset(image);
for (let i = 0; i < 4; i++) {
let x = 44*i;
ctx.save();
let region = new Path2D();
region.roundRect(x, 0, 44, 44, 7);
ctx.clip(region);
ctx.drawImage(image, ...offsets, x, 0, 44, 44);
// Apply the menu icon if specified.
if (withIcon) {
drawMenuIcon(ctx, x, color);
}
if (i === 0) {
applyGrayscale(ctx);
}
ctx.restore();
}
// At last draw the overlay.
ctx.drawImage(overlay, 0, 0, 176, 44);
}
function drawMenuIcon(ctx, x, color) {
let w = 6;
let dx = 6;
let dy = 7;
ctx.save();
ctx.strokeStyle = color;
ctx.strokeWidth = '1px';
for (let i = 0; i < 3; i++) {
ctx.moveTo(x+dx, dy+2*i-0.5);
ctx.lineTo(x+dx+w, dy+2*i-0.5);
ctx.stroke();
}
ctx.restore();
}
// # openIcon(file)
async function openFile(file) {
set(sourceImage = await loadImage(URL.createObjectURL(file)));
}
// # applyGrayscale(ctx)
// Applies the grayscaling of the first image, in the same way the GIMP template
// does it.
function applyGrayscale(ctx) {
ctx.save();
ctx.fillStyle = 'white';
ctx.filter = 'opacity(0.7)';
ctx.globalCompositeOperation = 'saturation';
ctx.fillRect(0, 0, 44, 44);
ctx.restore();
ctx.save();
ctx.fillStyle = 'white';
ctx.globalCompositeOperation = 'source-over';
ctx.filter = 'opacity(0.2)';
ctx.fillRect(0, 0, 44, 44);
ctx.restore();
}
// # toBuffer(canvas)
// Gets the canvas' contents as an array buffer so that we can save them to the
// server.
function toBuffer(canvas) {
return new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
const reader = new FileReader();
reader.onload = () => {
resolve(reader.result);
};
reader.readAsArrayBuffer(blob);
});
});
}
// # fetchUrl(url)
// Fetches a image from a url. Note that we pass by the server for this because
// otherwise we might not be able to serialize the canvas to a buffer because of
// cors!
async function fetchUrl(url) {
if (url.protocol === 'data:') {
set(sourceImage = await loadImage(url));
let file = new File([], '<binary>');
let dtf = new DataTransfer();
dtf.items.add(file);
input.files = dtf.files;
return;
}
let search = new URLSearchParams({ url });
let res = await fetch('/fetch?'+search);
if (res.status !== 200) return;
let buffer = await res.arrayBuffer();
let file = new File([buffer], url);
setFile(file);
}
// Private state, svelte-like.
let sourceImage = null;
let overlayImage = null;
let message = '';
let templated = true;
let withIcon = false;
let color = '#ffffff';
// # setFile(file)
async function setFile(file) {
set(sourceImage = null, templated = true);
await openFile(file);
let dtf = new DataTransfer();
dtf.items.add(file);
input.files = dtf.files;
}
// # useMemo()
// React-like render hook that allows caching a function call based on a deps
// array.
let former;
function useMemo(fn, deps) {
if (!former || deps.some((dep, i) => dep !== former[i])) {
fn();
former = deps;
}
}
// # set()
// Helper for automatically calling render.
function set() {
render();
}
// # render()
// The master render function. This is functionally equivalent to Vue's render
// function, except that we now have to call it manually.
const input = document.querySelector('input[type="file"]');
const $templated = document.querySelector('input[name="templated"]');
const $withIcon = document.querySelector('input[name="with-icon"]');
const $color = document.querySelector('input[type="color"]');
const canvas = document.querySelector('canvas');
const form = document.querySelector('form');
const h1 = document.querySelector('h1');
function render() {
// Render the canvas. Note that we need to cache here that in case the
// source image did not change, we don't constantly rerender.
useMemo(() => {
if (sourceImage && overlayImage) {
draw(canvas, {
image: sourceImage,
overlay: overlayImage,
templated,
color,
withIcon,
});
} else {
clear(canvas);
}
}, [sourceImage, overlayImage, templated, withIcon, color]);
// Update the heading text.
h1.textContent = message;
$templated.checked = templated;
}
// Load the overlay as soon as possible.
loadImage('/overlay.png').then(img => set(overlayImage = img));
// Setup code goes below.
input.addEventListener('change', async event => {
let [file] = event.target.files;
setFile(file, { dispatch: false });
});
// Add the save listener.
form.addEventListener('submit', async event => {
event.preventDefault();
await fetch(form.getAttribute('action'), {
method: form.getAttribute('method') || 'POST',
headers: {
'Content-Type': 'image/png',
},
body: await toBuffer(canvas),
});
window.close();
});
document.querySelector('button#download')
.addEventListener('click', async event => {
event.preventDefault();
let buffer = await toBuffer(canvas);
let blob = new Blob([buffer], { type: 'image/png' });
let url = URL.createObjectURL(blob);
let a = document.createElement('a');
a.href = url;
a.download = 'icon.png';
a.click();
});
// Setup the drag/drop behavior.
const dropArea = document.body;
dropArea.addEventListener('dragover', e => {
e.preventDefault();
e.target.style.background = '#eee';
});
dropArea.addEventListener('dragleave', e => {
e.target.style.background = '';
});
dropArea.addEventListener('drop', e => {
e.preventDefault();
e.target.style.background = '';
input.files = e.dataTransfer.files;
setFile(input.files[0]);
});
// Handle paste events.
window.addEventListener('paste', e => {
let [file] = e.clipboardData.files;
if (file) {
setFile(file);
return;
}
// If no files were pasted, but only text, then treat it as a url being
// pasted.
let [item] = e.clipboardData.items;
if (item.kind === 'string') {
let text = e.clipboardData.getData('text');
try {
fetchUrl(new URL(text));
set(sourceImage = null, templated = true);
} catch {}
}
});
// Listen to the raw being checked or not.
$templated.addEventListener('input', event => {
set(templated = event.target.checked);
});
$withIcon.addEventListener('input', event => {
set(withIcon = event.target.checked);
});
$color.addEventListener('input', event => {
set(color = event.target.value);
});
// Get the configuration data.
let config = await fetch('/data').then(res => res.json());
({ message, templated = true } = config);
if (config.default) {
fetchUrl(new URL(config.default));
}
// Perform the initial render now.
render();