Skip to content
Snippets Groups Projects
canvas.ts 13.6 KiB
Newer Older
  • Learn to ignore specific revisions
  • import EventEmitter from "eventemitter3";
    
    Grant's avatar
    Grant committed
    import { ClientConfig, IPosition, Pixel } from "@sc07-canvas/lib/src/net";
    
    import Network from "./network";
    
    Grant's avatar
    Grant committed
    import {
    
      ClickEvent,
      HoverEvent,
      PanZoom,
    } from "@sc07-canvas/lib/src/renderer/PanZoom";
    
    Grant's avatar
    Grant committed
    import { toast } from "react-toastify";
    
    import { KeybindManager } from "./keybinds";
    
    Grant's avatar
    Grant committed
    import { getRenderer } from "./utils";
    import { CanvasPixel } from "./canvasRenderer";
    
    Grant's avatar
    Grant committed
    
    
    interface CanvasEvents {
      /**
       * Cursor canvas position
       * (-1, -1) is not on canvas
       * @param position Canvas position
       * @returns
       */
      cursorPos: (position: IPosition) => void;
    
    Grant's avatar
    Grant committed
      canvasReady: () => void;
    
    Grant's avatar
    Grant committed
    
    
    export class Canvas extends EventEmitter<CanvasEvents> {
      static instance: Canvas | undefined;
    
    Grant's avatar
    Grant committed
    
    
    Grant's avatar
    Grant committed
      private config: ClientConfig = {} as any;
    
      private canvas: HTMLCanvasElement;
      private PanZoom: PanZoom;
    
    Grant's avatar
    Grant committed
    
    
    Grant's avatar
    Grant committed
      private cursor: { x: number; y: number; color?: number } = { x: -1, y: -1 };
    
    Grant's avatar
    Grant committed
      private pixels: {
        [x_y: string]: { color: number; type: "full" | "pending" };
      } = {};
    
    Grant's avatar
    Grant committed
      lastPlace: number | undefined;
    
    Grant's avatar
    Grant committed
    
    
      private bypassCooldown = false;
    
      // private _delayedLoad: ReturnType<typeof setTimeout>;
    
    Grant's avatar
    Grant committed
      constructor(canvas: HTMLCanvasElement, PanZoom: PanZoom) {
    
        super();
        Canvas.instance = this;
    
    Grant's avatar
    Grant committed
        getRenderer().startRender();
    
        getRenderer().on("ready", () => this.emit("canvasReady"));
    
    Grant's avatar
    Grant committed
    
    
        this.canvas = canvas;
        this.PanZoom = PanZoom;
    
        this.loadRenderer();
    
    
        this.PanZoom.addListener("hover", this.handleMouseMove.bind(this));
        this.PanZoom.addListener("click", this.handleMouseDown.bind(this));
    
        this.PanZoom.addListener("longPress", this.handleLongPress);
    
        Network.on("pixel", this.handlePixel);
    
    Grant's avatar
    Grant committed
        Network.on("square", this.handleSquare);
    
        Network.on("pixelLastPlaced", this.handlePixelLastPlaced);
    
      destroy() {
    
    Grant's avatar
    Grant committed
        getRenderer().stopRender();
        getRenderer().off("ready");
    
    
        this.PanZoom.removeListener("hover", this.handleMouseMove.bind(this));
        this.PanZoom.removeListener("click", this.handleMouseDown.bind(this));
    
        this.PanZoom.removeListener("longPress", this.handleLongPress);
    
    Grant's avatar
    Grant committed
        Network.off("pixel", this.handlePixel);
    
    Grant's avatar
    Grant committed
        Network.off("square", this.handleSquare);
    
        Network.off("pixelLastPlaced", this.handlePixelLastPlaced);
    
    Grant's avatar
    Grant committed
      }
    
      /**
       * React.Strict remounts the main component, causing a quick remount, which then causes errors related to webworkers
    
       *
       * If #useCanvas fails, it's most likely due to that
    
    Grant's avatar
    Grant committed
       */
    
      loadRenderer() {
        try {
          getRenderer().useCanvas(this.canvas, "main");
        } catch (e) {
          console.warn(
            "[Canvas#loadRenderer] Failed at #useCanvas, this shouldn't be fatal",
            e
          );
        }
    
    Grant's avatar
    Grant committed
      }
    
      setSize(width: number, height: number) {
        getRenderer().setSize(width, height);
    
    Grant's avatar
    Grant committed
      }
    
      loadConfig(config: ClientConfig) {
        this.config = config;
    
    
    Grant's avatar
    Grant committed
        this.setSize(config.canvas.size[0], config.canvas.size[1]);
    
        // we want the new one if possible
        // (this might cause a timing issue though)
        // if we don't clear the old one, if the canvas gets resized we get weird stretching
    
        if (Object.keys(this.pixels).length > 0)
          Network.clearPreviousState("canvas");
    
    Grant's avatar
    Grant committed
        // Network.waitForState("canvas").then(([pixels]) => {
        //   console.log("loadConfig just received new canvas data");
        //   this.handleBatch(pixels);
        // });
    
        Network.on("canvas", (start, end, pixels) => {
          console.log("[Canvas] received canvas section");
          this.handleBatch(start, end, pixels);
    
    Grant's avatar
    Grant committed
        });
    
    Grant's avatar
    Grant committed
    
        const chunks = Network.getCanvasChunks();
        console.log(`[Canvas] Received ${chunks.length} chunks to load`);
        let loaded = 0;
        for (const chunk of chunks) {
          console.log(`[Canvas] Loading canvas chunk ${loaded}...`);
          this.handleBatch(chunk.start, chunk.end, chunk.pixels);
    
          loaded++;
        }
    
    Grant's avatar
    Grant committed
      }
    
      hasConfig() {
        return !!this.config;
    
      getPanZoom() {
        return this.PanZoom;
      }
    
    
      setCooldownBypass(value: boolean) {
        this.bypassCooldown = value;
      }
    
      getCooldownBypass() {
        return this.bypassCooldown;
      }
    
    
    Grant's avatar
    Grant committed
      getAllPixels() {
        let pixels: {
          x: number;
          y: number;
          color: number;
        }[] = [];
    
        for (const [x_y, value] of Object.entries(this.pixels)) {
          if (value.type === "pending") continue;
    
          const [x, y] = x_y.split("_").map((v) => parseInt(v));
          pixels.push({
            x,
            y,
            color: value.color,
          });
        }
    
        return pixels;
      }
    
    
      /**
       * Get nearby pixels
       * @param x
       * @param y
       * @param around (x,y) +- around
       */
      getSurroundingPixels(x: number, y: number, around: number = 3) {
        let pixels = [];
    
        for (let offsetY = 0; offsetY <= around + 1; offsetY++) {
          let arr = [];
          for (let offsetX = 0; offsetX <= around + 1; offsetX++) {
            let targetX = x + (offsetX - around + 1);
            let targetY = y + (offsetY - around + 1);
            let pixel = this.pixels[targetX + "_" + targetY];
    
            if (pixel) {
              arr.push("#" + (this.Pallete.getColor(pixel.color)?.hex || "ffffff"));
            } else {
              arr.push("transparent");
            }
          }
          pixels.push(arr);
        }
    
        return pixels;
      }
    
    
    Grant's avatar
    Grant committed
      getPixel(x: number, y: number): { color: number } | undefined {
        return this.pixels[x + "_" + y];
      }
    
    
      handleLongPress = (clientX: number, clientY: number) => {
        KeybindManager.handleInteraction(
          {
            key: "LONG_PRESS",
          },
          {
            clientX,
            clientY,
          }
        );
      };
    
    
    Grant's avatar
    Grant committed
      previousCanvasClicks: { x: number; y: number }[] = [];
    
    
      handleMouseDown(e: ClickEvent) {
    
        if (!e.alt && !e.ctrl && !e.meta && !e.shift && e.button === "LCLICK") {
          const [x, y] = this.screenToPos(e.clientX, e.clientY);
          this.place(x, y);
        } else {
          // KeybindManager.handleInteraction({
          //   key: e.button,
          //   alt: e.alt,
          //   ctrl: e.ctrl,
          //   meta: e.meta,
          //   shift: e.meta
          // }, )
        }
    
    Grant's avatar
    Grant committed
    
        if (e.button === "RCLICK" && !e.alt && !e.ctrl && !e.meta && !e.shift) {
          const [x, y] = this.screenToPos(e.clientX, e.clientY);
    
          // keep track of the last X pixels right clicked
          // used by the ModModal to determine areas selected
    
          this.previousCanvasClicks.push({ x, y });
          this.previousCanvasClicks = this.previousCanvasClicks.slice(-2);
        }
    
      handleMouseMove(e: HoverEvent) {
        const canvasRect = this.canvas.getBoundingClientRect();
        if (
          canvasRect.left <= e.clientX &&
          canvasRect.right >= e.clientX &&
          canvasRect.top <= e.clientY &&
          canvasRect.bottom >= e.clientY
        ) {
          const [x, y] = this.screenToPos(e.clientX, e.clientY);
          this.cursor.x = x;
          this.cursor.y = y;
        } else {
          this.cursor.x = -1;
          this.cursor.y = -1;
        }
    
        this.emit("cursorPos", this.cursor);
      }
    
    Grant's avatar
    Grant committed
      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);
      };
    
    
    Grant's avatar
    Grant committed
      handleBatch = (
        start: [x: number, y: number],
        end: [x: number, y: number],
        pixels: string[]
      ) => {
    
    Grant's avatar
    Grant committed
        if (!this.config.canvas) {
          throw new Error("handleBatch called with no config");
        }
    
    
    Grant's avatar
    Grant committed
        let serializeBuild: CanvasPixel[] = [];
    
    Grant's avatar
    Grant committed
        const width = end[0] - start[0];
        const height = end[1] - start[1];
    
    Grant's avatar
    Grant committed
    
    
    Grant's avatar
    Grant committed
        for (let x = 0; x < width; x++) {
          for (let y = 0; y < height; y++) {
            const hex = pixels[width * y + x];
    
    Grant's avatar
    Grant committed
            const palette = this.Pallete.getColorFromHex(hex);
    
    Grant's avatar
    Grant committed
            const canvasX = x + start[0];
            const canvasY = y + start[1];
    
    
    Grant's avatar
    Grant committed
            // we still store a copy of the pixels in this instance for non-rendering functions
    
    Grant's avatar
    Grant committed
            this.pixels[canvasX + "_" + canvasY] = {
    
    Grant's avatar
    Grant committed
              type: "full",
    
    Grant's avatar
    Grant committed
              color: palette?.id || -1,
    
    Grant's avatar
    Grant committed
            };
    
    Grant's avatar
    Grant committed
    
            serializeBuild.push({
    
    Grant's avatar
    Grant committed
              x: canvasX,
              y: canvasY,
    
    Grant's avatar
    Grant committed
              hex: hex === "transparent" ? "null" : hex,
            });
    
    Grant's avatar
    Grant committed
    
        getRenderer().usePixels(serializeBuild, true);
    
    Grant's avatar
    Grant committed
      };
    
      handlePixel = ({ x, y, color }: Pixel) => {
    
    Grant's avatar
    Grant committed
        // we still store a copy of the pixels in this instance for non-rendering functions
    
        this.pixels[x + "_" + y] = {
          type: "full",
    
    Grant's avatar
    Grant committed
          color,
    
    Grant's avatar
    Grant committed
        };
    
    Grant's avatar
    Grant committed
    
        const palette = this.Pallete.getColor(color);
    
        getRenderer().usePixel({ x, y, hex: palette?.hex || "null" });
    
      handlePixelLastPlaced = (time: number) => {
        this.lastPlace = time;
      };
    
    
      Pallete = {
        getColor: (colorId: number) => {
          return this.config.pallete.colors.find((c) => c.id === colorId);
        },
    
        getSelectedColor: () => {
    
    Grant's avatar
    Grant committed
          if (!this.cursor.color) return undefined;
    
    Grant's avatar
    Grant committed
          return this.Pallete.getColor(this.cursor.color);
    
        getColorFromHex: (hex: string) => {
          return this.config.pallete.colors.find((c) => c.hex === hex);
        },
      };
    
    Grant's avatar
    Grant committed
      /**
       * Changes the cursor color as tracked by the Canvas instance
       *
       * @see Toolbar/Palette.tsx
       * @param color
       */
      updateCursor(color?: number) {
        this.cursor.color = color;
    
      place(x: number, y: number) {
        if (!this.Pallete.getSelectedColor()) return;
    
    Grant's avatar
    Grant committed
        // TODO: redo this as the server now verifies placements differently
        // if (this.lastPlace) {
        //   if (this.lastPlace + this.config.pallete.pixel_cooldown > Date.now()) {
        //     console.log("cannot place; cooldown");
        //     return;
        //   }
        // }
    
    
        Network.socket
    
          .emitWithAck(
            "place",
            {
              x,
              y,
              color: this.Pallete.getSelectedColor()!.id,
            },
            this.bypassCooldown
          )
    
          .then((ack) => {
            if (ack.success) {
              this.handlePixel(ack.data);
    
    Grant's avatar
    Grant committed
            } else {
    
    Grant's avatar
    Grant committed
              console.warn(
                "Attempted to place pixel",
                { x, y, color: this.Pallete.getSelectedColor()!.id },
                "and got error",
                ack
              );
    
    
              switch (ack.error) {
                case "invalid_pixel":
                  toast.error(
                    "Cannot place, invalid pixel location. Are you even on the canvas?"
                  );
                  break;
                case "no_user":
                  toast.error("You are not logged in.");
                  break;
    
    Ategon Dev's avatar
    Ategon Dev committed
                case "pixel_already_pending":
                  toast.error("You are already placing a pixel");
                  break;
    
                case "palette_color_invalid":
                  toast.error("This isn't a color that you can use...?");
                  break;
                case "pixel_cooldown":
                  toast.error("You're on pixel cooldown, cannot place");
                  break;
                case "you_already_placed_that":
                  toast.error("You already placed this color at this location");
                  break;
                default:
                  toast.error("Error while placing pixel: " + ack.error);
              }
    
    Grant's avatar
    Grant committed
            }
    
      canvasToPanZoomTransform(x: number, y: number) {
        let transformX = 0;
        let transformY = 0;
    
        if (this.PanZoom.flags.useZoom) {
          // CSS Zoom does not alter this (obviously)
          transformX = this.canvas.width / 2 - x;
          transformY = this.canvas.height / 2 - y;
        } else {
          transformX = this.canvas.width / 2 - x;
          transformY = this.canvas.height / 2 - y;
        }
    
        return { transformX, transformY };
      }
    
    
      panZoomTransformToCanvas() {
        const { x, y, scale: zoom } = this.PanZoom.transform;
        const rect = this.canvas.getBoundingClientRect();
    
        let canvasX = 0;
        let canvasY = 0;
    
        if (this.PanZoom.flags.useZoom) {
          // css zoom doesn't change the bounding client rect
          // therefore dividing by zoom doesn't return the correct output
          canvasX = this.canvas.width - (x + rect.width / 2);
          canvasY = this.canvas.height - (y + rect.height / 2);
        } else {
          canvasX = this.canvas.width / 2 - (x + rect.width / zoom);
          canvasY = this.canvas.height / 2 - (y + rect.height / zoom);
    
          canvasX += this.canvas.width;
          canvasY += this.canvas.height;
        }
    
        canvasX >>= 0;
        canvasY >>= 0;
    
        return { canvasX, canvasY };
    
      debug(x: number, y: number, id?: string) {
        if (document.getElementById("debug-" + id)) {
          document.getElementById("debug-" + id)!.style.top = y + "px";
          document.getElementById("debug-" + id)!.style.left = x + "px";
    
    Grant's avatar
    Grant committed
          return;
        }
    
        let el = document.createElement("div");
        if (id) el.id = "debug-" + id;
        el.classList.add("debug-point");
        el.style.setProperty("top", y + "px");
        el.style.setProperty("left", x + "px");
        document.body.appendChild(el);
      }
    
    
      /**
       * Screen (clientX, clientY) to Canvas position
       * @param x
       * @param y
       * @returns
       */
    
      screenToPos(x: number, y: number) {
        // the rendered dimentions in the browser
        const rect = this.canvas.getBoundingClientRect();
    
        let output = {
          x: 0,
          y: 0,
        };
    
        if (this.PanZoom.flags.useZoom) {
          const scale = this.PanZoom.transform.scale;
    
          output.x = x / scale - rect.left;
          output.y = y / scale - rect.top;
        } else {
          // get the ratio
          const scale = [
            this.canvas.width / rect.width,
            this.canvas.height / rect.height,
          ];
    
          output.x = (x - rect.left) * scale[0];
          output.y = (y - rect.top) * scale[1];
        }
    
        // floor it, we're getting canvas coords, which can't have decimals
        output.x >>= 0;
        output.y >>= 0;
    
        return [output.x, output.y];
      }
    
    Grant's avatar
    Grant committed
    }