Files
vgstation13/code/modules/html_interface/paintTool/paintTool.js
jellyveggie2 f668a164d4 Some painting fixes (#31632)
* Fix supply packs

* Fix painting descriptions so you can tell what kind of canvas an item is

* Have palette (item) colors show up on painting UI palette

* Fixes templates sometimes being broken due to opacity, and a few minor things here and there.

* Fixes remote painting. Also fixes a bunch of inconsistencies between paintings in item vs structure form, conversion between the two, glass pane behavior, descriptions, etc.
2021-12-14 16:05:47 +00:00

484 lines
14 KiB
JavaScript

/**
* #### Painting tool ####
*
* A fairly simple painting tool, don't expect too many bells and whistles.
*
* === Requirements ===
*
* Your html document must contain the following:
*
* - 'Width': An <input> with id="width". This will determine how many pixels wide our drawing will be
*
* - 'Height': An <input> with id="width". This will determine how many pixels tall our drawing will be
*
* - 'Bitmap': An <input> with id="bitmap". It's value must be a comma separated list of colors, with no
* spaces (eg: "#0000ff,#ff0000,#ff0000,#0000ff"). It must contain 'Width'x'Height' colors,
* as each color will be mapped to a pixel in our drawing. It's value will be updates as the
* user draws.
*
* - 'Canvas': A <canvas> with id="canvas", and the events onmousedown="is_mouse_down = true;" and
* onmousemove="draw_on_bitmap();".
* This will be where the user will draw and see the result. It's width and height attributes
* should be a multiple of 'Width' and 'Height', or you'll get some visual artifacts.
*
* - 'Tool Strength': An <input> with id="paint_opacity". It's value must be between 1 and 0 inclusive, and
* represents the color's "alpha value", aka how much of an effect it has in changing a
* pixel's color.
*
* - 'Min. Strength': An <input> with id="minPaintStrength". It's value must be between 1 and 0 inclusive,
* and serves to limit 'Tool Strength'
*
* - 'Max. Strength': An <input> with id="maxPaintStrength". It's value must be between 1 and 0 inclusive,
* and serves to limit 'Tool Strength'
*
* Inputs may be of type="hidden" as needed: eg. 'Bitmap' should usually be a hidden input, unless you'd
* like for the user to import and export it's value freely.
*
* === Functions ===
*
* --- init() ---
*
* Must be called as soon as all data is loaded and in place. Will load the html data (all those <input>s in the
* requirements section) into the script.
*
*
* --- setColor(color) ---
*
* There must be an element with id="current_color" for this function to be called.
* Calling this function will change the color in use to the specified color, which must be a string in hex
* format (eg: "#ffaa22"), and will update the 'current_color' element's background to said color.
* If you'd rather forego this, you may use 'paint_color = "#ffaa22";' instead, at your own peril.
*
*
* --- setStrength() ---
*
* Updates the tool strenght to that of the 'Tool Strenght' input, sanitizing it's value in the process. Turns
* NaNs to 0, rounds the value to the second decimal and clamps it to the min. and max. values.
* Useful if you wish to let the user modify the strength directly.
*
*
* --- changeStrength(diff) ---
*
* Modifies the tool strenght by 'diff', sanitizes the result and updates the value of the 'Tool Strenght' input.
* 'diff' should be no lower than 0.01, any values lower than that will end up ignored once the result's sanitized.
* Useful if placing buttons to increase/decrease the tool strenght, rather than modifying it directly.
*
*
* --- hexToRgba(hex), rgbaToHex(r, g, b) ---
*
* Helper functions to deal with rgb/hex conversions.
* 'hexToRgba(hex)' takes eg. an "#aa88ff" string and returns an {r:170, g:136, b:255} object.
* 'rgbaToHex(r, g, b)' takes three integers ranging from 0 to 255 and returns the corresponding string, eg. "#aa88ff"
*/
//Canvas and context for the player to draw in
var canvas;
var ctx;
//Define our "bitmap"
var width;
var height;
var bitmap;
//Keep track of how scaled up the canvas is vs the actual bitmap
var scaleX = 20;
var scaleY = 20;
//Keep track of what the mouse is up to
var previousX = -1;
var previousY = -1;
var is_mouse_down = false;
//Color and tool data
var paint_color = "#000000";
var paint_opacity = 0.5;
var minPaintStrength = 0;
var maxPaintStrength = 1;
// Milliseconds to wait since we last moved the mouse in ordder to draw.
// Ensures all browsers draw lines with the same opacity, instead of letting those that check more often drawing darker lines
const PAINT_TOOL_MOVEMENT_THROTTLE = 10;
/**
* Initialize the script
*/
function initPaint(initData) {
initData = JSON.parse(initData);
canvas = document.getElementById("canvas");
ctx = canvas.getContext("2d");
width = initData.width;
height = initData.height;
canvas.width = width * scaleX;
canvas.height = height * scaleY;
bitmap = initData.bitmap;
minPaintStrength = initData.minPaintStrength;
maxPaintStrength = initData.maxPaintStrength;
setOpacity(maxPaintStrength);
//No data? start with a blank canvas
if (bitmap.length != width * height) {
while (bitmap.length < width * height) {
bitmap.push("#ffffff");
}
}
// Listener to catch any mouseup events to stop drawing
window.addEventListener('mouseup', function(event){
end_path();
})
//Everything initialized, display the bitmap to the user for the first time.
display_bitmap();
}
/**
* Sets the current color to the specified color, updating the "selected color" display
*/
function setPaintColor(color) {
paint_color = color;
}
function getPaintColor() {
return paint_color;
}
function setOpacity(opacity) {
paint_opacity = isNaN(opacity) ? 0 : opacity;
paint_opacity = Math.round(paint_opacity*100)/100;
paint_opacity = Math.min(Math.max(paint_opacity, minPaintStrength), maxPaintStrength);
return paint_opacity;
}
function getOpacity() {
return paint_opacity;
}
/**
* Convert hex to RGBA objects.
* Helper function, converts an hex string (eg: #AA88FF or #AA88FFFF) to an
* {r:170, g:136, b:255, a:255} object.
* If the alpha component is missing it'll be treated as FF
*/
function hexToRgba(hex) {
//Pad with alpha component if missing
hex = hex.slice(1);
if (hex.length < 8) hex += "FF";
//Get rid of '#'
hex = parseInt(hex, 16);
//Bitwise magic
return {
r: (hex >> 24) & 255,
g: (hex >> 16) & 255,
b: (hex >> 8) & 255,
a: hex & 255
};
}
/**
* Convert RGBA to hex.
* Helper function, converts {r, g, b, a} objects (eg: {r:170, g:136, b:255, r:170}) to an
* hex string (eg: #AA88FFAA).
* If the alpha component is FF, it will be omitted on the hex
*/
function rgbaToHex(rgba) {
for (k in rgba) {
//Convert to hex value
rgba[k] = Math.round(rgba[k]).toString(16);
//Pad with 0 if needed
rgba[k] = rgba[k].length > 1 ? rgba[k] : "0" + rgba[k];
}
//Put it together
return "#" + rgba.r + rgba.g + rgba.b + (rgba.a != "ff" ? rgba.a : "")
}
/*
*--------------------------------------------------------------------------------------------------
*
*/
var blendFunction = colorRybBlend;
/**
* Draw a pixel into the bitmap.
* Given 'rgba' as an hex string (eg: #AA88FF) and 'a' (alpha) as a 0-1 value, will mix said color
* with whatever's on the specified pixel on the bitmap.
*/
function pixelDraw(x, y, rgba, alpha) {
//Figure out the pixel index off the x and y
let pixel = y * width + x;
//Convert to numeric values
rgba = hexToRgba(rgba);
let orgba = hexToRgba(bitmap[pixel]);
//Mix both color values
rgba = blendFunction(rgba, orgba, alpha);
//Save result into bitmap
bitmap[pixel] = rgbaToHex(rgba);
}
/* -------------------------------------------------------------------
Color blends
Mostly based on:
https://www.w3.org/TR/compositing-1/#valdef-blend-mode-hard-light
*/
function colorAlphaBlend(c1, c2, alpha) {
var result = {};
for (k in c1)
result[k] = Math.round(Math.sqrt(alpha * Math.pow(c1[k], 2) + (1-alpha) * Math.pow(c2[k], 2)));
return result;
}
function colorMultiplyBlend(c1, c2, alpha) {
var result = {};
for (k in c1)
result[k] = Math.round(c1[k]*c2[k]/255);
return colorAlphaBlend(result, c2, alpha);
}
function colorScreenBlend(c1, c2, alpha) {
var result = {};
for (k in c1)
result[k] = Math.round(c1[k] + c2[k] - c1[k]*c2[k]/255);
return colorAlphaBlend(result, c2, alpha);
}
function colorHardLightBlend(c1, c2, alpha) {
var result = {r: c1.r * 2, g: c1.g * 2, b: c1.b * 2};
for (var c in c1) {
if (c1[c] <= 127.5) {
result[c] = Math.round(result[c]*c2[c]/255);
} else {
result[c] -= 255;
result[c] = Math.round((result[c] + c2[c] - result[c]*c2[c]/255));
}
}
return colorAlphaBlend(result, c2, alpha);
}
function colorOverlayBlend(c1, c2, alpha) {
return colorHardLightBlend(c2, c1, alpha);
}
function colorRybBlend(c1, c2, alpha) {
var c1Ryb = rgbToRyb(c1);
var c2Ryb = rgbToRyb(c2);
var resultRyb = {r:0, y:0, b:0, a:c2Ryb.a};
alpha *= c1Ryb.a / 255.0;
resultRyb.r = Math.round(alpha * c1Ryb.r + (1-alpha) * c2Ryb.r);
resultRyb.y = Math.round(alpha * c1Ryb.y + (1-alpha) * c2Ryb.y);
resultRyb.b = Math.round(alpha * c1Ryb.b + (1-alpha) * c2Ryb.b);
return rybToRgb(resultRyb);
}
/*---------------------------------------------
end color blends
*/
/**
* RGB to RYB (red, yellow, blue) converter
* Takes a RGB color object such as {r:40, g:15, b:90} and returns an RYB object such
* as {r:215, y:165, b:240}.
* Formula based on the following papers:
* - http://nishitalab.org/user/UEI/publication/Sugita_IWAIT2015.pdf
* - http://nishitalab.org/user/UEI/publication/Sugita_SIG2015.pdf
*/
function rgbToRyb(rgb) {
// Soon-to-be result
var ryb = {r:0, y:0, b:0, a:rgb.a};
// Make a copy of the input to work on
var tmpRgb = {r: rgb.r, g: rgb.g, b: rgb.b};
// Remove white component
var i = Math.min(rgb.r, rgb.g, rgb.b);
tmpRgb.r -= i;
tmpRgb.g -= i;
tmpRgb.b -= i;
// Convert colors
ryb.r = tmpRgb.r - Math.min(tmpRgb.r, tmpRgb.g);
ryb.y = (tmpRgb.g + Math.min(tmpRgb.r, tmpRgb.g))/2;
ryb.b = (tmpRgb.b + tmpRgb.g - Math.min(tmpRgb.r, tmpRgb.g))/2;
// Normalize
var n = Math.max(ryb.r, ryb.y, ryb.b)/Math.max(tmpRgb.r, tmpRgb.g, tmpRgb.b);
if (n > 0.000001) { // Should be zero, but floating point error could be an issue
ryb.r /= n;
ryb.y /= n;
ryb.b /= n;
}
// Add black component, and round floating point errors
i = Math.min(255 - rgb.r, 255 - rgb.g, 255 - rgb.b);
ryb.r = Math.round(ryb.r + i);
ryb.y = Math.round(ryb.y + i);
ryb.b = Math.round(ryb.b + i);
return ryb;
}
/**
* RYB (red, yellow, blue) to RGB converter
* Takes a RYB color object such as {r:215, y:165, b:240} and returns an RGB object such
* as {r:40, g:15, b:90}.
* Formula based on the following papers:
* - http://nishitalab.org/user/UEI/publication/Sugita_IWAIT2015.pdf
* - http://nishitalab.org/user/UEI/publication/Sugita_SIG2015.pdf
*/
function rybToRgb(ryb) {
// Soon-to-be result
var rgb = {r:0, g:0, b:0, a:ryb.a};
// Make a copy of the input to work on
var tmpRyb = {r: ryb.r, y: ryb.y, b: ryb.b};
// Remove black component
var i = Math.min(ryb.r, ryb.y, ryb.b);
tmpRyb.r -= i;
tmpRyb.y -= i;
tmpRyb.b -= i;
// Convert colors
rgb.r = tmpRyb.r + tmpRyb.y - Math.min(tmpRyb.y, tmpRyb.b);
rgb.g = tmpRyb.y + Math.min(tmpRyb.y, tmpRyb.b);
rgb.b = 2*(tmpRyb.b - Math.min(tmpRyb.y, tmpRyb.b));
/* According to the RYB papers linked, the formula for green should be
* "g = y + 2*min(y, b)"
* But for whatever godforsaken reason that returns wrong values for colors where y < b
* (eg: cyan). Got rid of the '2*' on a hunch and sure it WORKS without breaking anything
* else, but WHY?????
*/
// Normalize
var n = Math.max(rgb.r, rgb.g, rgb.b)/Math.max(tmpRyb.r, tmpRyb.y, tmpRyb.b);
if (n > 0.000001) { // Should be zero, but floating point error could be an issue
rgb.r /= n;
rgb.g /= n;
rgb.b /= n;
}
// Add white component, and round floating point errors
i = Math.min(255 - ryb.r, 255 - ryb.y, 255 - ryb.b);
rgb.r = Math.round(rgb.r + i);
rgb.g = Math.round(rgb.g + i);
rgb.b = Math.round(rgb.b + i);
return rgb;
}
/**
* Listen to mouse actions and draw.
* Gets called whenever the mouse either moves within the canvas or is released from being
* pressed down (eg. single clicks)
*/
var lastMove = 0;
function draw_on_bitmap() {
//If the mouse is pressed down and inside the canvas...
if (is_mouse_down
&& event.offsetX > 0 && event.offsetX < canvas.width
&& event.offsetY > 0 && event.offsetY < canvas.height
&& (Date.now() - lastMove) > PAINT_TOOL_MOVEMENT_THROTTLE)
{
//Translate mouse position to bitmap position
var x = Math.floor(width * event.offsetX/canvas.width);
var y = Math.floor(height * event.offsetY/canvas.height);
//If the mouse moves too fast, "skipping" pixels, fill the gap by drawing a line
// between it and the last recorded position
if (previousX > -1 && (Math.abs(previousX - x) > 1 || Math.abs(previousY - y) > 1 )) {
lineDraw(previousX, previousY, x, y, paint_color, paint_opacity);
}
//Draw a pixel wherever we're at
pixelDraw(x, y, paint_color, paint_opacity);
//Record our current position as last recorded
previousX = x;
previousY = y;
// Record the time of our last movement, for throttling
lastMove = Date.now();
//Update the UI
display_bitmap();
}
}
/**
* Act on mouse no longer being pressed down.
* Draws a single pixel if we just did a single click. Clears the last recorded position so
* future clicks aren't treated as incredibly fast movements.
*/
function end_path () {
if (previousX == -1)
draw_on_bitmap();
is_mouse_down = false;
previousX = -1;
previousY = -1;
}
/**
* Draws a line between two points, neither point included.
* Sensitive both to mouse movement and the mouse button being released.
*/
function lineDraw(x1, y1, x2, y2, rgb, a) {
//Difference in "steps" between both axes
var sx = x2 - x1;
var sy = y2 - y1;
//Figure out how much to advance between steps, and how many steps to take (sx or sy, whichever is greater)
var dx;
var dy;
var steps = 0;
if (Math.abs(sx) > Math.abs(sy)) {
steps = Math.abs(sx);
dx = sx/Math.abs(sx);//Either 1 or -1
dy = sy/Math.abs(sx);
} else if (sy != 0){
steps = Math.abs(sy);
dx = sx/Math.abs(sy);
dy = sy/Math.abs(sy);//Either 1 or -1
}
//Move however many steps we decided on, starting from x1 and y1 and increasing both by dx and dy each step
//Skip the first and last step though, draw_on_bitmap() already handles those
for (var i = 1; i < steps; i++) {
//Result might look like x:0.1 y:1, x:0.2 y:2.. Decimals are not possible, so round it
pixelDraw(x1 + Math.round(i*dx), y1 + Math.round(i*dy), rgb, a);
}
}
/**
* Display the bitmap to the player
* Draws the bitmap's contents on screen, scaled up for visibility
*/
function display_bitmap() {
//Go through our pixel data and draw scaled up squares with the corresponding color
for (var x = 0; x < width; x++) {
for(var y = 0; y < height; y++) {
//Convert to pixel index
var pixel = (y * width + x);
//Grab the pixel's color
ctx.fillStyle = bitmap[pixel];
//Draw a square, scaled up as needed
ctx.fillRect(x*scaleX, y*scaleY, scaleX, scaleY);
}
}
}