massive performance rewrite
- main canvas & blank canvas drawing moved to separate worker thread (if possible) - server jobs moved to separate process (fixing CPU leak on heatmap generation) - pixels now store if they are on top reducing db queries - remove various methods to store pixel data in redis, reducing delay for various actions additional fixed: (came up during performance fixes) - added square fill (fixes #15) - redraw loop (fixes #59) - added keybind to deselect current color (fixes #54) - pixel undos no longer delete the pixel from the db - server logging now indicates what module triggered the log
This commit is contained in:
parent
78d97b52e3
commit
b09ddd13b4
|
@ -70,7 +70,7 @@ RUN npm -w packages/server run build
|
|||
FROM base as run
|
||||
WORKDIR /home/node/app
|
||||
COPY --from=dep /home/node/app/ ./
|
||||
COPY package*.json docker-start.sh .git ./
|
||||
COPY package*.json docker-start*.sh .git ./
|
||||
|
||||
# --- prepare lib ---
|
||||
|
||||
|
|
|
@ -19,6 +19,17 @@ services:
|
|||
condition: service_healthy
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
worker:
|
||||
image: sc07/canvas
|
||||
build: .
|
||||
environment:
|
||||
- REDIS_HOST=redis://redis
|
||||
- DATABASE_URL=postgres://postgres@postgres/canvas
|
||||
env_file:
|
||||
- .env.local
|
||||
depends_on:
|
||||
- canvas
|
||||
command: ./docker-start-worker.sh
|
||||
redis:
|
||||
restart: always
|
||||
image: redis:7-alpine
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
#!/bin/sh
|
||||
|
||||
npm -w packages/server run tool start_job_worker
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useContext, useEffect, useRef } from "react";
|
||||
import { useCallback, useContext, useEffect, useRef, useState } from "react";
|
||||
import { Canvas } from "../lib/canvas";
|
||||
import { useAppContext } from "../contexts/AppContext";
|
||||
import { PanZoomWrapper } from "@sc07-canvas/lib/src/renderer";
|
||||
|
@ -23,29 +23,56 @@ export const CanvasWrapper = () => {
|
|||
<HeatmapOverlay />
|
||||
{config && <Template />}
|
||||
<CanvasInner />
|
||||
<Cursor />
|
||||
</PanZoomWrapper>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
const Cursor = () => {
|
||||
const { cursor } = useAppContext();
|
||||
const [color, setColor] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
console.log("color", color);
|
||||
}, [color]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof cursor.color === "number") {
|
||||
const color = Canvas.instance?.Pallete.getColor(cursor.color);
|
||||
setColor(color?.hex);
|
||||
} else {
|
||||
setColor(undefined);
|
||||
}
|
||||
}, [setColor, cursor.color]);
|
||||
|
||||
if (!color) return <></>;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="noselect"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: cursor.y,
|
||||
left: cursor.x,
|
||||
backgroundColor: "#" + color,
|
||||
width: "1px",
|
||||
height: "1px",
|
||||
opacity: 0.5,
|
||||
}}
|
||||
></div>
|
||||
);
|
||||
};
|
||||
|
||||
const CanvasInner = () => {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>();
|
||||
const canvas = useRef<Canvas>();
|
||||
const { config, setCanvasPosition, setCursorPosition, setPixelWhois } =
|
||||
const { config, setCanvasPosition, setCursor, setPixelWhois } =
|
||||
useAppContext();
|
||||
const PanZoom = useContext(RendererContext);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canvasRef.current) return;
|
||||
canvas.current = new Canvas(canvasRef.current!, PanZoom);
|
||||
|
||||
const handlePixelWhois = ({
|
||||
clientX,
|
||||
clientY,
|
||||
}: {
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
}) => {
|
||||
const handlePixelWhois = useCallback(
|
||||
({ clientX, clientY }: { clientX: number; clientY: number }) => {
|
||||
if (!canvas.current) {
|
||||
console.warn(
|
||||
"[CanvasWrapper#handlePixelWhois] canvas instance does not exist"
|
||||
|
@ -83,7 +110,20 @@ const CanvasInner = () => {
|
|||
const surrounding = canvas.current.getSurroundingPixels(x, y, 3);
|
||||
|
||||
setPixelWhois({ x, y, surrounding });
|
||||
};
|
||||
},
|
||||
[canvas.current]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canvasRef.current) return;
|
||||
canvas.current = new Canvas(canvasRef.current!, PanZoom);
|
||||
canvas.current.on("canvasReady", () => {
|
||||
console.log("[CanvasWrapper] received canvasReady");
|
||||
|
||||
// refresh because canvas might've resized
|
||||
const initialRouter = Router.get();
|
||||
handleNavigate(initialRouter);
|
||||
});
|
||||
|
||||
KeybindManager.on("PIXEL_WHOIS", handlePixelWhois);
|
||||
|
||||
|
@ -91,7 +131,7 @@ const CanvasInner = () => {
|
|||
KeybindManager.off("PIXEL_WHOIS", handlePixelWhois);
|
||||
canvas.current!.destroy();
|
||||
};
|
||||
}, [PanZoom, setCursorPosition]);
|
||||
}, [PanZoom]);
|
||||
|
||||
useEffect(() => {
|
||||
Router.PanZoom = PanZoom;
|
||||
|
@ -115,10 +155,18 @@ const CanvasInner = () => {
|
|||
pos.x > config.canvas.size[0] ||
|
||||
pos.y > config.canvas.size[1]
|
||||
) {
|
||||
setCursorPosition();
|
||||
setCursor((v) => ({
|
||||
...v,
|
||||
x: undefined,
|
||||
y: undefined,
|
||||
}));
|
||||
} else {
|
||||
// fixes not passing the current value
|
||||
setCursorPosition({ ...pos });
|
||||
setCursor((v) => ({
|
||||
...v,
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
}));
|
||||
}
|
||||
}, 1);
|
||||
|
||||
|
@ -127,7 +175,7 @@ const CanvasInner = () => {
|
|||
return () => {
|
||||
canvas.current!.off("cursorPos", handleCursorPos);
|
||||
};
|
||||
}, [config, setCursorPosition]);
|
||||
}, [config, setCursor]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canvas.current) {
|
||||
|
@ -217,7 +265,7 @@ const CanvasInner = () => {
|
|||
PanZoom.removeListener("viewportMove", handleViewportMove);
|
||||
Router.off("navigate", handleNavigate);
|
||||
};
|
||||
}, [PanZoom, setCanvasPosition, setCursorPosition]);
|
||||
}, [PanZoom, setCanvasPosition]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
|
|
|
@ -2,7 +2,6 @@ import {
|
|||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
Switch,
|
||||
} from "@nextui-org/react";
|
||||
|
@ -32,7 +31,7 @@ export const ModModal = () => {
|
|||
return () => {
|
||||
KeybindManager.off("TOGGLE_MOD_MENU", handleKeybind);
|
||||
};
|
||||
}, []);
|
||||
}, [hasAdmin]);
|
||||
|
||||
const setBypassCooldown = useCallback(
|
||||
(value: boolean) => {
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import { useEffect, useRef } from "react";
|
||||
import { useAppContext } from "../../contexts/AppContext";
|
||||
import { Canvas } from "../../lib/canvas";
|
||||
import { KeybindManager } from "../../lib/keybinds";
|
||||
import { getRenderer } from "../../lib/utils";
|
||||
|
||||
export const BlankOverlay = () => {
|
||||
const { config, blankOverlay, setBlankOverlay } = useAppContext();
|
||||
const { blankOverlay, setBlankOverlay } = useAppContext();
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -20,51 +20,21 @@ export const BlankOverlay = () => {
|
|||
}, [setBlankOverlay]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!config) {
|
||||
console.warn("[BlankOverlay] config is not defined");
|
||||
return;
|
||||
}
|
||||
if (!canvasRef.current) {
|
||||
console.warn("[BlankOverlay] canvasRef is not defined");
|
||||
return;
|
||||
}
|
||||
|
||||
const [width, height] = config.canvas.size;
|
||||
let timeout = setTimeout(() => {
|
||||
if (!canvasRef.current) return;
|
||||
|
||||
canvasRef.current.width = width;
|
||||
canvasRef.current.height = height;
|
||||
}, [config]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canvasRef.current) {
|
||||
console.warn("[BlankOverlay] canvasRef is not defined");
|
||||
return;
|
||||
}
|
||||
|
||||
const updateVirginmap = () => {
|
||||
const ctx = canvasRef.current!.getContext("2d");
|
||||
if (!ctx) {
|
||||
console.warn("[BlankOverlay] canvas context cannot be aquired");
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.clearRect(0, 0, canvasRef.current!.width, canvasRef.current!.height);
|
||||
|
||||
const pixels = Canvas.instance!.getAllPixels();
|
||||
for (const pixel of pixels) {
|
||||
if (pixel.color !== -1) continue;
|
||||
|
||||
ctx.fillStyle = "rgba(0,140,0,0.5)";
|
||||
ctx.fillRect(pixel.x, pixel.y, 1, 1);
|
||||
}
|
||||
};
|
||||
|
||||
var updateInterval = setInterval(updateVirginmap, 1000);
|
||||
getRenderer().useCanvas(canvasRef.current, "blank");
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
clearInterval(updateInterval);
|
||||
clearTimeout(timeout);
|
||||
getRenderer().removeCanvas("blank");
|
||||
};
|
||||
}, [canvasRef]);
|
||||
}, [canvasRef.current]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
|
|
|
@ -4,17 +4,36 @@ import { Canvas } from "../../lib/canvas";
|
|||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faXmark } from "@fortawesome/free-solid-svg-icons";
|
||||
import { IPaletteContext } from "@sc07-canvas/lib/src/net";
|
||||
import { KeybindManager } from "../../lib/keybinds";
|
||||
|
||||
export const Palette = () => {
|
||||
const { config, user } = useAppContext<true>();
|
||||
const { config, user, setCursor } = useAppContext<true>();
|
||||
const [pallete, setPallete] = useState<IPaletteContext>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (!Canvas.instance) return;
|
||||
Canvas.instance?.updatePallete(pallete);
|
||||
|
||||
Canvas.instance.updatePallete(pallete);
|
||||
setCursor((v) => ({
|
||||
...v,
|
||||
color: pallete.color,
|
||||
}));
|
||||
}, [pallete]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleDeselect = () => {
|
||||
setCursor((v) => ({
|
||||
...v,
|
||||
color: undefined,
|
||||
}));
|
||||
};
|
||||
|
||||
KeybindManager.addListener("DESELECT_COLOR", handleDeselect);
|
||||
|
||||
return () => {
|
||||
KeybindManager.removeListener("DESELECT_COLOR", handleDeselect);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div id="pallete">
|
||||
<div className="pallete-colors">
|
||||
|
|
|
@ -17,8 +17,8 @@ interface IAppContext {
|
|||
|
||||
canvasPosition?: ICanvasPosition;
|
||||
setCanvasPosition: (v: ICanvasPosition) => void;
|
||||
cursorPosition?: IPosition;
|
||||
setCursorPosition: (v?: IPosition) => void;
|
||||
cursor: ICursor;
|
||||
setCursor: React.Dispatch<React.SetStateAction<ICursor>>;
|
||||
pixels: { available: number };
|
||||
undo?: { available: true; expireAt: number };
|
||||
|
||||
|
@ -53,6 +53,12 @@ interface ICanvasPosition {
|
|||
zoom: number;
|
||||
}
|
||||
|
||||
interface ICursor {
|
||||
x?: number;
|
||||
y?: number;
|
||||
color?: number;
|
||||
}
|
||||
|
||||
interface IMapOverlay {
|
||||
enabled: boolean;
|
||||
|
||||
|
@ -88,7 +94,7 @@ export const AppContext = ({ children }: PropsWithChildren) => {
|
|||
const [config, setConfig] = useState<ClientConfig>(undefined as any);
|
||||
const [auth, setAuth] = useState<AuthSession>();
|
||||
const [canvasPosition, setCanvasPosition] = useState<ICanvasPosition>();
|
||||
const [cursorPosition, setCursorPosition] = useState<IPosition>();
|
||||
const [cursor, setCursor] = useState<ICursor>({});
|
||||
const [connected, setConnected] = useState(false);
|
||||
|
||||
// --- settings ---
|
||||
|
@ -205,8 +211,8 @@ export const AppContext = ({ children }: PropsWithChildren) => {
|
|||
user: auth,
|
||||
canvasPosition,
|
||||
setCanvasPosition,
|
||||
cursorPosition,
|
||||
setCursorPosition,
|
||||
cursor,
|
||||
setCursor,
|
||||
pixels,
|
||||
settingsSidebar,
|
||||
setSettingsSidebar,
|
||||
|
|
|
@ -13,6 +13,8 @@ import {
|
|||
} from "@sc07-canvas/lib/src/renderer/PanZoom";
|
||||
import { toast } from "react-toastify";
|
||||
import { KeybindManager } from "./keybinds";
|
||||
import { getRenderer } from "./utils";
|
||||
import { CanvasPixel } from "./canvasRenderer";
|
||||
|
||||
interface CanvasEvents {
|
||||
/**
|
||||
|
@ -22,16 +24,15 @@ interface CanvasEvents {
|
|||
* @returns
|
||||
*/
|
||||
cursorPos: (position: IPosition) => void;
|
||||
canvasReady: () => void;
|
||||
}
|
||||
|
||||
export class Canvas extends EventEmitter<CanvasEvents> {
|
||||
static instance: Canvas | undefined;
|
||||
|
||||
private _destroy = false;
|
||||
private config: ClientConfig = {} as any;
|
||||
private canvas: HTMLCanvasElement;
|
||||
private PanZoom: PanZoom;
|
||||
private ctx: CanvasRenderingContext2D;
|
||||
|
||||
private cursor = { x: -1, y: -1 };
|
||||
private pixels: {
|
||||
|
@ -40,14 +41,18 @@ export class Canvas extends EventEmitter<CanvasEvents> {
|
|||
lastPlace: number | undefined;
|
||||
|
||||
private bypassCooldown = false;
|
||||
private _delayedLoad: ReturnType<typeof setTimeout>;
|
||||
|
||||
constructor(canvas: HTMLCanvasElement, PanZoom: PanZoom) {
|
||||
super();
|
||||
Canvas.instance = this;
|
||||
getRenderer().startRender();
|
||||
|
||||
getRenderer().on("ready", () => this.emit("canvasReady"));
|
||||
|
||||
this.canvas = canvas;
|
||||
this.PanZoom = PanZoom;
|
||||
this.ctx = canvas.getContext("2d")!;
|
||||
this._delayedLoad = setTimeout(() => this.delayedLoad(), 1000);
|
||||
|
||||
this.PanZoom.addListener("hover", this.handleMouseMove.bind(this));
|
||||
this.PanZoom.addListener("click", this.handleMouseDown.bind(this));
|
||||
|
@ -57,23 +62,37 @@ export class Canvas extends EventEmitter<CanvasEvents> {
|
|||
([time]) => (this.lastPlace = time)
|
||||
);
|
||||
Network.on("pixel", this.handlePixel);
|
||||
Network.on("square", this.handleSquare);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this._destroy = true;
|
||||
getRenderer().stopRender();
|
||||
getRenderer().off("ready");
|
||||
if (this._delayedLoad) clearTimeout(this._delayedLoad);
|
||||
|
||||
this.PanZoom.removeListener("hover", this.handleMouseMove.bind(this));
|
||||
this.PanZoom.removeListener("click", this.handleMouseDown.bind(this));
|
||||
this.PanZoom.removeListener("longPress", this.handleLongPress);
|
||||
|
||||
Network.off("pixel", this.handlePixel);
|
||||
Network.off("square", this.handleSquare);
|
||||
}
|
||||
|
||||
/**
|
||||
* React.Strict remounts the main component, causing a quick remount, which then causes errors related to webworkers
|
||||
*/
|
||||
delayedLoad() {
|
||||
getRenderer().useCanvas(this.canvas, "main");
|
||||
}
|
||||
|
||||
setSize(width: number, height: number) {
|
||||
getRenderer().setSize(width, height);
|
||||
}
|
||||
|
||||
loadConfig(config: ClientConfig) {
|
||||
this.config = config;
|
||||
|
||||
this.canvas.width = config.canvas.size[0];
|
||||
this.canvas.height = config.canvas.size[1];
|
||||
this.setSize(config.canvas.size[0], config.canvas.size[1]);
|
||||
|
||||
// we want the new one if possible
|
||||
// (this might cause a timing issue though)
|
||||
|
@ -83,10 +102,7 @@ export class Canvas extends EventEmitter<CanvasEvents> {
|
|||
Network.waitFor("canvas").then(([pixels]) => {
|
||||
console.log("loadConfig just received new canvas data");
|
||||
this.handleBatch(pixels);
|
||||
this.draw();
|
||||
});
|
||||
|
||||
this.draw();
|
||||
}
|
||||
|
||||
hasConfig() {
|
||||
|
@ -204,29 +220,73 @@ export class Canvas extends EventEmitter<CanvasEvents> {
|
|||
this.emit("cursorPos", this.cursor);
|
||||
}
|
||||
|
||||
handleSquare = (
|
||||
start: [x: number, y: number],
|
||||
end: [x: number, y: number],
|
||||
color: number
|
||||
) => {
|
||||
const palette = this.Pallete.getColor(color);
|
||||
let serializeBuild: CanvasPixel[] = [];
|
||||
|
||||
for (let x = start[0]; x <= end[0]; x++) {
|
||||
for (let y = start[1]; y <= end[1]; y++) {
|
||||
// we still store a copy of the pixels in this instance for non-rendering functions
|
||||
this.pixels[x + "_" + y] = {
|
||||
type: "full",
|
||||
color: palette?.id || -1,
|
||||
};
|
||||
|
||||
serializeBuild.push({
|
||||
x,
|
||||
y,
|
||||
hex:
|
||||
!palette || palette?.hex === "transparent" ? "null" : palette.hex,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getRenderer().usePixels(serializeBuild);
|
||||
};
|
||||
|
||||
handleBatch = (pixels: string[]) => {
|
||||
if (!this.config.canvas) {
|
||||
throw new Error("handleBatch called with no config");
|
||||
}
|
||||
|
||||
let serializeBuild: CanvasPixel[] = [];
|
||||
|
||||
for (let x = 0; x < this.config.canvas.size[0]; x++) {
|
||||
for (let y = 0; y < this.config.canvas.size[1]; y++) {
|
||||
const hex = pixels[this.config.canvas.size[0] * y + x];
|
||||
const color = this.Pallete.getColorFromHex(hex);
|
||||
const palette = this.Pallete.getColorFromHex(hex);
|
||||
|
||||
// we still store a copy of the pixels in this instance for non-rendering functions
|
||||
this.pixels[x + "_" + y] = {
|
||||
color: color ? color.id : -1,
|
||||
type: "full",
|
||||
color: palette?.id || -1,
|
||||
};
|
||||
|
||||
serializeBuild.push({
|
||||
x,
|
||||
y,
|
||||
hex: hex === "transparent" ? "null" : hex,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getRenderer().usePixels(serializeBuild, true);
|
||||
};
|
||||
|
||||
handlePixel = ({ x, y, color }: Pixel) => {
|
||||
// we still store a copy of the pixels in this instance for non-rendering functions
|
||||
this.pixels[x + "_" + y] = {
|
||||
color,
|
||||
type: "full",
|
||||
color,
|
||||
};
|
||||
|
||||
const palette = this.Pallete.getColor(color);
|
||||
|
||||
getRenderer().usePixel({ x, y, hex: palette?.hex || "null" });
|
||||
};
|
||||
|
||||
palleteCtx: IPaletteContext = {};
|
||||
|
@ -401,44 +461,4 @@ export class Canvas extends EventEmitter<CanvasEvents> {
|
|||
|
||||
return [output.x, output.y];
|
||||
}
|
||||
|
||||
draw() {
|
||||
this.ctx.imageSmoothingEnabled = false;
|
||||
|
||||
const bezier = (n: number) => n * n * (3 - 2 * n);
|
||||
|
||||
this.ctx.globalAlpha = 1;
|
||||
|
||||
this.ctx.fillStyle = "#fff";
|
||||
this.ctx.fillRect(
|
||||
0,
|
||||
0,
|
||||
this.config.canvas.size[0],
|
||||
this.config.canvas.size[1]
|
||||
);
|
||||
|
||||
for (const [x_y, pixel] of Object.entries(this.pixels)) {
|
||||
const [x, y] = x_y.split("_").map((a) => parseInt(a));
|
||||
|
||||
this.ctx.globalAlpha = pixel.type === "full" ? 1 : 0.5;
|
||||
this.ctx.fillStyle =
|
||||
pixel.color > -1
|
||||
? "#" + this.Pallete.getColor(pixel.color)!.hex
|
||||
: "transparent";
|
||||
this.ctx.fillRect(x, y, 1, 1);
|
||||
}
|
||||
|
||||
if (this.palleteCtx.color && this.cursor.x > -1 && this.cursor.y > -1) {
|
||||
const color = this.config.pallete.colors.find(
|
||||
(c) => c.id === this.palleteCtx.color
|
||||
);
|
||||
|
||||
let t = ((Date.now() / 100) % 10) / 10;
|
||||
this.ctx.globalAlpha = t < 0.5 ? bezier(t) : -bezier(t) + 1;
|
||||
this.ctx.fillStyle = "#" + color!.hex;
|
||||
this.ctx.fillRect(this.cursor.x, this.cursor.y, 1, 1);
|
||||
}
|
||||
|
||||
if (!this._destroy) window.requestAnimationFrame(() => this.draw());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,264 @@
|
|||
import EventEmitter from "eventemitter3";
|
||||
|
||||
type RCanvas = HTMLCanvasElement | OffscreenCanvas;
|
||||
type RContext = OffscreenCanvasRenderingContext2D | CanvasRenderingContext2D;
|
||||
export type CanvasPixel = {
|
||||
x: number;
|
||||
y: number;
|
||||
hex: string;
|
||||
};
|
||||
|
||||
const bezier = (n: number) => n * n * (3 - 2 * n);
|
||||
|
||||
const isWorker = () => {
|
||||
return (
|
||||
// @ts-ignore
|
||||
typeof WorkerGlobalScope !== "undefined" &&
|
||||
// @ts-ignore
|
||||
self instanceof WorkerGlobalScope
|
||||
);
|
||||
};
|
||||
|
||||
export interface RendererEvents {
|
||||
ready: () => void;
|
||||
}
|
||||
|
||||
export type CanvasRole = "main" | "blank";
|
||||
|
||||
/**
|
||||
* Generic renderer
|
||||
*
|
||||
* Can be instansiated inside worker or on the main thread
|
||||
*/
|
||||
export class CanvasRenderer extends EventEmitter<RendererEvents> {
|
||||
private canvas: RCanvas = undefined as any;
|
||||
private ctx: RContext = undefined as any;
|
||||
private dimentions = {
|
||||
width: 0,
|
||||
height: 0,
|
||||
};
|
||||
|
||||
private blank?: RCanvas;
|
||||
private blank_ctx?: RContext;
|
||||
|
||||
private pixels: CanvasPixel[] = [];
|
||||
private allPixels: CanvasPixel[] = [];
|
||||
private isWorker = isWorker();
|
||||
|
||||
private _stopRender = false;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
console.log("[CanvasRenderer] Initialized", { isWorker: this.isWorker });
|
||||
}
|
||||
|
||||
useCanvas(canvas: HTMLCanvasElement | OffscreenCanvas, role: CanvasRole) {
|
||||
console.log("[CanvasRenderer] Received canvas reference for " + role);
|
||||
|
||||
let ctx = canvas.getContext("2d")! as any;
|
||||
if (!ctx) {
|
||||
throw new Error("Unable to get canvas context for " + role);
|
||||
}
|
||||
|
||||
canvas.width = this.dimentions.width;
|
||||
canvas.height = this.dimentions.height;
|
||||
|
||||
switch (role) {
|
||||
case "main":
|
||||
this.canvas = canvas;
|
||||
this.ctx = ctx;
|
||||
break;
|
||||
case "blank":
|
||||
this.blank = canvas;
|
||||
this.blank_ctx = ctx;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
removeCanvas(role: CanvasRole) {
|
||||
switch (role) {
|
||||
case "main":
|
||||
throw new Error("Cannot remove main canvas");
|
||||
case "blank":
|
||||
this.blank = undefined;
|
||||
this.blank_ctx = undefined;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
usePixels(pixels: CanvasPixel[], replace = false) {
|
||||
if (replace) {
|
||||
this.pixels = pixels;
|
||||
this.allPixels = pixels;
|
||||
} else {
|
||||
for (const pixel of pixels) {
|
||||
this.usePixel(pixel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
usePixel(pixel: CanvasPixel) {
|
||||
{
|
||||
let existing = this.pixels.find(
|
||||
(p) => p.x === pixel.x && p.y === pixel.y
|
||||
);
|
||||
if (existing) {
|
||||
this.pixels.splice(this.pixels.indexOf(existing), 1);
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let existing = this.allPixels.find(
|
||||
(p) => p.x === pixel.x && p.y === pixel.y
|
||||
);
|
||||
if (existing) {
|
||||
this.allPixels.splice(this.allPixels.indexOf(existing), 1);
|
||||
}
|
||||
}
|
||||
|
||||
this.pixels.push(pixel);
|
||||
this.allPixels.push(pixel);
|
||||
}
|
||||
|
||||
startRender() {
|
||||
console.log("[CanvasRenderer] Started rendering loop");
|
||||
this._stopRender = false;
|
||||
this.tryDrawFull();
|
||||
this.tryDrawBlank();
|
||||
this.renderLoop();
|
||||
}
|
||||
|
||||
stopRender() {
|
||||
console.log("[CanvasRenderer] Stopped rendering loop");
|
||||
// used when not in worker
|
||||
// kills the requestAnimationFrame loop
|
||||
this._stopRender = true;
|
||||
}
|
||||
|
||||
private tryDrawFull() {
|
||||
if (this._stopRender) return;
|
||||
|
||||
if (this.ctx) {
|
||||
this.drawFull();
|
||||
} else {
|
||||
requestAnimationFrame(() => this.tryDrawFull());
|
||||
}
|
||||
}
|
||||
|
||||
private tryDrawBlank() {
|
||||
if (this._stopRender) return;
|
||||
|
||||
if (this.blank_ctx) {
|
||||
this.drawBlank();
|
||||
|
||||
setTimeout(() => requestAnimationFrame(() => this.tryDrawBlank()), 1000);
|
||||
} else {
|
||||
requestAnimationFrame(() => this.tryDrawBlank());
|
||||
}
|
||||
}
|
||||
|
||||
private renderLoop() {
|
||||
if (this._stopRender) return;
|
||||
|
||||
if (this.ctx) {
|
||||
this.draw();
|
||||
} else {
|
||||
console.warn("[CanvasRenderer#renderLoop] has no canvas context");
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => this.renderLoop());
|
||||
}
|
||||
|
||||
private drawTimes: number[] = [];
|
||||
|
||||
/**
|
||||
* Draw canvas
|
||||
*
|
||||
* This should be done using differences
|
||||
*/
|
||||
draw() {
|
||||
const start = performance.now();
|
||||
|
||||
const pixels = [...this.pixels];
|
||||
this.pixels = [];
|
||||
|
||||
if (pixels.length) {
|
||||
console.log("[CanvasRenderer#draw] drawing " + pixels.length + " pixels");
|
||||
}
|
||||
|
||||
for (const pixel of pixels) {
|
||||
this.ctx.fillStyle = pixel.hex === "null" ? "#fff" : "#" + pixel.hex;
|
||||
this.ctx.fillRect(pixel.x, pixel.y, 1, 1);
|
||||
}
|
||||
|
||||
const diff = performance.now() - start;
|
||||
this.drawTimes = this.drawTimes.slice(0, 300);
|
||||
const drawavg =
|
||||
this.drawTimes.length > 0
|
||||
? this.drawTimes.reduce((a, b) => a + b) / this.drawTimes.length
|
||||
: 0;
|
||||
if (diff > 0) this.drawTimes.push(diff);
|
||||
|
||||
if (diff > drawavg) {
|
||||
console.warn(
|
||||
`canvas#draw took ${diff} ms (> avg: ${drawavg} ; ${this.drawTimes.length} samples)`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* fully draw canvas
|
||||
*/
|
||||
private drawFull() {
|
||||
// --- main canvas ---
|
||||
|
||||
this.ctx.imageSmoothingEnabled = false;
|
||||
this.ctx.globalAlpha = 1;
|
||||
|
||||
// clear canvas
|
||||
this.ctx.fillStyle = "#fff";
|
||||
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
|
||||
for (const pixel of this.allPixels) {
|
||||
this.ctx.fillStyle = pixel.hex === "null" ? "#fff" : "#" + pixel.hex;
|
||||
this.ctx.fillRect(pixel.x, pixel.y, 1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
private drawBlank() {
|
||||
if (this.blank && this.blank_ctx) {
|
||||
// --- blank canvas ---
|
||||
|
||||
let canvas = this.blank;
|
||||
let ctx = this.blank_ctx;
|
||||
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
for (const pixel of this.allPixels) {
|
||||
if (pixel.hex !== "null") continue;
|
||||
|
||||
ctx.fillStyle = "rgba(0,140,0,0.5)";
|
||||
ctx.fillRect(pixel.x, pixel.y, 1, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setSize(width: number, height: number) {
|
||||
console.log("[CanvasRenderer] Received size set", { width, height });
|
||||
|
||||
this.dimentions = { width, height };
|
||||
|
||||
if (this.canvas) {
|
||||
this.canvas.width = width;
|
||||
this.canvas.height = height;
|
||||
}
|
||||
|
||||
if (this.blank) {
|
||||
this.blank.width = width;
|
||||
this.blank.height = height;
|
||||
}
|
||||
|
||||
this.tryDrawFull();
|
||||
this.emit("ready");
|
||||
}
|
||||
}
|
|
@ -54,6 +54,11 @@ const KEYBINDS = enforceObjectType({
|
|||
key: "KeyM",
|
||||
},
|
||||
],
|
||||
DESELECT_COLOR: [
|
||||
{
|
||||
key: "Escape",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
class KeybindManager_ extends EventEmitter<{
|
||||
|
|
|
@ -21,6 +21,11 @@ export interface INetworkEvents {
|
|||
pixelLastPlaced: (time: number) => void;
|
||||
online: (count: number) => void;
|
||||
pixel: (pixel: Pixel) => void;
|
||||
square: (
|
||||
start: [x: number, y: number],
|
||||
end: [x: number, y: number],
|
||||
color: number
|
||||
) => void;
|
||||
undo: (
|
||||
data: { available: false } | { available: true; expireAt: number }
|
||||
) => void;
|
||||
|
@ -105,6 +110,10 @@ class Network extends EventEmitter<INetworkEvents> {
|
|||
this.emit("pixel", pixel);
|
||||
});
|
||||
|
||||
this.socket.on("square", (...square) => {
|
||||
this.emit("square", ...square);
|
||||
});
|
||||
|
||||
this.socket.on("undo", (undo) => {
|
||||
this.emit("undo", undo);
|
||||
});
|
||||
|
|
|
@ -0,0 +1,160 @@
|
|||
import { toast } from "react-toastify";
|
||||
import RenderWorker from "../worker/render.worker?worker";
|
||||
import {
|
||||
CanvasPixel,
|
||||
CanvasRenderer,
|
||||
CanvasRole,
|
||||
RendererEvents,
|
||||
} from "./canvasRenderer";
|
||||
import { ExtractMethods } from "./utils";
|
||||
import EventEmitter from "eventemitter3";
|
||||
|
||||
const hasWorkerSupport =
|
||||
typeof Worker !== "undefined" && !localStorage.getItem("no_workers");
|
||||
|
||||
export abstract class Renderer
|
||||
extends EventEmitter<RendererEvents>
|
||||
implements ICanvasRenderer
|
||||
{
|
||||
hasWorker: boolean;
|
||||
|
||||
constructor(hasWorker: boolean) {
|
||||
super();
|
||||
this.hasWorker = hasWorker;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the renderer that is available to the client
|
||||
* @returns
|
||||
*/
|
||||
static create(): Renderer {
|
||||
if (hasWorkerSupport) {
|
||||
return new WorkerRenderer();
|
||||
} else {
|
||||
return new LocalRenderer();
|
||||
}
|
||||
}
|
||||
|
||||
abstract usePixels(pixels: CanvasPixel[], replace?: boolean): void;
|
||||
abstract usePixel(pixel: CanvasPixel): void;
|
||||
abstract draw(): void;
|
||||
abstract setSize(width: number, height: number): void;
|
||||
abstract useCanvas(canvas: HTMLCanvasElement, role: CanvasRole): void;
|
||||
abstract removeCanvas(role: CanvasRole): void;
|
||||
abstract startRender(): void;
|
||||
abstract stopRender(): void;
|
||||
}
|
||||
|
||||
type ICanvasRenderer = Omit<
|
||||
ExtractMethods<CanvasRenderer>,
|
||||
"useCanvas" | keyof ExtractMethods<EventEmitter>
|
||||
> & {
|
||||
useCanvas: (canvas: HTMLCanvasElement, role: CanvasRole) => void;
|
||||
};
|
||||
|
||||
class WorkerRenderer extends Renderer implements ICanvasRenderer {
|
||||
private worker: Worker;
|
||||
|
||||
constructor() {
|
||||
super(true);
|
||||
this.worker = new RenderWorker();
|
||||
this.worker.addEventListener("message", (req) => {
|
||||
if (req.data.type === "ready") {
|
||||
this.emit("ready");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
console.warn("[WorkerRender#destroy] Destroying worker");
|
||||
this.worker.terminate();
|
||||
}
|
||||
|
||||
useCanvas(canvas: HTMLCanvasElement, role: CanvasRole): void {
|
||||
const offscreen = canvas.transferControlToOffscreen();
|
||||
this.worker.postMessage({ type: "canvas", role, canvas: offscreen }, [
|
||||
offscreen,
|
||||
]);
|
||||
}
|
||||
|
||||
removeCanvas(role: CanvasRole): void {
|
||||
this.worker.postMessage({ type: "remove-canvas", role });
|
||||
}
|
||||
|
||||
usePixels(pixels: CanvasPixel[], replace: boolean): void {
|
||||
this.worker.postMessage({
|
||||
type: "pixels",
|
||||
replace,
|
||||
pixels: pixels
|
||||
.map((pixel) => pixel.x + "," + pixel.y + "," + pixel.hex)
|
||||
.join(";"),
|
||||
});
|
||||
}
|
||||
|
||||
usePixel({ x, y, hex }: CanvasPixel): void {
|
||||
this.worker.postMessage({
|
||||
type: "pixel",
|
||||
pixel: x + "," + y + "," + (hex || "null"),
|
||||
});
|
||||
}
|
||||
|
||||
startDrawLoop(): void {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
startRender(): void {
|
||||
this.worker.postMessage({ type: "startRender" });
|
||||
}
|
||||
|
||||
stopRender(): void {
|
||||
this.worker.postMessage({ type: "stopRender" });
|
||||
}
|
||||
|
||||
draw(): void {
|
||||
this.worker.postMessage({ type: "draw" });
|
||||
}
|
||||
|
||||
setSize(width: number, height: number): void {
|
||||
this.worker.postMessage({ type: "size", width, height });
|
||||
}
|
||||
}
|
||||
|
||||
class LocalRenderer extends Renderer implements ICanvasRenderer {
|
||||
reference: CanvasRenderer;
|
||||
|
||||
constructor() {
|
||||
super(false);
|
||||
|
||||
toast.error(
|
||||
"Your browser doesn't support WebWorkers, this will cause performance issues"
|
||||
);
|
||||
|
||||
this.reference = new CanvasRenderer();
|
||||
this.reference.on("ready", () => this.emit("ready"));
|
||||
}
|
||||
|
||||
useCanvas(canvas: HTMLCanvasElement, role: CanvasRole): void {
|
||||
this.reference.useCanvas(canvas, role);
|
||||
}
|
||||
removeCanvas(role: CanvasRole) {
|
||||
this.reference.removeCanvas(role);
|
||||
}
|
||||
usePixels(pixels: CanvasPixel[], replace: boolean): void {
|
||||
this.reference.usePixels(pixels, replace);
|
||||
}
|
||||
usePixel(pixel: CanvasPixel): void {
|
||||
this.reference.usePixel(pixel);
|
||||
}
|
||||
startRender(): void {
|
||||
this.reference.startRender();
|
||||
}
|
||||
stopRender(): void {
|
||||
this.reference.stopRender();
|
||||
}
|
||||
draw(): void {
|
||||
this.reference.draw();
|
||||
}
|
||||
setSize(width: number, height: number): void {
|
||||
this.reference.setSize(width, height);
|
||||
}
|
||||
}
|
|
@ -1,4 +1,21 @@
|
|||
import { toast } from "react-toastify";
|
||||
import { Renderer } from "./renderer";
|
||||
import { Debug } from "@sc07-canvas/lib/src/debug";
|
||||
|
||||
let _renderer: Renderer;
|
||||
|
||||
/**
|
||||
* Get the renderer instance or create one
|
||||
* @returns
|
||||
*/
|
||||
export const getRenderer = (): Renderer => {
|
||||
if (_renderer) return _renderer;
|
||||
|
||||
_renderer = Renderer.create();
|
||||
return _renderer;
|
||||
};
|
||||
|
||||
Debug._getRenderer = getRenderer;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export const api = async <T = unknown, Error = string>(
|
||||
|
@ -35,6 +52,12 @@ export const api = async <T = unknown, Error = string>(
|
|||
};
|
||||
};
|
||||
|
||||
export type PickMatching<T, V> = {
|
||||
[K in keyof T as T[K] extends V ? K : never]: T[K];
|
||||
};
|
||||
|
||||
export type ExtractMethods<T> = PickMatching<T, Function>;
|
||||
|
||||
export type EnforceObjectType<T> = <V extends { [k: string]: T }>(
|
||||
v: V
|
||||
) => { [k in keyof V]: T };
|
||||
|
|
|
@ -0,0 +1,81 @@
|
|||
/**
|
||||
* Worker to handle canvas draws to free the main thread
|
||||
*/
|
||||
|
||||
import { CanvasPixel, CanvasRenderer, CanvasRole } from "../lib/canvasRenderer";
|
||||
|
||||
console.log("[Render Worker] Initialize");
|
||||
|
||||
const renderer = new CanvasRenderer();
|
||||
|
||||
renderer.on("ready", () => {
|
||||
postMessage({ type: "ready" });
|
||||
});
|
||||
|
||||
addEventListener("message", (req) => {
|
||||
switch (req.data.type) {
|
||||
case "canvas": {
|
||||
const canvas: OffscreenCanvas = req.data.canvas;
|
||||
const role: CanvasRole = req.data.role;
|
||||
renderer.useCanvas(canvas, role);
|
||||
renderer.renderLoop();
|
||||
break;
|
||||
}
|
||||
case "remove-canvas": {
|
||||
const role: CanvasRole = req.data.role;
|
||||
renderer.removeCanvas(role);
|
||||
break;
|
||||
}
|
||||
case "size": {
|
||||
const width: number = req.data.width;
|
||||
const height: number = req.data.height;
|
||||
renderer.setSize(width, height);
|
||||
break;
|
||||
}
|
||||
case "pixels": {
|
||||
const pixelsIn: string = req.data.pixels;
|
||||
const replace: boolean = req.data.replace;
|
||||
const pixels = deserializePixels(pixelsIn);
|
||||
renderer.usePixels(pixels, replace);
|
||||
break;
|
||||
}
|
||||
case "pixel": {
|
||||
const pixel = deserializePixel(req.data.pixel);
|
||||
renderer.usePixel(pixel);
|
||||
break;
|
||||
}
|
||||
case "startRender": {
|
||||
renderer.startRender();
|
||||
break;
|
||||
}
|
||||
case "stopRender": {
|
||||
renderer.stopRender();
|
||||
break;
|
||||
}
|
||||
default:
|
||||
console.warn(
|
||||
"[Render Worker] Received unknown message type",
|
||||
req.data.type
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const deserializePixel = (str: string): CanvasPixel => {
|
||||
let [x, y, hex] = str.split(",");
|
||||
return {
|
||||
x: parseInt(x),
|
||||
y: parseInt(y),
|
||||
hex,
|
||||
};
|
||||
};
|
||||
|
||||
const deserializePixels = (str: string): CanvasPixel[] => {
|
||||
let pixels: CanvasPixel[] = [];
|
||||
|
||||
const pixelsIn = str.split(";");
|
||||
for (const pixel of pixelsIn) {
|
||||
pixels.push(deserializePixel(pixel));
|
||||
}
|
||||
|
||||
return pixels;
|
||||
};
|
|
@ -100,6 +100,7 @@ class FlagManager extends EventEmitter<FlagEvents> {
|
|||
*/
|
||||
class Debugcl extends EventEmitter<DebugEvents> {
|
||||
readonly flags = new FlagManager();
|
||||
_getRenderer: any;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
@ -180,6 +181,15 @@ class Debugcl extends EventEmitter<DebugEvents> {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
getRenderer() {
|
||||
return this._getRenderer();
|
||||
}
|
||||
}
|
||||
|
||||
export const Debug = new Debugcl();
|
||||
const Debug = new Debugcl();
|
||||
|
||||
// @ts-ignore
|
||||
window.Debug = Debug;
|
||||
|
||||
export { Debug };
|
||||
|
|
|
@ -13,6 +13,11 @@ export interface ServerToClientEvents {
|
|||
undo: (
|
||||
data: { available: false } | { available: true; expireAt: number }
|
||||
) => void;
|
||||
square: (
|
||||
start: [x: number, y: number],
|
||||
end: [x: number, y: number],
|
||||
color: number
|
||||
) => void;
|
||||
|
||||
/* --- subscribe events --- */
|
||||
|
||||
|
@ -39,7 +44,9 @@ export interface ClientToServerEvents {
|
|||
>
|
||||
) => void
|
||||
) => void;
|
||||
undo: (ack: (_: PacketAck<{}, "no_user" | "unavailable">) => void) => void;
|
||||
undo: (
|
||||
ack: (_: PacketAck<{}, "no_user" | "unavailable" | "pixel_covered">) => void
|
||||
) => void;
|
||||
|
||||
subscribe: (topic: Subscription) => void;
|
||||
unsubscribe: (topic: Subscription) => void;
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import { Debug } from "../../debug";
|
||||
import { PanZoom } from "../PanZoom";
|
||||
|
||||
export function handleCalculateZoomPositions(
|
||||
|
|
|
@ -9,8 +9,8 @@ Table Setting {
|
|||
|
||||
Table User {
|
||||
sub String [pk]
|
||||
picture_url String
|
||||
display_name String
|
||||
picture_url String
|
||||
profile_url String
|
||||
lastPixelTime DateTime [default: `now()`, not null]
|
||||
pixelStack Int [not null, default: 0]
|
||||
|
@ -42,7 +42,10 @@ Table Pixel {
|
|||
x Int [not null]
|
||||
y Int [not null]
|
||||
color String [not null]
|
||||
isTop Boolean [not null, default: false]
|
||||
isModAction Boolean [not null, default: false]
|
||||
createdAt DateTime [default: `now()`, not null]
|
||||
deletedAt DateTime
|
||||
user User [not null]
|
||||
pallete PaletteColor [not null]
|
||||
}
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "Pixel" ADD COLUMN "isModAction" BOOLEAN NOT NULL DEFAULT false;
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "Pixel" ADD COLUMN "isTop" BOOLEAN NOT NULL DEFAULT false;
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "Pixel" ADD COLUMN "deletedAt" TIMESTAMP(3);
|
|
@ -58,8 +58,11 @@ model Pixel {
|
|||
x Int
|
||||
y Int
|
||||
color String
|
||||
isTop Boolean @default(false)
|
||||
isModAction Boolean @default(false)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
deletedAt DateTime?
|
||||
|
||||
user User @relation(fields: [userId], references: [sub])
|
||||
pallete PaletteColor @relation(fields: [color], references: [hex])
|
||||
|
|
|
@ -1,10 +1,13 @@
|
|||
import { Router } from "express";
|
||||
import { User } from "../models/User";
|
||||
import Canvas from "../lib/Canvas";
|
||||
import { Logger } from "../lib/Logger";
|
||||
import { getLogger } from "../lib/Logger";
|
||||
import { RateLimiter } from "../lib/RateLimiter";
|
||||
import { prisma } from "../lib/prisma";
|
||||
import { SocketServer } from "../lib/SocketServer";
|
||||
|
||||
const app = Router();
|
||||
const Logger = getLogger("HTTP/ADMIN");
|
||||
|
||||
app.use(RateLimiter.ADMIN);
|
||||
|
||||
|
@ -85,4 +88,117 @@ app.put("/canvas/heatmap", async (req, res) => {
|
|||
}
|
||||
});
|
||||
|
||||
app.post("/canvas/forceUpdateTop", async (req, res) => {
|
||||
Logger.info("Starting force updating isTop");
|
||||
|
||||
await Canvas.forceUpdatePixelIsTop();
|
||||
|
||||
Logger.info("Finished force updating isTop");
|
||||
res.send({ success: true });
|
||||
});
|
||||
|
||||
app.get("/canvas/:x/:y", async (req, res) => {
|
||||
const x = parseInt(req.params.x);
|
||||
const y = parseInt(req.params.y);
|
||||
|
||||
res.json(await Canvas.getPixel(x, y));
|
||||
});
|
||||
|
||||
app.post("/canvas/stress", async (req, res) => {
|
||||
if (
|
||||
typeof req.body?.width !== "number" ||
|
||||
typeof req.body?.height !== "number"
|
||||
) {
|
||||
res.status(400).json({ success: false, error: "width/height is invalid" });
|
||||
return;
|
||||
}
|
||||
|
||||
const width: number = req.body.width;
|
||||
const height: number = req.body.height;
|
||||
|
||||
for (let x = 0; x < width; x++) {
|
||||
for (let y = 0; y < height; y++) {
|
||||
let color = Math.floor(Math.random() * 30) + 1;
|
||||
SocketServer.instance.io.emit("pixel", {
|
||||
x,
|
||||
y,
|
||||
color,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
res.send("ok");
|
||||
});
|
||||
|
||||
/**
|
||||
* Fill an area
|
||||
*/
|
||||
app.put("/canvas/fill", async (req, res) => {
|
||||
if (
|
||||
typeof req.body?.start?.x !== "number" ||
|
||||
typeof req.body?.start?.y !== "number"
|
||||
) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ success: false, error: "start position is invalid" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof req.body?.end?.x !== "number" ||
|
||||
typeof req.body?.end?.y !== "number"
|
||||
) {
|
||||
res.status(400).json({ success: false, error: "end position is invalid" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof req.body.color !== "number") {
|
||||
res.status(400).json({ success: false, error: "color is invalid" });
|
||||
return;
|
||||
}
|
||||
|
||||
const user_sub =
|
||||
req.session.user!.user.username +
|
||||
"@" +
|
||||
req.session.user!.service.instance.hostname;
|
||||
const start_position: [x: number, y: number] = [
|
||||
req.body.start.x,
|
||||
req.body.start.y,
|
||||
];
|
||||
const end_position: [x: number, y: number] = [req.body.end.x, req.body.end.y];
|
||||
const palette = await prisma.paletteColor.findFirst({
|
||||
where: { id: req.body.color },
|
||||
});
|
||||
|
||||
if (!palette) {
|
||||
res.status(400).json({ success: false, error: "invalid color" });
|
||||
return;
|
||||
}
|
||||
|
||||
const width = end_position[0] - start_position[0];
|
||||
const height = end_position[1] - start_position[1];
|
||||
const area = width * height;
|
||||
|
||||
// if (area > 50 * 50) {
|
||||
// res.status(400).json({ success: false, error: "Area too big" });
|
||||
// return;
|
||||
// }
|
||||
|
||||
await Canvas.fillArea(
|
||||
{ sub: user_sub },
|
||||
start_position,
|
||||
end_position,
|
||||
palette.hex
|
||||
);
|
||||
|
||||
SocketServer.instance.io.emit(
|
||||
"square",
|
||||
start_position,
|
||||
end_position,
|
||||
palette.id
|
||||
);
|
||||
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
export default app;
|
||||
|
|
|
@ -2,10 +2,12 @@ import { Router } from "express";
|
|||
import { prisma } from "../lib/prisma";
|
||||
import { OpenID } from "../lib/oidc";
|
||||
import { TokenSet, errors as OIDC_Errors } from "openid-client";
|
||||
import { Logger } from "../lib/Logger";
|
||||
import { getLogger } from "../lib/Logger";
|
||||
import Canvas from "../lib/Canvas";
|
||||
import { RateLimiter } from "../lib/RateLimiter";
|
||||
|
||||
const Logger = getLogger("HTTP/CLIENT");
|
||||
|
||||
const ClientParams = {
|
||||
TYPE: "auth_type",
|
||||
ERROR: "auth_error",
|
||||
|
|
|
@ -1,12 +1,13 @@
|
|||
// load declare module
|
||||
import "./types";
|
||||
import { Redis } from "./lib/redis";
|
||||
import { Logger } from "./lib/Logger";
|
||||
import { getLogger } from "./lib/Logger";
|
||||
import { ExpressServer } from "./lib/Express";
|
||||
import { SocketServer } from "./lib/SocketServer";
|
||||
import { OpenID } from "./lib/oidc";
|
||||
import { loadSettings } from "./lib/Settings";
|
||||
import { Jobs } from "./lib/Jobs";
|
||||
|
||||
const Logger = getLogger("MAIN");
|
||||
|
||||
// Validate environment variables
|
||||
|
||||
|
@ -86,8 +87,8 @@ Promise.all([
|
|||
loadSettings(),
|
||||
]).then(() => {
|
||||
Logger.info("Startup tasks have completed, starting server");
|
||||
Logger.warn("Make sure the jobs process is running");
|
||||
|
||||
new Jobs();
|
||||
const express = new ExpressServer();
|
||||
new SocketServer(express.httpServer);
|
||||
});
|
||||
|
|
|
@ -1,8 +1,17 @@
|
|||
import Canvas from "./Canvas";
|
||||
import { Logger } from "./Logger";
|
||||
import Canvas from "../lib/Canvas";
|
||||
import { getLogger } from "../lib/Logger";
|
||||
|
||||
const Logger = getLogger("JOB_WORKER");
|
||||
|
||||
/**
|
||||
* Job scheduler
|
||||
*
|
||||
* This should run in a different process
|
||||
*/
|
||||
export class Jobs {
|
||||
constructor() {
|
||||
Logger.info("Starting job worker...");
|
||||
|
||||
// every 5 minutes
|
||||
setInterval(this.generateHeatmap, 1000 * 60 * 5);
|
||||
|
|
@ -2,7 +2,10 @@ import { CanvasConfig } from "@sc07-canvas/lib/src/net";
|
|||
import { prisma } from "./prisma";
|
||||
import { Redis } from "./redis";
|
||||
import { SocketServer } from "./SocketServer";
|
||||
import { Logger } from "./Logger";
|
||||
import { getLogger } from "./Logger";
|
||||
import { Pixel } from "@prisma/client";
|
||||
|
||||
const Logger = getLogger("CANVAS");
|
||||
|
||||
class Canvas {
|
||||
/**
|
||||
|
@ -37,8 +40,14 @@ class Canvas {
|
|||
* @param width
|
||||
* @param height
|
||||
*/
|
||||
async setSize(width: number, height: number) {
|
||||
Logger.info("Canvas#setSize has started", {
|
||||
async setSize(width: number, height: number, useStatic = false) {
|
||||
if (useStatic) {
|
||||
this.canvasSize = [width, height];
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
Logger.info("[Canvas#setSize] has started", {
|
||||
old: this.canvasSize,
|
||||
new: [width, height],
|
||||
});
|
||||
|
@ -56,46 +65,123 @@ class Canvas {
|
|||
},
|
||||
});
|
||||
|
||||
// we're about to use the redis keys, make sure they are all updated
|
||||
await this.pixelsToRedis();
|
||||
// the redis key is 1D, since the dimentions changed we need to update it
|
||||
await this.canvasToRedis();
|
||||
|
||||
// this gets called on startup, before the SocketServer is initialized
|
||||
// so only call if it's available
|
||||
if (SocketServer.instance) {
|
||||
// announce the new config, which contains the canvas size
|
||||
SocketServer.instance.broadcastConfig();
|
||||
|
||||
// announce new pixel array that was generated previously
|
||||
await this.getPixelsArray().then((pixels) => {
|
||||
SocketServer.instance.io.emit("canvas", pixels);
|
||||
SocketServer.instance?.io.emit("canvas", pixels);
|
||||
});
|
||||
|
||||
Logger.info("Canvas#setSize has finished");
|
||||
} else {
|
||||
Logger.warn(
|
||||
"[Canvas#setSize] No SocketServer instance, cannot broadcast config change"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Latest database pixels -> Redis
|
||||
*/
|
||||
async pixelsToRedis() {
|
||||
const redis = await Redis.getClient();
|
||||
Logger.info(
|
||||
"[Canvas#setSize] has finished in " +
|
||||
((Date.now() - now) / 1000).toFixed(1) +
|
||||
" seconds"
|
||||
);
|
||||
}
|
||||
|
||||
const key = Redis.keyRef("pixelColor");
|
||||
async forceUpdatePixelIsTop() {
|
||||
const now = Date.now();
|
||||
Logger.info("[Canvas#forceUpdatePixelIsTop] is starting...");
|
||||
|
||||
for (let x = 0; x < this.canvasSize[0]; x++) {
|
||||
for (let y = 0; y < this.canvasSize[1]; y++) {
|
||||
const pixel = await this.getPixel(x, y);
|
||||
const pixel = (
|
||||
await prisma.pixel.findMany({
|
||||
where: { x, y },
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
take: 1,
|
||||
})
|
||||
)?.[0];
|
||||
|
||||
await redis.set(key(x, y), pixel?.color || "transparent");
|
||||
if (pixel) {
|
||||
await prisma.pixel.update({
|
||||
where: {
|
||||
id: pixel.id,
|
||||
},
|
||||
data: {
|
||||
isTop: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Logger.info(
|
||||
"[Canvas#forceUpdatePixelIsTop] has finished in " +
|
||||
((Date.now() - now) / 1000).toFixed(1) +
|
||||
" seconds"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo a pixel
|
||||
* @throws Error "Pixel is not on top"
|
||||
* @param pixel
|
||||
*/
|
||||
async undoPixel(pixel: Pixel) {
|
||||
if (!pixel.isTop) throw new Error("Pixel is not on top");
|
||||
|
||||
await prisma.pixel.update({
|
||||
where: { id: pixel.id },
|
||||
data: {
|
||||
deletedAt: new Date(),
|
||||
isTop: false,
|
||||
},
|
||||
});
|
||||
|
||||
const coveringPixel = (
|
||||
await prisma.pixel.findMany({
|
||||
where: { x: pixel.x, y: pixel.y, createdAt: { lt: pixel.createdAt } },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 1,
|
||||
})
|
||||
)?.[0];
|
||||
|
||||
if (coveringPixel) {
|
||||
await prisma.pixel.update({
|
||||
where: { id: coveringPixel.id },
|
||||
data: {
|
||||
isTop: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Redis pixels -> single Redis comma separated list of hex
|
||||
* Database pixels -> single Redis comma separated list of hex
|
||||
* @returns 1D array of pixel values
|
||||
*/
|
||||
async canvasToRedis() {
|
||||
const redis = await Redis.getClient();
|
||||
|
||||
const dbpixels = await prisma.pixel.findMany({
|
||||
where: {
|
||||
x: {
|
||||
gte: 0,
|
||||
lt: this.getCanvasConfig().size[0],
|
||||
},
|
||||
y: {
|
||||
gte: 0,
|
||||
lt: this.getCanvasConfig().size[1],
|
||||
},
|
||||
isTop: true,
|
||||
},
|
||||
});
|
||||
|
||||
const pixels: string[] = [];
|
||||
|
||||
// (y -> x) because of how the conversion needs to be done later
|
||||
|
@ -104,7 +190,8 @@ class Canvas {
|
|||
for (let y = 0; y < this.canvasSize[1]; y++) {
|
||||
for (let x = 0; x < this.canvasSize[0]; x++) {
|
||||
pixels.push(
|
||||
(await redis.get(Redis.key("pixelColor", x, y))) || "transparent"
|
||||
dbpixels.find((px) => px.x === x && px.y === y)?.color ||
|
||||
"transparent"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -124,8 +211,25 @@ class Canvas {
|
|||
(await redis.get(Redis.key("canvas"))) || ""
|
||||
).split(",");
|
||||
|
||||
pixels[this.canvasSize[0] * y + x] =
|
||||
(await redis.get(Redis.key("pixelColor", x, y))) || "transparent";
|
||||
const dbpixel = await this.getPixel(x, y);
|
||||
|
||||
pixels[this.canvasSize[0] * y + x] = dbpixel?.color || "transparent";
|
||||
|
||||
await redis.set(Redis.key("canvas"), pixels.join(","), { EX: 60 * 5 });
|
||||
}
|
||||
|
||||
async updateCanvasRedisWithBatch(
|
||||
pixelBatch: { x: number; y: number; hex: string }[]
|
||||
) {
|
||||
const redis = await Redis.getClient();
|
||||
|
||||
const pixels: string[] = (
|
||||
(await redis.get(Redis.key("canvas"))) || ""
|
||||
).split(",");
|
||||
|
||||
for (const pixel of pixelBatch) {
|
||||
pixels[this.canvasSize[0] * pixel.y + pixel.x] = pixel.hex;
|
||||
}
|
||||
|
||||
await redis.set(Redis.key("canvas"), pixels.join(","), { EX: 60 * 5 });
|
||||
}
|
||||
|
@ -148,33 +252,89 @@ class Canvas {
|
|||
* @returns
|
||||
*/
|
||||
async isPixelEmpty(x: number, y: number) {
|
||||
const redis = await Redis.getClient();
|
||||
const pixelColor = await redis.get(Redis.key("pixelColor", x, y));
|
||||
|
||||
if (pixelColor === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return pixelColor === "transparent";
|
||||
const pixel = await this.getPixel(x, y);
|
||||
return pixel === null;
|
||||
}
|
||||
|
||||
async getPixel(x: number, y: number) {
|
||||
return (
|
||||
await prisma.pixel.findMany({
|
||||
return await prisma.pixel.findFirst({
|
||||
where: {
|
||||
x,
|
||||
y,
|
||||
isTop: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
take: 1,
|
||||
})
|
||||
)?.[0];
|
||||
});
|
||||
}
|
||||
|
||||
async setPixel(user: { sub: string }, x: number, y: number, hex: string) {
|
||||
const redis = await Redis.getClient();
|
||||
async fillArea(
|
||||
user: { sub: string },
|
||||
start: [x: number, y: number],
|
||||
end: [x: number, y: number],
|
||||
hex: string
|
||||
) {
|
||||
await prisma.pixel.updateMany({
|
||||
where: {
|
||||
x: {
|
||||
gte: start[0],
|
||||
lt: end[0],
|
||||
},
|
||||
y: {
|
||||
gte: start[1],
|
||||
lt: end[1],
|
||||
},
|
||||
isTop: true,
|
||||
},
|
||||
data: {
|
||||
isTop: false,
|
||||
},
|
||||
});
|
||||
|
||||
let pixels: {
|
||||
x: number;
|
||||
y: number;
|
||||
}[] = [];
|
||||
|
||||
for (let x = start[0]; x <= end[0]; x++) {
|
||||
for (let y = start[1]; y <= end[1]; y++) {
|
||||
pixels.push({
|
||||
x,
|
||||
y,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.pixel.createMany({
|
||||
data: pixels.map((px) => ({
|
||||
userId: user.sub,
|
||||
color: hex,
|
||||
isTop: true,
|
||||
isModAction: true,
|
||||
...px,
|
||||
})),
|
||||
});
|
||||
|
||||
await this.updateCanvasRedisWithBatch(
|
||||
pixels.map((px) => ({
|
||||
...px,
|
||||
hex,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
async setPixel(
|
||||
user: { sub: string },
|
||||
x: number,
|
||||
y: number,
|
||||
hex: string,
|
||||
isModAction: boolean
|
||||
) {
|
||||
// only one pixel can be on top at (x,y)
|
||||
await prisma.pixel.updateMany({
|
||||
where: { x, y, isTop: true },
|
||||
data: {
|
||||
isTop: false,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.pixel.create({
|
||||
data: {
|
||||
|
@ -182,6 +342,8 @@ class Canvas {
|
|||
color: hex,
|
||||
x,
|
||||
y,
|
||||
isTop: true,
|
||||
isModAction,
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -190,8 +352,6 @@ class Canvas {
|
|||
data: { lastPixelTime: new Date() },
|
||||
});
|
||||
|
||||
await redis.set(Redis.key("pixelColor", x, y), hex);
|
||||
|
||||
// maybe only update specific element?
|
||||
// i don't think it needs to be awaited
|
||||
await this.updateCanvasRedisAtPos(x, y);
|
||||
|
@ -203,21 +363,15 @@ class Canvas {
|
|||
* @param y
|
||||
*/
|
||||
async refreshPixel(x: number, y: number) {
|
||||
const redis = await Redis.getClient();
|
||||
const key = Redis.key("pixelColor", x, y);
|
||||
|
||||
// find if any pixels exist at this spot, and pick the most recent one
|
||||
const pixel = await this.getPixel(x, y);
|
||||
let paletteColorID = -1;
|
||||
|
||||
// if pixel exists in redis
|
||||
if (pixel) {
|
||||
redis.set(key, pixel.color);
|
||||
paletteColorID = (await prisma.paletteColor.findFirst({
|
||||
where: { hex: pixel.color },
|
||||
}))!.id;
|
||||
} else {
|
||||
redis.del(key);
|
||||
}
|
||||
|
||||
await this.updateCanvasRedisAtPos(x, y);
|
||||
|
@ -238,7 +392,9 @@ class Canvas {
|
|||
* @returns 2 character strings with 0-100 in radix 36 (depends on canvas size)
|
||||
*/
|
||||
async generateHeatmap() {
|
||||
const redis = await Redis.getClient();
|
||||
const redis_set = await Redis.getClient("MAIN");
|
||||
const redis_sub = await Redis.getClient("SUB");
|
||||
|
||||
const now = Date.now();
|
||||
const minimumDate = new Date();
|
||||
minimumDate.setHours(minimumDate.getHours() - 3); // 3 hours ago
|
||||
|
@ -247,23 +403,15 @@ class Canvas {
|
|||
|
||||
const heatmap: string[] = [];
|
||||
|
||||
const topPixels = await prisma.pixel.findMany({
|
||||
where: { isTop: true, createdAt: { gte: minimumDate } },
|
||||
});
|
||||
|
||||
for (let y = 0; y < this.canvasSize[1]; y++) {
|
||||
const arr: number[] = [];
|
||||
|
||||
for (let x = 0; x < this.canvasSize[0]; x++) {
|
||||
const pixel = (
|
||||
await prisma.pixel.findMany({
|
||||
where: {
|
||||
x,
|
||||
y,
|
||||
createdAt: { gt: minimumDate },
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
take: 1,
|
||||
})
|
||||
)?.[0];
|
||||
const pixel = topPixels.find((px) => px.x === x && px.y === y);
|
||||
|
||||
if (pixel) {
|
||||
arr.push(
|
||||
|
@ -284,10 +432,11 @@ class Canvas {
|
|||
const heatmapStr = heatmap.join("");
|
||||
|
||||
// cache for 5 minutes
|
||||
await redis.setEx(Redis.key("heatmap"), 60 * 5, heatmapStr);
|
||||
await redis_set.setEx(Redis.key("heatmap"), 60 * 5, heatmapStr);
|
||||
|
||||
// notify anyone interested about the new heatmap
|
||||
SocketServer.instance.io.to("sub:heatmap").emit("heatmap", heatmapStr);
|
||||
await redis_sub.publish(Redis.key("channel_heatmap"), heatmapStr);
|
||||
// SocketServer.instance.io.to("sub:heatmap").emit("heatmap", heatmapStr);
|
||||
|
||||
return heatmapStr;
|
||||
}
|
||||
|
|
|
@ -7,10 +7,12 @@ import cors from "cors";
|
|||
import { Redis } from "./redis";
|
||||
import APIRoutes_client from "../api/client";
|
||||
import APIRoutes_admin from "../api/admin";
|
||||
import { Logger } from "./Logger";
|
||||
import { getLogger } from "./Logger";
|
||||
import bodyParser from "body-parser";
|
||||
import { handleMetricsEndpoint } from "./Prometheus";
|
||||
|
||||
const Logger = getLogger("HTTP");
|
||||
|
||||
export const session = expressSession({
|
||||
secret: process.env.SESSION_SECRET,
|
||||
resave: false,
|
||||
|
|
|
@ -1,7 +1,42 @@
|
|||
import winston, { format } from "winston";
|
||||
import { createEnum } from "./utils";
|
||||
|
||||
export const Logger = winston.createLogger({
|
||||
const formatter = format.printf((options) => {
|
||||
let maxModuleWidth = 0;
|
||||
for (const module of Object.values(LoggerType)) {
|
||||
maxModuleWidth = Math.max(maxModuleWidth, `[${module}]`.length);
|
||||
}
|
||||
|
||||
let modulePadding = " ".repeat(
|
||||
Math.max(0, maxModuleWidth - `[${options.moduleName}]`.length)
|
||||
);
|
||||
|
||||
let parts: string[] = [
|
||||
options.timestamp + ` [${options.moduleName || "---"}]` + modulePadding,
|
||||
options.level + ":",
|
||||
options.message,
|
||||
];
|
||||
|
||||
return parts.join("\t");
|
||||
});
|
||||
|
||||
const Winston = winston.createLogger({
|
||||
level: process.env.LOG_LEVEL || "info",
|
||||
format: format.combine(format.splat(), format.cli()),
|
||||
format: format.combine(format.timestamp(), formatter),
|
||||
transports: [new winston.transports.Console()],
|
||||
});
|
||||
|
||||
export const LoggerType = createEnum([
|
||||
"MAIN",
|
||||
"SETTINGS",
|
||||
"CANVAS",
|
||||
"HTTP",
|
||||
"HTTP/ADMIN",
|
||||
"HTTP/CLIENT",
|
||||
"REDIS",
|
||||
"SOCKET",
|
||||
"JOB_WORKER",
|
||||
]);
|
||||
|
||||
export const getLogger = (module?: keyof typeof LoggerType) =>
|
||||
Winston.child({ moduleName: module });
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
import Canvas from "./Canvas";
|
||||
import { Logger } from "./Logger";
|
||||
import { getLogger } from "./Logger";
|
||||
import { prisma } from "./prisma";
|
||||
|
||||
export const loadSettings = async () => {
|
||||
const Logger = getLogger("SETTINGS");
|
||||
|
||||
export const loadSettings = async (frozen = false) => {
|
||||
Logger.info("Loading settings...");
|
||||
|
||||
const sideEffects: Promise<unknown>[] = [];
|
||||
|
@ -14,8 +16,9 @@ export const loadSettings = async () => {
|
|||
if (canvasSize) {
|
||||
const data = JSON.parse(canvasSize.value);
|
||||
Logger.info("Canvas size loaded as " + JSON.stringify(data));
|
||||
|
||||
sideEffects.push(
|
||||
Canvas.setSize(data.width, data.height).then(() => {
|
||||
Canvas.setSize(data.width, data.height, frozen).then(() => {
|
||||
Logger.info("Canvas size successfully updated");
|
||||
})
|
||||
);
|
||||
|
|
|
@ -12,10 +12,12 @@ import { session } from "./Express";
|
|||
import Canvas from "./Canvas";
|
||||
import { PaletteColor } from "@prisma/client";
|
||||
import { prisma } from "./prisma";
|
||||
import { Logger } from "./Logger";
|
||||
import { getLogger } from "./Logger";
|
||||
import { Redis } from "./redis";
|
||||
import { User } from "../models/User";
|
||||
|
||||
const Logger = getLogger("SOCKET");
|
||||
|
||||
// maybe move to a constants file?
|
||||
const commitHash = child
|
||||
.execSync("git rev-parse --short HEAD")
|
||||
|
@ -248,7 +250,13 @@ export class SocketServer {
|
|||
}
|
||||
|
||||
await user.modifyStack(-1);
|
||||
await Canvas.setPixel(user, pixel.x, pixel.y, paletteColor.hex);
|
||||
await Canvas.setPixel(
|
||||
user,
|
||||
pixel.x,
|
||||
pixel.y,
|
||||
paletteColor.hex,
|
||||
bypassCooldown
|
||||
);
|
||||
// give undo capabilities
|
||||
await user.setUndo(
|
||||
new Date(Date.now() + Canvas.getCanvasConfig().undo.grace_period)
|
||||
|
@ -300,12 +308,17 @@ export class SocketServer {
|
|||
return;
|
||||
}
|
||||
|
||||
// delete most recent pixel
|
||||
try {
|
||||
await Canvas.undoPixel(pixel);
|
||||
} catch (e) {
|
||||
ack({ success: false, error: "pixel_covered" });
|
||||
return;
|
||||
}
|
||||
|
||||
// mark the undo as used
|
||||
await user.setUndo();
|
||||
|
||||
// delete most recent pixel
|
||||
await prisma.pixel.delete({ where: { id: pixel.id } });
|
||||
|
||||
// trigger re-cache on redis
|
||||
await Canvas.refreshPixel(pixel.x, pixel.y);
|
||||
|
||||
|
@ -330,7 +343,7 @@ export class SocketServer {
|
|||
*
|
||||
* this does work with multiple socket.io instances, so this needs to only be executed by one shard
|
||||
*/
|
||||
setupMasterShard() {
|
||||
async setupMasterShard() {
|
||||
// online announcement event
|
||||
setInterval(async () => {
|
||||
// possible issue: this includes every connected socket, not user count
|
||||
|
@ -339,5 +352,10 @@ export class SocketServer {
|
|||
socket.emit("online", { count: sockets.length });
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
const redis = await Redis.getClient("SUB");
|
||||
redis.subscribe(Redis.key("channel_heatmap"), (message, channel) => {
|
||||
this.io.to("sub:heatmap").emit("heatmap", message);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,34 +1,39 @@
|
|||
import { RedisClientType } from "@redis/client";
|
||||
import { createClient } from "redis";
|
||||
import { Logger } from "./Logger";
|
||||
import { getLogger } from "./Logger";
|
||||
|
||||
const Logger = getLogger("REDIS");
|
||||
|
||||
/**
|
||||
* Typedef for RedisKeys
|
||||
*/
|
||||
interface IRedisKeys {
|
||||
// canvas
|
||||
pixelColor(x: number, y: number): string;
|
||||
canvas(): string;
|
||||
heatmap(): string;
|
||||
|
||||
// users
|
||||
socketToSub(socketId: string): string;
|
||||
|
||||
// pub/sub channels
|
||||
channel_heatmap(): string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Defined as a variable due to boottime augmentation
|
||||
*/
|
||||
const RedisKeys: IRedisKeys = {
|
||||
pixelColor: (x: number, y: number) => `CANVAS:PIXELS[${x},${y}]:COLOR`,
|
||||
canvas: () => `CANVAS:PIXELS`,
|
||||
heatmap: () => `CANVAS:HEATMAP`,
|
||||
socketToSub: (socketId: string) => `CANVAS:SOCKET:${socketId}`,
|
||||
channel_heatmap: () => `CANVAS:HEATMAP`,
|
||||
};
|
||||
|
||||
class _Redis {
|
||||
isConnecting = false;
|
||||
isConnected = false;
|
||||
client: RedisClientType;
|
||||
sub_client: RedisClientType; // the client used for pubsub
|
||||
|
||||
waitingForConnect: ((...args: any) => any)[] = [];
|
||||
|
||||
|
@ -43,6 +48,9 @@ class _Redis {
|
|||
this.client = createClient({
|
||||
url: process.env.REDIS_HOST,
|
||||
});
|
||||
this.sub_client = createClient({
|
||||
url: process.env.REDIS_HOST,
|
||||
});
|
||||
|
||||
this.keys = keys;
|
||||
}
|
||||
|
@ -53,6 +61,7 @@ class _Redis {
|
|||
|
||||
this.isConnecting = true;
|
||||
await this.client.connect();
|
||||
await this.sub_client.connect();
|
||||
Logger.info(
|
||||
`Connected to Redis, there's ${this.waitingForConnect.length} function(s) waiting for Redis`
|
||||
);
|
||||
|
@ -75,7 +84,7 @@ class _Redis {
|
|||
this.isConnected = false;
|
||||
}
|
||||
|
||||
async getClient() {
|
||||
async getClient(intent: "MAIN" | "SUB" = "MAIN") {
|
||||
if (this.isConnecting) {
|
||||
await (() =>
|
||||
new Promise((res) => {
|
||||
|
@ -89,6 +98,10 @@ class _Redis {
|
|||
this.isConnected = true;
|
||||
}
|
||||
|
||||
if (intent === "SUB") {
|
||||
return this.sub_client;
|
||||
}
|
||||
|
||||
return this.client;
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,16 @@
|
|||
/**
|
||||
* Create enum from array of strings
|
||||
*
|
||||
* @param values
|
||||
* @returns
|
||||
*/
|
||||
export const createEnum = <T extends string>(values: T[]): { [k in T]: k } => {
|
||||
// @ts-ignore
|
||||
let ret: { [k in T]: k } = {};
|
||||
|
||||
for (const val of values) {
|
||||
ret[val] = val;
|
||||
}
|
||||
|
||||
return ret;
|
||||
};
|
|
@ -1,5 +1,5 @@
|
|||
import { Socket } from "socket.io";
|
||||
import { Logger } from "../lib/Logger";
|
||||
import { getLogger } from "../lib/Logger";
|
||||
import { prisma } from "../lib/prisma";
|
||||
import {
|
||||
AuthSession,
|
||||
|
@ -7,6 +7,8 @@ import {
|
|||
ServerToClientEvents,
|
||||
} from "@sc07-canvas/lib/src/net";
|
||||
|
||||
const Logger = getLogger();
|
||||
|
||||
interface IUserData {
|
||||
sub: string;
|
||||
lastPixelTime: Date;
|
||||
|
|
|
@ -1,14 +0,0 @@
|
|||
import Canvas from "../lib/Canvas";
|
||||
import { Redis } from "../lib/redis";
|
||||
|
||||
const log = (...data: any) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(...data);
|
||||
};
|
||||
|
||||
(async () => {
|
||||
log("Caching pixels from database to Redis...");
|
||||
await Canvas.pixelsToRedis();
|
||||
await Redis.disconnect();
|
||||
log("Cached");
|
||||
})();
|
|
@ -0,0 +1,6 @@
|
|||
import { Jobs } from "../jobs/Jobs";
|
||||
import { loadSettings } from "../lib/Settings";
|
||||
|
||||
loadSettings(true).then(() => {
|
||||
new Jobs();
|
||||
});
|
Loading…
Reference in New Issue