a whole load of work in progress
This commit is contained in:
parent
caec0b8ac9
commit
26ba098183
63 changed files with 3234 additions and 321 deletions
37
static/brush-editor.js
Normal file
37
static/brush-editor.js
Normal 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
154
static/canvas-renderer.js
Normal 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);
|
BIN
static/font/FiraCode-VariableFont_wght.ttf
Normal file
BIN
static/font/FiraCode-VariableFont_wght.ttf
Normal file
Binary file not shown.
BIN
static/font/FiraSans-Black.ttf
Normal file
BIN
static/font/FiraSans-Black.ttf
Normal file
Binary file not shown.
BIN
static/font/FiraSans-BlackItalic.ttf
Normal file
BIN
static/font/FiraSans-BlackItalic.ttf
Normal file
Binary file not shown.
BIN
static/font/FiraSans-Bold.ttf
Normal file
BIN
static/font/FiraSans-Bold.ttf
Normal file
Binary file not shown.
BIN
static/font/FiraSans-BoldItalic.ttf
Normal file
BIN
static/font/FiraSans-BoldItalic.ttf
Normal file
Binary file not shown.
BIN
static/font/FiraSans-ExtraBold.ttf
Normal file
BIN
static/font/FiraSans-ExtraBold.ttf
Normal file
Binary file not shown.
BIN
static/font/FiraSans-ExtraBoldItalic.ttf
Normal file
BIN
static/font/FiraSans-ExtraBoldItalic.ttf
Normal file
Binary file not shown.
BIN
static/font/FiraSans-ExtraLight.ttf
Normal file
BIN
static/font/FiraSans-ExtraLight.ttf
Normal file
Binary file not shown.
BIN
static/font/FiraSans-ExtraLightItalic.ttf
Normal file
BIN
static/font/FiraSans-ExtraLightItalic.ttf
Normal file
Binary file not shown.
BIN
static/font/FiraSans-Italic.ttf
Normal file
BIN
static/font/FiraSans-Italic.ttf
Normal file
Binary file not shown.
BIN
static/font/FiraSans-Light.ttf
Normal file
BIN
static/font/FiraSans-Light.ttf
Normal file
Binary file not shown.
BIN
static/font/FiraSans-LightItalic.ttf
Normal file
BIN
static/font/FiraSans-LightItalic.ttf
Normal file
Binary file not shown.
BIN
static/font/FiraSans-Medium.ttf
Normal file
BIN
static/font/FiraSans-Medium.ttf
Normal file
Binary file not shown.
BIN
static/font/FiraSans-MediumItalic.ttf
Normal file
BIN
static/font/FiraSans-MediumItalic.ttf
Normal file
Binary file not shown.
BIN
static/font/FiraSans-Regular.ttf
Normal file
BIN
static/font/FiraSans-Regular.ttf
Normal file
Binary file not shown.
BIN
static/font/FiraSans-SemiBold.ttf
Normal file
BIN
static/font/FiraSans-SemiBold.ttf
Normal file
Binary file not shown.
BIN
static/font/FiraSans-SemiBoldItalic.ttf
Normal file
BIN
static/font/FiraSans-SemiBoldItalic.ttf
Normal file
Binary file not shown.
BIN
static/font/FiraSans-Thin.ttf
Normal file
BIN
static/font/FiraSans-Thin.ttf
Normal file
Binary file not shown.
BIN
static/font/FiraSans-ThinItalic.ttf
Normal file
BIN
static/font/FiraSans-ThinItalic.ttf
Normal file
Binary file not shown.
30
static/framework.js
Normal file
30
static/framework.js
Normal 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
203
static/haku.js
Normal 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
232
static/index.css
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
|
|
198
static/index.js
198
static/index.js
|
@ -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
19
static/online-users.js
Normal 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
22
static/painter.js
Normal 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
130
static/reticle-renderer.js
Normal 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
200
static/session.js
Normal 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
18
static/throbber.js
Normal 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
47
static/viewport.js
Normal 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
36
static/wall.js
Normal 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
36
static/welcome.js
Normal 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);
|
Loading…
Add table
Add a link
Reference in a new issue