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
|
FROM base as run
|
||||||
WORKDIR /home/node/app
|
WORKDIR /home/node/app
|
||||||
COPY --from=dep /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 ---
|
# --- prepare lib ---
|
||||||
|
|
||||||
|
|
|
@ -19,6 +19,17 @@ services:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
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:
|
redis:
|
||||||
restart: always
|
restart: always
|
||||||
image: redis:7-alpine
|
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 { Canvas } from "../lib/canvas";
|
||||||
import { useAppContext } from "../contexts/AppContext";
|
import { useAppContext } from "../contexts/AppContext";
|
||||||
import { PanZoomWrapper } from "@sc07-canvas/lib/src/renderer";
|
import { PanZoomWrapper } from "@sc07-canvas/lib/src/renderer";
|
||||||
|
@ -23,29 +23,56 @@ export const CanvasWrapper = () => {
|
||||||
<HeatmapOverlay />
|
<HeatmapOverlay />
|
||||||
{config && <Template />}
|
{config && <Template />}
|
||||||
<CanvasInner />
|
<CanvasInner />
|
||||||
|
<Cursor />
|
||||||
</PanZoomWrapper>
|
</PanZoomWrapper>
|
||||||
</main>
|
</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 CanvasInner = () => {
|
||||||
const canvasRef = useRef<HTMLCanvasElement | null>();
|
const canvasRef = useRef<HTMLCanvasElement | null>();
|
||||||
const canvas = useRef<Canvas>();
|
const canvas = useRef<Canvas>();
|
||||||
const { config, setCanvasPosition, setCursorPosition, setPixelWhois } =
|
const { config, setCanvasPosition, setCursor, setPixelWhois } =
|
||||||
useAppContext();
|
useAppContext();
|
||||||
const PanZoom = useContext(RendererContext);
|
const PanZoom = useContext(RendererContext);
|
||||||
|
|
||||||
useEffect(() => {
|
const handlePixelWhois = useCallback(
|
||||||
if (!canvasRef.current) return;
|
({ clientX, clientY }: { clientX: number; clientY: number }) => {
|
||||||
canvas.current = new Canvas(canvasRef.current!, PanZoom);
|
|
||||||
|
|
||||||
const handlePixelWhois = ({
|
|
||||||
clientX,
|
|
||||||
clientY,
|
|
||||||
}: {
|
|
||||||
clientX: number;
|
|
||||||
clientY: number;
|
|
||||||
}) => {
|
|
||||||
if (!canvas.current) {
|
if (!canvas.current) {
|
||||||
console.warn(
|
console.warn(
|
||||||
"[CanvasWrapper#handlePixelWhois] canvas instance does not exist"
|
"[CanvasWrapper#handlePixelWhois] canvas instance does not exist"
|
||||||
|
@ -83,7 +110,20 @@ const CanvasInner = () => {
|
||||||
const surrounding = canvas.current.getSurroundingPixels(x, y, 3);
|
const surrounding = canvas.current.getSurroundingPixels(x, y, 3);
|
||||||
|
|
||||||
setPixelWhois({ x, y, surrounding });
|
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);
|
KeybindManager.on("PIXEL_WHOIS", handlePixelWhois);
|
||||||
|
|
||||||
|
@ -91,7 +131,7 @@ const CanvasInner = () => {
|
||||||
KeybindManager.off("PIXEL_WHOIS", handlePixelWhois);
|
KeybindManager.off("PIXEL_WHOIS", handlePixelWhois);
|
||||||
canvas.current!.destroy();
|
canvas.current!.destroy();
|
||||||
};
|
};
|
||||||
}, [PanZoom, setCursorPosition]);
|
}, [PanZoom]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Router.PanZoom = PanZoom;
|
Router.PanZoom = PanZoom;
|
||||||
|
@ -115,10 +155,18 @@ const CanvasInner = () => {
|
||||||
pos.x > config.canvas.size[0] ||
|
pos.x > config.canvas.size[0] ||
|
||||||
pos.y > config.canvas.size[1]
|
pos.y > config.canvas.size[1]
|
||||||
) {
|
) {
|
||||||
setCursorPosition();
|
setCursor((v) => ({
|
||||||
|
...v,
|
||||||
|
x: undefined,
|
||||||
|
y: undefined,
|
||||||
|
}));
|
||||||
} else {
|
} else {
|
||||||
// fixes not passing the current value
|
// fixes not passing the current value
|
||||||
setCursorPosition({ ...pos });
|
setCursor((v) => ({
|
||||||
|
...v,
|
||||||
|
x: pos.x,
|
||||||
|
y: pos.y,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
}, 1);
|
}, 1);
|
||||||
|
|
||||||
|
@ -127,7 +175,7 @@ const CanvasInner = () => {
|
||||||
return () => {
|
return () => {
|
||||||
canvas.current!.off("cursorPos", handleCursorPos);
|
canvas.current!.off("cursorPos", handleCursorPos);
|
||||||
};
|
};
|
||||||
}, [config, setCursorPosition]);
|
}, [config, setCursor]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!canvas.current) {
|
if (!canvas.current) {
|
||||||
|
@ -217,7 +265,7 @@ const CanvasInner = () => {
|
||||||
PanZoom.removeListener("viewportMove", handleViewportMove);
|
PanZoom.removeListener("viewportMove", handleViewportMove);
|
||||||
Router.off("navigate", handleNavigate);
|
Router.off("navigate", handleNavigate);
|
||||||
};
|
};
|
||||||
}, [PanZoom, setCanvasPosition, setCursorPosition]);
|
}, [PanZoom, setCanvasPosition]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<canvas
|
<canvas
|
||||||
|
|
|
@ -2,7 +2,6 @@ import {
|
||||||
Modal,
|
Modal,
|
||||||
ModalBody,
|
ModalBody,
|
||||||
ModalContent,
|
ModalContent,
|
||||||
ModalFooter,
|
|
||||||
ModalHeader,
|
ModalHeader,
|
||||||
Switch,
|
Switch,
|
||||||
} from "@nextui-org/react";
|
} from "@nextui-org/react";
|
||||||
|
@ -32,7 +31,7 @@ export const ModModal = () => {
|
||||||
return () => {
|
return () => {
|
||||||
KeybindManager.off("TOGGLE_MOD_MENU", handleKeybind);
|
KeybindManager.off("TOGGLE_MOD_MENU", handleKeybind);
|
||||||
};
|
};
|
||||||
}, []);
|
}, [hasAdmin]);
|
||||||
|
|
||||||
const setBypassCooldown = useCallback(
|
const setBypassCooldown = useCallback(
|
||||||
(value: boolean) => {
|
(value: boolean) => {
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { useAppContext } from "../../contexts/AppContext";
|
import { useAppContext } from "../../contexts/AppContext";
|
||||||
import { Canvas } from "../../lib/canvas";
|
|
||||||
import { KeybindManager } from "../../lib/keybinds";
|
import { KeybindManager } from "../../lib/keybinds";
|
||||||
|
import { getRenderer } from "../../lib/utils";
|
||||||
|
|
||||||
export const BlankOverlay = () => {
|
export const BlankOverlay = () => {
|
||||||
const { config, blankOverlay, setBlankOverlay } = useAppContext();
|
const { blankOverlay, setBlankOverlay } = useAppContext();
|
||||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -20,51 +20,21 @@ export const BlankOverlay = () => {
|
||||||
}, [setBlankOverlay]);
|
}, [setBlankOverlay]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!config) {
|
|
||||||
console.warn("[BlankOverlay] config is not defined");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!canvasRef.current) {
|
if (!canvasRef.current) {
|
||||||
console.warn("[BlankOverlay] canvasRef is not defined");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [width, height] = config.canvas.size;
|
let timeout = setTimeout(() => {
|
||||||
|
if (!canvasRef.current) return;
|
||||||
|
|
||||||
canvasRef.current.width = width;
|
getRenderer().useCanvas(canvasRef.current, "blank");
|
||||||
canvasRef.current.height = height;
|
}, 1000);
|
||||||
}, [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);
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
clearInterval(updateInterval);
|
clearTimeout(timeout);
|
||||||
|
getRenderer().removeCanvas("blank");
|
||||||
};
|
};
|
||||||
}, [canvasRef]);
|
}, [canvasRef.current]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<canvas
|
<canvas
|
||||||
|
|
|
@ -4,17 +4,36 @@ import { Canvas } from "../../lib/canvas";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { faXmark } from "@fortawesome/free-solid-svg-icons";
|
import { faXmark } from "@fortawesome/free-solid-svg-icons";
|
||||||
import { IPaletteContext } from "@sc07-canvas/lib/src/net";
|
import { IPaletteContext } from "@sc07-canvas/lib/src/net";
|
||||||
|
import { KeybindManager } from "../../lib/keybinds";
|
||||||
|
|
||||||
export const Palette = () => {
|
export const Palette = () => {
|
||||||
const { config, user } = useAppContext<true>();
|
const { config, user, setCursor } = useAppContext<true>();
|
||||||
const [pallete, setPallete] = useState<IPaletteContext>({});
|
const [pallete, setPallete] = useState<IPaletteContext>({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!Canvas.instance) return;
|
Canvas.instance?.updatePallete(pallete);
|
||||||
|
|
||||||
Canvas.instance.updatePallete(pallete);
|
setCursor((v) => ({
|
||||||
|
...v,
|
||||||
|
color: pallete.color,
|
||||||
|
}));
|
||||||
}, [pallete]);
|
}, [pallete]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleDeselect = () => {
|
||||||
|
setCursor((v) => ({
|
||||||
|
...v,
|
||||||
|
color: undefined,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
KeybindManager.addListener("DESELECT_COLOR", handleDeselect);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
KeybindManager.removeListener("DESELECT_COLOR", handleDeselect);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div id="pallete">
|
<div id="pallete">
|
||||||
<div className="pallete-colors">
|
<div className="pallete-colors">
|
||||||
|
|
|
@ -17,8 +17,8 @@ interface IAppContext {
|
||||||
|
|
||||||
canvasPosition?: ICanvasPosition;
|
canvasPosition?: ICanvasPosition;
|
||||||
setCanvasPosition: (v: ICanvasPosition) => void;
|
setCanvasPosition: (v: ICanvasPosition) => void;
|
||||||
cursorPosition?: IPosition;
|
cursor: ICursor;
|
||||||
setCursorPosition: (v?: IPosition) => void;
|
setCursor: React.Dispatch<React.SetStateAction<ICursor>>;
|
||||||
pixels: { available: number };
|
pixels: { available: number };
|
||||||
undo?: { available: true; expireAt: number };
|
undo?: { available: true; expireAt: number };
|
||||||
|
|
||||||
|
@ -53,6 +53,12 @@ interface ICanvasPosition {
|
||||||
zoom: number;
|
zoom: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ICursor {
|
||||||
|
x?: number;
|
||||||
|
y?: number;
|
||||||
|
color?: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface IMapOverlay {
|
interface IMapOverlay {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
|
||||||
|
@ -88,7 +94,7 @@ export const AppContext = ({ children }: PropsWithChildren) => {
|
||||||
const [config, setConfig] = useState<ClientConfig>(undefined as any);
|
const [config, setConfig] = useState<ClientConfig>(undefined as any);
|
||||||
const [auth, setAuth] = useState<AuthSession>();
|
const [auth, setAuth] = useState<AuthSession>();
|
||||||
const [canvasPosition, setCanvasPosition] = useState<ICanvasPosition>();
|
const [canvasPosition, setCanvasPosition] = useState<ICanvasPosition>();
|
||||||
const [cursorPosition, setCursorPosition] = useState<IPosition>();
|
const [cursor, setCursor] = useState<ICursor>({});
|
||||||
const [connected, setConnected] = useState(false);
|
const [connected, setConnected] = useState(false);
|
||||||
|
|
||||||
// --- settings ---
|
// --- settings ---
|
||||||
|
@ -205,8 +211,8 @@ export const AppContext = ({ children }: PropsWithChildren) => {
|
||||||
user: auth,
|
user: auth,
|
||||||
canvasPosition,
|
canvasPosition,
|
||||||
setCanvasPosition,
|
setCanvasPosition,
|
||||||
cursorPosition,
|
cursor,
|
||||||
setCursorPosition,
|
setCursor,
|
||||||
pixels,
|
pixels,
|
||||||
settingsSidebar,
|
settingsSidebar,
|
||||||
setSettingsSidebar,
|
setSettingsSidebar,
|
||||||
|
|
|
@ -13,6 +13,8 @@ import {
|
||||||
} from "@sc07-canvas/lib/src/renderer/PanZoom";
|
} from "@sc07-canvas/lib/src/renderer/PanZoom";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import { KeybindManager } from "./keybinds";
|
import { KeybindManager } from "./keybinds";
|
||||||
|
import { getRenderer } from "./utils";
|
||||||
|
import { CanvasPixel } from "./canvasRenderer";
|
||||||
|
|
||||||
interface CanvasEvents {
|
interface CanvasEvents {
|
||||||
/**
|
/**
|
||||||
|
@ -22,16 +24,15 @@ interface CanvasEvents {
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
cursorPos: (position: IPosition) => void;
|
cursorPos: (position: IPosition) => void;
|
||||||
|
canvasReady: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Canvas extends EventEmitter<CanvasEvents> {
|
export class Canvas extends EventEmitter<CanvasEvents> {
|
||||||
static instance: Canvas | undefined;
|
static instance: Canvas | undefined;
|
||||||
|
|
||||||
private _destroy = false;
|
|
||||||
private config: ClientConfig = {} as any;
|
private config: ClientConfig = {} as any;
|
||||||
private canvas: HTMLCanvasElement;
|
private canvas: HTMLCanvasElement;
|
||||||
private PanZoom: PanZoom;
|
private PanZoom: PanZoom;
|
||||||
private ctx: CanvasRenderingContext2D;
|
|
||||||
|
|
||||||
private cursor = { x: -1, y: -1 };
|
private cursor = { x: -1, y: -1 };
|
||||||
private pixels: {
|
private pixels: {
|
||||||
|
@ -40,14 +41,18 @@ export class Canvas extends EventEmitter<CanvasEvents> {
|
||||||
lastPlace: number | undefined;
|
lastPlace: number | undefined;
|
||||||
|
|
||||||
private bypassCooldown = false;
|
private bypassCooldown = false;
|
||||||
|
private _delayedLoad: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
constructor(canvas: HTMLCanvasElement, PanZoom: PanZoom) {
|
constructor(canvas: HTMLCanvasElement, PanZoom: PanZoom) {
|
||||||
super();
|
super();
|
||||||
Canvas.instance = this;
|
Canvas.instance = this;
|
||||||
|
getRenderer().startRender();
|
||||||
|
|
||||||
|
getRenderer().on("ready", () => this.emit("canvasReady"));
|
||||||
|
|
||||||
this.canvas = canvas;
|
this.canvas = canvas;
|
||||||
this.PanZoom = PanZoom;
|
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("hover", this.handleMouseMove.bind(this));
|
||||||
this.PanZoom.addListener("click", this.handleMouseDown.bind(this));
|
this.PanZoom.addListener("click", this.handleMouseDown.bind(this));
|
||||||
|
@ -57,23 +62,37 @@ export class Canvas extends EventEmitter<CanvasEvents> {
|
||||||
([time]) => (this.lastPlace = time)
|
([time]) => (this.lastPlace = time)
|
||||||
);
|
);
|
||||||
Network.on("pixel", this.handlePixel);
|
Network.on("pixel", this.handlePixel);
|
||||||
|
Network.on("square", this.handleSquare);
|
||||||
}
|
}
|
||||||
|
|
||||||
destroy() {
|
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("hover", this.handleMouseMove.bind(this));
|
||||||
this.PanZoom.removeListener("click", this.handleMouseDown.bind(this));
|
this.PanZoom.removeListener("click", this.handleMouseDown.bind(this));
|
||||||
this.PanZoom.removeListener("longPress", this.handleLongPress);
|
this.PanZoom.removeListener("longPress", this.handleLongPress);
|
||||||
|
|
||||||
Network.off("pixel", this.handlePixel);
|
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) {
|
loadConfig(config: ClientConfig) {
|
||||||
this.config = config;
|
this.config = config;
|
||||||
|
|
||||||
this.canvas.width = config.canvas.size[0];
|
this.setSize(config.canvas.size[0], config.canvas.size[1]);
|
||||||
this.canvas.height = config.canvas.size[1];
|
|
||||||
|
|
||||||
// we want the new one if possible
|
// we want the new one if possible
|
||||||
// (this might cause a timing issue though)
|
// (this might cause a timing issue though)
|
||||||
|
@ -83,10 +102,7 @@ export class Canvas extends EventEmitter<CanvasEvents> {
|
||||||
Network.waitFor("canvas").then(([pixels]) => {
|
Network.waitFor("canvas").then(([pixels]) => {
|
||||||
console.log("loadConfig just received new canvas data");
|
console.log("loadConfig just received new canvas data");
|
||||||
this.handleBatch(pixels);
|
this.handleBatch(pixels);
|
||||||
this.draw();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.draw();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
hasConfig() {
|
hasConfig() {
|
||||||
|
@ -204,29 +220,73 @@ export class Canvas extends EventEmitter<CanvasEvents> {
|
||||||
this.emit("cursorPos", this.cursor);
|
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[]) => {
|
handleBatch = (pixels: string[]) => {
|
||||||
if (!this.config.canvas) {
|
if (!this.config.canvas) {
|
||||||
throw new Error("handleBatch called with no config");
|
throw new Error("handleBatch called with no config");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let serializeBuild: CanvasPixel[] = [];
|
||||||
|
|
||||||
for (let x = 0; x < this.config.canvas.size[0]; x++) {
|
for (let x = 0; x < this.config.canvas.size[0]; x++) {
|
||||||
for (let y = 0; y < this.config.canvas.size[1]; y++) {
|
for (let y = 0; y < this.config.canvas.size[1]; y++) {
|
||||||
const hex = pixels[this.config.canvas.size[0] * y + x];
|
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] = {
|
this.pixels[x + "_" + y] = {
|
||||||
color: color ? color.id : -1,
|
|
||||||
type: "full",
|
type: "full",
|
||||||
|
color: palette?.id || -1,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
serializeBuild.push({
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
hex: hex === "transparent" ? "null" : hex,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getRenderer().usePixels(serializeBuild, true);
|
||||||
};
|
};
|
||||||
|
|
||||||
handlePixel = ({ x, y, color }: Pixel) => {
|
handlePixel = ({ x, y, color }: Pixel) => {
|
||||||
|
// we still store a copy of the pixels in this instance for non-rendering functions
|
||||||
this.pixels[x + "_" + y] = {
|
this.pixels[x + "_" + y] = {
|
||||||
color,
|
|
||||||
type: "full",
|
type: "full",
|
||||||
|
color,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const palette = this.Pallete.getColor(color);
|
||||||
|
|
||||||
|
getRenderer().usePixel({ x, y, hex: palette?.hex || "null" });
|
||||||
};
|
};
|
||||||
|
|
||||||
palleteCtx: IPaletteContext = {};
|
palleteCtx: IPaletteContext = {};
|
||||||
|
@ -401,44 +461,4 @@ export class Canvas extends EventEmitter<CanvasEvents> {
|
||||||
|
|
||||||
return [output.x, output.y];
|
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",
|
key: "KeyM",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
DESELECT_COLOR: [
|
||||||
|
{
|
||||||
|
key: "Escape",
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
class KeybindManager_ extends EventEmitter<{
|
class KeybindManager_ extends EventEmitter<{
|
||||||
|
|
|
@ -21,6 +21,11 @@ export interface INetworkEvents {
|
||||||
pixelLastPlaced: (time: number) => void;
|
pixelLastPlaced: (time: number) => void;
|
||||||
online: (count: number) => void;
|
online: (count: number) => void;
|
||||||
pixel: (pixel: Pixel) => void;
|
pixel: (pixel: Pixel) => void;
|
||||||
|
square: (
|
||||||
|
start: [x: number, y: number],
|
||||||
|
end: [x: number, y: number],
|
||||||
|
color: number
|
||||||
|
) => void;
|
||||||
undo: (
|
undo: (
|
||||||
data: { available: false } | { available: true; expireAt: number }
|
data: { available: false } | { available: true; expireAt: number }
|
||||||
) => void;
|
) => void;
|
||||||
|
@ -105,6 +110,10 @@ class Network extends EventEmitter<INetworkEvents> {
|
||||||
this.emit("pixel", pixel);
|
this.emit("pixel", pixel);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.socket.on("square", (...square) => {
|
||||||
|
this.emit("square", ...square);
|
||||||
|
});
|
||||||
|
|
||||||
this.socket.on("undo", (undo) => {
|
this.socket.on("undo", (undo) => {
|
||||||
this.emit("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 { 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
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
export const api = async <T = unknown, Error = string>(
|
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 }>(
|
export type EnforceObjectType<T> = <V extends { [k: string]: T }>(
|
||||||
v: V
|
v: V
|
||||||
) => { [k in keyof V]: T };
|
) => { [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> {
|
class Debugcl extends EventEmitter<DebugEvents> {
|
||||||
readonly flags = new FlagManager();
|
readonly flags = new FlagManager();
|
||||||
|
_getRenderer: any;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
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: (
|
undo: (
|
||||||
data: { available: false } | { available: true; expireAt: number }
|
data: { available: false } | { available: true; expireAt: number }
|
||||||
) => void;
|
) => void;
|
||||||
|
square: (
|
||||||
|
start: [x: number, y: number],
|
||||||
|
end: [x: number, y: number],
|
||||||
|
color: number
|
||||||
|
) => void;
|
||||||
|
|
||||||
/* --- subscribe events --- */
|
/* --- subscribe events --- */
|
||||||
|
|
||||||
|
@ -39,7 +44,9 @@ export interface ClientToServerEvents {
|
||||||
>
|
>
|
||||||
) => void
|
) => void
|
||||||
) => void;
|
) => void;
|
||||||
undo: (ack: (_: PacketAck<{}, "no_user" | "unavailable">) => void) => void;
|
undo: (
|
||||||
|
ack: (_: PacketAck<{}, "no_user" | "unavailable" | "pixel_covered">) => void
|
||||||
|
) => void;
|
||||||
|
|
||||||
subscribe: (topic: Subscription) => void;
|
subscribe: (topic: Subscription) => void;
|
||||||
unsubscribe: (topic: Subscription) => void;
|
unsubscribe: (topic: Subscription) => void;
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import { Debug } from "../../debug";
|
|
||||||
import { PanZoom } from "../PanZoom";
|
import { PanZoom } from "../PanZoom";
|
||||||
|
|
||||||
export function handleCalculateZoomPositions(
|
export function handleCalculateZoomPositions(
|
||||||
|
|
|
@ -9,8 +9,8 @@ Table Setting {
|
||||||
|
|
||||||
Table User {
|
Table User {
|
||||||
sub String [pk]
|
sub String [pk]
|
||||||
picture_url String
|
|
||||||
display_name String
|
display_name String
|
||||||
|
picture_url String
|
||||||
profile_url String
|
profile_url String
|
||||||
lastPixelTime DateTime [default: `now()`, not null]
|
lastPixelTime DateTime [default: `now()`, not null]
|
||||||
pixelStack Int [not null, default: 0]
|
pixelStack Int [not null, default: 0]
|
||||||
|
@ -42,7 +42,10 @@ Table Pixel {
|
||||||
x Int [not null]
|
x Int [not null]
|
||||||
y Int [not null]
|
y Int [not null]
|
||||||
color String [not null]
|
color String [not null]
|
||||||
|
isTop Boolean [not null, default: false]
|
||||||
|
isModAction Boolean [not null, default: false]
|
||||||
createdAt DateTime [default: `now()`, not null]
|
createdAt DateTime [default: `now()`, not null]
|
||||||
|
deletedAt DateTime
|
||||||
user User [not null]
|
user User [not null]
|
||||||
pallete PaletteColor [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
|
x Int
|
||||||
y Int
|
y Int
|
||||||
color String
|
color String
|
||||||
|
isTop Boolean @default(false)
|
||||||
|
isModAction Boolean @default(false)
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
deletedAt DateTime?
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [sub])
|
user User @relation(fields: [userId], references: [sub])
|
||||||
pallete PaletteColor @relation(fields: [color], references: [hex])
|
pallete PaletteColor @relation(fields: [color], references: [hex])
|
||||||
|
|
|
@ -1,10 +1,13 @@
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import { User } from "../models/User";
|
import { User } from "../models/User";
|
||||||
import Canvas from "../lib/Canvas";
|
import Canvas from "../lib/Canvas";
|
||||||
import { Logger } from "../lib/Logger";
|
import { getLogger } from "../lib/Logger";
|
||||||
import { RateLimiter } from "../lib/RateLimiter";
|
import { RateLimiter } from "../lib/RateLimiter";
|
||||||
|
import { prisma } from "../lib/prisma";
|
||||||
|
import { SocketServer } from "../lib/SocketServer";
|
||||||
|
|
||||||
const app = Router();
|
const app = Router();
|
||||||
|
const Logger = getLogger("HTTP/ADMIN");
|
||||||
|
|
||||||
app.use(RateLimiter.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;
|
export default app;
|
||||||
|
|
|
@ -2,10 +2,12 @@ import { Router } from "express";
|
||||||
import { prisma } from "../lib/prisma";
|
import { prisma } from "../lib/prisma";
|
||||||
import { OpenID } from "../lib/oidc";
|
import { OpenID } from "../lib/oidc";
|
||||||
import { TokenSet, errors as OIDC_Errors } from "openid-client";
|
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 Canvas from "../lib/Canvas";
|
||||||
import { RateLimiter } from "../lib/RateLimiter";
|
import { RateLimiter } from "../lib/RateLimiter";
|
||||||
|
|
||||||
|
const Logger = getLogger("HTTP/CLIENT");
|
||||||
|
|
||||||
const ClientParams = {
|
const ClientParams = {
|
||||||
TYPE: "auth_type",
|
TYPE: "auth_type",
|
||||||
ERROR: "auth_error",
|
ERROR: "auth_error",
|
||||||
|
|
|
@ -1,12 +1,13 @@
|
||||||
// load declare module
|
// load declare module
|
||||||
import "./types";
|
import "./types";
|
||||||
import { Redis } from "./lib/redis";
|
import { Redis } from "./lib/redis";
|
||||||
import { Logger } from "./lib/Logger";
|
import { getLogger } from "./lib/Logger";
|
||||||
import { ExpressServer } from "./lib/Express";
|
import { ExpressServer } from "./lib/Express";
|
||||||
import { SocketServer } from "./lib/SocketServer";
|
import { SocketServer } from "./lib/SocketServer";
|
||||||
import { OpenID } from "./lib/oidc";
|
import { OpenID } from "./lib/oidc";
|
||||||
import { loadSettings } from "./lib/Settings";
|
import { loadSettings } from "./lib/Settings";
|
||||||
import { Jobs } from "./lib/Jobs";
|
|
||||||
|
const Logger = getLogger("MAIN");
|
||||||
|
|
||||||
// Validate environment variables
|
// Validate environment variables
|
||||||
|
|
||||||
|
@ -86,8 +87,8 @@ Promise.all([
|
||||||
loadSettings(),
|
loadSettings(),
|
||||||
]).then(() => {
|
]).then(() => {
|
||||||
Logger.info("Startup tasks have completed, starting server");
|
Logger.info("Startup tasks have completed, starting server");
|
||||||
|
Logger.warn("Make sure the jobs process is running");
|
||||||
|
|
||||||
new Jobs();
|
|
||||||
const express = new ExpressServer();
|
const express = new ExpressServer();
|
||||||
new SocketServer(express.httpServer);
|
new SocketServer(express.httpServer);
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,8 +1,17 @@
|
||||||
import Canvas from "./Canvas";
|
import Canvas from "../lib/Canvas";
|
||||||
import { Logger } from "./Logger";
|
import { getLogger } from "../lib/Logger";
|
||||||
|
|
||||||
|
const Logger = getLogger("JOB_WORKER");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Job scheduler
|
||||||
|
*
|
||||||
|
* This should run in a different process
|
||||||
|
*/
|
||||||
export class Jobs {
|
export class Jobs {
|
||||||
constructor() {
|
constructor() {
|
||||||
|
Logger.info("Starting job worker...");
|
||||||
|
|
||||||
// every 5 minutes
|
// every 5 minutes
|
||||||
setInterval(this.generateHeatmap, 1000 * 60 * 5);
|
setInterval(this.generateHeatmap, 1000 * 60 * 5);
|
||||||
|
|
|
@ -2,7 +2,10 @@ import { CanvasConfig } from "@sc07-canvas/lib/src/net";
|
||||||
import { prisma } from "./prisma";
|
import { prisma } from "./prisma";
|
||||||
import { Redis } from "./redis";
|
import { Redis } from "./redis";
|
||||||
import { SocketServer } from "./SocketServer";
|
import { SocketServer } from "./SocketServer";
|
||||||
import { Logger } from "./Logger";
|
import { getLogger } from "./Logger";
|
||||||
|
import { Pixel } from "@prisma/client";
|
||||||
|
|
||||||
|
const Logger = getLogger("CANVAS");
|
||||||
|
|
||||||
class Canvas {
|
class Canvas {
|
||||||
/**
|
/**
|
||||||
|
@ -37,8 +40,14 @@ class Canvas {
|
||||||
* @param width
|
* @param width
|
||||||
* @param height
|
* @param height
|
||||||
*/
|
*/
|
||||||
async setSize(width: number, height: number) {
|
async setSize(width: number, height: number, useStatic = false) {
|
||||||
Logger.info("Canvas#setSize has started", {
|
if (useStatic) {
|
||||||
|
this.canvasSize = [width, height];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
Logger.info("[Canvas#setSize] has started", {
|
||||||
old: this.canvasSize,
|
old: this.canvasSize,
|
||||||
new: [width, height],
|
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
|
// the redis key is 1D, since the dimentions changed we need to update it
|
||||||
await this.canvasToRedis();
|
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
|
// announce the new config, which contains the canvas size
|
||||||
SocketServer.instance.broadcastConfig();
|
SocketServer.instance.broadcastConfig();
|
||||||
|
|
||||||
// announce new pixel array that was generated previously
|
// announce new pixel array that was generated previously
|
||||||
await this.getPixelsArray().then((pixels) => {
|
await this.getPixelsArray().then((pixels) => {
|
||||||
SocketServer.instance.io.emit("canvas", pixels);
|
SocketServer.instance?.io.emit("canvas", pixels);
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
Logger.info("Canvas#setSize has finished");
|
Logger.warn(
|
||||||
|
"[Canvas#setSize] No SocketServer instance, cannot broadcast config change"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
Logger.info(
|
||||||
* Latest database pixels -> Redis
|
"[Canvas#setSize] has finished in " +
|
||||||
*/
|
((Date.now() - now) / 1000).toFixed(1) +
|
||||||
async pixelsToRedis() {
|
" seconds"
|
||||||
const redis = await Redis.getClient();
|
);
|
||||||
|
}
|
||||||
|
|
||||||
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 x = 0; x < this.canvasSize[0]; x++) {
|
||||||
for (let y = 0; y < this.canvasSize[1]; y++) {
|
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
|
* @returns 1D array of pixel values
|
||||||
*/
|
*/
|
||||||
async canvasToRedis() {
|
async canvasToRedis() {
|
||||||
const redis = await Redis.getClient();
|
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[] = [];
|
const pixels: string[] = [];
|
||||||
|
|
||||||
// (y -> x) because of how the conversion needs to be done later
|
// (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 y = 0; y < this.canvasSize[1]; y++) {
|
||||||
for (let x = 0; x < this.canvasSize[0]; x++) {
|
for (let x = 0; x < this.canvasSize[0]; x++) {
|
||||||
pixels.push(
|
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"))) || ""
|
(await redis.get(Redis.key("canvas"))) || ""
|
||||||
).split(",");
|
).split(",");
|
||||||
|
|
||||||
pixels[this.canvasSize[0] * y + x] =
|
const dbpixel = await this.getPixel(x, y);
|
||||||
(await redis.get(Redis.key("pixelColor", x, y))) || "transparent";
|
|
||||||
|
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 });
|
await redis.set(Redis.key("canvas"), pixels.join(","), { EX: 60 * 5 });
|
||||||
}
|
}
|
||||||
|
@ -148,33 +252,89 @@ class Canvas {
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
async isPixelEmpty(x: number, y: number) {
|
async isPixelEmpty(x: number, y: number) {
|
||||||
const redis = await Redis.getClient();
|
const pixel = await this.getPixel(x, y);
|
||||||
const pixelColor = await redis.get(Redis.key("pixelColor", x, y));
|
return pixel === null;
|
||||||
|
|
||||||
if (pixelColor === null) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return pixelColor === "transparent";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPixel(x: number, y: number) {
|
async getPixel(x: number, y: number) {
|
||||||
return (
|
return await prisma.pixel.findFirst({
|
||||||
await prisma.pixel.findMany({
|
|
||||||
where: {
|
where: {
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
|
isTop: true,
|
||||||
},
|
},
|
||||||
orderBy: {
|
});
|
||||||
createdAt: "desc",
|
|
||||||
},
|
|
||||||
take: 1,
|
|
||||||
})
|
|
||||||
)?.[0];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async setPixel(user: { sub: string }, x: number, y: number, hex: string) {
|
async fillArea(
|
||||||
const redis = await Redis.getClient();
|
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({
|
await prisma.pixel.create({
|
||||||
data: {
|
data: {
|
||||||
|
@ -182,6 +342,8 @@ class Canvas {
|
||||||
color: hex,
|
color: hex,
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
|
isTop: true,
|
||||||
|
isModAction,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -190,8 +352,6 @@ class Canvas {
|
||||||
data: { lastPixelTime: new Date() },
|
data: { lastPixelTime: new Date() },
|
||||||
});
|
});
|
||||||
|
|
||||||
await redis.set(Redis.key("pixelColor", x, y), hex);
|
|
||||||
|
|
||||||
// maybe only update specific element?
|
// maybe only update specific element?
|
||||||
// i don't think it needs to be awaited
|
// i don't think it needs to be awaited
|
||||||
await this.updateCanvasRedisAtPos(x, y);
|
await this.updateCanvasRedisAtPos(x, y);
|
||||||
|
@ -203,21 +363,15 @@ class Canvas {
|
||||||
* @param y
|
* @param y
|
||||||
*/
|
*/
|
||||||
async refreshPixel(x: number, y: number) {
|
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
|
// find if any pixels exist at this spot, and pick the most recent one
|
||||||
const pixel = await this.getPixel(x, y);
|
const pixel = await this.getPixel(x, y);
|
||||||
let paletteColorID = -1;
|
let paletteColorID = -1;
|
||||||
|
|
||||||
// if pixel exists in redis
|
// if pixel exists in redis
|
||||||
if (pixel) {
|
if (pixel) {
|
||||||
redis.set(key, pixel.color);
|
|
||||||
paletteColorID = (await prisma.paletteColor.findFirst({
|
paletteColorID = (await prisma.paletteColor.findFirst({
|
||||||
where: { hex: pixel.color },
|
where: { hex: pixel.color },
|
||||||
}))!.id;
|
}))!.id;
|
||||||
} else {
|
|
||||||
redis.del(key);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.updateCanvasRedisAtPos(x, y);
|
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)
|
* @returns 2 character strings with 0-100 in radix 36 (depends on canvas size)
|
||||||
*/
|
*/
|
||||||
async generateHeatmap() {
|
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 now = Date.now();
|
||||||
const minimumDate = new Date();
|
const minimumDate = new Date();
|
||||||
minimumDate.setHours(minimumDate.getHours() - 3); // 3 hours ago
|
minimumDate.setHours(minimumDate.getHours() - 3); // 3 hours ago
|
||||||
|
@ -247,23 +403,15 @@ class Canvas {
|
||||||
|
|
||||||
const heatmap: string[] = [];
|
const heatmap: string[] = [];
|
||||||
|
|
||||||
|
const topPixels = await prisma.pixel.findMany({
|
||||||
|
where: { isTop: true, createdAt: { gte: minimumDate } },
|
||||||
|
});
|
||||||
|
|
||||||
for (let y = 0; y < this.canvasSize[1]; y++) {
|
for (let y = 0; y < this.canvasSize[1]; y++) {
|
||||||
const arr: number[] = [];
|
const arr: number[] = [];
|
||||||
|
|
||||||
for (let x = 0; x < this.canvasSize[0]; x++) {
|
for (let x = 0; x < this.canvasSize[0]; x++) {
|
||||||
const pixel = (
|
const pixel = topPixels.find((px) => px.x === x && px.y === y);
|
||||||
await prisma.pixel.findMany({
|
|
||||||
where: {
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
createdAt: { gt: minimumDate },
|
|
||||||
},
|
|
||||||
orderBy: {
|
|
||||||
createdAt: "desc",
|
|
||||||
},
|
|
||||||
take: 1,
|
|
||||||
})
|
|
||||||
)?.[0];
|
|
||||||
|
|
||||||
if (pixel) {
|
if (pixel) {
|
||||||
arr.push(
|
arr.push(
|
||||||
|
@ -284,10 +432,11 @@ class Canvas {
|
||||||
const heatmapStr = heatmap.join("");
|
const heatmapStr = heatmap.join("");
|
||||||
|
|
||||||
// cache for 5 minutes
|
// 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
|
// 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;
|
return heatmapStr;
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,10 +7,12 @@ import cors from "cors";
|
||||||
import { Redis } from "./redis";
|
import { Redis } from "./redis";
|
||||||
import APIRoutes_client from "../api/client";
|
import APIRoutes_client from "../api/client";
|
||||||
import APIRoutes_admin from "../api/admin";
|
import APIRoutes_admin from "../api/admin";
|
||||||
import { Logger } from "./Logger";
|
import { getLogger } from "./Logger";
|
||||||
import bodyParser from "body-parser";
|
import bodyParser from "body-parser";
|
||||||
import { handleMetricsEndpoint } from "./Prometheus";
|
import { handleMetricsEndpoint } from "./Prometheus";
|
||||||
|
|
||||||
|
const Logger = getLogger("HTTP");
|
||||||
|
|
||||||
export const session = expressSession({
|
export const session = expressSession({
|
||||||
secret: process.env.SESSION_SECRET,
|
secret: process.env.SESSION_SECRET,
|
||||||
resave: false,
|
resave: false,
|
||||||
|
|
|
@ -1,7 +1,42 @@
|
||||||
import winston, { format } from "winston";
|
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",
|
level: process.env.LOG_LEVEL || "info",
|
||||||
format: format.combine(format.splat(), format.cli()),
|
format: format.combine(format.timestamp(), formatter),
|
||||||
transports: [new winston.transports.Console()],
|
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 Canvas from "./Canvas";
|
||||||
import { Logger } from "./Logger";
|
import { getLogger } from "./Logger";
|
||||||
import { prisma } from "./prisma";
|
import { prisma } from "./prisma";
|
||||||
|
|
||||||
export const loadSettings = async () => {
|
const Logger = getLogger("SETTINGS");
|
||||||
|
|
||||||
|
export const loadSettings = async (frozen = false) => {
|
||||||
Logger.info("Loading settings...");
|
Logger.info("Loading settings...");
|
||||||
|
|
||||||
const sideEffects: Promise<unknown>[] = [];
|
const sideEffects: Promise<unknown>[] = [];
|
||||||
|
@ -14,8 +16,9 @@ export const loadSettings = async () => {
|
||||||
if (canvasSize) {
|
if (canvasSize) {
|
||||||
const data = JSON.parse(canvasSize.value);
|
const data = JSON.parse(canvasSize.value);
|
||||||
Logger.info("Canvas size loaded as " + JSON.stringify(data));
|
Logger.info("Canvas size loaded as " + JSON.stringify(data));
|
||||||
|
|
||||||
sideEffects.push(
|
sideEffects.push(
|
||||||
Canvas.setSize(data.width, data.height).then(() => {
|
Canvas.setSize(data.width, data.height, frozen).then(() => {
|
||||||
Logger.info("Canvas size successfully updated");
|
Logger.info("Canvas size successfully updated");
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
|
@ -12,10 +12,12 @@ import { session } from "./Express";
|
||||||
import Canvas from "./Canvas";
|
import Canvas from "./Canvas";
|
||||||
import { PaletteColor } from "@prisma/client";
|
import { PaletteColor } from "@prisma/client";
|
||||||
import { prisma } from "./prisma";
|
import { prisma } from "./prisma";
|
||||||
import { Logger } from "./Logger";
|
import { getLogger } from "./Logger";
|
||||||
import { Redis } from "./redis";
|
import { Redis } from "./redis";
|
||||||
import { User } from "../models/User";
|
import { User } from "../models/User";
|
||||||
|
|
||||||
|
const Logger = getLogger("SOCKET");
|
||||||
|
|
||||||
// maybe move to a constants file?
|
// maybe move to a constants file?
|
||||||
const commitHash = child
|
const commitHash = child
|
||||||
.execSync("git rev-parse --short HEAD")
|
.execSync("git rev-parse --short HEAD")
|
||||||
|
@ -248,7 +250,13 @@ export class SocketServer {
|
||||||
}
|
}
|
||||||
|
|
||||||
await user.modifyStack(-1);
|
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
|
// give undo capabilities
|
||||||
await user.setUndo(
|
await user.setUndo(
|
||||||
new Date(Date.now() + Canvas.getCanvasConfig().undo.grace_period)
|
new Date(Date.now() + Canvas.getCanvasConfig().undo.grace_period)
|
||||||
|
@ -300,12 +308,17 @@ export class SocketServer {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// delete most recent pixel
|
||||||
|
try {
|
||||||
|
await Canvas.undoPixel(pixel);
|
||||||
|
} catch (e) {
|
||||||
|
ack({ success: false, error: "pixel_covered" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// mark the undo as used
|
// mark the undo as used
|
||||||
await user.setUndo();
|
await user.setUndo();
|
||||||
|
|
||||||
// delete most recent pixel
|
|
||||||
await prisma.pixel.delete({ where: { id: pixel.id } });
|
|
||||||
|
|
||||||
// trigger re-cache on redis
|
// trigger re-cache on redis
|
||||||
await Canvas.refreshPixel(pixel.x, pixel.y);
|
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
|
* this does work with multiple socket.io instances, so this needs to only be executed by one shard
|
||||||
*/
|
*/
|
||||||
setupMasterShard() {
|
async setupMasterShard() {
|
||||||
// online announcement event
|
// online announcement event
|
||||||
setInterval(async () => {
|
setInterval(async () => {
|
||||||
// possible issue: this includes every connected socket, not user count
|
// possible issue: this includes every connected socket, not user count
|
||||||
|
@ -339,5 +352,10 @@ export class SocketServer {
|
||||||
socket.emit("online", { count: sockets.length });
|
socket.emit("online", { count: sockets.length });
|
||||||
}
|
}
|
||||||
}, 5000);
|
}, 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 { RedisClientType } from "@redis/client";
|
||||||
import { createClient } from "redis";
|
import { createClient } from "redis";
|
||||||
import { Logger } from "./Logger";
|
import { getLogger } from "./Logger";
|
||||||
|
|
||||||
|
const Logger = getLogger("REDIS");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Typedef for RedisKeys
|
* Typedef for RedisKeys
|
||||||
*/
|
*/
|
||||||
interface IRedisKeys {
|
interface IRedisKeys {
|
||||||
// canvas
|
// canvas
|
||||||
pixelColor(x: number, y: number): string;
|
|
||||||
canvas(): string;
|
canvas(): string;
|
||||||
heatmap(): string;
|
heatmap(): string;
|
||||||
|
|
||||||
// users
|
// users
|
||||||
socketToSub(socketId: string): string;
|
socketToSub(socketId: string): string;
|
||||||
|
|
||||||
|
// pub/sub channels
|
||||||
|
channel_heatmap(): string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Defined as a variable due to boottime augmentation
|
* Defined as a variable due to boottime augmentation
|
||||||
*/
|
*/
|
||||||
const RedisKeys: IRedisKeys = {
|
const RedisKeys: IRedisKeys = {
|
||||||
pixelColor: (x: number, y: number) => `CANVAS:PIXELS[${x},${y}]:COLOR`,
|
|
||||||
canvas: () => `CANVAS:PIXELS`,
|
canvas: () => `CANVAS:PIXELS`,
|
||||||
heatmap: () => `CANVAS:HEATMAP`,
|
heatmap: () => `CANVAS:HEATMAP`,
|
||||||
socketToSub: (socketId: string) => `CANVAS:SOCKET:${socketId}`,
|
socketToSub: (socketId: string) => `CANVAS:SOCKET:${socketId}`,
|
||||||
|
channel_heatmap: () => `CANVAS:HEATMAP`,
|
||||||
};
|
};
|
||||||
|
|
||||||
class _Redis {
|
class _Redis {
|
||||||
isConnecting = false;
|
isConnecting = false;
|
||||||
isConnected = false;
|
isConnected = false;
|
||||||
client: RedisClientType;
|
client: RedisClientType;
|
||||||
|
sub_client: RedisClientType; // the client used for pubsub
|
||||||
|
|
||||||
waitingForConnect: ((...args: any) => any)[] = [];
|
waitingForConnect: ((...args: any) => any)[] = [];
|
||||||
|
|
||||||
|
@ -43,6 +48,9 @@ class _Redis {
|
||||||
this.client = createClient({
|
this.client = createClient({
|
||||||
url: process.env.REDIS_HOST,
|
url: process.env.REDIS_HOST,
|
||||||
});
|
});
|
||||||
|
this.sub_client = createClient({
|
||||||
|
url: process.env.REDIS_HOST,
|
||||||
|
});
|
||||||
|
|
||||||
this.keys = keys;
|
this.keys = keys;
|
||||||
}
|
}
|
||||||
|
@ -53,6 +61,7 @@ class _Redis {
|
||||||
|
|
||||||
this.isConnecting = true;
|
this.isConnecting = true;
|
||||||
await this.client.connect();
|
await this.client.connect();
|
||||||
|
await this.sub_client.connect();
|
||||||
Logger.info(
|
Logger.info(
|
||||||
`Connected to Redis, there's ${this.waitingForConnect.length} function(s) waiting for Redis`
|
`Connected to Redis, there's ${this.waitingForConnect.length} function(s) waiting for Redis`
|
||||||
);
|
);
|
||||||
|
@ -75,7 +84,7 @@ class _Redis {
|
||||||
this.isConnected = false;
|
this.isConnected = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getClient() {
|
async getClient(intent: "MAIN" | "SUB" = "MAIN") {
|
||||||
if (this.isConnecting) {
|
if (this.isConnecting) {
|
||||||
await (() =>
|
await (() =>
|
||||||
new Promise((res) => {
|
new Promise((res) => {
|
||||||
|
@ -89,6 +98,10 @@ class _Redis {
|
||||||
this.isConnected = true;
|
this.isConnected = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (intent === "SUB") {
|
||||||
|
return this.sub_client;
|
||||||
|
}
|
||||||
|
|
||||||
return this.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 { Socket } from "socket.io";
|
||||||
import { Logger } from "../lib/Logger";
|
import { getLogger } from "../lib/Logger";
|
||||||
import { prisma } from "../lib/prisma";
|
import { prisma } from "../lib/prisma";
|
||||||
import {
|
import {
|
||||||
AuthSession,
|
AuthSession,
|
||||||
|
@ -7,6 +7,8 @@ import {
|
||||||
ServerToClientEvents,
|
ServerToClientEvents,
|
||||||
} from "@sc07-canvas/lib/src/net";
|
} from "@sc07-canvas/lib/src/net";
|
||||||
|
|
||||||
|
const Logger = getLogger();
|
||||||
|
|
||||||
interface IUserData {
|
interface IUserData {
|
||||||
sub: string;
|
sub: string;
|
||||||
lastPixelTime: Date;
|
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