diff --git a/packages/client/src/components/App.tsx b/packages/client/src/components/App.tsx
index 3b55af3..63eb065 100644
--- a/packages/client/src/components/App.tsx
+++ b/packages/client/src/components/App.tsx
@@ -2,13 +2,19 @@ import { Header } from "./Header";
import { AppContext } from "../contexts/AppContext";
import { CanvasWrapper } from "./CanvasWrapper";
import { Pallete } from "./Pallete";
+import { TemplateContext } from "../contexts/TemplateContext";
+import { SettingsSidebar } from "./Settings/SettingsSidebar";
const App = () => {
return (
-
-
-
+
+
+
+
+
+
+
);
};
diff --git a/packages/client/src/components/CanvasWrapper.tsx b/packages/client/src/components/CanvasWrapper.tsx
index b339d13..c1b26e4 100644
--- a/packages/client/src/components/CanvasWrapper.tsx
+++ b/packages/client/src/components/CanvasWrapper.tsx
@@ -7,12 +7,14 @@ import { ViewportMoveEvent } from "@sc07-canvas/lib/src/renderer/PanZoom";
import throttle from "lodash.throttle";
import { Routes } from "../lib/routes";
import { ICanvasPosition, IPosition } from "@sc07-canvas/lib/src/net";
+import { Template } from "./Template";
export const CanvasWrapper = () => {
// to prevent safari from blurring things, use the zoom css property
return (
+
@@ -90,7 +92,7 @@ const CanvasInner = () => {
setCanvasPosition(canvasPosition);
- window.location.replace(Routes.canvas(canvasPosition));
+ window.location.replace(Routes.canvas({ pos: canvasPosition }));
}, 1000);
const handleCursorPos = throttle((pos: IPosition) => {
diff --git a/packages/client/src/components/Header.tsx b/packages/client/src/components/Header.tsx
index 2089c35..af3c1f3 100644
--- a/packages/client/src/components/Header.tsx
+++ b/packages/client/src/components/Header.tsx
@@ -1,12 +1,17 @@
+import { Button } from "@nextui-org/react";
+import { useAppContext } from "../contexts/AppContext";
import { User } from "./Header/User";
export const Header = () => {
+ const { setSettingsSidebar } = useAppContext();
+
return (
+
);
diff --git a/packages/client/src/components/Settings/SettingsSidebar.tsx b/packages/client/src/components/Settings/SettingsSidebar.tsx
new file mode 100644
index 0000000..09cdf3a
--- /dev/null
+++ b/packages/client/src/components/Settings/SettingsSidebar.tsx
@@ -0,0 +1,26 @@
+import { Button } from "@nextui-org/react";
+import { useAppContext } from "../../contexts/AppContext";
+import { TemplateSettings } from "./TemplateSettings";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { faXmark } from "@fortawesome/free-solid-svg-icons/faXmark";
+
+export const SettingsSidebar = () => {
+ const { settingsSidebar, setSettingsSidebar } = useAppContext();
+
+ return (
+
+
+ Settings
+
+
+
+
+
+
+ );
+};
diff --git a/packages/client/src/components/Settings/TemplateSettings.tsx b/packages/client/src/components/Settings/TemplateSettings.tsx
new file mode 100644
index 0000000..3c8ef27
--- /dev/null
+++ b/packages/client/src/components/Settings/TemplateSettings.tsx
@@ -0,0 +1,70 @@
+import { useTemplateContext } from "../../contexts/TemplateContext";
+import { Input, Slider, Switch } from "@nextui-org/react";
+
+export const TemplateSettings = () => {
+ const {
+ enable,
+ setEnable,
+ url,
+ setURL,
+ width,
+ setWidth,
+ x,
+ setX,
+ y,
+ setY,
+ opacity,
+ setOpacity,
+ } = useTemplateContext();
+
+ return (
+ <>
+
+
+ >
+ );
+};
diff --git a/packages/client/src/components/Template.scss b/packages/client/src/components/Template.scss
new file mode 100644
index 0000000..d9b0434
--- /dev/null
+++ b/packages/client/src/components/Template.scss
@@ -0,0 +1,15 @@
+#template {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100px;
+
+ > * {
+ width: 100%;
+ // height: 100%;
+
+ position: absolute;
+ top: 0;
+ left: 0;
+ }
+}
diff --git a/packages/client/src/components/Template.tsx b/packages/client/src/components/Template.tsx
new file mode 100644
index 0000000..447fa45
--- /dev/null
+++ b/packages/client/src/components/Template.tsx
@@ -0,0 +1,89 @@
+import { useEffect, useRef } from "react";
+import { Template as TemplateCl } from "../lib/template";
+import { useAppContext } from "../contexts/AppContext";
+import { useTemplateContext } from "../contexts/TemplateContext";
+
+export const Template = () => {
+ const { config } = useAppContext();
+ const { enable, url, width, setWidth, x, y, opacity } = useTemplateContext();
+ const templateHolder = useRef(null);
+ const instance = useRef();
+
+ useEffect(() => {
+ if (!templateHolder?.current) {
+ console.warn("No templateHolder, cannot initialize");
+ return;
+ }
+
+ instance.current = new TemplateCl(config, templateHolder.current);
+
+ instance.current.on("autoDetectWidth", (width) => {
+ console.log("autodetectwidth", width);
+ setWidth(width);
+ });
+
+ return () => {
+ instance.current?.destroy();
+ };
+ }, []);
+
+ useEffect(() => {
+ if (!instance.current) {
+ console.warn("Received template enable but no instance exists");
+ return;
+ }
+
+ instance.current.setOption("enable", enable);
+
+ if (enable && url) {
+ instance.current.loadImage(url).then(() => {
+ console.log("enable: load image finished");
+ });
+ }
+ }, [enable]);
+
+ useEffect(() => {
+ if (!instance.current) {
+ console.warn(
+ "recieved template url update but no template instance exists"
+ );
+ return;
+ }
+
+ if (!url) {
+ console.warn("received template url blank");
+ return;
+ }
+
+ if (!enable) {
+ console.info("Got template URL but not enabled, ignoring");
+ return;
+ }
+
+ instance.current.loadImage(url).then(() => {
+ console.log("template loader finished");
+ });
+ }, [url]);
+
+ useEffect(() => {
+ if (!instance.current) {
+ console.warn("received template width with no instance");
+ return;
+ }
+
+ instance.current.setOption("width", width);
+ instance.current.rasterizeTemplate();
+ }, [width]);
+
+ return (
+
+ );
+};
diff --git a/packages/client/src/contexts/AppContext.tsx b/packages/client/src/contexts/AppContext.tsx
index 0357a0d..903e8d1 100644
--- a/packages/client/src/contexts/AppContext.tsx
+++ b/packages/client/src/contexts/AppContext.tsx
@@ -26,6 +26,9 @@ export const AppContext = ({ children }: PropsWithChildren) => {
const [pixels, setPixels] = useState({ available: 0 });
+ // overlays visible
+ const [settingsSidebar, setSettingsSidebar] = useState(false);
+
useEffect(() => {
function handleConfig(config: ClientConfig) {
console.info("Server sent config", config);
@@ -64,6 +67,8 @@ export const AppContext = ({ children }: PropsWithChildren) => {
cursorPosition,
setCursorPosition,
pixels,
+ settingsSidebar,
+ setSettingsSidebar,
}}
>
{config ? children : "Loading..."}
diff --git a/packages/client/src/contexts/TemplateContext.tsx b/packages/client/src/contexts/TemplateContext.tsx
new file mode 100644
index 0000000..edbf8bd
--- /dev/null
+++ b/packages/client/src/contexts/TemplateContext.tsx
@@ -0,0 +1,65 @@
+import { PropsWithChildren, createContext, useContext, useState } from "react";
+
+interface ITemplate {
+ /**
+ * If the template is being used
+ */
+ enable: boolean;
+
+ /**
+ * URL of the template image being used
+ */
+ url?: string;
+
+ /**
+ * Width of the template being displayed
+ *
+ * @default min(template.width,canvas.width)
+ */
+ width?: number;
+
+ x: number;
+ y: number;
+ opacity: number;
+
+ setEnable(v: boolean): void;
+ setURL(v?: string): void;
+ setWidth(v?: number): void;
+ setX(v: number): void;
+ setY(v: number): void;
+ setOpacity(v: number): void;
+}
+
+const templateContext = createContext({} as any);
+
+export const useTemplateContext = () => useContext(templateContext);
+
+export const TemplateContext = ({ children }: PropsWithChildren) => {
+ const [enable, setEnable] = useState(false);
+ const [url, setURL] = useState();
+ const [width, setWidth] = useState();
+ const [x, setX] = useState(0);
+ const [y, setY] = useState(0);
+ const [opacity, setOpacity] = useState(100);
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/packages/client/src/lib/routes.ts b/packages/client/src/lib/routes.ts
index a0698bf..5d090cb 100644
--- a/packages/client/src/lib/routes.ts
+++ b/packages/client/src/lib/routes.ts
@@ -1,11 +1,37 @@
-import { ICanvasPosition } from "../types";
+import { ICanvasPosition } from "@sc07-canvas/lib/src/net";
+
+export interface ITemplateState {
+ url: string;
+ width: number;
+ x: number;
+ y: number;
+ opacity: number;
+}
export const Routes = {
- canvas: (pos: ICanvasPosition) => {
+ canvas: ({
+ pos,
+ template,
+ }: {
+ pos?: ICanvasPosition;
+ template?: ITemplateState;
+ }) => {
const params = new URLSearchParams();
- params.set("x", pos.x + "");
- params.set("y", pos.y + "");
- params.set("zoom", pos.zoom + "");
+
+ if (pos) {
+ params.set("x", pos.x + "");
+ params.set("y", pos.y + "");
+ params.set("zoom", pos.zoom + "");
+ }
+
+ if (template) {
+ let { url, width, x, y, opacity } = template;
+ params.set("template.url", url);
+ params.set("template.width", width + "");
+ params.set("template.x", x + "");
+ params.set("template.y", y + "");
+ params.set("template.opacity", opacity + "");
+ }
return "/#" + params;
},
diff --git a/packages/client/src/lib/template.ts b/packages/client/src/lib/template.ts
new file mode 100644
index 0000000..730bd65
--- /dev/null
+++ b/packages/client/src/lib/template.ts
@@ -0,0 +1,570 @@
+import EventEmitter from "eventemitter3";
+import { WebGLUtils } from "./webgl";
+import { ClientConfig } from "@sc07-canvas/lib/src/net";
+
+interface TemplateEvents {
+ updateImageURL(url: string | undefined): void;
+ option(
+ option: T,
+ value: ITemplateOptions[T]
+ ): void;
+ autoDetectWidth(width: number): void;
+}
+
+interface ITemplateOptions {
+ enable: boolean;
+ width?: number;
+ style: TemplateStyle;
+}
+
+enum TemplateStyle {
+ SOURCE = "",
+ ONE_TO_ONE = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAACXBIWXMAAAsTAAALEwEAmpwYAAAAGklEQVQoz2P8//8/AymAiYFEMKphVMPQ0QAAVW0DHZ8uFaIAAAAASUVORK5CYII=",
+ ONE_TO_ONE_INCORRECT = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAACXBIWXMAAAsTAAALEwEAmpwYAAAAGklEQVQoz2P8//8/AymAiYFEMKphVMPQ0QAAVW0DHZ8uFaIAAAAASUVORK5CYII=",
+ DOTTED_SMALL = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAQAAAD9CzEMAAAAAmJLR0QA/4ePzL8AAAAzSURBVFjD7dBBDQAACMSw828aVEAI6R4T0GShGv6DECFChAgRIkSIECFChAgRIkSIruA0nub+AuTzLZoAAAAASUVORK5CYII=",
+ DOTTED_BIG = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAWklEQVR42u3UwQkAIAwEwcX+e9aP2INkBvK4d2CLqva9cXv5PWgAoAGgARoAGqABoAEaABqgAaABGgAaoAGgARoAGqABoAEaABqgAaABGgAaoAGgAT/vRwOmO8dS/DI1VxCbAAAAAElFTkSuQmCC",
+ SYMBOLS = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAHAAAABwAQMAAAD8LmYIAAAAAXNSR0IB2cksfwAAAAlwSFlzAAAuIwAALiMBeKU/dgAAAAZQTFRFAAAAAwMDFQUF7wAAAAJ0Uk5TAP9bkSK1AAAAuUlEQVR4nGNgQAUhjQvdz0uwMfx82OrVIgPkBj/xaOEQ6GRuP9vHAeQGsPjzyVj8LH5+iAXEDTziMd+uplHg+VE+GQaNjwHt5+WB3A+HO+bbMRACDoed+Xg0FIMW97dIMLAwNC45OF8ip+Dh8aN8Ngwsjc2sXfNFBAoePz8xX46B5+DhNj4WlpwCx+NH5W0Yan5+fn6+xU5DwWlxf58EAWs0DFC4NQX4uBaoXAFUvaNgFIyCUTAKaAYAzI49GM5w0hQAAAAOZVhJZk1NACoAAAAIAAAAAAAAANJTkwAAAABJRU5ErkJggg==",
+ NUMBERS = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAALAAAACwCAYAAACvt+ReAAAE10lEQVR42u3d3bKjKBQGULDy/q/MXE1VdzqRH9kqZq2bqZn5JKAmErbm5JRSSbCw0pGTlb1VdttpoAS9AUpANgW1u0q2dGxTOo7faLu92TTa7vZlozz55P2/zRyQ7RXRj97+zsyWjm1GT97aGI9kR8aWaydwxIEg7g05ms0nvcnCsq8bzrVnvVNr2RyQzReP7eeO33bBp0We/E6NnJr0nJAR7UZOpR5x/LYEC9smrCyMrETMXErpvazd4fI9c3+VnW/2teze8Ss7qwAt7ZYJ50y13deqk/fBbVYb28iY8mLZvf9ebTcnlTgeOIWAZShJyi6bfX3YOH84sfOXF7oyW3amQrXs++vMarc3m7/048w+rJT957htlU/i3HCQ93J77R7N5o4vD+/ZUvmSkRvHdiSbOvqwt/2RbA7av6cdt+0Bqw8jlMDX9M9xq5WS71xKjS5VtmxbDvZ3JJsDsvnEsU09dq+GM75MPnl72s2VQZx1JehdA23pb8/YevdDax/KhWMrM84Vy2gs7dOXuJGSZMslYLTUWbsUtbT7nm25ibqlhPqp3Z7+po7+RuyHnj707t/S8fql8/XLyHzE2qPs7bJKyTxmCgFLcimSXTa7fdiwfPn3NDGbgtq9ezYNZke++JaAbApqdzj75zrw+9rd3lrekeye1vsljmZ7+5snZL/1q2clJw3uwxnZXlGPWP3VX3PgNSh9f/HaeaeXzk+FEpzNAdl88dhSQPanjttWeafX7lZq/ZRovQPqSLanDyWo3ci70XqyvXeutbQbeVdez91onkrmmVOII3c1RV02I+8Ei2g36sc/SuOVo+WSfKS/EdOfw/2wnii7bFYpmaWZA7M8lyLZZbOvD0sUf/4z7XyJ68n++f88PfyDTw9H9WHWI0W17JFHXmqv+WnHzcymjj7Utj2yvpwC9u/yx+3uc2Al1DWddtxelfnw7DJjxI9Kt14pSuM7flY2B2TzxWO73XF7/12IM8qMtXeuEmpDCfWEsR2dSvVOu4ZuWbCMxtJaf9gkHcjNKM3WVgBqlzGl7/7+HhlfrfQ9ejdaOXqSysreKquUzNLMgVmeS5Hsstlv9wMroY5lW7+4KH1Pyr6vQiihHnsquTSMy1Pf4/v3n6w58FxK3yf7VkpWQo35M7Ol4xPzvd0SnM0B2Rw9tq1y+f7Fp4fPOHlr/SgdYysHxta7H3pOyIh2/a1kfmMK0fqJ0rrd3Uq5nh6O3Q8peP8Obywre6usUjJLMwdmeS5Fsstma6Xkb8scSqjPyC5/3Fp+nfKbI0+hRq0vp45s72MsOaC/V2eXP26z5sBKqGta/rjNWgfuyfrh7Pix/cxx2w68Iy95CvWiS5wfzt7f/rKnvi2j8egpxC2fQr355TCiXU9972xrPVF22axSMo+aQkCUsCU7lyLZM7Lhn8BKqOf39xdL31PN+kOHSqhj+yF1ju0ppe+wE9h8jKW/xK1WQj1D5GM3I9mIH5vOF49tyifwij/AfOYndk8JNqLNiDJ/CWr3tOOmlMxjphB+gPn4VErp+4Jpn3VK2TOyYXM7pWTO+h4BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAE/1H4IIqRgL4W2oAAAAAElFTkSuQmCC",
+}
+
+export class Template extends EventEmitter {
+ config: ClientConfig;
+
+ $wrapper: HTMLDivElement;
+ $imageLoader: HTMLImageElement;
+ $style: HTMLImageElement;
+ $canvas: HTMLCanvasElement;
+ imageURL: string | undefined;
+
+ options: ITemplateOptions = {
+ enable: false,
+ style: TemplateStyle.ONE_TO_ONE,
+ };
+
+ constructor(config: ClientConfig, templateHolder: HTMLDivElement) {
+ super();
+ this.config = config;
+
+ console.log("template init", config, templateHolder);
+
+ this.$wrapper = templateHolder;
+
+ this.$imageLoader = document.createElement("img");
+ this.$imageLoader.setAttribute("crossorigin", "");
+ this.$imageLoader.addEventListener("load", () => {
+ console.log("imageLoader loaded image");
+ if (!this.options.width) {
+ this.setOption("width", this.$imageLoader.naturalWidth);
+ this.emit("autoDetectWidth", this.$imageLoader.naturalWidth);
+ }
+ this.rasterizeTemplate();
+ });
+
+ this.$style = document.createElement("img");
+ this.$style.setAttribute("crossorigin", "");
+ this.$style.setAttribute("src", this.options!.style);
+
+ this.$canvas = document.createElement("canvas");
+
+ [this.$imageLoader, this.$style, this.$canvas].forEach((el) =>
+ el.classList.add("pixelate")
+ );
+
+ templateHolder.style.width = this.options!.width + "px";
+ templateHolder.appendChild(this.$imageLoader);
+ // templateHolder.appendChild(this.$style);
+ templateHolder.appendChild(this.$canvas);
+
+ this.setupWebGL();
+
+ // @ts-ignore
+ window.TemplateTest = this;
+ }
+
+ destroy() {
+ this.$imageLoader.remove();
+ this.$style.remove();
+ this.$canvas.remove();
+ this.removeAllListeners();
+ }
+
+ /**
+ * Update settings
+ *
+ * NOTE: this does not cause re-render
+ *
+ * @param key
+ * @param value
+ */
+ setOption(
+ key: T,
+ value: ITemplateOptions[T]
+ ) {
+ this.options[key] = value;
+
+ switch (key) {
+ case "enable":
+ this.setElementVisible([this.$canvas, this.$imageLoader], !!value);
+ break;
+ }
+
+ this.emit("option", key, value);
+ }
+
+ setElementVisible(els: HTMLElement[], visible: boolean) {
+ for (const el of els) {
+ el.style.display = visible ? "block" : "none";
+ }
+ }
+
+ rasterizeTemplate() {
+ this.downscaleTemplate();
+ this.stylizeTemplate();
+ }
+
+ loadImage(url: string) {
+ return fetch(url, { method: "GET", credentials: "omit" })
+ .then((resp) => {
+ if (resp.ok) {
+ return resp.blob();
+ } else {
+ throw new Error(`HTTP ${resp.status} ${resp.statusText}`);
+ }
+ })
+ .then((blob) => {
+ return new Promise((res) => {
+ const reader = new FileReader();
+ reader.onload = () => {
+ // reader.result will be a string because of reader.readAsDataURL being called
+ this.imageURL = reader.result as string;
+ this.$imageLoader.setAttribute("src", this.imageURL);
+ this.emit("updateImageURL", this.imageURL);
+ res();
+ };
+ reader.readAsDataURL(blob);
+ });
+ })
+ .catch((err) => {
+ // TODO: Better error handling
+ alert("template loadimage error: " + err);
+ });
+ }
+
+ getDimentions() {
+ let source = {
+ width: this.$imageLoader.naturalWidth,
+ height: this.$imageLoader.naturalHeight,
+ };
+
+ let aspectRatio = source.height / source.width;
+
+ return {
+ source,
+ display: {
+ width: Math.round(this.options?.width || source.width),
+ height: Math.round((this.options?.width || source.width) * aspectRatio),
+ },
+ aspectRatio,
+ };
+ }
+
+ private context: WebGLRenderingContext | undefined;
+
+ private textures: {
+ source: WebGLTexture | null;
+ downscaled: WebGLTexture | null;
+ style: WebGLTexture | null;
+ } = {} as any;
+
+ private framebuffers: {
+ intermediate: WebGLFramebuffer | null;
+ main: WebGLFramebuffer | null;
+ } = {} as any;
+ private buffers: { vertex: WebGLBuffer | null } = {} as any;
+ private programs: {
+ downscaling: { unconverted: WebGLProgram; nearestCustom: WebGLProgram };
+ stylize: WebGLProgram;
+ } = { downscaling: {} } as any;
+
+ updateSize() {
+ const {
+ display: { width, height },
+ } = this.getDimentions();
+
+ this.$wrapper.style.width = width + "px";
+ this.$imageLoader.style.width = width + "px";
+
+ this.$canvas.width = width;
+ this.$canvas.height = height;
+ }
+
+ /**
+ * Initialize webgl
+ *
+ * This originates from Pxls and their contributors
+ * https://github.com/pxlsspace/pxls-web/blob/0c5a680c4611af277205886df77ac9014c759aba/public/include/template.js#L616
+ */
+ setupWebGL() {
+ const palette: { value: string }[] = this.config.pallete.colors.map(
+ (color) => ({ value: color.hex })
+ );
+ const STYLES_Y = 16;
+ const STYLES_X = 16;
+
+ const context = this.$canvas.getContext("webgl", {
+ premultipliedAlpha: true,
+ });
+
+ if (context === null) {
+ throw new Error("WebGL is not supported");
+ }
+
+ this.context = context;
+
+ const utils = new WebGLUtils(context);
+
+ context.clearColor(0, 0, 0, 0);
+ context.pixelStorei(context.UNPACK_FLIP_Y_WEBGL, true);
+
+ this.textures.source = utils.createTexture();
+ this.textures.downscaled = utils.createTexture();
+ this.framebuffers.intermediate = context.createFramebuffer();
+
+ context.bindFramebuffer(
+ context.FRAMEBUFFER,
+ this.framebuffers.intermediate
+ );
+ context.framebufferTexture2D(
+ context.FRAMEBUFFER,
+ context.COLOR_ATTACHMENT0,
+ context.TEXTURE_2D,
+ this.textures.downscaled,
+ 0
+ );
+
+ this.textures.style = utils.createTexture();
+ this.loadStyle(false);
+ this.buffers.vertex = context.createBuffer();
+ context.bindBuffer(context.ARRAY_BUFFER, this.buffers.vertex);
+ // prettier-ignore
+ context.bufferData(
+ context.ARRAY_BUFFER,
+ new Float32Array([
+ -1, -1,
+ -1, 1,
+ 1, -1,
+ 1, 1
+ ]),
+ context.STATIC_DRAW
+ );
+
+ const identityVertexShader = `
+ attribute vec2 a_Pos;
+ varying vec2 v_TexCoord;
+ void main() {
+ v_TexCoord = a_Pos * vec2(0.5, 0.5) + vec2(0.5, 0.5);
+ gl_Position = vec4(a_Pos, 0.0, 1.0);
+ }
+ `;
+ const paletteDefs = `
+ #define PALETTE_LENGTH ${palette.length}
+ #define PALETTE_MAXSIZE 255.0
+ #define PALETTE_TRANSPARENT (PALETTE_MAXSIZE - 1.0) / PALETTE_MAXSIZE
+ #define PALETTE_UNKNOWN 1.0
+ `;
+ const diffCustom = `
+ #define LUMA_WEIGHTS vec3(0.299, 0.587, 0.114)
+ // a simple custom colorspace that stores:
+ // - brightness
+ // - red/green-ness
+ // - blue/yellow-ness
+ // this storing of contrasts is similar to how humans
+ // see color difference and provides a simple difference function
+ // with decent results.
+ vec3 rgb2Custom(vec3 rgb) {
+ return vec3(
+ length(rgb * LUMA_WEIGHTS),
+ rgb.r - rgb.g,
+ rgb.b - (rgb.r + rgb.g) / 2.0
+ );
+ }
+ float diffCustom(vec3 col1, vec3 col2) {
+ return length(rgb2Custom(col1) - rgb2Custom(col2));
+ }
+ `;
+ const downscalingFragmentShader = (comparisonFunctionName?: string) => `
+ precision mediump float;
+ // GLES (and thus WebGL) does not support dynamic for loops
+ // the workaround is to specify the condition as an upper bound
+ // then break the loop early if we reach our dynamic limit
+ #define MAX_SAMPLE_SIZE 16.0
+
+ ${paletteDefs}
+ ${comparisonFunctionName !== undefined ? "#define CONVERT_COLORS" : ""}
+ #define HIGHEST_DIFF 999999.9
+ uniform sampler2D u_Template;
+ uniform vec2 u_TexelSize;
+ uniform vec2 u_SampleSize;
+ uniform vec3 u_Palette[PALETTE_LENGTH];
+ varying vec2 v_TexCoord;
+ const float epsilon = 1.0 / 128.0;
+ // The alpha channel is used to index the palette:
+ const vec4 transparentColor = vec4(0.0, 0.0, 0.0, PALETTE_TRANSPARENT);
+ ${diffCustom}
+ void main () {
+ vec4 color = vec4(0.0);
+ vec2 actualSampleSize = min(u_SampleSize, vec2(MAX_SAMPLE_SIZE));
+ vec2 sampleTexSize = u_TexelSize / actualSampleSize;
+ // sample is taken from center of fragment
+ // this moves the coordinates to the starting corner and to the center of the sample texel
+ vec2 sampleOrigin = v_TexCoord - sampleTexSize * (actualSampleSize / 2.0 - 0.5);
+ float sampleCount = 0.0;
+ for(float x = 0.0; x < MAX_SAMPLE_SIZE; x++) {
+ if(x >= u_SampleSize.x) {
+ break;
+ }
+ for(float y = 0.0; y < MAX_SAMPLE_SIZE; y++) {
+ if(y >= u_SampleSize.y) {
+ break;
+ }
+ vec2 pos = sampleOrigin + sampleTexSize * vec2(x, y);
+ vec4 sample = texture2D(u_Template, pos);
+ // pxlsfiddle uses the alpha channel of the first pixel to store
+ // scale information. This can affect color sampling, so drop the
+ // top-left-most subtexel unless its alpha is typical (1 or 0 exactly).
+ if(x == 0.0 && y == 0.0
+ && pos.x < u_TexelSize.x && (1.0 - pos.y) < u_TexelSize.y
+ && sample.a != 1.0) {
+ continue;
+ }
+ if(sample.a == 0.0) {
+ continue;
+ }
+ color += sample;
+ sampleCount++;
+ }
+ }
+ if(sampleCount == 0.0) {
+ gl_FragColor = transparentColor;
+ return;
+ }
+ color /= sampleCount;
+ #ifdef CONVERT_COLORS
+ float bestDiff = HIGHEST_DIFF;
+ int bestIndex = int(PALETTE_MAXSIZE);
+ vec3 bestColor = vec3(0.0);
+ for(int i = 0; i < PALETTE_LENGTH; i++) {
+ float diff = ${comparisonFunctionName}(color.rgb, u_Palette[i]);
+ if(diff < bestDiff) {
+ bestDiff = diff;
+ bestIndex = i;
+ bestColor = u_Palette[i];
+ }
+ }
+ gl_FragColor = vec4(bestColor, float(bestIndex) / PALETTE_MAXSIZE);
+ #else
+ for(int i = 0; i < PALETTE_LENGTH; i++) {
+ if(all(lessThan(abs(u_Palette[i] - color.rgb), vec3(epsilon)))) {
+ gl_FragColor = vec4(u_Palette[i], float(i) / PALETTE_MAXSIZE);
+ return;
+ }
+ }
+ gl_FragColor = vec4(color.rgb, PALETTE_UNKNOWN);
+ #endif
+ }
+ `;
+ this.programs.downscaling.unconverted = utils.createProgram(
+ identityVertexShader,
+ downscalingFragmentShader()
+ );
+ this.programs.downscaling.nearestCustom = utils.createProgram(
+ identityVertexShader,
+ downscalingFragmentShader("diffCustom")
+ );
+ const int2rgb = (i: number) => [
+ (i >> 16) & 0xff,
+ (i >> 8) & 0xff,
+ i & 0xff,
+ ];
+ const paletteBuffer = new Float32Array(
+ palette.flatMap((c) => int2rgb(parseInt(c.value, 16)).map((c) => c / 255))
+ );
+ for (const program of Object.values(this.programs.downscaling)) {
+ context.useProgram(program);
+ const posLocation = context.getAttribLocation(program, "a_Pos");
+ context.vertexAttribPointer(posLocation, 2, context.FLOAT, false, 0, 0);
+ context.enableVertexAttribArray(posLocation);
+ context.uniform1i(context.getUniformLocation(program, "u_Template"), 0);
+ context.uniform3fv(
+ context.getUniformLocation(program, "u_Palette"),
+ paletteBuffer
+ );
+ }
+ this.programs.stylize = utils.createProgram(
+ identityVertexShader,
+ `
+ precision mediump float;
+ #define STYLES_X float(${STYLES_X})
+ #define STYLES_Y float(${STYLES_Y})
+ ${paletteDefs}
+ uniform sampler2D u_Template;
+ uniform sampler2D u_Style;
+ uniform vec2 u_TexelSize;
+ varying vec2 v_TexCoord;
+ const vec2 styleSize = vec2(1.0 / STYLES_X, 1.0 / STYLES_Y);
+ void main () {
+ vec4 templateSample = texture2D(u_Template, v_TexCoord);
+ float index = floor(templateSample.a * PALETTE_MAXSIZE + 0.5);
+ vec2 indexCoord = vec2(mod(index, STYLES_X), STYLES_Y - floor(index / STYLES_Y) - 1.0);
+ vec2 subTexCoord = mod(v_TexCoord, u_TexelSize) / u_TexelSize;
+ vec2 styleCoord = (indexCoord + subTexCoord) * styleSize;
+
+ vec4 styleMask = vec4(1.0, 1.0, 1.0, texture2D(u_Style, styleCoord).a);
+ gl_FragColor = vec4(templateSample.rgb, templateSample.a == PALETTE_TRANSPARENT ? 0.0 : 1.0) * styleMask;
+ }
+ `
+ );
+ context.useProgram(this.programs.stylize);
+ const stylePosLocation = context.getAttribLocation(
+ this.programs.stylize,
+ "a_Pos"
+ );
+ context.vertexAttribPointer(
+ stylePosLocation,
+ 2,
+ context.FLOAT,
+ false,
+ 0,
+ 0
+ );
+ context.enableVertexAttribArray(stylePosLocation);
+ context.uniform1i(
+ context.getUniformLocation(this.programs.stylize, "u_Template"),
+ 0
+ );
+ context.uniform1i(
+ context.getUniformLocation(this.programs.stylize, "u_Style"),
+ 1
+ );
+ }
+
+ loadStyle(redraw = true) {
+ if (
+ this.context &&
+ this.$style.naturalWidth !== 0 &&
+ this.$style.naturalHeight !== 0
+ ) {
+ this.context.activeTexture(this.context.TEXTURE1);
+ this.context.bindTexture(this.context.TEXTURE_2D, this.textures.style);
+ this.context.texImage2D(
+ this.context.TEXTURE_2D,
+ 0,
+ this.context.ALPHA,
+ this.context.ALPHA,
+ this.context.UNSIGNED_BYTE,
+ this.$style
+ );
+
+ if (redraw) {
+ this.stylizeTemplate();
+ }
+ }
+ }
+
+ downscaleTemplate() {
+ const dimentions = this.getDimentions();
+ const {
+ display: { width, height },
+ } = dimentions;
+ if (!this.context || width === 0 || height === 0) return;
+
+ const downscaleWidth = dimentions.source.width / dimentions.display.width;
+ const downscaleHeight =
+ dimentions.source.height / dimentions.display.height;
+
+ // set size of framebuffer
+ this.context.activeTexture(this.context.TEXTURE0);
+ this.context.bindTexture(this.context.TEXTURE_2D, this.textures.downscaled);
+ this.context.texImage2D(
+ this.context.TEXTURE_2D,
+ 0,
+ this.context.RGBA,
+ width,
+ height,
+ 0,
+ this.context.RGBA,
+ this.context.UNSIGNED_BYTE,
+ null
+ );
+
+ this.context.bindFramebuffer(
+ this.context.FRAMEBUFFER,
+ this.framebuffers.intermediate
+ );
+ this.context.clear(this.context.COLOR_BUFFER_BIT);
+ this.context.viewport(0, 0, width, height);
+
+ const program = this.programs.downscaling.nearestCustom;
+
+ this.context.useProgram(program);
+
+ this.context.uniform2f(
+ this.context.getUniformLocation(program, "u_SampleSize"),
+ Math.max(1, downscaleWidth),
+ Math.max(1, downscaleHeight)
+ );
+ this.context.uniform2f(
+ this.context.getUniformLocation(program, "u_TexelSize"),
+ 1 / width,
+ 1 / height
+ );
+
+ this.context.bindTexture(this.context.TEXTURE_2D, this.textures.source);
+
+ this.context.texImage2D(
+ this.context.TEXTURE_2D,
+ 0,
+ this.context.RGBA,
+ this.context.RGBA,
+ this.context.UNSIGNED_BYTE,
+ this.$imageLoader
+ );
+
+ this.context.drawArrays(this.context.TRIANGLE_STRIP, 0, 4);
+ }
+
+ stylizeTemplate() {
+ this.updateSize();
+
+ const {
+ display: { width, height },
+ } = this.getDimentions();
+
+ if (this.context == null || width === 0 || height === 0) {
+ return;
+ }
+
+ this.context.bindFramebuffer(
+ this.context.FRAMEBUFFER,
+ this.framebuffers.main
+ );
+ this.context.clear(this.context.COLOR_BUFFER_BIT);
+ this.context.viewport(0, 0, width, height);
+
+ this.context.useProgram(this.programs.stylize);
+
+ this.context.uniform2f(
+ this.context.getUniformLocation(this.programs.stylize, "u_TexelSize"),
+ 1 / width,
+ 1 / height
+ );
+
+ this.context.activeTexture(this.context.TEXTURE0);
+ this.context.bindTexture(this.context.TEXTURE_2D, this.textures.downscaled);
+
+ this.context.activeTexture(this.context.TEXTURE1);
+ this.context.bindTexture(this.context.TEXTURE_2D, this.textures.style);
+
+ this.context.drawArrays(this.context.TRIANGLE_STRIP, 0, 4);
+ }
+}
diff --git a/packages/client/src/lib/webgl.ts b/packages/client/src/lib/webgl.ts
new file mode 100644
index 0000000..f8fdb25
--- /dev/null
+++ b/packages/client/src/lib/webgl.ts
@@ -0,0 +1,99 @@
+/**
+ * Utilities for WebGL contexts
+ */
+export class WebGLUtils {
+ context: WebGLRenderingContext;
+
+ constructor(context: WebGLRenderingContext) {
+ this.context = context;
+ }
+
+ /**
+ * Create WebGL texture
+ *
+ * Originates from Pxls
+ *
+ * @returns WebGL texture
+ */
+ createTexture() {
+ const texture = this.context.createTexture();
+ this.context.bindTexture(this.context.TEXTURE_2D, texture);
+ this.context.texParameteri(
+ this.context.TEXTURE_2D,
+ this.context.TEXTURE_WRAP_S,
+ this.context.CLAMP_TO_EDGE
+ );
+ this.context.texParameteri(
+ this.context.TEXTURE_2D,
+ this.context.TEXTURE_WRAP_T,
+ this.context.CLAMP_TO_EDGE
+ );
+ this.context.texParameteri(
+ this.context.TEXTURE_2D,
+ this.context.TEXTURE_MIN_FILTER,
+ this.context.NEAREST
+ );
+ this.context.texParameteri(
+ this.context.TEXTURE_2D,
+ this.context.TEXTURE_MAG_FILTER,
+ this.context.NEAREST
+ );
+ return texture;
+ }
+
+ /**
+ * Create WebGL program
+ *
+ * Originates from Pxls
+ *
+ * @param vertexSource
+ * @param fragmentSource
+ * @returns WebGL program
+ */
+ createProgram(vertexSource: string, fragmentSource: string) {
+ // i believe null is only returned when webgl context is destroyed?
+ // maybe add proper handling here
+ const program = this.context.createProgram()!;
+ this.context.attachShader(
+ program,
+ this.createShader(this.context.VERTEX_SHADER, vertexSource)
+ );
+ this.context.attachShader(
+ program,
+ this.createShader(this.context.FRAGMENT_SHADER, fragmentSource)
+ );
+ this.context.linkProgram(program);
+ if (!this.context.getProgramParameter(program, this.context.LINK_STATUS)) {
+ throw new Error(
+ `Failed to link WebGL template program:\n\n${this.context.getProgramInfoLog(program)}`
+ );
+ }
+ return program;
+ }
+
+ /**
+ * Create WebGL shader
+ *
+ * Originates from Pxls
+ *
+ * @param type
+ * @param source
+ * @returns WebGL shader
+ */
+ createShader(type: number, source: string) {
+ // i believe null is only returned when webgl context is destroyed?
+ // maybe add proper handling here
+ const shader = this.context.createShader(type)!;
+
+ this.context.shaderSource(shader, source);
+ this.context.compileShader(shader);
+
+ if (!this.context.getShaderParameter(shader, this.context.COMPILE_STATUS)) {
+ throw new Error(
+ `Failed to compile WebGL template shader:\n\n${this.context.getShaderInfoLog(shader)}`
+ );
+ }
+
+ return shader;
+ }
+}
diff --git a/packages/client/src/style.scss b/packages/client/src/style.scss
index ff5cba8..9c1c2af 100644
--- a/packages/client/src/style.scss
+++ b/packages/client/src/style.scss
@@ -23,7 +23,7 @@ header#main-header {
display: flex;
flex-direction: row;
box-sizing: border-box;
- z-index: 9999;
+ z-index: 9980;
touch-action: none;
pointer-events: none;
@@ -142,5 +142,58 @@ main {
}
}
+.sidebar {
+ position: fixed;
+ top: 0;
+ z-index: 9998;
+ background-color: #fff;
+ color: #000;
+ height: 100%;
+
+ min-width: 20rem;
+ max-width: 75vw;
+
+ &-right {
+ right: 0;
+ }
+
+ header {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: 5px;
+
+ background-color: rgba(0, 0, 0, 0.05);
+ padding: 5px;
+
+ h1,
+ h2 {
+ padding: 0;
+ margin: 0;
+ font-weight: 600;
+ }
+ h1 {
+ font-size: 1.2rem;
+ }
+ h2 {
+ font-size: 1rem;
+ }
+ }
+
+ section {
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+ padding: 5px 1rem;
+
+ > div {
+ input {
+ margin-right: 5px;
+ }
+ }
+ }
+}
+
@import "./components/Pallete.scss";
+@import "./components/Template.scss";
@import "./board.scss";
diff --git a/packages/lib/src/net.ts b/packages/lib/src/net.ts
index 8eb16b6..7381553 100644
--- a/packages/lib/src/net.ts
+++ b/packages/lib/src/net.ts
@@ -32,6 +32,8 @@ export interface IAppContext {
cursorPosition?: IPosition;
setCursorPosition: (v?: IPosition) => void;
pixels: { available: number };
+ settingsSidebar: boolean;
+ setSettingsSidebar: (v: boolean) => void;
}
export interface IPalleteContext {