Skip to content
canvas.ts 12.3 KiB
Newer Older
Grant's avatar
Grant committed
import EventEmitter from "eventemitter3";
Grant's avatar
Grant committed
import { ClientConfig, IPosition, Pixel } from "@sc07-canvas/lib/src/net";
Grant's avatar
Grant committed
import Network from "./network";
Grant's avatar
Grant committed
import {
Grant's avatar
Grant committed
  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

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
}
Grant's avatar
Grant committed

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;
Grant's avatar
Grant committed
  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) {
Grant's avatar
Grant committed
    super();
    Canvas.instance = this;
Grant's avatar
Grant committed
    getRenderer().startRender();

    getRenderer().on("ready", () => this.emit("canvasReady"));
Grant's avatar
Grant committed

Grant's avatar
Grant committed
    this.canvas = canvas;
    this.PanZoom = PanZoom;
    this.loadRenderer();
Grant's avatar
Grant committed

    this.PanZoom.addListener("hover", this.handleMouseMove.bind(this));
    this.PanZoom.addListener("click", this.handleMouseDown.bind(this));
    this.PanZoom.addListener("longPress", this.handleLongPress);
Grant's avatar
Grant committed
    Network.waitFor("pixelLastPlaced").then(
      ([time]) => (this.lastPlace = time)
    );
    Network.on("pixel", this.handlePixel);
Grant's avatar
Grant committed
    Network.on("square", this.handleSquare);
Grant's avatar
Grant committed
  }

Grant's avatar
Grant committed
  destroy() {
Grant's avatar
Grant committed
    getRenderer().stopRender();
    getRenderer().off("ready");
Grant's avatar
Grant committed

    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);
  }

  /**
   * 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.clearPrevious("canvas");

Grant's avatar
Grant committed
    Network.waitFor("canvas").then(([pixels]) => {
      console.log("loadConfig just received new canvas data");
      this.handleBatch(pixels);
    });
  }

  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
  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

Grant's avatar
Grant committed
  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;
    }
Grant's avatar
Grant committed

Grant's avatar
Grant committed
    this.emit("cursorPos", this.cursor);
  }
Grant's avatar
Grant committed

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 = (pixels: string[]) => {
    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
    for (let x = 0; x < this.config.canvas.size[0]; x++) {
      for (let y = 0; y < this.config.canvas.size[1]; y++) {
        const hex = pixels[this.config.canvas.size[0] * y + x];
Grant's avatar
Grant committed
        const palette = this.Pallete.getColorFromHex(hex);
Grant's avatar
Grant committed

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[x + "_" + y] = {
          type: "full",
Grant's avatar
Grant committed
          color: palette?.id || -1,
Grant's avatar
Grant committed
        };
Grant's avatar
Grant committed

        serializeBuild.push({
          x,
          y,
          hex: hex === "transparent" ? "null" : hex,
        });
Grant's avatar
Grant committed

    getRenderer().usePixels(serializeBuild, true);
Grant's avatar
Grant committed
  };
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
Grant's avatar
Grant committed
    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" });
Grant's avatar
Grant committed

Grant's avatar
Grant committed
  Pallete = {
    getColor: (colorId: number) => {
      return this.config.pallete.colors.find((c) => c.id === colorId);
    },
Grant's avatar
Grant committed

Grant's avatar
Grant committed
    getSelectedColor: () => {
Grant's avatar
Grant committed
      if (!this.cursor.color) return undefined;
Grant's avatar
Grant committed

Grant's avatar
Grant committed
      return this.Pallete.getColor(this.cursor.color);
Grant's avatar
Grant committed
    },
Grant's avatar
Grant committed

Grant's avatar
Grant committed
    getColorFromHex: (hex: string) => {
      return this.config.pallete.colors.find((c) => c.hex === hex);
    },
  };
Grant's avatar
Grant committed

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;
Grant's avatar
Grant committed

Grant's avatar
Grant committed
  place(x: number, y: number) {
    if (!this.Pallete.getSelectedColor()) return;
Grant's avatar
Grant committed

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;
    //   }
    // }
Grant's avatar
Grant committed

    Network.socket
      .emitWithAck(
        "place",
        {
          x,
          y,
          color: this.Pallete.getSelectedColor()!.id,
        },
        this.bypassCooldown
      )
Grant's avatar
Grant committed
      .then((ack) => {
        if (ack.success) {
Grant's avatar
Grant committed
          this.lastPlace = Date.now();
Grant's avatar
Grant committed
          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;
            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
        }
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 };
  }

Grant's avatar
Grant committed
  panZoomTransformToCanvas() {
    const { x, y, scale: zoom } = this.PanZoom.transform;
    const rect = this.canvas.getBoundingClientRect();
Grant's avatar
Grant committed

Grant's avatar
Grant committed
    let canvasX = 0;
    let canvasY = 0;
Grant's avatar
Grant committed

Grant's avatar
Grant committed
    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 };
Grant's avatar
Grant committed
  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;
    }
Grant's avatar
Grant committed
    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
   */
Grant's avatar
Grant committed
  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;
Grant's avatar
Grant committed

Grant's avatar
Grant committed
    return [output.x, output.y];
  }
Grant's avatar
Grant committed
}