Custom painting revival (#31563)

* First steps. Template and some BYOND-side work

* Change of plans, fuck NanoUI, we html_interface nao.
Canvas painting code moved to it's own .dm, .tmpl split into .tpml and .js.
You can use a pen on a canvas to start drawing, can't save your drawing yet but it loads whatever data was already in there.
So far so good.

* Tweaks nanotrasen.css cause it SUCKS.
Link buttons behave like shared.css', and text inputs look better (that border had no business being so thick).
TODO: Check the MSGS' _UI cause it had a button input that looked like ass because of the CSS applying to all inputs

* UI and UI code overhaul. Topic receives data.
TODO: clean up UI code, It's a mess. paintTool.js should probably get split up further, separate the painting from the blending modes or something.

* Sanity, saving, description, and href-multi integration

* Yay, procastrination. Also, split UI and Topic logic into reusable datum separate from object, and some more sanity.
I really should get to the "display the painting in-game" part at some point

* IT WORKS! Paintings are showing up in game.

* Crayons! And pens and color and stuff

* Soap to clean/blank, and moving "Title" before "Author" on UI

* Couple fixes

* Paint brushes! Icons may need replacing, specially inhands

* Cyan paint bucket. UPDATE WIKI: SUPPLY PACKS

* Container fix. Picking a human's blood color as paint is funny, but weird

* Can't quite remember what I was up to last time, moved files to their own folder on /module I guess. Just, getting my stuff out there for a PR

* Supply crate and Eneocho's glass idea

* fugg

* Color transparency and movement throttling

* Hair dye spray cans.
Outperformed by rainbow crayons, those get two colors on top of the picker. Should implement brush size to give them an actual advantage. Maybe stronger paint?

* Fixes a couple oopsies after rebasing to (as of writing) current Bleeding Edge

* new icons

* .dme

* Palette

Co-authored-by: JellyVeggie2 <78439377+jellyveggie2@users.noreply.github.com>
This commit is contained in:
ShiftyRail
2021-12-12 20:33:51 +00:00
committed by GitHub
parent 53cde8f906
commit d66a3da9d5
23 changed files with 1924 additions and 21 deletions

View File

@@ -51,6 +51,9 @@
parent = null
parts = null
/datum/href_multipart_handler/proc/set_parent(parent)
src.parent = parent
/datum/href_multipart_handler/Topic(href, href_list)
if(href_list["multipart"])
// Initialize the list to the size specified by 'multipart-total'

View File

@@ -46,6 +46,10 @@ hr
height: 1px;
}
.link, .linkOn, .linkOff, .selected, .disabled, .button {
height: 22px;
}
a, a:link, a:visited, a:active, .linkOn, .linkOff
{
color: #ffffff;
@@ -73,10 +77,10 @@ a.nobg:hover
color:#40628a;
}
a:hover
{
color: #40628a;
background: #ffffff;
a:hover, .zoomLink:hover, .linkActive:hover, .buttonActive:hover {
color:#ffffff;
background: #507aac;
text-decoration: none;
}
a.white, a.white:link, a.white:visited, a.white:active
@@ -357,10 +361,9 @@ div.notice
clear: both;
}
input
input[type="text"], textarea
{
background-color: #202020;
border-color: #40628a;
border-style: solid;
border: 1px solid #40628a;
color: white;
}

View File

@@ -0,0 +1,99 @@
.mainPanel {
height:650px;
white-space: nowrap;
}
.mainPanel > * {
white-space: normal;
vertical-align: top;
display: inline-block;
}
.controlColumn {
overflow: auto;
height:100%;
width: 250px;
padding: 20px;
}
.controlColumn input[type="text"] {
width:100%;
}
.controlColumn textarea {
width:100%;
max-width:100%;
min-width:100%;
}
.colorHint {
width: 22px;
}
.navigationBar a {
width: 32.3%;
box-sizing: border-box;
}
.paintColumn {
min-width: 320px;
max-width: 560px;
}
.canvasPanel {
border: 1px solid #40628a;
margin: 19px;
background-color: #202020;
line-height: 0px;
}
.canvasPanel p {
line-height: normal;
position: absolute;
width: 130px;
margin: 10px;
z-index: 0;
}
.canvasPanel canvas {
background: #202020;
z-index: 1;
position: relative;
}
.paintPanel {
margin: 20px;
}
.paintPanel label {
margin-right:20px;
}
.colorPanel {
margin-top: 20px;
}
.selectedColor {
float: left;
}
.palette {
overflow: auto;
}
.selectedColor div {
width: 58px;
height: 58px;
border: 1px solid #000000;
}
.palette div {
width: 28px;
height: 28px;
display: inline-block;
border: 1px solid #000000;
}
.toolPanel input[type="text"] {
width: 5ch;
text-align:center
}

View File

@@ -0,0 +1,300 @@
/*
* ### canvas.js ###
* Scripts meant to handle canvas.html
*/
// Tab selector
var panelIdList = ["infoPanel", "templatePanel", "exportPanel"];
function panelSelect(panelId) {
var panelClass;
var buttonClass;
for (id in panelIdList) {
id = panelIdList[id];
panelClass = document.getElementById(id).classList;
buttonClass = document.getElementById(id + "Button").classList;
if (id != panelId) {
panelClass.add("hidden");
buttonClass.remove("linkOn");
} else {
panelClass.remove("hidden");
buttonClass.add("linkOn");
}
}
}
function toggleClass(id, className) {
var classList = document.getElementById(id).classList;
if (classList.contains(className)) {
classList.remove(className);
} else {
classList.add(className);
}
}
function toggleHelp(helpButton, helpPanel) {
toggleClass(helpButton, "linkOn");
toggleClass(helpPanel, "hidden");
}
// Template control
var currentTemplate = {};
/* A test template for 14x14 sized paintings
{
"rgn":[
{"clr":"#ffffff","txt":"Mask"},
{"clr":"#ffdddd","txt":"Mask border"},
{"clr":"#ff8800","txt":"Hair"},
{"clr":"#ee6000","txt":"Hair shade"},
{"clr":"#ffff00","txt":"Eyeliner"},
{"clr":"#0000ff","txt":"Eyes"},
{"clr":"#ff8888","txt":"Nose shade"},
{"clr":"#ff0000","txt":"Nose"},
{"clr":"#000000","txt":"Background"}
],
"bmp": [
8,8,8,8,8,8,8,8,8,8,8,8,8,8,
8,8,8,8,8,8,8,8,8,8,8,8,8,8,
8,8,8,8,8,8,8,8,8,8,8,8,8,8,
2,2,2,8,8,8,8,8,8,8,2,2,2,8,
2,2,2,2,2,8,8,8,2,2,2,2,2,2,
2,2,2,2,1,1,1,1,1,2,2,2,2,2,
2,2,3,3,1,4,0,4,1,3,3,2,2,2,
3,3,3,1,1,5,4,5,1,1,3,3,3,2,
3,3,3,1,0,4,0,4,0,1,3,3,3,8,
8,8,8,1,0,6,7,6,0,1,8,8,8,8,
8,8,8,1,1,0,6,0,1,1,8,8,8,8,
8,8,8,8,1,1,1,1,1,8,8,8,8,8,
8,8,8,8,8,8,8,8,8,8,8,8,8,8,
8,8,8,8,8,8,8,8,8,8,8,8,8,8
]
}
*/
function templateMover(id, isTargetToDone) {
var item = document.getElementById("tp-" + id);
// "Disable" item's buttons (show disabled versions, hide enabled versions)
var items = item.getElementsByClassName("tp-pending-class");
for (i = 0; i < items.length; i++) {
if (isTargetToDone)
items[i].classList.add("hidden");
else
items[i].classList.remove("hidden");
}
items = item.getElementsByClassName("tp-done-class");
for (i = 0; i < items.length; i++) {
if (isTargetToDone)
items[i].classList.remove("hidden");
else
items[i].classList.add("hidden");
}
// Move item to Done list
var target = isTargetToDone ? "doneRegions" : "pendingRegions";
item.parentElement.removeChild(item);
items = document.getElementById(target).children;
inserted = false;
for (i = 0; i < items.length; i++) {
if (items[i].id > ("tp-" + id)) {
items[i].insertAdjacentElement("beforebegin", item);
inserted = true;
break;
}
}
if (!inserted) document.getElementById(target).insertAdjacentElement("beforeend", item);
}
function templatePending(id) {
templateMover(id, false);
}
function templateDone(id) {
templateMover(id, true);
}
var colorRegex = /^#[0-9A-Fa-f]{6}$/;
function buildTemplateItem(id, colorHint, textHint) {
// Create a new color region element
var item = document.createElement("LI");
item.id = "tp-" + id;
item.classList.add("item");
// Color hint. Invalid colors get a red cross over a white background instead
var missingColorIcon = "";
if (!colorRegex.test(colorHint)) {
missingColorIcon = '<span style="color: #ff0000; font-weight: bold">X</span>';
colorHint = "#ffffff";
}
item.innerHTML = '<a class="button colorHint" style="background: {color}">{icon}</a>'
.replace("{color}", colorHint).replace("{icon}", missingColorIcon);
// Paint button, and disabled button variant
item.innerHTML +=
'<a class="tp-pending-class button" onclick="templatePaint(\'{id}\');">Paint Over</a>'
.replace("{id}", id)
+ '<a class="tp-done-class button linkOff hidden">Paint Over</a>';
// Text hint toggle button
item.innerHTML +=
'<a class="button" id="tp-{id}-infoButton" onclick="toggleHelp(\'tp-{id}-infoButton\', \'tp-{id}-help\');"><div class="uiIcon16 icon-info"></div></a>'
.replace("{id}", id).replace("{id}", id).replace("{id}", id);
// Done/Pending button
item.innerHTML +=
'<a class="tp-pending-class button" onclick="templateDone(\'{id}\')"><div class="uiIcon16 icon-carat-1-s"></div></a>'
.replace("{id}", id)
+ '<a class="tp-done-class button hidden" onclick="templatePending(\'{id}\')"><div class="uiIcon16 icon-carat-1-n"></div></a>'
.replace("{id}", id);
// Text hint. Actual text added through innerText, lest players inject their own html
var helpText = document.createElement("P");
helpText.innerText = textHint;
item.innerHTML +=
'<div id="tp-{id}-help" class="line hidden"></div>'
.replace("{id}", id);
item.lastChild.appendChild(helpText);
return item;
}
function loadTemplate(template) {
//Empty both Pending and Done lists
var itemLister = document.getElementById("doneRegions");
while (itemLister.childElementCount > 0) {
itemLister.removeChild(itemLister.firstChild);
}
itemLister = document.getElementById("pendingRegions");
while (itemLister.childElementCount > 0) {
itemLister.removeChild(itemLister.firstChild);
}
// Fill Pending list with new color regions
currentTemplate = JSON.parse(template);
for (i = 0; i < currentTemplate.rgn.length; i++) {
itemLister.appendChild(buildTemplateItem(i, currentTemplate.rgn[i].clr, currentTemplate.rgn[i].txt));
}
}
function exportTemplate() {
var output = document.getElementById("export-text");
var template = {rgn: [], bmp: []};
var colors = [];
for (pixel in bitmap) {
if (colors.indexOf(bitmap[pixel]) < 0) {
colors.push(bitmap[pixel]);
template.rgn.push({clr: bitmap[pixel], txt: ""});
}
template.bmp.push(colors.indexOf(bitmap[pixel]));
}
output.value = JSON.stringify(template);
}
function templatePaint(id) {
for (i in currentTemplate.bmp) {
if (currentTemplate.bmp[i] == id) {
var x = i % width;
var y = (i - x)/width;
pixelDraw(x, y, paint_color, paint_strength);
}
}
display_bitmap();
document.getElementById("bitmap").value = bitmap;
}
var src;
function initCanvas(paintInitData, canvasInitData) {
initPaint(paintInitData);
document.getElementById("paintColumn").style.maxWidth = (document.getElementById("canvas").width + 40) + "px";
canvasInitData = JSON.parse(canvasInitData);
src = canvasInitData.src;
document.getElementById("title").value = canvasInitData.title;
document.getElementById("author").value = canvasInitData.author;
document.getElementById("description").value = canvasInitData.description;
sanitizeLength("author", "authorLengthMeter");
sanitizeLength("title", "titleLengthMeter");
sanitizeLength("description", "descriptionLengthMeter");
var paletteButtonPanel = document.getElementById("palette_buttons");
var palette = canvasInitData.palette;
while (paletteButtonPanel.childElementCount > 0) {
paletteButtonPanel.removeChild(paletteButtonPanel.firstChild);
}
for (color in palette) {
paletteButtonPanel.innerHTML +=
'<div onclick="setColor(\'' + palette[color] + '\');" style="background-image:' + generateColorPaletteBackgroundStyle(palette[color]) + '; background-image:' + generateColorPaletteBackgroundStyle(palette[color], true) + '"></div>\n';
}
setColor(palette[0]);
}
function generateColorPaletteBackgroundStyle (color, ieMode) {
let colorOpaque = hexToRgba(color);
colorOpaque.a = 255;
colorOpaque = rgbaToHex(colorOpaque);
// Stupid IE has to use this
if (ieMode) {
let ocolor = hexToRgba(color);
return "-ms-linear-gradient(-45deg, " + colorOpaque + " 0%, " + colorOpaque + " 25%, rgba(" + ocolor.r + "," + ocolor.g + "," + ocolor.b + "," + ocolor.a/255.0 + ") 26%), url(checkerboard.png)";
} else {
// Sane browsers use this line
return "linear-gradient(135deg, " + colorOpaque + " 0%, 25%, " + color + " 26%), url(checkerboard.png)";
}
}
function setColor(color){
setPaintColor(color);
updateSelectedColorDisplay(color, getOpacity())
}
function updateSelectedColorDisplay (color, alpha) {
color = hexToRgba(color);
color.a = ((color.a/255.0) * alpha) * 255
color = rgbaToHex(color)
document.getElementById("current_color").style["background-image"] = generateColorPaletteBackgroundStyle(color);
document.getElementById("current_color").style["background-image"] = generateColorPaletteBackgroundStyle(color, true);
}
function changeStrength(diff) {
var strengthInput = document.getElementById("paint_strength");
paint_strength = parseFloat(strengthInput.value, 10);
paint_strength += diff;
strengthInput.value = setOpacity(paint_strength);
updateSelectedColorDisplay(getPaintColor(), strengthInput.value);
}
function setStrength() {
var strengthInput = document.getElementById("paint_strength");
strengthInput.value = setOpacity(parseFloat(strengthInput.value, 10));
updateSelectedColorDisplay(getPaintColor(), strengthInput.value);
}
function sanitizeLength (inputId, meterId) {
var input = document.getElementById(inputId);
if (input.value.length > input.maxLength)
input.value = slice(input.value, 0, input.maxLength);
document.getElementById(meterId).innerHTML = "(" + input.value.length + "/" + input.maxLength + ")";
}
const MAX_AUTHOR_LENGTH = 52;
const MAX_TITLE_LENGTH = 52;
const MAX_DESCRIPTION_LENGTH = 1024;
function submitData() {
var content = "bitmap=" + encodeURIComponent(bitmap) + ";";
content += "author=" + encodeURIComponent(document.getElementById("author").value.slice(0, MAX_AUTHOR_LENGTH)) + ";";
content += "title=" + encodeURIComponent(document.getElementById("title").value.slice(0, MAX_TITLE_LENGTH)) + ";";
content += "description=" + encodeURIComponent(document.getElementById("description").value.slice(0, MAX_DESCRIPTION_LENGTH));
HREFmultipartHandler(src, content);
}

View File

@@ -0,0 +1,150 @@
<input id="bitmap" type="hidden" value=""/>
<!-- Main Content -->
<div class="mainPanel">
<!-- Control column -->
<div id="controlColumn" class="controlColumn">
<!-- Navigavtion -->
<div class="navigationBar">
<a
id="infoPanelButton"
class="button linkOn"
onclick="panelSelect('infoPanel');"
>Info</a><a
id="exportPanelButton"
class="button"
onclick="panelSelect('exportPanel');"
>Import</a><a
id="templatePanelButton"
class="button"
onclick="panelSelect('templatePanel');"
>Template</a>
</div>
<hr class="line">
<!-- Info panel -->
<div id="infoPanel" class="">
<div>
<label class="itemLabel">Title: </label><br/>
<input
id="title"
type="text"
maxlength="52"
onkeyup="sanitizeLength('title', 'titleLengthMeter');"
/>
<div id="titleLengthMeter">(0/52)</div>
</div><br>
<div>
<label class="itemLabel">Author: </label><br/>
<input
id="author"
type="text"
maxlength="52"
onkeyup="sanitizeLength('author', 'authorLengthMeter');"
/>
<div id="authorLengthMeter">(0/52)</div>
</div><br>
<div>
<label class="itemLabel">Description: </label><br/>
<textarea
id="description"
rows="5"
maxlength="1024"
onkeyup="sanitizeLength('description', 'descriptionLengthMeter');"
></textarea>
<div id="descriptionLengthMeter">(0/1024)</div>
</div><br>
<a class="button" onclick="submitData();">Save changes</a>
</div>
<!-- Template import/export panel -->
<div id="exportPanel" class="hidden">
<div class="line"><a id="importHelpButton" class="button" onclick="toggleHelp('importHelpButton', 'importHelp');">Help</a></div><br/>
<div id="importHelp" class="hidden block">
<p>Templates define a number of same-color regions, and assigns each pixel on your painting to a region, allowing you to use the "<i>Template</i>" tab to paint faster.</p>
<p>Templates are formatted as a <i>JSON</i>, using the following pattern:</p>
<code>{"rgn":[<br>
&nbsp;{"clr":"#ff0000","txt":"A"},<br>
&nbsp;{"clr":"#00ff00","txt":"B"},<br>
&nbsp;{"clr":"#0000ff","txt":""}<br>
], "bmp": [1,0,1,2]}</code>
<p>The first list (<code>"rgn"</code>) is a list of regions, each defining an hexadecimal RGB color hint (<code>"clr"</code>), and some text (<code>"txt"</code>).</p>
<p>The second list (<code>"bmp"</code>) assigns a region to each pixel, identified by their position on the <code>"rgn"</code> list: Pixels assigned to the first region will be marked with a "0", pixels assigned to the second with a "1", pixels assigned with to the third with a "2", and so on.</p>
<p>Make sure the size of your painting's canvas matches that of the template's bitmap! A 14x14 template will look distorted on a 26x26 canvas.</p>
<p>You may also export your current painting as a template, but be aware it may result in dozens of regions, and they will not be given any description</p>
</div><br/>
<h3>Import</h3>
<div>
<textarea id="import-text" rows="5"></textarea>
<a class="button" onclick="loadTemplate(document.getElementById('import-text').value)">Import template</a><br/>
</div><br/>
<h3>Export</h3>
<div>
<textarea id="export-text" rows="5"></textarea>
<a class="button" onclick="exportTemplate()">Export painting</a>
</div><br/>
</div>
<!-- Template panel -->
<div id="templatePanel" class="hidden">
<div class="line"><a id="templateHelpButton" class="button" onclick="toggleHelp('templateHelpButton', 'templateHelp');">Help</a></div><br/>
<div id="templateHelp" class="block hidden">
<p>Use the "<i>Import</i>" tab to import a painting template, a list of regions meant to have the same color that will be displayed on this tab.</p>
<p>Use "<i>Paint Over</i>" to paint a given region with your currently selected color and opacity. Each region usually comes with a color hint you're meant to match (color square to the left of "<i>Paint Over</i>"), and hopefully some text explaining what each region is meant to be (info button).</p>
<p>You may move any region to "<i>Done</i>" at any time, so as to get it out of the way if you're done with it, or back to "<i>In Progress</i>" if you need to touch it up again.</p>
</div><br/>
<h3>In Progress</h3>
<ul id="pendingRegions">
</ul>
<h3>Done</h3>
<ul id="doneRegions">
</ul>
</div>
</div>
<!-- Canvas panel -->
<div id="paintColumn" class="paintColumn">
<!-- Canvas -->
<div class="canvasPanel">
<p style="">
If you can read this then either your version of Internet Explorer is too old (IE8 or older) or something went wrong.</p>
<canvas
id="canvas"
width="280" height="280"
onmousedown="is_mouse_down = true;"
onmousemove="draw_on_bitmap();">
</canvas><!--520x520-->
</div>
<!-- Paint Tool Controls -->
<div class="paintPanel">
<span class="toolPanel">
<label class="itemLabelNarrow">Opacity: </label>
<a id="paint_strength_lower" class="button" onclick="changeStrength(-0.05);"><div class="uiIcon16 icon-minus"></div></a>
<span class="statusValue" id="tool_data_inputs">&nbsp;
<!-- Example. Replace with actual value using "interface.updateContent(...)"; -->
<input type="hidden" id="min_paint_strength" value="0"/>
<input type="hidden" id="max_paint_strength" value="1"/>
<input type="text" id="paint_strength" value="0.5" onchange="setStrength();"/>
&nbsp;</span>
<a id="paint_strength_lower" class="button" onclick="changeStrength(0.05);"><div class="uiIcon16 icon-plus"></div></a>
</span>
<br/>
<!-- Selected Color -->
<div class="colorPanel">
<label class="selectedColor">
<div style="background: #000000" id="current_color"></div>
</label>
<!-- Color palette -->
<div id="palette_buttons" class="palette"></div>
</div>
</div>
</div>
</div>

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 B

View File

@@ -0,0 +1,232 @@
#define PENCIL_STRENGTH_MAX 0.15
#define PENCIL_STRENGTH_MIN 0
#define BRUSH_STRENGTH_MAX 1
#define BRUSH_STRENGTH_MIN 0
/*
* PAINTING UTENSIL DATUM
*
* Add any items that can be used to it's New() so it is properly converted into the right data
*
*/
/datum/painting_utensil
var/min_strength = 0
var/max_strength = 1
var/list/palette = list()
var/base_color
/datum/painting_utensil/New(mob/user, obj/item/held_item)
if (!user) // Special case
return
if (!held_item)
held_item = user.get_active_hand()
if (istype(held_item, /obj/item/weapon/pen))
var/obj/item/weapon/pen/p = held_item
max_strength = PENCIL_STRENGTH_MAX
min_strength = PENCIL_STRENGTH_MIN
palette += p.colour_rgb
base_color = p.colour_rgb
if (istype(held_item, /obj/item/toy/crayon))
var/obj/item/toy/crayon/c = held_item
max_strength = PENCIL_STRENGTH_MAX
min_strength = PENCIL_STRENGTH_MIN
palette += c.colour
palette += c.shadeColour
base_color = c.color
if (istype(held_item, /obj/item/weapon/hair_dye))
var/obj/item/weapon/hair_dye/h = held_item
max_strength = PENCIL_STRENGTH_MAX
min_strength = PENCIL_STRENGTH_MIN
palette += rgb(h.color_r, h.color_g, h.color_b)
base_color = rgb(h.color_r, h.color_g, h.color_b)
if (istype(held_item, /obj/item/weapon/painting_brush))
var/obj/item/weapon/painting_brush/b = held_item
if (b.paint_color)
max_strength = BRUSH_STRENGTH_MAX
min_strength = BRUSH_STRENGTH_MIN
palette += b.paint_color
base_color = b.paint_color
/datum/painting_utensil/proc/duplicate()
var/datum/painting_utensil/dupe = new(null, null)
dupe.max_strength = src.max_strength
dupe.min_strength = src.min_strength
dupe.palette = src.palette
dupe.base_color = src.base_color
dupe.tag = "\ref[dupe]"
return dupe
/*
* CUSTOM PAINTING DATUM
*
* Add this to any object you should be able to paint on, setting said object as this datum's parent, either through New()
* or set_parent() if moving this datum to a different object
*
* Call interact() when the user starts painting
*/
/datum/custom_painting
var/parent
// Array listing all colors, starting from the upper left corner
var/list/bitmap = list()
var/bitmap_width = 14
var/bitmap_height = 14
// Color that shows up on creation or after cleaning
var/base_color = "#ffffff"
// Position of the lower left corner of the image when rendering the bitmap onto an icon
var/offset_x = 0
var/offset_y = 0
// UI and JS stuff
var/datum/html_interface/interface
var/datum/href_multipart_handler/mp_handler
var/author = ""
var/title = ""
var/description = ""
/datum/custom_painting/New(parent, bitmap_width, bitmap_height, offset_x=0, offset_y=0, base_color=src.base_color)
src.parent = parent
src.bitmap_width = bitmap_width
src.bitmap_height = bitmap_height
src.offset_x = offset_x
src.offset_y = offset_y
src.base_color = base_color
mp_handler = new /datum/href_multipart_handler(parent)
blank_contents()
setup_UI()
/datum/custom_painting/Destroy()
..()
parent = null
qdel(interface)
interface = null
qdel(mp_handler)
mp_handler = null
/datum/custom_painting/proc/Copy()
var/datum/custom_painting/copy = new(parent, bitmap_width, bitmap_height, offset_x, offset_y, base_color)
copy.author = author
copy.title = title
copy.description = description
copy.bitmap = bitmap.Copy()
return copy
/datum/custom_painting/proc/set_parent(parent)
src.parent = parent
mp_handler.set_parent(parent)
/datum/custom_painting/proc/blank_contents()
bitmap = list()
for (var/i = 0, i < bitmap_height * bitmap_width, i++)
bitmap += base_color
/datum/custom_painting/proc/setup_UI()
// Setup head
var/head = {"
<link rel=\"stylesheet\" type=\"text/css\" href=\"canvas.css\" />
<link rel=\"stylesheet\" type=\"text/css\" href=\"shared.css\" />
<link rel=\"stylesheet\" type=\"text/css\" href=\"html_interface_icons.css\" />
<script src=\"paintTool.js\"></script>
<script src=\"canvas.js\"></script>
<script src=\"href_multipart_handler.js\"></script>
"}
// Use NT-style UI
src.interface = new/datum/html_interface/nanotrasen(src, "Canvas", 600, 600, head)
// Setup contents
interface.updateContent("content", file2text("code/modules/html_interface/paintTool/canvas.tmpl"))
/datum/custom_painting/proc/interact(mob/user, datum/painting_utensil/p)
var/paint_init_inputs = json_encode(list(
"width" = bitmap_width,
"height" = bitmap_height,
"bitmap" = bitmap,
"minPaintStrength" = p.min_strength,
"maxPaintStrength" = p.max_strength
))
var/canvas_init_inputs = json_encode(list(
"src" = "\ref[parent]",
"palette" = p.palette, //list("#000000", "#ffffff", "#ff0000", "#ffff00", "#00ff00", "#00ffff", "#0000ff", "#ff00ff"),
"title" = title,
"author" = author,
"description" = description
))
// Send assets, wait for them to load before showing UI and initializing scripts
var/delay = 0
delay += send_asset(user.client, "paintTool.js")
delay += send_asset(user.client, "canvas.js")
delay += send_asset(user.client, "href_multipart_handler.js")
delay += send_asset(user.client, "canvas.css")
delay += send_asset(user.client, "checkerboard.png")
spawn(delay)
interface.show(user)
interface.callJavaScript("initCanvas", list(paint_init_inputs,canvas_init_inputs), user)
/datum/custom_painting/Topic(href, href_list)
// Handle multipart href
if (href_list["multipart"])
mp_handler.Topic(href, href_list)
return
// Save changes
else
// Make sure the player can actually paint
if(!usr || usr.incapacitated())
return
var /datum/painting_utensil/pu = new /datum/painting_utensil(usr)
if(!pu.palette.len)
//TODO other tools (crayons, brushes)
to_chat(usr, "<span class='warning'>You need to be holding a painting utensil in your active hand.</span>")
return
if (!do_after(usr, parent, 30))
return
//Save and sanitize bitmap
bitmap = splittext(url_decode(href_list["bitmap"]), ",")
for (var/i = 1; i <= bitmap.len; i++)
bitmap[i] = sanitize_hexcolor(bitmap[i])
//Save and sanitize author, title and description
author = copytext(sanitize(url_decode(href_list["author"])), 1, MAX_NAME_LEN)
title = copytext(sanitize(url_decode(href_list["title"])), 1, MAX_NAME_LEN)
description = copytext(sanitize(url_decode(href_list["description"])), 1, MAX_MESSAGE_LEN)
return TRUE
/datum/custom_painting/proc/render_on(icon/ico, offset_x = src.offset_x, offset_y = src.offset_y)
var/x
var/y
for (var/pixel = 0; pixel < bitmap.len; pixel++)
x = pixel % bitmap_width
y = (pixel - x)/bitmap_width
//for DrawBox, (x:1,y:1) is the lower left corner. On bitmap, (x:0,y:0) is the upper left
x = 1 + offset_x + x
y = 1 + offset_y + bitmap_height - y
ico.DrawBox(bitmap[pixel + 1], x, y)
return ico
#undef PENCIL_STRENGTH_MAX
#undef PENCIL_STRENGTH_MIN
#undef BRUSH_STRENGTH_MAX
#undef BRUSH_STRENGTH_MIN

View File

@@ -0,0 +1,483 @@
/**
* #### 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;
setStrength();
//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);
}
}
}