rkgk/static/index.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

373 lines
12 KiB
JavaScript

import { Wall } from "rkgk/wall.js";
import {
getLoginSecret,
getUserId,
isUserLoggedIn,
newSession,
registerUser,
waitForLogin,
} from "rkgk/session.js";
import { debounce } from "rkgk/framework.js";
import { ReticleCursor } from "rkgk/reticle-renderer.js";
import { selfController } from "rkgk/painter.js";
const updateInterval = 1000 / 60;
let main = document.querySelector("main");
let canvasRenderer = main.querySelector("rkgk-canvas-renderer");
let reticleRenderer = main.querySelector("rkgk-reticle-renderer");
let brushBox = main.querySelector("rkgk-brush-box");
let brushEditor = main.querySelector("rkgk-brush-editor");
let zoomIndicator = main.querySelector("rkgk-zoom-indicator");
let welcome = main.querySelector("rkgk-welcome");
let connectionStatus = main.querySelector("rkgk-connection-status");
document.getElementById("js-loading").remove();
reticleRenderer.connectViewport(canvasRenderer.viewport);
reticleRenderer.updateInterval = updateInterval;
function updateUrl(session, viewport) {
let url = new URL(window.location);
url.hash = `${session.wallId}&x=${Math.floor(viewport.panX)}&y=${Math.floor(viewport.panY)}&zoom=${viewport.zoomLevel}`;
history.replaceState(null, "", url);
}
updateUrl = debounce(500, updateUrl);
function readUrl(urlString) {
let url = new URL(urlString);
let fragments = url.hash.substring(1).split("&");
let wallId = null;
let viewport = { x: 0, y: 0, zoom: 0 };
if (fragments.length == 0) return { wallId, viewport };
if (fragments[0].startsWith("wall_") && fragments[0].length == 48) {
wallId = fragments[0];
}
for (let i = 1; i < fragments.length; ++i) {
let pair = fragments[i].split("=");
if (pair.length != 2) continue;
let [key, value] = pair;
try {
if (key == "x") viewport.x = parseFloat(value);
if (key == "y") viewport.y = parseFloat(value);
if (key == "zoom") viewport.zoom = parseFloat(value);
} catch (error) {
console.error(`broken fragment url value: ${key}=${value}`);
}
}
return { wallId, viewport };
}
// In the background, connect to the server.
(async () => {
// Initialization
console.info("checking for user registration status");
if (!isUserLoggedIn()) {
await welcome.show({
async onRegister(nickname) {
return await registerUser(nickname);
},
});
}
connectionStatus.showLoggingIn();
await waitForLogin();
console.info("login ready! starting session");
let urlData = readUrl(window.location);
canvasRenderer.viewport.panX = urlData.viewport.x;
canvasRenderer.viewport.panY = urlData.viewport.y;
canvasRenderer.viewport.zoomLevel = urlData.viewport.zoom;
zoomIndicator.setZoom(canvasRenderer.viewport.zoom);
let session = await newSession({
userId: getUserId(),
secret: getLoginSecret(),
wallId: urlData.wallId ?? localStorage.getItem("rkgk.mostRecentWallId"),
userInit: {
brush: brushEditor.code,
},
onError(error) {
connectionStatus.showError(error.source);
},
async onDisconnect() {
let duration = 5000 + Math.random() * 1000;
while (true) {
console.info("waiting a bit for the server to come back up", duration);
await connectionStatus.showDisconnected(duration);
try {
console.info("trying to reconnect");
let response = await fetch("/auto-reload/back-up");
if (response.status == 200) {
window.location.reload();
break;
}
} catch (e) {}
duration = duration * 1.618033989 + Math.random() * 1000;
}
},
});
localStorage.setItem("rkgk.mostRecentWallId", session.wallId);
connectionStatus.hideLoggingIn();
updateUrl(session, canvasRenderer.viewport);
addEventListener("hashchange", (event) => {
let newUrlData = readUrl(event.newURL);
if (newUrlData.wallId != urlData.wallId) {
// Different wall, reload the app.
window.location.reload();
} else {
canvasRenderer.viewport.panX = newUrlData.viewport.x;
canvasRenderer.viewport.panY = newUrlData.viewport.y;
canvasRenderer.viewport.zoomLevel = newUrlData.viewport.zoom;
zoomIndicator.setZoom(canvasRenderer.viewport.zoom);
canvasRenderer.sendViewportUpdate();
}
});
let wall = new Wall(session.wallInfo);
brushBox.initialize(session.wallInfo.hakuLimits);
canvasRenderer.initialize(wall);
for (let onlineUser of session.wallInfo.online) {
wall.onlineUsers.addUser(onlineUser.sessionId, {
nickname: onlineUser.nickname,
brush: onlineUser.init.brush,
});
}
let currentUser = wall.onlineUsers.getUser(session.sessionId);
// Event loop
session.addEventListener("error", (event) => console.error(event));
session.addEventListener("wallEvent", (event) => {
let wallEvent = event.wallEvent;
if (wallEvent.sessionId != session.sessionId) {
if (wallEvent.kind.event == "join") {
wall.onlineUsers.addUser(wallEvent.sessionId, {
nickname: wallEvent.kind.nickname,
brush: wallEvent.kind.init.brush,
});
}
let user = wall.onlineUsers.getUser(wallEvent.sessionId);
if (user == null) {
console.warn("received event for an unknown user", wallEvent);
return;
}
if (wallEvent.kind.event == "leave") {
if (user.reticle != null) {
reticleRenderer.removeReticle(user.reticle);
}
wall.onlineUsers.removeUser(wallEvent.sessionId);
}
if (wallEvent.kind.event == "cursor") {
if (user.reticle == null) {
user.reticle = new ReticleCursor(
wall.onlineUsers.getUser(wallEvent.sessionId).nickname,
);
reticleRenderer.addReticle(user.reticle);
}
let { x, y } = wallEvent.kind.position;
user.reticle.setCursor(x, y);
}
if (wallEvent.kind.event == "interact") {
user.simulate(wall, wallEvent.kind.interactions);
}
}
});
let sendViewportUpdate = debounce(updateInterval / 4, () => {
let visibleRect = canvasRenderer.getVisibleChunkRect();
session.sendViewport(visibleRect);
});
canvasRenderer.addEventListener(".viewportUpdate", sendViewportUpdate);
sendViewportUpdate();
session.addEventListener("chunks", async (event) => {
let { chunkInfo, chunkData, hasMore } = event;
console.debug("received data for chunks", {
chunkInfo,
chunkDataSize: chunkData.size,
});
let updatePromises = [];
for (let info of event.chunkInfo) {
if (info.length > 0) {
let blob = chunkData.slice(info.offset, info.offset + info.length, "image/webp");
updatePromises.push(
createImageBitmap(blob).then((bitmap) => {
let chunk = wall.mainLayer.getOrCreateChunk(
info.position.x,
info.position.y,
);
if (chunk == null) return;
chunk.ctx.globalCompositeOperation = "copy";
chunk.ctx.drawImage(bitmap, 0, 0);
chunk.syncToPixmap();
chunk.markModified();
}),
);
}
}
await Promise.all(updatePromises);
if (hasMore) {
console.info("more chunks are pending; requesting more");
session.sendMoreChunks();
}
});
let reportCursor = debounce(updateInterval, (x, y) => session.sendCursor(x, y));
canvasRenderer.addEventListener(".cursor", async (event) => {
reportCursor(event.x, event.y);
});
let interactionQueue = [];
function flushInteractionQueue() {
if (interactionQueue.length != 0) {
session.sendInteraction(interactionQueue);
interactionQueue.splice(0);
}
}
setInterval(flushInteractionQueue, updateInterval);
canvasRenderer.addEventListener(".interact", async (event) => {
if (!currentUser.haku.ok) return;
let layer = currentUser.getScratchLayer(wall);
let result = await currentUser.haku.evalBrush(
selfController(interactionQueue, wall, layer, event),
);
brushEditor.renderHakuResult(result);
});
canvasRenderer.addEventListener(".commitInteraction", async () => {
let scratchLayer = currentUser.commitScratchLayer(wall);
if (scratchLayer == null) return;
canvasRenderer.deallocateChunks(scratchLayer);
let edits = await scratchLayer.toEdits();
scratchLayer.destroy();
let editRecords = [];
let dataParts = [];
let cursor = 0;
for (let edit of edits) {
editRecords.push({
chunk: edit.chunk,
dataType: edit.data.type,
dataOffset: cursor,
dataLength: edit.data.size,
});
dataParts.push(edit.data);
cursor += edit.data.size;
}
session.sendEdit(editRecords, new Blob(dataParts));
});
canvasRenderer.addEventListener(".viewportUpdate", () => reticleRenderer.render());
canvasRenderer.addEventListener(".viewportUpdateEnd", () =>
updateUrl(session, canvasRenderer.viewport),
);
// Brush editor
function updateBrushPreview() {
brushEditor.updatePreview(currentUser.haku, {
getStats: () => currentUser.getStats(session.wallInfo),
});
}
function compileBrush() {
let compileResult = currentUser.setBrush(brushEditor.code);
brushEditor.renderHakuResult(compileResult);
if (compileResult.status == "ok") {
updateBrushPreview();
}
}
compileBrush();
brushEditor.addEventListener(".codeChanged", async () => {
compileBrush();
interactionQueue.push({
kind: "setBrush",
brush: brushEditor.code,
});
});
brushEditor.brushPreview.addEventListener(".pixmapLost", updateBrushPreview);
// Brush box
function updateBrushBoxDirtyState() {
if (brushBox.currentPresetId == null) return;
let currentPreset = brushBox.findBrushPreset(brushBox.currentPresetId);
if (brushEditor.name != currentPreset.name || brushEditor.code != currentPreset.code) {
brushBox.markDirty();
} else {
brushBox.markClean();
}
}
updateBrushBoxDirtyState();
brushEditor.addEventListener(".nameChanged", updateBrushBoxDirtyState);
brushEditor.addEventListener(".codeChanged", updateBrushBoxDirtyState);
brushBox.addEventListener(".clickNew", () => {
let id = brushBox.saveUserPreset({ name: brushEditor.name, code: brushEditor.code });
brushBox.updateBrushes();
brushBox.renderBrushes();
brushBox.setCurrentBrush(id);
brushBox.markClean();
});
brushBox.addEventListener(".brushChange", (event) => {
let preset = event.preset;
brushEditor.setName(preset.name);
brushEditor.setCode(preset.code);
brushBox.setCurrentBrush(preset.id);
brushBox.markClean();
});
brushBox.addEventListener(".overwritePreset", (event) => {
let preset = event.preset;
preset.name = brushEditor.name;
preset.code = brushEditor.code;
});
// Zoom indicator
canvasRenderer.addEventListener(".viewportUpdate", () => {
zoomIndicator.setZoom(canvasRenderer.viewport.zoom);
});
zoomIndicator.addEventListener(".zoomUpdate", (event) => {
canvasRenderer.viewport.zoomLevel += event.delta;
canvasRenderer.sendViewportUpdate();
});
// All done.
session.eventLoop();
})();