a whole load of work in progress

This commit is contained in:
りき萌 2024-08-10 23:13:20 +02:00
parent caec0b8ac9
commit 26ba098183
63 changed files with 3234 additions and 321 deletions

37
static/brush-editor.js Normal file
View file

@ -0,0 +1,37 @@
const defaultBrush = `
; This is your brush.
; Feel free to edit it to your liking!
(stroke
8 ; thickness
(rgba 0.0 0.0 0.0 1.0) ; color
(vec)) ; position
`.trim();
export class BrushEditor extends HTMLElement {
constructor() {
super();
}
connectedCallback() {
this.classList.add("rkgk-panel");
this.textArea = this.appendChild(document.createElement("pre"));
this.textArea.classList.add("text-area");
this.textArea.textContent = defaultBrush;
this.textArea.contentEditable = true;
this.textArea.spellcheck = false;
this.textArea.addEventListener("input", () => {
this.dispatchEvent(
Object.assign(new Event(".codeChanged"), {
newCode: this.textArea.value,
}),
);
});
}
get code() {
return this.textArea.textContent;
}
}
customElements.define("rkgk-brush-editor", BrushEditor);

154
static/canvas-renderer.js Normal file
View file

@ -0,0 +1,154 @@
import { listen } from "./framework.js";
import { Viewport } from "./viewport.js";
class CanvasRenderer extends HTMLElement {
viewport = new Viewport();
constructor() {
super();
}
connectedCallback() {
this.canvas = this.appendChild(document.createElement("canvas"));
this.ctx = this.canvas.getContext("2d");
let resizeObserver = new ResizeObserver(() => this.#updateSize());
resizeObserver.observe(this);
this.#cursorReportingBehaviour();
this.#draggingBehaviour();
this.#zoomingBehaviour();
this.#paintingBehaviour();
}
initialize(wall, painter) {
this.wall = wall;
this.painter = painter;
requestAnimationFrame(() => this.#render());
}
#updateSize() {
this.canvas.width = this.clientWidth;
this.canvas.height = this.clientHeight;
// Rerender immediately after the canvas is resized, as its contents have now been invalidated.
this.#render();
}
#render() {
// NOTE: We should probably render on-demand only when it's needed.
requestAnimationFrame(() => this.#render());
this.#renderWall();
}
#renderWall() {
if (this.wall == null) {
console.debug("wall is not available, skipping rendering");
return;
}
this.ctx.fillStyle = "white";
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
this.ctx.save();
this.ctx.translate(Math.floor(this.clientWidth / 2), Math.floor(this.clientHeight / 2));
this.ctx.scale(this.viewport.zoom, this.viewport.zoom);
this.ctx.translate(-this.viewport.panX, -this.viewport.panY);
let visibleRect = this.viewport.getVisibleRect({
width: this.clientWidth,
height: this.clientHeight,
});
let left = Math.floor(visibleRect.x / this.wall.chunkSize);
let top = Math.floor(visibleRect.y / this.wall.chunkSize);
let right = Math.ceil((visibleRect.x + visibleRect.width) / this.wall.chunkSize);
let bottom = Math.ceil((visibleRect.y + visibleRect.height) / this.wall.chunkSize);
for (let chunkY = top; chunkY < bottom; ++chunkY) {
for (let chunkX = left; chunkX < right; ++chunkX) {
let x = chunkX * this.wall.chunkSize;
let y = chunkY * this.wall.chunkSize;
let chunk = this.wall.getChunk(chunkX, chunkY);
if (chunk != null) {
this.ctx.drawImage(chunk.canvas, x, y);
}
}
}
this.ctx.restore();
if (this.ctx.brushPreview != null) {
this.ctx.drawImage(this.ctx.brushPreview, 0, 0);
}
}
async #cursorReportingBehaviour() {
while (true) {
let event = await listen([this, "mousemove"]);
let [x, y] = this.viewport.toViewportSpace(
event.clientX - this.clientLeft,
event.offsetY - this.clientTop,
{
width: this.clientWidth,
height: this.clientHeight,
},
);
this.dispatchEvent(Object.assign(new Event(".cursor"), { x, y }));
}
}
async #draggingBehaviour() {
while (true) {
let mouseDown = await listen([this, "mousedown"]);
if (mouseDown.button == 1) {
mouseDown.preventDefault();
while (true) {
let event = await listen([window, "mousemove"], [window, "mouseup"]);
if (event.type == "mousemove") {
this.viewport.panAround(event.movementX, event.movementY);
this.dispatchEvent(new Event(".viewportUpdate"));
} else if (event.type == "mouseup") {
break;
}
}
}
}
}
async #zoomingBehaviour() {
while (true) {
let event = await listen([this, "wheel"]);
// TODO: Touchpad zoom
this.viewport.zoomIn(event.deltaY > 0 ? -1 : 1);
this.dispatchEvent(new Event(".viewportUpdate"));
}
}
async #paintingBehaviour() {
const paint = (x, y) => {
let [wallX, wallY] = this.viewport.toViewportSpace(x, y, {
width: this.clientWidth,
height: this.clientHeight,
});
this.dispatchEvent(Object.assign(new Event(".paint"), { x: wallX, y: wallY }));
};
while (true) {
let mouseDown = await listen([this, "mousedown"]);
if (mouseDown.button == 0) {
paint(mouseDown.offsetX, mouseDown.offsetY);
while (true) {
let event = await listen([window, "mousemove"], [window, "mouseup"]);
if (event.type == "mousemove") {
paint(event.clientX - this.clientLeft, event.offsetY - this.clientTop);
} else if (event.type == "mouseup") {
break;
}
}
}
}
}
}
customElements.define("rkgk-canvas-renderer", CanvasRenderer);

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

30
static/framework.js Normal file
View file

@ -0,0 +1,30 @@
export function listen(...listenerSpecs) {
return new Promise((resolve) => {
let removeAllEventListeners;
let listeners = listenerSpecs.map(([element, eventName]) => {
let listener = (event) => {
removeAllEventListeners();
resolve(event);
};
element.addEventListener(eventName, listener);
return { element, eventName, func: listener };
});
removeAllEventListeners = () => {
for (let listener of listeners) {
listener.element.removeEventListener(listener.eventName, listener.func);
}
};
});
}
export function debounce(time, fn) {
let timeout = null;
return (...args) => {
if (timeout == null) {
fn(...args);
timeout = setTimeout(() => (timeout = null), time);
}
};
}

203
static/haku.js Normal file
View file

@ -0,0 +1,203 @@
let panicImpl;
let logImpl;
function makeLogFunction(level) {
return (length, pMessage) => {
logImpl(level, length, pMessage);
};
}
let { instance: hakuInstance, module: hakuModule } = await WebAssembly.instantiateStreaming(
fetch(import.meta.resolve("./wasm/haku.wasm")),
{
env: {
panic(length, pMessage) {
panicImpl(length, pMessage);
},
trace: makeLogFunction("trace"),
debug: makeLogFunction("debug"),
info: makeLogFunction("info"),
warn: makeLogFunction("warn"),
error: makeLogFunction("error"),
},
},
);
let memory = hakuInstance.exports.memory;
let w = hakuInstance.exports;
let textEncoder = new TextEncoder();
function allocString(string) {
let size = string.length * 3;
let align = 1;
let pString = w.haku_alloc(size, align);
let buffer = new Uint8Array(memory.buffer, pString, size);
let result = textEncoder.encodeInto(string, buffer);
return {
ptr: pString,
length: result.written,
size,
align,
};
}
function freeString(alloc) {
w.haku_free(alloc.ptr, alloc.size, alloc.align);
}
let textDecoder = new TextDecoder();
function readString(size, pString) {
let buffer = new Uint8Array(memory.buffer, pString, size);
return textDecoder.decode(buffer);
}
function readCString(pCString) {
let memoryBuffer = new Uint8Array(memory.buffer);
let pCursor = pCString;
while (memoryBuffer[pCursor] != 0 && memoryBuffer[pCursor] != null) {
pCursor++;
}
let size = pCursor - pCString;
return readString(size, pCString);
}
class Panic extends Error {
name = "Panic";
}
panicImpl = (length, pMessage) => {
throw new Panic(readString(length, pMessage));
};
logImpl = (level, length, pMessage) => {
console[level](readString(length, pMessage));
};
w.haku_init_logging();
export class Pixmap {
#pPixmap = 0;
constructor(width, height) {
this.#pPixmap = w.haku_pixmap_new(width, height);
this.width = width;
this.height = height;
}
destroy() {
w.haku_pixmap_destroy(this.#pPixmap);
}
clear(r, g, b, a) {
w.haku_pixmap_clear(this.#pPixmap, r, g, b, a);
}
get ptr() {
return this.#pPixmap;
}
get imageData() {
return new ImageData(
new Uint8ClampedArray(
memory.buffer,
w.haku_pixmap_data(this.#pPixmap),
this.width * this.height * 4,
),
this.width,
this.height,
);
}
}
export class Haku {
#pInstance = 0;
#pBrush = 0;
#brushCode = null;
constructor() {
this.#pInstance = w.haku_instance_new();
this.#pBrush = w.haku_brush_new();
}
setBrush(code) {
w.haku_reset(this.#pInstance);
// NOTE: Brush is invalid at this point, because we reset removes all defs and registered chunks.
if (this.#brushCode != null) freeString(this.#brushCode);
this.#brushCode = allocString(code);
let statusCode = w.haku_compile_brush(
this.#pInstance,
this.#pBrush,
this.#brushCode.length,
this.#brushCode.ptr,
);
if (!w.haku_is_ok(statusCode)) {
if (w.haku_is_diagnostics_emitted(statusCode)) {
let diagnostics = [];
for (let i = 0; i < w.haku_num_diagnostics(this.#pBrush); ++i) {
diagnostics.push({
start: w.haku_diagnostic_start(this.#pBrush, i),
end: w.haku_diagnostic_end(this.#pBrush, i),
message: readString(
w.haku_diagnostic_message_len(this.#pBrush, i),
w.haku_diagnostic_message(this.#pBrush, i),
),
});
}
return {
status: "error",
errorKind: "diagnostics",
diagnostics,
};
} else {
return {
status: "error",
errorKind: "plain",
message: readCString(w.haku_status_string(statusCode)),
};
}
}
return { status: "ok" };
}
renderBrush(pixmap, translationX, translationY) {
let statusCode = w.haku_render_brush(
this.#pInstance,
this.#pBrush,
pixmap.ptr,
// If we ever want to detect which pixels were touched (USING A SHADER.), we can use
// this to rasterize the brush _twice_, and then we can detect which pixels are the same
// between the two pixmaps.
0,
translationX,
translationY,
);
if (!w.haku_is_ok(statusCode)) {
if (w.haku_is_exception(statusCode)) {
return {
status: "error",
errorKind: "exception",
description: readCString(w.haku_status_string(statusCode)),
message: readString(
w.haku_exception_message_len(this.#pInstance),
w.haku_exception_message(this.#pInstance),
),
};
} else {
return {
status: "error",
errorKind: "plain",
message: readCString(w.haku_status_string(statusCode)),
};
}
}
return { status: "ok" };
}
}

232
static/index.css Normal file
View file

@ -0,0 +1,232 @@
/* Variables */
:root {
--color-text: #111;
--color-error: #db344b;
--color-panel-border: rgba(0, 0, 0, 20%);
--color-panel-background: #fff;
--panel-border-radius: 16px;
--panel-box-shadow: 0 0 0 1px var(--color-panel-border);
--panel-padding: 12px;
--dialog-backdrop: rgba(255, 255, 255, 0.5);
}
/* Reset */
body {
margin: 0;
width: 100vw;
height: 100vh;
color: var(--color-text);
line-height: 1.4;
}
/* Fonts */
@font-face {
font-family: "Fira Sans";
src:
local("Fira Sans Regular"),
url("font/FiraSans-Regular.ttf");
font-weight: 400;
}
@font-face {
font-family: "Fira Sans";
src:
local("Fira Sans Bold"),
url("font/FiraSans-Bold.ttf");
font-weight: 700;
}
@font-face {
font-family: "Fira Code";
src:
local("Fira Code"),
url("font/FiraCode-VariableFont_wght.ttf");
font-weight: 400;
}
:root {
font-size: 87.5%;
font-family: "Fira Sans", sans-serif;
}
button, textarea, input {
font-size: inherit;
font-family: inherit;
}
/* Main container layout */
main {
width: 100%;
height: 100%;
position: relative;
&>rkgk-canvas-renderer {
width: 100%;
height: 100%;
position: absolute;
left: 0;
top: 0;
}
&>rkgk-reticle-renderer {
width: 100%;
height: 100%;
position: absolute;
left: 0;
top: 0;
overflow: hidden;
}
&>rkgk-brush-editor {
width: 384px;
position: absolute;
right: 0;
top: 0;
margin: 16px;
}
}
/* Buttons */
button {
border: 1px solid var(--color-panel-border);
border-radius: 9999px;
padding: 0.5rem 1.5rem;
background-color: var(--color-panel-background);
}
/* Text areas */
input {
border: none;
border-bottom: 1px solid var(--color-panel-border);
}
*[contenteditable]:focus, input:focus {
border-radius: 2px;
outline: 1px solid #40b1f4;
outline-offset: 4px;
}
/* Modal dialogs */
dialog:not([open]) {
/* Weird this doesn't seem to work by default. */
display: none;
}
dialog::backdrop {
background-color: var(--dialog-backdrop);
backdrop-filter: blur(8px);
}
/* Throbbers */
rkgk-throbber {
display: inline;
&.loading {
&::before {
/* This could use an entertaining animation. */
content: "Please wait...";
}
}
&.error {
/* This could use an icon. */
color: var(--color-error);
}
}
/* Panels */
.rkgk-panel {
display: block;
background: var(--color-panel-background);
padding: var(--panel-border-radius);
border: none;
border-radius: 16px;
box-shadow: var(--panel-box-shadow);
}
/* Canvas renderer */
rkgk-canvas-renderer {
display: block;
&>canvas {
display: block;
}
}
/* Reticle renderer */
rkgk-reticle-renderer {
display: block;
pointer-events: none;
&>.reticles {
position: relative;
}
}
rkgk-reticle {
--color: black;
position: absolute;
display: block;
&>.container {
&>.arrow {
width: 24px;
height: 24px;
background-color: var(--color);
clip-path: path("M 0,0 L 13,13 L 6,13 L 0,19 Z");
}
&>.nickname {
position: absolute;
top: 20px;
left: 8px;
color: white;
background-color: var(--color);
padding: 1px 6px;
border-radius: 9999px;
text-align: center;
font-weight: bold;
}
}
}
/* Brush editor */
rkgk-brush-editor {
&>.text-area {
width: 100%;
height: 100%;
margin: 0;
resize: none;
font-family: "Fira Code", monospace;
}
}
/* Welcome screen */
rkgk-welcome {
&>dialog {
h3 {
margin: 0.5rem 0;
font-size: 2rem;
font-weight: bold;
}
}
}

View file

@ -2,17 +2,53 @@
<html>
<head>
<title>canvane</title>
<script src="static/index.js" type="module"></script>
<title>rakugaki</title>
<link rel="stylesheet" href="static/index.css">
<script src="static/live-reload.js" type="module"></script>
<script src="static/brush-editor.js" type="module"></script>
<script src="static/canvas-renderer.js" type="module"></script>
<script src="static/framework.js" type="module"></script>
<script src="static/reticle-renderer.js" type="module"></script>
<script src="static/session.js" type="module"></script>
<script src="static/throbber.js" type="module"></script>
<script src="static/viewport.js" type="module"></script>
<script src="static/welcome.js" type="module"></script>
<script src="static/index.js" type="module"></script>
</head>
<body>
<main>
<canvas id="render" width="256" height="256">Please enable JavaScript</canvas>
<br>
<textarea id="code" cols="80" rows="25">(stroke 1 (rgba 0 0 0 255) (vec 32 32))</textarea>
<p id="output" style="white-space: pre-wrap;"></p>
<rkgk-canvas-renderer></rkgk-canvas-renderer>
<rkgk-reticle-renderer></rkgk-reticle-renderer>
<rkgk-brush-editor></rkgk-brush-editor>
<rkgk-welcome>
<dialog name="welcome-dialog" class="rkgk-panel">
<form method="dialog">
<h3>
My name is
<input
name="nickname"
type="text"
required minlength="1" maxlength="32"
placeholder="... (type here!)"
autocomplete="off"
autofocus></input>
</h3>
<p>This name will be visible to any friends drawing along with you, so choose something recognizable!<br>
Keep in mind you can always change it later.</p>
<div style="display: flex; flex-direction: row; align-items: center; justify-content: end; gap: 8px;">
<rkgk-throbber name="register-progress"></rkgk-throbber>
<button name="register">Register</button>
</div>
</form>
</dialog>
</rkgk-welcome>
</main>
</body>
</html>

View file

@ -1,154 +1,74 @@
let panicImpl;
let logImpl;
import { Painter } from "./painter.js";
import { Wall } from "./wall.js";
import { Haku } from "./haku.js";
import { getUserId, newSession, waitForLogin } from "./session.js";
import { debounce } from "./framework.js";
function makeLogFunction(level) {
return (length, pMessage) => {
logImpl(level, length, pMessage);
};
}
let main = document.querySelector("main");
let canvasRenderer = main.querySelector("rkgk-canvas-renderer");
let reticleRenderer = main.querySelector("rkgk-reticle-renderer");
let brushEditor = main.querySelector("rkgk-brush-editor");
let { instance: hakuInstance, module: hakuModule } = await WebAssembly.instantiateStreaming(
fetch(import.meta.resolve("./wasm/haku.wasm")),
{
env: {
panic(length, pMessage) {
panicImpl(length, pMessage);
},
trace: makeLogFunction("trace"),
debug: makeLogFunction("debug"),
info: makeLogFunction("info"),
warn: makeLogFunction("warn"),
error: makeLogFunction("error"),
},
},
);
let haku = new Haku();
let painter = new Painter(512);
let memory = hakuInstance.exports.memory;
let w = hakuInstance.exports;
reticleRenderer.connectViewport(canvasRenderer.viewport);
canvasRenderer.addEventListener(".viewportUpdate", () => reticleRenderer.updateTransform());
let textEncoder = new TextEncoder();
function allocString(string) {
let size = string.length * 3;
let align = 1;
let pString = w.haku_alloc(size, align);
// In the background, connect to the server.
(async () => {
await waitForLogin();
console.info("login ready! starting session");
let buffer = new Uint8Array(memory.buffer, pString, size);
let result = textEncoder.encodeInto(string, buffer);
let session = await newSession(getUserId(), localStorage.getItem("rkgk.mostRecentWallId"));
localStorage.setItem("rkgk.mostRecentWallId", session.wallId);
return {
ptr: pString,
length: result.written,
size,
align,
};
}
let wall = new Wall(session.wallInfo.chunkSize);
canvasRenderer.initialize(wall);
function freeString(alloc) {
w.haku_free(alloc.ptr, alloc.size, alloc.align);
}
let textDecoder = new TextDecoder();
function readString(size, pString) {
let buffer = new Uint8Array(memory.buffer, pString, size);
return textDecoder.decode(buffer);
}
function readCString(pCString) {
let memoryBuffer = new Uint8Array(memory.buffer);
let pCursor = pCString;
while (memoryBuffer[pCursor] != 0 && memoryBuffer[pCursor] != null) {
pCursor++;
for (let onlineUser of session.wallInfo.online) {
wall.onlineUsers.addUser(onlineUser.sessionId, { nickname: onlineUser.nickname });
}
let size = pCursor - pCString;
return readString(size, pCString);
}
session.addEventListener("error", (event) => console.error(event));
session.addEventListener("action", (event) => {
if (event.kind.event == "cursor") {
let reticle = reticleRenderer.getOrAddReticle(wall.onlineUsers, event.sessionId);
let { x, y } = event.kind.position;
reticle.setCursor(x, y);
}
});
class Panic extends Error {
name = "Panic";
}
let compileBrush = () => haku.setBrush(brushEditor.code);
compileBrush();
brushEditor.addEventListener(".codeChanged", () => compileBrush());
panicImpl = (length, pMessage) => {
throw new Panic(readString(length, pMessage));
};
let reportCursor = debounce(1000 / 60, (x, y) => session.reportCursor(x, y));
canvasRenderer.addEventListener(".cursor", async (event) => {
reportCursor(event.x, event.y);
});
logImpl = (level, length, pMessage) => {
console[level](readString(length, pMessage));
};
canvasRenderer.addEventListener(".paint", async (event) => {
painter.renderBrush(haku);
let imageBitmap = await painter.createImageBitmap();
w.haku_init_logging();
let left = event.x - painter.paintArea / 2;
let top = event.y - painter.paintArea / 2;
/* ------ */
let leftChunk = Math.floor(left / wall.chunkSize);
let topChunk = Math.floor(top / wall.chunkSize);
let rightChunk = Math.ceil((left + painter.paintArea) / wall.chunkSize);
let bottomChunk = Math.ceil((top + painter.paintArea) / wall.chunkSize);
for (let chunkY = topChunk; chunkY < bottomChunk; ++chunkY) {
for (let chunkX = leftChunk; chunkX < rightChunk; ++chunkX) {
let chunk = wall.getOrCreateChunk(chunkX, chunkY);
let x = Math.floor(-chunkX * wall.chunkSize + left);
let y = Math.floor(-chunkY * wall.chunkSize + top);
chunk.ctx.drawImage(imageBitmap, x, y);
}
}
imageBitmap.close();
});
let renderCanvas = document.getElementById("render");
let codeTextArea = document.getElementById("code");
let outputP = document.getElementById("output");
let ctx = renderCanvas.getContext("2d");
function rerender() {
console.log("rerender");
let width = renderCanvas.width;
let height = renderCanvas.height;
let logs = [];
let pInstance = w.haku_instance_new();
let pBrush = w.haku_brush_new();
let pBitmap = w.haku_bitmap_new(width, height);
let code = allocString(codeTextArea.value);
let deallocEverything = () => {
freeString(code);
w.haku_bitmap_destroy(pBitmap);
w.haku_brush_destroy(pBrush);
w.haku_instance_destroy(pInstance);
outputP.textContent = logs.join("\n");
};
let compileStatusCode = w.haku_compile_brush(pInstance, pBrush, code.length, code.ptr);
let pCompileStatusString = w.haku_status_string(compileStatusCode);
logs.push(`compile: ${readCString(pCompileStatusString)}`);
for (let i = 0; i < w.haku_num_diagnostics(pBrush); ++i) {
let start = w.haku_diagnostic_start(pBrush, i);
let end = w.haku_diagnostic_end(pBrush, i);
let length = w.haku_diagnostic_message_len(pBrush, i);
let pMessage = w.haku_diagnostic_message(pBrush, i);
let message = readString(length, pMessage);
logs.push(`${start}..${end}: ${message}`);
}
if (w.haku_num_diagnostics(pBrush) > 0) {
deallocEverything();
return;
}
let renderStatusCode = w.haku_render_brush(pInstance, pBrush, pBitmap);
let pRenderStatusString = w.haku_status_string(renderStatusCode);
logs.push(`render: ${readCString(pRenderStatusString)}`);
if (w.haku_has_exception(pInstance)) {
let length = w.haku_exception_message_len(pInstance);
let pMessage = w.haku_exception_message(pInstance);
let message = readString(length, pMessage);
logs.push(`exception: ${message}`);
deallocEverything();
return;
}
let pBitmapData = w.haku_bitmap_data(pBitmap);
let bitmapDataBuffer = new Float32Array(memory.buffer, pBitmapData, width * height * 4);
let imageData = new ImageData(width, height);
for (let i = 0; i < bitmapDataBuffer.length; ++i) {
imageData.data[i] = bitmapDataBuffer[i] * 255;
}
ctx.putImageData(imageData, 0, 0);
deallocEverything();
}
rerender();
codeTextArea.addEventListener("input", rerender);
session.eventLoop();
})();

19
static/online-users.js Normal file
View file

@ -0,0 +1,19 @@
export class OnlineUsers extends EventTarget {
#users = new Map();
constructor() {
super();
}
addUser(sessionId, userInfo) {
this.#users.set(sessionId, userInfo);
}
getUser(sessionId) {
return this.#users.get(sessionId);
}
removeUser(sessionId) {
this.#users.delete(sessionId);
}
}

22
static/painter.js Normal file
View file

@ -0,0 +1,22 @@
import { Pixmap } from "./haku.js";
export class Painter {
#pixmap;
imageBitmap;
constructor(paintArea) {
this.paintArea = paintArea;
this.#pixmap = new Pixmap(paintArea, paintArea);
}
async createImageBitmap() {
return await createImageBitmap(this.#pixmap.imageData);
}
renderBrush(haku) {
this.#pixmap.clear(0, 0, 0, 0);
let result = haku.renderBrush(this.#pixmap, this.paintArea / 2, this.paintArea / 2);
return result;
}
}

130
static/reticle-renderer.js Normal file
View file

@ -0,0 +1,130 @@
export class Reticle extends HTMLElement {
#kind = null;
#data = {};
#container;
constructor(nickname) {
super();
this.nickname = nickname;
}
connectedCallback() {
this.style.setProperty("--color", this.getColor());
this.#container = this.appendChild(document.createElement("div"));
this.#container.classList.add("container");
}
getColor() {
let hash = 5381;
for (let i = 0; i < this.nickname.length; ++i) {
hash <<= 5;
hash += hash;
hash += this.nickname.charCodeAt(i);
hash &= 0xffff;
}
return `oklch(70% 0.2 ${(hash / 0xffff) * 360}deg)`;
}
#update(kind, data) {
this.#data = data;
if (kind != this.#kind) {
this.classList = "";
this.#container.replaceChildren();
this.#kind = kind;
}
this.dispatchEvent(new Event(".update"));
}
setCursor(x, y) {
this.#update("cursor", { x, y });
}
render(viewport, windowSize) {
if (!this.rendered) {
if (this.#kind == "cursor") {
this.classList.add("cursor");
let arrow = this.#container.appendChild(document.createElement("div"));
arrow.classList.add("arrow");
let nickname = this.#container.appendChild(document.createElement("div"));
nickname.classList.add("nickname");
nickname.textContent = this.nickname;
}
}
if (this.#kind == "cursor") {
let { x, y } = this.#data;
let [viewportX, viewportY] = viewport.toScreenSpace(x, y, windowSize);
this.style.transform = `translate(${viewportX}px, ${viewportY}px)`;
}
this.rendered = true;
}
}
customElements.define("rkgk-reticle", Reticle);
export class ReticleRenderer extends HTMLElement {
#reticles = new Map();
#reticlesDiv;
connectedCallback() {
this.#reticlesDiv = this.appendChild(document.createElement("div"));
this.#reticlesDiv.classList.add("reticles");
this.updateTransform();
let resizeObserver = new ResizeObserver(() => this.updateTransform());
resizeObserver.observe(this);
}
connectViewport(viewport) {
this.viewport = viewport;
this.updateTransform();
}
getOrAddReticle(onlineUsers, sessionId) {
if (this.#reticles.has(sessionId)) {
return this.#reticles.get(sessionId);
} else {
let reticle = new Reticle(onlineUsers.getUser(sessionId).nickname);
reticle.addEventListener(".update", () => {
if (this.viewport != null) {
reticle.render(this.viewport, {
width: this.clientWidth,
height: this.clientHeight,
});
}
});
this.#reticles.set(sessionId, reticle);
this.#reticlesDiv.appendChild(reticle);
return reticle;
}
}
removeReticle(sessionId) {
if (this.#reticles.has(sessionId)) {
let reticle = this.#reticles.get(sessionId);
this.#reticles.delete(sessionId);
this.#reticlesDiv.removeChild(reticle);
}
}
updateTransform() {
if (this.viewport == null) {
console.debug("viewport is disconnected, skipping transform update");
return;
}
let windowSize = { width: this.clientWidth, height: this.clientHeight };
for (let [_, reticle] of this.#reticles) {
reticle.render(this.viewport, windowSize);
}
}
}
customElements.define("rkgk-reticle-renderer", ReticleRenderer);

200
static/session.js Normal file
View file

@ -0,0 +1,200 @@
import { listen } from "./framework.js";
let loginStorage = JSON.parse(localStorage.getItem("rkgk.login") ?? "{}");
function saveLoginStorage() {
localStorage.setItem("rkgk.login", JSON.stringify(loginStorage));
}
let resolveLoggedInPromise;
let loggedInPromise = new Promise((resolve) => (resolveLoggedInPromise = resolve));
export function isUserLoggedIn() {
return loginStorage.userId != null;
}
export function getUserId() {
return loginStorage.userId;
}
export function waitForLogin() {
return loggedInPromise;
}
if (isUserLoggedIn()) {
resolveLoggedInPromise();
}
export async function registerUser(nickname) {
try {
let response = await fetch("/api/login", {
method: "POST",
body: JSON.stringify({ nickname }),
headers: {
"Content-Type": "application/json",
},
});
if (response.status == 500) {
console.error("login service returned 500 status", response);
return {
status: "error",
message:
"We're sorry, but we ran into some trouble registering your account. Please try again.",
};
}
let responseText = await response.text();
let responseJson = JSON.parse(responseText);
if (responseJson.status != "ok") {
console.error("registering user failed", responseJson);
return {
status: "error",
message: "Something seems to have gone wrong. Please try again.",
};
}
console.log(responseJson);
loginStorage.userId = responseJson.userId;
console.info("user registered", loginStorage.userId);
saveLoginStorage();
resolveLoggedInPromise();
return { status: "ok" };
} catch (error) {
console.error("registering user failed", error);
return {
status: "error",
message: "Something seems to have gone wrong. Please try again.",
};
}
}
class Session extends EventTarget {
constructor(userId) {
super();
this.userId = userId;
}
async #recvJson() {
let event = await listen([this.ws, "message"]);
if (typeof event.data == "string") {
return JSON.parse(event.data);
} else {
throw new Error("received a binary message where a JSON text message was expected");
}
}
#sendJson(object) {
console.debug("sendJson", object);
this.ws.send(JSON.stringify(object));
}
#dispatchError(source, kind, message) {
this.dispatchEvent(
Object.assign(new Event("error"), {
source,
errorKind: kind,
message,
}),
);
}
async join(wallId) {
console.info("joining wall", wallId);
this.wallId = wallId;
this.ws = new WebSocket("/api/wall");
this.ws.addEventListener("error", (event) => {
console.error("WebSocket connection error", error);
this.dispatchEvent(Object.assign(new Event("error"), event));
});
this.ws.addEventListener("message", (event) => {
if (typeof event.data == "string") {
let json = JSON.parse(event.data);
if (json.error != null) {
console.error("received error from server:", json.error);
this.#dispatchError(json, "protocol", json.error);
}
}
});
try {
await listen([this.ws, "open"]);
await this.joinInner();
} catch (error) {
this.#dispatchError(error, "connection", `communication failed: ${error.toString()}`);
}
}
async joinInner() {
let version = await this.#recvJson();
console.info("protocol version", version.version);
// TODO: This should probably verify that the version is compatible.
// We don't have a way of sending Rust stuff to JavaScript just yet, so we don't care about it.
if (this.wallId == null) {
this.#sendJson({ login: "new", user: this.userId });
} else {
this.#sendJson({ login: "join", user: this.userId, wall: this.wallId });
}
let loginResponse = await this.#recvJson();
if (loginResponse.response == "loggedIn") {
this.wallId = loginResponse.wall;
this.wallInfo = loginResponse.wallInfo;
this.sessionId = loginResponse.sessionId;
console.info("logged in", this.wallId, this.sessionId);
console.info("wall info:", this.wallInfo);
} else {
this.#dispatchError(
loginResponse,
loginResponse.response,
"login failed; check error kind for details",
);
return;
}
}
async eventLoop() {
try {
while (true) {
let event = await listen([this.ws, "message"]);
if (typeof event.data == "string") {
await this.#processEvent(JSON.parse(event.data));
} else {
console.warn("binary event not yet supported");
}
}
} catch (error) {
this.#dispatchError(error, "protocol", `error in event loop: ${error.toString()}`);
}
}
async #processEvent(event) {
if (event.kind != null) {
this.dispatchEvent(
Object.assign(new Event("action"), {
sessionId: event.sessionId,
kind: event.kind,
}),
);
}
}
async reportCursor(x, y) {
this.#sendJson({
event: "cursor",
position: { x, y },
});
}
}
export async function newSession(userId, wallId) {
let session = new Session(userId);
await session.join(wallId);
return session;
}

18
static/throbber.js Normal file
View file

@ -0,0 +1,18 @@
export class Throbber extends HTMLElement {
constructor() {
super();
}
connectedCallback() {}
beginLoading() {
this.className = "loading";
}
showError(message) {
this.className = "error";
this.textContent = message;
}
}
customElements.define("rkgk-throbber", Throbber);

47
static/viewport.js Normal file
View file

@ -0,0 +1,47 @@
export class Viewport {
constructor() {
this.panX = 0;
this.panY = 0;
this.zoomLevel = 0;
}
get zoom() {
return Math.pow(2, this.zoomLevel * 0.25);
}
panAround(x, y) {
this.panX -= x / this.zoom;
this.panY -= y / this.zoom;
}
zoomIn(delta) {
this.zoomLevel += delta;
this.zoomLevel = Math.max(-16, Math.min(20, this.zoomLevel));
}
getVisibleRect(windowSize) {
let invZoom = 1 / this.zoom;
let width = windowSize.width * invZoom;
let height = windowSize.height * invZoom;
return {
x: this.panX - width / 2,
y: this.panY - height / 2,
width,
height,
};
}
toViewportSpace(x, y, windowSize) {
return [
(x - windowSize.width / 2) / this.zoom + this.panX,
(y - windowSize.height / 2) / this.zoom + this.panY,
];
}
toScreenSpace(x, y, windowSize) {
return [
(x - this.panX) * this.zoom + windowSize.width / 2,
(y - this.panY) * this.zoom + windowSize.height / 2,
];
}
}

36
static/wall.js Normal file
View file

@ -0,0 +1,36 @@
import { OnlineUsers } from "./online-users.js";
export class Chunk {
constructor(size) {
this.canvas = new OffscreenCanvas(size, size);
this.ctx = this.canvas.getContext("2d");
}
}
export class Wall {
#chunks = new Map();
onlineUsers = new OnlineUsers();
constructor(chunkSize) {
this.chunkSize = chunkSize;
}
static chunkKey(x, y) {
return `(${x},${y})`;
}
getChunk(x, y) {
return this.#chunks.get(Wall.chunkKey(x, y));
}
getOrCreateChunk(x, y) {
let key = Wall.chunkKey(x, y);
if (this.#chunks.has(key)) {
return this.#chunks.get(key);
} else {
let chunk = new Chunk(this.chunkSize);
this.#chunks.set(key, chunk);
return chunk;
}
}
}

36
static/welcome.js Normal file
View file

@ -0,0 +1,36 @@
import { isUserLoggedIn, registerUser } from "./session.js";
export class Welcome extends HTMLElement {
constructor() {
super();
}
connectedCallback() {
this.dialog = this.querySelector("dialog[name='welcome-dialog']");
this.form = this.dialog.querySelector("form");
this.nicknameField = this.querySelector("input[name='nickname']");
this.registerButton = this.querySelector("button[name='register']");
this.registerProgress = this.querySelector("rkgk-throbber[name='register-progress']");
if (!isUserLoggedIn()) {
this.dialog.showModal();
// Require an account to use the website.
this.dialog.addEventListener("close", (event) => event.preventDefault());
this.form.addEventListener("submit", async (event) => {
event.preventDefault();
this.registerProgress.beginLoading();
let response = await registerUser(this.nicknameField.value);
if (response.status != "ok") {
this.registerProgress.showError(response.message);
}
this.dialog.close();
});
}
}
}
customElements.define("rkgk-welcome", Welcome);