410 lines
12 KiB
JavaScript
Executable File
410 lines
12 KiB
JavaScript
Executable File
import { sprintf } from "sprintf-js";
|
|
import { Xorshift32 } from "../utils/random.js";
|
|
import { WfcGrid } from "./WfcGrid.js";
|
|
import { SourceCell } from "./SourceCell.js";
|
|
import { SourceGrid } from "./SourceGrid.js";
|
|
|
|
class PainterApp {
|
|
/** @type {number} The index of the color we're currently painting with */
|
|
toolPaletteIndex = 0;
|
|
|
|
/** @type {string} Mode. Draw or fill */
|
|
mode = "draw";
|
|
/** @type {boolean} */
|
|
isDrawing = false;
|
|
|
|
/**@param {string[]} pal */
|
|
constructor(dim, pal, gridElement, paletteElement, previewElement) {
|
|
/** @type {number} */
|
|
this.dim = dim;
|
|
/** @type {string[]} Default color palette */
|
|
this.palette = pal;
|
|
/** @type {HTMLElement} */
|
|
this.gridElement = gridElement;
|
|
/** @type {HTMLElement} */
|
|
this.previewElement = previewElement;
|
|
/** @type {HTMLElement} */
|
|
this.paletteElement = paletteElement;
|
|
|
|
this.reset();
|
|
}
|
|
|
|
reset() {
|
|
// Assume the "background" color is always the last color in the palette.
|
|
const fillWith = 0;
|
|
this.sourceGrid = new SourceGrid(
|
|
Array(this.dim ** 2)
|
|
.fill(null)
|
|
.map(() => new SourceCell(new Uint8Array(9).fill(fillWith))),
|
|
);
|
|
|
|
this.createGridHtmlElements();
|
|
this.createPaletteSwatch();
|
|
this.updatePreview();
|
|
this.setToolPaletteIndex(1);
|
|
this.updateSourceGrid();
|
|
}
|
|
|
|
createGridHtmlElements() {
|
|
this.gridElement.innerHTML = "";
|
|
|
|
for (let i = 0; i < this.dim ** 2; i++) {
|
|
const pixel = document.createElement("div");
|
|
pixel.className = `pal-idx-${this.getCell(i)}`;
|
|
pixel.setAttribute("id", "cell-idx-" + i);
|
|
|
|
pixel.addEventListener("mousedown", (e) => this.mouseDown(e, i));
|
|
|
|
this.gridElement.appendChild(pixel);
|
|
}
|
|
|
|
// Prevent context menu and handle mouse events
|
|
this.gridElement.addEventListener("contextmenu", (e) => e.preventDefault());
|
|
}
|
|
|
|
createPaletteSwatch() {
|
|
this.paletteElement.innerHTML = "";
|
|
|
|
this.palette.forEach((color, paletteIndex) => {
|
|
const swatch = document.createElement("div");
|
|
swatch.classList.add("color-swatch");
|
|
swatch.classList.add(`pal-idx-${paletteIndex}`);
|
|
swatch.style.backgroundColor = color;
|
|
swatch.onclick = () => this.setToolPaletteIndex(paletteIndex);
|
|
this.paletteElement.appendChild(swatch);
|
|
});
|
|
}
|
|
|
|
setToolPaletteIndex(paletteIndex) {
|
|
//
|
|
this.toolPaletteIndex = paletteIndex;
|
|
|
|
const colorSwatches = this.paletteElement.querySelectorAll(".color-swatch");
|
|
colorSwatches.forEach((swatch) => {
|
|
const isActive = swatch.classList.contains(`pal-idx-${paletteIndex}`);
|
|
swatch.classList.toggle("active", isActive);
|
|
});
|
|
}
|
|
|
|
setTool(tool) {
|
|
this.mode = tool;
|
|
document.querySelectorAll(".tools button").forEach((btn) => btn.classList.remove("active"));
|
|
document.getElementById(tool + "Btn").classList.add("active");
|
|
|
|
const status = document.getElementById("status");
|
|
switch (tool) {
|
|
case "draw":
|
|
status.textContent = "Drawing mode - Click to paint pixels";
|
|
break;
|
|
case "fill":
|
|
status.textContent = "Fill mode - Click to fill area";
|
|
break;
|
|
}
|
|
}
|
|
|
|
mouseDown(e, index) {
|
|
e.preventDefault();
|
|
this.applyTool(index);
|
|
}
|
|
|
|
getCell(idx, y = undefined) {
|
|
if (y === undefined) {
|
|
return this.sourceGrid.cells[idx].value;
|
|
}
|
|
|
|
// Treat idx as an x-coordinate, and calculate an index
|
|
return this.sourceGrid.cells[y * this.dim + idx].value;
|
|
}
|
|
|
|
applyTool(index) {
|
|
switch (this.mode) {
|
|
case "draw":
|
|
this.setPixel(index, this.toolPaletteIndex);
|
|
break;
|
|
case "fill":
|
|
this.floodFill(index, this.toolPaletteIndex);
|
|
break;
|
|
}
|
|
}
|
|
|
|
setPixel(cellIdx, palIdx) {
|
|
const pixEl = document.getElementById("cell-idx-" + cellIdx);
|
|
this.sourceGrid.cells[cellIdx].value = palIdx;
|
|
pixEl.className = "pal-idx-" + palIdx;
|
|
this.updateSourceCell(cellIdx);
|
|
this.updatePreview();
|
|
}
|
|
|
|
floodFill(startIndex, fillColorPalIdx) {
|
|
const targetPalIdx = this.getCell(startIndex);
|
|
|
|
if (targetPalIdx === fillColorPalIdx) {
|
|
return;
|
|
}
|
|
|
|
const stack = [startIndex];
|
|
const visited = new Set();
|
|
|
|
while (stack.length > 0) {
|
|
const index = stack.pop();
|
|
|
|
if (visited.has(index) || this.getCell(index) !== targetPalIdx) continue;
|
|
|
|
visited.add(index);
|
|
this.setPixel(index, fillColorPalIdx);
|
|
|
|
// Add neighbors
|
|
const row = Math.floor(index / this.dim);
|
|
const col = index % this.dim;
|
|
|
|
if (row > 0) stack.push(index - this.dim); // up
|
|
if (row < 8) stack.push(index + this.dim); // down
|
|
if (col > 0) stack.push(index - 1); // left
|
|
if (col < 8) stack.push(index + 1); // right
|
|
}
|
|
}
|
|
|
|
randomFill() {
|
|
for (let i = 0; i < this.dim ** 2; i++) {
|
|
this.setPixel(i, Math.floor(Math.random() * this.palette.length));
|
|
}
|
|
}
|
|
|
|
invertColors() {
|
|
for (let i = 0; i < this.dim ** 2; i++) {
|
|
const cell = this.getCell(i);
|
|
|
|
const inverted = cell % 2 === 0 ? cell + 1 : cell - 1;
|
|
|
|
this.setPixel(i, inverted);
|
|
}
|
|
this.setToolPaletteIndex(
|
|
this.toolPaletteIndex % 2 === 0 ? this.toolPaletteIndex + 1 : this.toolPaletteIndex - 1,
|
|
);
|
|
}
|
|
|
|
updatePreview() {
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = this.dim;
|
|
canvas.height = this.dim;
|
|
const ctx = canvas.getContext("2d");
|
|
|
|
for (let i = 0; i < this.dim ** 2; i++) {
|
|
const x = i % this.dim;
|
|
const y = Math.floor(i / this.dim);
|
|
ctx.fillStyle = this.palette[this.getCell(i)];
|
|
ctx.fillRect(x, y, 1, 1);
|
|
}
|
|
this.previewElement.style.backgroundImage = `url(${canvas.toDataURL()})`;
|
|
this.previewElement.style.backgroundSize = "100%";
|
|
return;
|
|
}
|
|
|
|
updateSourceGrid() {
|
|
for (let i = 0; i < this.dim ** 2; i++) {
|
|
this.updateSourceCell(i);
|
|
}
|
|
}
|
|
|
|
/** @param {number} i */
|
|
updateSourceCell(idx) {
|
|
const dim = this.dim;
|
|
const x = idx % dim;
|
|
const y = Math.floor(idx / dim);
|
|
|
|
const valueAt = (dX, dY) => {
|
|
const _x = (x + dim + dX) % dim; // add dim before modulo because JS modulo allows negative results
|
|
const _y = (y + dim + dY) % dim;
|
|
return this.getCell(_y * dim + _x);
|
|
};
|
|
|
|
this.sourceGrid.cells[idx].values = new Uint8Array([
|
|
// | neighbour
|
|
// ---------------------|-----------
|
|
valueAt(-1, -1), // | northwest
|
|
valueAt(0, -1), // | north
|
|
valueAt(1, -1), // | northeast
|
|
|
|
valueAt(-1, 0), // | east
|
|
this.getCell(idx), // | -- self --
|
|
valueAt(1, 0), // | west
|
|
|
|
valueAt(-1, 1), // | southwest
|
|
valueAt(0, 1), // | south
|
|
valueAt(1, 1), // | southeast
|
|
]);
|
|
}
|
|
|
|
exportAsImage() {
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = this.dim;
|
|
canvas.height = this.dim;
|
|
const ctx = canvas.getContext("2d");
|
|
ctx.imageSmoothingEnabled = false;
|
|
|
|
for (let i = 0; i < this.dim ** 2; i++) {
|
|
const x = i % this.dim;
|
|
const y = Math.floor(i / this.dim);
|
|
ctx.fillStyle = this.palette[this.getCell(i)];
|
|
ctx.fillRect(x, y, 1, 1);
|
|
}
|
|
|
|
const link = document.createElement("a");
|
|
link.download = `pixel-art-${this.dim}x${this.dim}.png`;
|
|
link.href = canvas.toDataURL();
|
|
link.click();
|
|
}
|
|
|
|
exportAsData() {
|
|
const data = Array.from({ length: this.dim ** 2 }, (_, i) => this.getCell(i));
|
|
const blob = new Blob([data], { type: "application/json" });
|
|
const link = document.createElement("a");
|
|
link.download = "pixel-art-data.json";
|
|
link.href = URL.createObjectURL(blob);
|
|
link.click();
|
|
}
|
|
|
|
importData() {
|
|
const input = document.createElement("input");
|
|
input.type = "file";
|
|
input.accept = ".json";
|
|
input.onchange = (e) => {
|
|
const file = e.target.files[0];
|
|
if (file) {
|
|
const reader = new FileReader();
|
|
reader.onload = (event) => {
|
|
try {
|
|
const data = JSON.parse(event.target.result);
|
|
if (!Array.isArray(data) && data.length === this.dim ** 2) {
|
|
alert("Invalid data format!");
|
|
}
|
|
data.forEach((v, k) => {
|
|
this.setPixel(k, v);
|
|
});
|
|
this.createGridHtmlElements();
|
|
this.updatePreview();
|
|
this.updateSourceGrid();
|
|
} catch (error) {
|
|
alert("Error reading file!" + error);
|
|
}
|
|
};
|
|
reader.readAsText(file);
|
|
}
|
|
};
|
|
input.click();
|
|
}
|
|
|
|
waveFunction() {
|
|
this.updateSourceGrid();
|
|
const wfcImg = new WfcGrid(
|
|
// this.previewElement.clientWidth,
|
|
// this.previewElement.clientHeight,
|
|
10,
|
|
10,
|
|
this.sourceGrid.clone(),
|
|
new Xorshift32(Date.now()),
|
|
);
|
|
|
|
// Could not "collapse" the image.
|
|
// We should reset and try again?
|
|
let running = true;
|
|
let count = 0;
|
|
const maxCount = 1000;
|
|
|
|
const collapseFunc = () => {
|
|
running = wfcImg.collapse();
|
|
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = wfcImg.width;
|
|
canvas.height = wfcImg.height;
|
|
|
|
const ctx = canvas.getContext("2d");
|
|
let i = 0;
|
|
for (let y = 0; y < canvas.height; y++) {
|
|
for (let x = 0; x < canvas.width; x++) {
|
|
const cell = wfcImg.cells[i++];
|
|
ctx.fillStyle = cell.value;
|
|
ctx.fillRect(x, y, 1, 1);
|
|
}
|
|
}
|
|
this.previewElement.style.backgroundImage = `url(${canvas.toDataURL()})`;
|
|
this.previewElement.style.backgroundSize = "100%";
|
|
|
|
if (running && ++count < maxCount) {
|
|
setTimeout(collapseFunc, 1);
|
|
}
|
|
};
|
|
collapseFunc();
|
|
}
|
|
}
|
|
const base_palette = [
|
|
"#FFF",
|
|
"#007",
|
|
"#00F",
|
|
"#070",
|
|
"#077",
|
|
"#0F0",
|
|
"#0FF",
|
|
"#0f0",
|
|
"#700",
|
|
"#707",
|
|
"#770",
|
|
"#F00",
|
|
"#F0F",
|
|
"#FF0",
|
|
];
|
|
|
|
const palette = new Array();
|
|
|
|
base_palette.forEach((color) => {
|
|
//
|
|
// Calc inverted color
|
|
const invR = 15 - Number.parseInt(color.substr(1, 1), 16);
|
|
const invG = 15 - Number.parseInt(color.substr(2, 1), 16);
|
|
const invB = 15 - Number.parseInt(color.substr(3, 1), 16);
|
|
const invColor = sprintf("#%x%x%x", invR, invG, invB);
|
|
|
|
// populate the palette
|
|
palette.push(color);
|
|
palette.push(invColor);
|
|
});
|
|
|
|
window.painter = new PainterApp(
|
|
9,
|
|
palette,
|
|
document.getElementById("gridContainer"), //
|
|
document.getElementById("colorPalette"), //
|
|
document.getElementById("preview"), //
|
|
);
|
|
|
|
// ____ ____ ____
|
|
// / ___/ ___/ ___|
|
|
// | | \___ \___ \
|
|
// | |___ ___) |__) |
|
|
// \____|____/____/
|
|
//--------------------
|
|
|
|
//
|
|
// share the dimensions of the SourceGrid with CSS/HTML
|
|
document.getElementsByTagName("body")[0].style.setProperty("--dim", window.painter.dim);
|
|
|
|
//
|
|
// --------------------------------------
|
|
// Add the palette colors as CSS classes
|
|
// --------------------------------------
|
|
|
|
const styleElement = document.createElement("style");
|
|
styleElement.type = "text/css";
|
|
|
|
let cssRules = "";
|
|
palette.forEach((color, index) => {
|
|
const className = `pal-idx-${index}`;
|
|
cssRules += `.${className} { background-color: ${color} !important; }\n`;
|
|
});
|
|
|
|
// Add the CSS to the style element
|
|
styleElement.innerHTML = cssRules;
|
|
|
|
// Append to head
|
|
document.head.appendChild(styleElement);
|