rkgk/static/brush-box.js
リキ萌 bff899c9c0 removing server-side brush rendering
brush rendering is now completely client-side.
the server only receives edits the client would like to do, in the form of PNG images of chunks, that are then composited onto the wall

known issue: it is possible to brush up against the current 256 chunk edit limit pretty easily.
I'm not sure it can be solved very easily though. the perfect solution would involve splitting up the interaction into multiple edits, and I tried to do that, but there's a noticable stutter for some reason that I haven't managed to track down yet.
so it'll be kinda crap for the time being.
2025-06-30 18:55:53 +02:00

350 lines
10 KiB
JavaScript

import { BrushPreview } from "rkgk/brush-preview.js";
import { Haku } from "rkgk/haku.js";
import { randomId } from "rkgk/random.js";
import { SaveData } from "rkgk/framework.js";
import { ContextMenu, globalContextMenuSpace } from "rkgk/context-menu.js";
export const builtInPresets = [
{
id: "builtin/default",
name: "Default",
code: `
-- Try playing around with the values
-- and see what happens!
color = #000
thickness = 8
withDotter \\d ->
stroke thickness color (line (d From) (d To))
`.trim(),
},
{
id: "builtin/thick",
name: "Thick",
code: `
color = #000
thickness = 48
withDotter \\d ->
stroke thickness color (line (d From) (d To))
`.trim(),
},
{
id: "builtin/pencil",
name: "Pencil",
code: `
color = #0003
thickness = 6
withDotter \\d ->
stroke thickness color (line (d From) (d To))
`.trim(),
},
{
id: "builtin/woobly",
name: "Woobly",
code: `
color = #000
minThickness = 8
maxThickness = 20
wavelength = 1
withDotter \\d ->
let pi = 3.14159265
let a = (sin (d Num * wavelength / pi) + 1) / 2
let range = maxThickness - minThickness
let thickness = minThickness + a * range
stroke thickness color (line (d From) (d To))
`.trim(),
},
{
id: "builtin/wavy",
name: "Wavy",
code: `
color = #000
thickness = 4
amplitude = 50
wavelength = 1
mag = \\v ->
hypot (vecX v) (vecY v)
norm = \\u ->
let l = mag u
u / vec l l
perpClockwise = \\v ->
vec (vecY v) (-(vecX v))
withDotter \\d ->
let pi = 3.14159265
let a = sin (d Num * wavelength / pi) * amplitude
let direction = (d To) - (d From)
let clockwise = norm (perpClockwise direction) * vec a a
let from = d From + clockwise
let to = d To + clockwise
stroke thickness color (line from to)
`.trim(),
},
{
id: "builtin/rainbow",
name: "Rainbow",
code: `
wavelength = 0.1
thickness = 8
colorCurve = \\n ->
abs (cos n)
withDotter \\d ->
let pi = 3.14159265
let l = wavelength
let r = colorCurve (d Num * l)
let g = colorCurve (d Num * l + pi/3)
let b = colorCurve (d Num * l + 2*pi/3)
let color = rgba r g b 1
stroke thickness color (line (d From) (d To))
`.trim(),
},
// ...feel free to add more presets here!
];
function presetButton(info) {
let button = document.createElement("button");
let preview = button.appendChild(new BrushPreview(56, 56));
let label = button.appendChild(document.createElement("p"));
label.classList.add("label");
label.innerText = info.name;
return { button, preview };
}
export class BrushBox extends HTMLElement {
dirty = false;
saveData = new SaveData("brushBox");
connectedCallback() {
this.saveData.attachToElement(this);
this.brushes = [];
this.brushesContainer = this.appendChild(document.createElement("div"));
this.brushesContainer.classList.add("brushes");
this.userPresets = this.saveData.get("userPresets", []);
this.updateBrushes();
// NOTE: Restoring the currently selected brush from local storage must NOT end up in a
// code editor update, so as not to reset the user's currently WIP brush.
let savedCurrentBrush = this.saveData.get("currentBrush", "builtin/default");
if (this.findBrushPreset(savedCurrentBrush)) {
this.setCurrentBrush(savedCurrentBrush);
} else {
console.warn("brush preset does not exist", savedCurrentBrush);
this.setCurrentBrush("builtin/default");
this.#sendBrushChange(this.findBrushPreset(this.currentPresetId));
}
}
initialize(wallLimits) {
this.haku = new Haku(wallLimits);
this.renderBrushes();
}
updateBrushes() {
this.brushesContainer.replaceChildren();
this.brushes = [];
const addBrush = (preset, { mutable }) => {
let pb = presetButton(preset);
this.brushesContainer.appendChild(pb.button);
this.brushes.push({
preset,
presetButton: pb,
});
pb.button.addEventListener("click", (event) => {
if (this.dirty) {
let confirmation = new ContextMenu();
confirmation.addHtmlP(
"<strong>Your brush has unsaved changes.</strong>" +
"<br>Switching to another brush will discard them." +
"<br>Are you sure?",
);
confirmation.addSeparator();
confirmation
.addButton("Discard changes and switch brush", {
classList: ["destructive"],
})
.addEventListener("click", () => {
this.#sendBrushChange(preset);
});
confirmation.addHtmlP("Click anywhere else to cancel", {
classList: ["small"],
});
globalContextMenuSpace.openAtCursor(event, confirmation);
} else {
this.#sendBrushChange(preset);
}
});
pb.button.addEventListener("contextmenu", (event) => {
event.preventDefault();
let menu = new ContextMenu();
menu.addButton(this.currentPresetId == preset.id ? "Save" : "Overwrite", {
disabled: !mutable,
tooltip: mutable ? "" : "This is a built-in brush and cannot be modified",
}).addEventListener("click", () => {
this.dispatchEvent(
Object.assign(new Event(".overwritePreset"), {
preset,
}),
);
this.updateBrushes();
this.renderBrushes();
this.markClean();
this.saveUserPresets();
});
menu.addButton("Duplicate").addEventListener("click", () => {
let id = this.saveUserPreset(preset);
this.updateBrushes();
this.renderBrushes();
if (!this.dirty) {
this.currentPresetId = id;
let duped = this.findBrushPreset(this.currentPresetId);
this.#sendBrushChange(duped);
}
});
menu.addButton("Delete", {
classList: ["destructive"],
disabled: !mutable,
tooltip: mutable ? "" : "This is a built-in brush and cannot be deleted",
}).addEventListener("click", () => {
// NOTE: This leaves the box in a weird state where there is no current brush.
// That's okay; code should not assume there's a current preset ID.
if (this.currentPresetId == preset.id) {
this.currentPresetId = null;
this.markDirty();
}
let index = this.userPresets.indexOf(preset);
this.userPresets.splice(index, 1);
this.updateBrushes();
this.renderBrushes();
this.saveUserPresets();
// NOTE: No brush change because we don't want to overwrite the editor.
});
globalContextMenuSpace.openAtCursor(event, menu);
});
};
for (let preset of builtInPresets) addBrush(preset, { mutable: false });
for (let preset of this.userPresets) addBrush(preset, { mutable: true });
let saveButton = this.brushesContainer.appendChild(document.createElement("button"));
saveButton.classList.add("save-button");
let plusIcon = saveButton.appendChild(document.createElement("div"));
plusIcon.classList.add("icon", "icon-plus");
let saveLabel = saveButton.appendChild(document.createElement("p"));
saveLabel.classList.add("label");
saveLabel.innerText = "Save new";
saveButton.addEventListener("click", () => {
this.dispatchEvent(new Event(".clickNew"));
});
if (this.currentPresetId != null) this.setCurrentBrush(this.currentPresetId);
}
async renderBrushes() {
for (let brush of this.brushes) {
this.haku.setBrush(brush.preset.code);
await brush.presetButton.preview.renderBrush(this.haku);
}
}
setCurrentBrush(id) {
this.currentPresetId = id;
this.saveData.set("currentBrush", this.currentPresetId);
for (let brush of this.brushes) {
let button = brush.presetButton.button;
if (brush.preset.id != id) button.classList.remove("current");
else button.classList.add("current");
}
}
indexOfBrushPreset(id) {
for (let i in this.brushes) {
if (this.brushes[i].preset.id == id) return i;
}
return null;
}
findBrushPreset(id) {
let index = this.indexOfBrushPreset(id);
if (index == null) return null;
return this.brushes[index].preset;
}
markDirty() {
this.dirty = true;
this.classList.add("unsaved");
}
markClean() {
this.dirty = false;
this.classList.remove("unsaved");
}
saveUserPreset({ name, code }, id = null) {
if (id == null) {
// Save a new preset.
id = `user/${randomId()}`;
console.info("saving new brush", id);
this.userPresets.push({
id,
name,
code,
});
} else {
// Overwrite an existing one.
let preset = this.userPresets.find((p) => p.id == id);
console.info("overwriting existing brush", preset);
preset.code = code;
}
this.saveUserPresets();
return id;
}
saveUserPresets() {
this.saveData.set("userPresets", this.userPresets);
}
#sendBrushChange(preset) {
this.dispatchEvent(
Object.assign(new Event(".brushChange"), {
preset,
}),
);
}
}
customElements.define("rkgk-brush-box", BrushBox);