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, () => { 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.getOrCreateChunk(info.position.x, info.position.y); 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), console.log); 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 result = await currentUser.haku.evalBrush( selfController(interactionQueue, wall, event), ); brushEditor.renderHakuResult(result); }); 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(); })();