From 0dcf417a314fdf439380510cf76e53d01941b7d1 Mon Sep 17 00:00:00 2001
From: thepaperpilot <thepaperpilot@gmail.com>
Date: Thu, 19 Aug 2021 00:25:49 -0500
Subject: [PATCH] Implemented most of the basic features for new Board
 component

---
 package-lock.json                  |  83 ++++++++++++
 package.json                       |   1 +
 src/components/board/Board.vue     |  80 ++++++++++++
 src/components/board/BoardNode.vue | 203 +++++++++++++++++++++++++++++
 src/components/index.ts            |   2 +
 src/game/enums.ts                  |   5 +
 src/game/layers.ts                 |  29 +++++
 src/typings/features/board.d.ts    |  45 +++++++
 src/typings/layer.d.ts             |   4 +
 src/typings/player.d.ts            |  18 +--
 src/typings/theme.d.ts             |  14 +-
 src/util/layers.ts                 |  15 +++
 src/util/proxies.ts                |   1 -
 13 files changed, 490 insertions(+), 10 deletions(-)
 create mode 100644 src/components/board/Board.vue
 create mode 100644 src/components/board/BoardNode.vue
 create mode 100644 src/typings/features/board.d.ts

diff --git a/package-lock.json b/package-lock.json
index 82536ac..b496392 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -13,6 +13,7 @@
         "vue": "^3.2.2",
         "vue-class-component": "^8.0.0-rc.1",
         "vue-next-select": "^2.9.0",
+        "vue-panzoom": "^1.1.6",
         "vue-sortable": "github:Netbel/vue-sortable#master-fix",
         "vue-textarea-autosize": "^1.1.1",
         "vue-transition-expand": "^0.1.0"
@@ -14925,6 +14926,14 @@
       "integrity": "sha1-l6ERlkmyEa0zaR2fn0hqjsn74KM=",
       "dev": true
     },
+    "node_modules/amator": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/amator/-/amator-1.1.0.tgz",
+      "integrity": "sha1-CMa2C8k67Cthu/wMTWd9MDI8wPE=",
+      "dependencies": {
+        "bezier-easing": "^2.0.3"
+      }
+    },
     "node_modules/ansi-colors": {
       "version": "3.2.4",
       "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-3.2.4.tgz",
@@ -15495,6 +15504,11 @@
         "tweetnacl": "^0.14.3"
       }
     },
+    "node_modules/bezier-easing": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/bezier-easing/-/bezier-easing-2.1.0.tgz",
+      "integrity": "sha1-wE3+i5JtbsrKGBPWn/F5t8ICXYY="
+    },
     "node_modules/bfj": {
       "version": "6.1.2",
       "resolved": "https://registry.npmjs.org/bfj/-/bfj-6.1.2.tgz",
@@ -22071,6 +22085,11 @@
       "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
       "dev": true
     },
+    "node_modules/ngraph.events": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/ngraph.events/-/ngraph.events-1.2.1.tgz",
+      "integrity": "sha512-D4C+nXH/RFxioGXQdHu8ELDtC6EaCiNsZtih0IvyGN81OZSUby4jXoJ5+RNWasfsd0FnKxxpAROyUMzw64QNsw=="
+    },
     "node_modules/nice-try": {
       "version": "1.0.5",
       "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz",
@@ -22736,6 +22755,16 @@
       "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
       "dev": true
     },
+    "node_modules/panzoom": {
+      "version": "9.4.2",
+      "resolved": "https://registry.npmjs.org/panzoom/-/panzoom-9.4.2.tgz",
+      "integrity": "sha512-sQLr0t6EmNFXpZHag0HQVtOKqF9xjF7iZdgWg3Ss1o7uh2QZLvcrz7S0Cl8M0d2TkPZ69JfPJdknXN3I0e/2aQ==",
+      "dependencies": {
+        "amator": "^1.1.0",
+        "ngraph.events": "^1.2.1",
+        "wheel": "^1.0.0"
+      }
+    },
     "node_modules/parallel-transform": {
       "version": "1.2.0",
       "resolved": "https://registry.npmjs.org/parallel-transform/-/parallel-transform-1.2.0.tgz",
@@ -27245,6 +27274,14 @@
         "vue": "^3.0.0"
       }
     },
+    "node_modules/vue-panzoom": {
+      "version": "1.1.6",
+      "resolved": "https://registry.npmjs.org/vue-panzoom/-/vue-panzoom-1.1.6.tgz",
+      "integrity": "sha512-yEE60C/gnc5NGL6YBD++CErD820va7fkBJE5dCWZZzXX2aMGklj/UKmtqu1u5xDkuOIjnGUr412LNHwOOE711w==",
+      "dependencies": {
+        "panzoom": "^9.4.1"
+      }
+    },
     "node_modules/vue-sortable": {
       "version": "0.1.3",
       "resolved": "git+ssh://git@github.com/Netbel/vue-sortable.git#f4d4870ace71ea59bd79252eb2ec1cf6bfb02fe7",
@@ -28083,6 +28120,11 @@
         "node": ">=0.8.0"
       }
     },
+    "node_modules/wheel": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/wheel/-/wheel-1.0.0.tgz",
+      "integrity": "sha512-XiCMHibOiqalCQ+BaNSwRoZ9FDTAvOsXxGHXChBugewDj7HC8VBIER71dEOiRH1fSdLbRCQzngKTSiZ06ZQzeA=="
+    },
     "node_modules/which": {
       "version": "1.3.1",
       "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz",
@@ -31140,6 +31182,14 @@
       "integrity": "sha1-l6ERlkmyEa0zaR2fn0hqjsn74KM=",
       "dev": true
     },
+    "amator": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/amator/-/amator-1.1.0.tgz",
+      "integrity": "sha1-CMa2C8k67Cthu/wMTWd9MDI8wPE=",
+      "requires": {
+        "bezier-easing": "^2.0.3"
+      }
+    },
     "ansi-colors": {
       "version": "3.2.4",
       "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-3.2.4.tgz",
@@ -31576,6 +31626,11 @@
         "tweetnacl": "^0.14.3"
       }
     },
+    "bezier-easing": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/bezier-easing/-/bezier-easing-2.1.0.tgz",
+      "integrity": "sha1-wE3+i5JtbsrKGBPWn/F5t8ICXYY="
+    },
     "bfj": {
       "version": "6.1.2",
       "resolved": "https://registry.npmjs.org/bfj/-/bfj-6.1.2.tgz",
@@ -36803,6 +36858,11 @@
       "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
       "dev": true
     },
+    "ngraph.events": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/ngraph.events/-/ngraph.events-1.2.1.tgz",
+      "integrity": "sha512-D4C+nXH/RFxioGXQdHu8ELDtC6EaCiNsZtih0IvyGN81OZSUby4jXoJ5+RNWasfsd0FnKxxpAROyUMzw64QNsw=="
+    },
     "nice-try": {
       "version": "1.0.5",
       "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz",
@@ -37323,6 +37383,16 @@
       "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
       "dev": true
     },
+    "panzoom": {
+      "version": "9.4.2",
+      "resolved": "https://registry.npmjs.org/panzoom/-/panzoom-9.4.2.tgz",
+      "integrity": "sha512-sQLr0t6EmNFXpZHag0HQVtOKqF9xjF7iZdgWg3Ss1o7uh2QZLvcrz7S0Cl8M0d2TkPZ69JfPJdknXN3I0e/2aQ==",
+      "requires": {
+        "amator": "^1.1.0",
+        "ngraph.events": "^1.2.1",
+        "wheel": "^1.0.0"
+      }
+    },
     "parallel-transform": {
       "version": "1.2.0",
       "resolved": "https://registry.npmjs.org/parallel-transform/-/parallel-transform-1.2.0.tgz",
@@ -41002,6 +41072,14 @@
       "integrity": "sha512-GjX4pHqZXXitquDeSAtLaf85jXdMUOKyCNzo+EF3xRr4DebGwbST4CtmRvL0TX3EhwLHQjUlAc3JcJX+azpLHg==",
       "requires": {}
     },
+    "vue-panzoom": {
+      "version": "1.1.6",
+      "resolved": "https://registry.npmjs.org/vue-panzoom/-/vue-panzoom-1.1.6.tgz",
+      "integrity": "sha512-yEE60C/gnc5NGL6YBD++CErD820va7fkBJE5dCWZZzXX2aMGklj/UKmtqu1u5xDkuOIjnGUr412LNHwOOE711w==",
+      "requires": {
+        "panzoom": "^9.4.1"
+      }
+    },
     "vue-sortable": {
       "version": "git+ssh://git@github.com/Netbel/vue-sortable.git#f4d4870ace71ea59bd79252eb2ec1cf6bfb02fe7",
       "from": "vue-sortable@github:Netbel/vue-sortable#master-fix",
@@ -41687,6 +41765,11 @@
       "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==",
       "dev": true
     },
+    "wheel": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/wheel/-/wheel-1.0.0.tgz",
+      "integrity": "sha512-XiCMHibOiqalCQ+BaNSwRoZ9FDTAvOsXxGHXChBugewDj7HC8VBIER71dEOiRH1fSdLbRCQzngKTSiZ06ZQzeA=="
+    },
     "which": {
       "version": "1.3.1",
       "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz",
diff --git a/package.json b/package.json
index e65641f..d09e8ea 100644
--- a/package.json
+++ b/package.json
@@ -13,6 +13,7 @@
     "vue": "^3.2.2",
     "vue-class-component": "^8.0.0-rc.1",
     "vue-next-select": "^2.9.0",
+    "vue-panzoom": "^1.1.6",
     "vue-sortable": "github:Netbel/vue-sortable#master-fix",
     "vue-textarea-autosize": "^1.1.1",
     "vue-transition-expand": "^0.1.0"
diff --git a/src/components/board/Board.vue b/src/components/board/Board.vue
new file mode 100644
index 0000000..f490b60
--- /dev/null
+++ b/src/components/board/Board.vue
@@ -0,0 +1,80 @@
+<template>
+    <panZoom
+        :style="style"
+        selector="#g1"
+        @init="onInit"
+        :options="{ initialZoom: 1, minZoom: 0.1, maxZoom: 10 }"
+        ref="stage"
+    >
+        <svg class="stage" width="100%" height="100%">
+            <g id="g1">
+                <BoardNode
+                    v-for="(node, nodeIndex) in nodes"
+                    :key="nodeIndex"
+                    :index="nodeIndex"
+                    :node="node"
+                    :nodeType="board.types[node.type]"
+                />
+            </g>
+        </svg>
+    </panZoom>
+</template>
+
+<script lang="ts">
+import { layers } from "@/game/layers";
+import player from "@/game/player";
+import { Board } from "@/typings/features/board";
+import { InjectLayerMixin } from "@/util/vue";
+import { defineComponent } from "vue";
+
+export default defineComponent({
+    name: "Board",
+    mixins: [InjectLayerMixin],
+    props: {
+        id: {
+            type: [Number, String],
+            required: true
+        }
+    },
+    provide() {
+        return {
+            getZoomLevel: () => (this.$refs.stage as any).$panZoomInstance.getTransform().scale
+        };
+    },
+    computed: {
+        board(): Board {
+            return layers[this.layer].boards!.data[this.id];
+        },
+        style(): Array<Partial<CSSStyleDeclaration> | undefined> {
+            return [
+                {
+                    width: this.board.width,
+                    height: this.board.height
+                },
+                layers[this.layer].componentStyles?.board,
+                this.board.style
+            ];
+        },
+        nodes() {
+            return player.layers[this.layer].boards[this.id];
+        }
+    },
+    methods: {
+        onInit: function(panzoomInstance) {
+            panzoomInstance.setTransformOrigin(null);
+        }
+    }
+});
+</script>
+
+<style>
+.vue-pan-zoom-scene {
+    width: 100%;
+    height: 100%;
+    cursor: move;
+}
+
+#g1 {
+    transition-duration: 0s;
+}
+</style>
diff --git a/src/components/board/BoardNode.vue b/src/components/board/BoardNode.vue
new file mode 100644
index 0000000..bec3081
--- /dev/null
+++ b/src/components/board/BoardNode.vue
@@ -0,0 +1,203 @@
+<template>
+    <g
+        class="boardnode"
+        :style="{ opacity: dragging ? 0.5 : 1 }"
+        :transform="`translate(${position.x},${position.y})`"
+        @mousedown="mouseDown"
+    >
+        <circle :r="size + 8" :fill="backgroundColor" stroke="#0F03" :stroke-width="2" />
+
+        <circle :r="size" :fill="fillColor" :stroke="outlineColor" :stroke-width="4" />
+
+        <circle
+            v-if="progressDisplay === ProgressDisplay.Fill"
+            :r="size * progress"
+            :fill="progressColor"
+        />
+        <circle
+            v-else
+            :r="size + 4.5"
+            class="progressRing"
+            fill="transparent"
+            :stroke-dasharray="(size + 4.5) * 2 * Math.PI"
+            :stroke-width="5"
+            :stroke-dashoffset="(size + 4.5) * 2 * Math.PI - progress * (size + 4.5) * 2 * Math.PI"
+            :stroke="progressColor"
+        />
+
+        <text :fill="titleColor" class="node-title">{{ title }}</text>
+    </g>
+</template>
+
+<script lang="ts">
+import themes from "@/data/themes";
+import { ProgressDisplay } from "@/game/enums";
+import player from "@/game/player";
+import { BoardNode, NodeType } from "@/typings/features/board";
+import { InjectLayerMixin } from "@/util/vue";
+import { defineComponent, PropType } from "vue";
+
+// TODO will blindly use any T given (can't restrict it to S[R] because I can't figure out how
+//  to make it support narrowing the return type)
+function getTypeProperty<T, S extends NodeType, R extends keyof S>(
+    nodeType: S,
+    node: BoardNode,
+    property: R
+): S[R] extends Pick<
+    S,
+    {
+        [K in keyof S]-?: undefined extends S[K] ? never : K;
+    }[keyof S]
+>
+    ? T
+    : T | undefined {
+    return typeof nodeType[property] === "function"
+        ? (nodeType[property] as (node: BoardNode) => T)(node)
+        : (nodeType[property] as T);
+}
+
+export default defineComponent({
+    name: "BoardNode",
+    mixins: [InjectLayerMixin],
+    inject: ["getZoomLevel"],
+    data() {
+        return {
+            ProgressDisplay,
+            lastMousePosition: { x: 0, y: 0 },
+            dragged: { x: 0, y: 0 },
+            dragging: false
+        };
+    },
+    props: {
+        index: {
+            type: Number,
+            required: true
+        },
+        node: {
+            type: Object as PropType<BoardNode>,
+            required: true
+        },
+        nodeType: {
+            type: Object as PropType<NodeType>,
+            required: true
+        }
+    },
+    computed: {
+        draggable(): boolean {
+            return getTypeProperty(this.nodeType, this.node, "draggable");
+        },
+        position(): { x: number; y: number } {
+            return this.draggable && this.dragging
+                ? {
+                      x: this.node.position.x + Math.round(this.dragged.x / 25) * 25,
+                      y: this.node.position.y + Math.round(this.dragged.y / 25) * 25
+                  }
+                : this.node.position;
+        },
+        size(): number {
+            return getTypeProperty(this.nodeType, this.node, "size");
+        },
+        title(): string {
+            return getTypeProperty(this.nodeType, this.node, "title");
+        },
+        progress(): number {
+            return getTypeProperty(this.nodeType, this.node, "progress") || 0;
+        },
+        backgroundColor(): string {
+            return themes[player.theme].variables["--background"];
+        },
+        outlineColor(): string {
+            return (
+                getTypeProperty(this.nodeType, this.node, "outlineColor") ||
+                themes[player.theme].variables["--separator"]
+            );
+        },
+        fillColor(): string {
+            return (
+                getTypeProperty(this.nodeType, this.node, "fillColor") ||
+                themes[player.theme].variables["--secondary-background"]
+            );
+        },
+        progressColor(): string {
+            return getTypeProperty(this.nodeType, this.node, "progressColor") || "none";
+        },
+        titleColor(): string {
+            return (
+                getTypeProperty(this.nodeType, this.node, "titleColor") ||
+                themes[player.theme].variables["--color"]
+            );
+        },
+        progressDisplay(): ProgressDisplay {
+            return (
+                getTypeProperty(this.nodeType, this.node, "progressDisplay") ||
+                ProgressDisplay.Outline
+            );
+        }
+    },
+    methods: {
+        mouseDown(e: MouseEvent) {
+            if (this.draggable) {
+                e.preventDefault();
+                e.stopPropagation();
+
+                this.lastMousePosition = {
+                    x: e.clientX,
+                    y: e.clientY
+                };
+                this.dragged = { x: 0, y: 0 };
+
+                this.dragging = true;
+                document.onmouseup = this.mouseUp;
+                document.onmousemove = this.mouseMove;
+            }
+        },
+        mouseMove(e: MouseEvent) {
+            if (this.draggable && this.dragging) {
+                e.preventDefault();
+                e.stopPropagation();
+
+                const zoom = (this.getZoomLevel as () => number)();
+                console.log(zoom);
+                this.dragged.x += (e.clientX - this.lastMousePosition.x) / zoom;
+                this.dragged.y += (e.clientY - this.lastMousePosition.y) / zoom;
+                this.lastMousePosition = {
+                    x: e.clientX,
+                    y: e.clientY
+                };
+            }
+        },
+        mouseUp(e: MouseEvent) {
+            if (this.draggable && this.dragging) {
+                e.preventDefault();
+                e.stopPropagation();
+
+                let node = player.layers[this.nodeType.layer].boards[this.nodeType.id][this.index];
+                node.position.x += Math.round(this.dragged.x / 25) * 25;
+                node.position.y += Math.round(this.dragged.y / 25) * 25;
+
+                this.dragging = false;
+                document.onmouseup = null;
+                document.onmousemove = null;
+            }
+        }
+    }
+});
+</script>
+
+<style scoped>
+.boardnode {
+    cursor: pointer;
+    transition-duration: 0s;
+}
+
+.node-title {
+    text-anchor: middle;
+    dominant-baseline: middle;
+    font-family: monospace;
+    font-size: 200%;
+}
+
+.progressRing {
+    transform: rotate(-90deg);
+}
+</style>
diff --git a/src/components/index.ts b/src/components/index.ts
index 9619597..408338f 100644
--- a/src/components/index.ts
+++ b/src/components/index.ts
@@ -6,6 +6,7 @@ import VueTextareaAutosize from "vue-textarea-autosize";
 import Sortable from "vue-sortable";
 import VueNextSelect from "vue-next-select";
 import "vue-next-select/dist/index.css";
+import panZoom from "vue-panzoom";
 import { App } from "vue";
 
 export function registerComponents(vue: App): void {
@@ -23,4 +24,5 @@ export function registerComponents(vue: App): void {
     vue.use(VueTextareaAutosize);
     vue.use(Sortable);
     vue.component("vue-select", VueNextSelect);
+    vue.use(panZoom);
 }
diff --git a/src/game/enums.ts b/src/game/enums.ts
index f175079..a9047d3 100644
--- a/src/game/enums.ts
+++ b/src/game/enums.ts
@@ -28,3 +28,8 @@ export enum ImportingStatus {
     WrongID = "WRONG_ID",
     Force = "FORCE"
 }
+
+export enum ProgressDisplay {
+    Outline = "Outline",
+    Fill = "Fill"
+}
diff --git a/src/game/layers.ts b/src/game/layers.ts
index 41c688e..d20d094 100644
--- a/src/game/layers.ts
+++ b/src/game/layers.ts
@@ -1,5 +1,6 @@
 import { CacheableFunction } from "@/typings/cacheableFunction";
 import { Achievement } from "@/typings/features/achievement";
+import { Board } from "@/typings/features/board";
 import { Buyable } from "@/typings/features/buyable";
 import { Challenge } from "@/typings/features/challenge";
 import { Clickable } from "@/typings/features/clickable";
@@ -23,6 +24,7 @@ import Decimal, { DecimalSource } from "@/util/bignum";
 import { isFunction } from "@/util/common";
 import {
     defaultLayerProperties,
+    getStartingBoards,
     getStartingBuyables,
     getStartingChallenges,
     getStartingClickables,
@@ -32,6 +34,7 @@ import { createGridProxy, createLayerProxy } from "@/util/proxies";
 import { applyPlayerData } from "@/util/save";
 import clone from "lodash.clonedeep";
 import { isRef } from "vue";
+import { ProgressDisplay } from "./enums";
 import { default as playerProxy } from "./player";
 
 export const layers: Record<string, Readonly<Layer>> = {};
@@ -70,6 +73,7 @@ export function addLayer(layer: RawLayer, player?: Partial<PlayerData>): void {
             buyables: getStartingBuyables(layer.buyables?.data),
             clickables: getStartingClickables(layer.clickables?.data),
             challenges: getStartingChallenges(layer.challenges?.data),
+            boards: getStartingBoards(layer.boards?.data),
             grids: {},
             confirmRespecBuyables: false,
             ...(layer.startData?.() || {})
@@ -423,6 +427,31 @@ export function addLayer(layer: RawLayer, player?: Partial<PlayerData>): void {
             layer.grids.data[id] = createGridProxy(layer.grids.data[id]) as Grid;
         }
     }
+    if (layer.boards) {
+        setupFeatures<NonNullable<RawLayer["boards"]>, Board>(layer.id, layer.boards);
+        for (const id in layer.boards.data) {
+            setDefault(layer.boards.data[id], "width", "100%");
+            setDefault(layer.boards.data[id], "height", "400px");
+            for (const nodeType in layer.boards.data[id].types) {
+                layer.boards.data[id].types[nodeType].layer = layer.id;
+                layer.boards.data[id].types[nodeType].id = id;
+                layer.boards.data[id].types[nodeType].type = nodeType;
+                setDefault(layer.boards.data[id].types[nodeType], "size", 50);
+                setDefault(layer.boards.data[id].types[nodeType], "draggable", false);
+                setDefault(layer.boards.data[id].types[nodeType], "canAccept", false);
+                setDefault(
+                    layer.boards.data[id].types[nodeType],
+                    "progressDisplay",
+                    ProgressDisplay.Fill
+                );
+                setDefault(layer.boards.data[id].types[nodeType], "nodes", function() {
+                    return playerProxy.layers[this.layer].boards[this.id].filter(
+                        node => node.type === this.type
+                    );
+                });
+            }
+        }
+    }
     if (layer.subtabs) {
         layer.activeSubtab = function() {
             if (
diff --git a/src/typings/features/board.d.ts b/src/typings/features/board.d.ts
new file mode 100644
index 0000000..520411e
--- /dev/null
+++ b/src/typings/features/board.d.ts
@@ -0,0 +1,45 @@
+import { State } from "../state";
+import { Feature, RawFeature } from "./feature";
+
+export interface BoardNode {
+    position: {
+        x: number;
+        y: number;
+    };
+    type: string;
+    data?: State;
+}
+
+export interface CardOption {
+    text: string;
+    selected: (node: BoardNode) => void;
+}
+
+export interface Board extends Feature {
+    startNodes: () => BoardNode[];
+    style?: Partial<CSSStyleDeclaration>;
+    height: string;
+    width: string;
+    types: Record<string, NodeType>;
+}
+
+export type RawBoard = Omit<RawFeature<Board>, "types"> & {
+    startNodes: () => BoardNode[];
+    types: Record<string, RawFeature<NodeType>>;
+};
+
+export interface NodeType extends Feature {
+    tooltip?: string | ((node: BoardNode) => string);
+    title: string | ((node: BoardNode) => string);
+    size: number | ((node: BoardNode) => number);
+    draggable: boolean | ((node: BoardNode) => boolean);
+    canAccept: boolean | ((node: BoardNode, otherNode: BoardNode) => boolean);
+    progress?: number | ((node: BoardNode) => number);
+    progressDisplay: ProgressDisplay | ((node: BoardNode) => ProgressDisplay);
+    progressColor: string | ((node: BoardNode) => string);
+    fillColor?: string | ((node: BoardNode) => string);
+    outlineColor?: string | ((node: BoardNode) => string);
+    titleColor?: string | ((node: BoardNode) => string);
+    onClick: (node: BoardNode) => void;
+    nodes: BoardNode[];
+}
diff --git a/src/typings/layer.d.ts b/src/typings/layer.d.ts
index 9059174..9dfd56a 100644
--- a/src/typings/layer.d.ts
+++ b/src/typings/layer.d.ts
@@ -3,6 +3,7 @@ import Decimal, { DecimalSource } from "@/util/bignum";
 import { CoercableComponent } from "./component";
 import { Achievement } from "./features/achievement";
 import { Bar } from "./features/bar";
+import { Board, RawBoard } from "./features/board";
 import { Buyable } from "./features/buyable";
 import { Challenge } from "./features/challenge";
 import { Clickable } from "./features/clickable";
@@ -31,6 +32,7 @@ export interface RawLayer extends RawFeature<Layer> {
     challenges?: RawGridFeatures<NonNullable<Layer["challenges"]>, Challenge>;
     clickables?: RawGridFeatures<NonNullable<Layer["clickables"]>, Clickable>;
     grids?: RawFeatures<NonNullable<Layer["grids"]>, Grid>;
+    boards?: RawFeatures<NonNullable<Layer["boards"]>, Board, RawBoard>;
     hotkeys?: RawFeature<Hotkey>[];
     infoboxes?: RawFeatures<NonNullable<Layer["infoboxes"]>, Infoboxe>;
     milestones?: RawFeatures<NonNullable<Layer["milestones"]>, Milestone>;
@@ -108,6 +110,7 @@ export interface Layer extends Feature {
         showMasterButton?: boolean;
     };
     grids?: Features<Grid>;
+    boards?: Features<Board>;
     hotkeys?: Hotkey[];
     infoboxes?: Features<Infobox>;
     milestones?: Features<Milestone>;
@@ -142,5 +145,6 @@ export interface ComponentStyles {
     "prestige-button"?: Partial<CSSStyleDeclaration>;
     "respec-button"?: Partial<CSSStyleDeclaration>;
     upgrade?: Partial<CSSStyleDeclaration>;
+    board?: Partial<CSSStyleDeclaration>;
     "tab-button"?: Partial<CSSStyleDeclaration>;
 }
diff --git a/src/typings/player.d.ts b/src/typings/player.d.ts
index f45c2cc..b75d2f3 100644
--- a/src/typings/player.d.ts
+++ b/src/typings/player.d.ts
@@ -1,6 +1,7 @@
 import { Themes } from "@/data/themes";
 import { DecimalSource } from "@/lib/break_eternity";
 import Decimal from "@/util/bignum";
+import { BoardNode } from "./features/board";
 import { MilestoneDisplay } from "./features/milestone";
 import { State } from "./state";
 
@@ -53,14 +54,15 @@ export interface LayerSaveData {
     unlockOrder?: number;
     forceTooltip?: boolean;
     resetTime: Decimal;
-    upgrades: Array<string | number>;
-    achievements: Array<string | number>;
-    milestones: Array<string | number>;
-    infoboxes: Record<string | number, boolean>;
-    buyables: Record<string | number, Decimal>;
-    clickables: Record<string | number, State>;
-    challenges: Record<string | number, Decimal>;
-    grids: Record<string | number, Record<string, number, State>>;
+    upgrades: Array<string>;
+    achievements: Array<string>;
+    milestones: Array<string>;
+    infoboxes: Record<string, boolean>;
+    buyables: Record<string, Decimal>;
+    clickables: Record<string, State>;
+    challenges: Record<string, Decimal>;
+    grids: Record<string, Record<string, State>>;
+    boards: Record<string, Array<BoardNode>>;
     confirmRespecBuyables: boolean;
     [index: string]: unknown;
 }
diff --git a/src/typings/theme.d.ts b/src/typings/theme.d.ts
index d05ddbb..b3236e4 100644
--- a/src/typings/theme.d.ts
+++ b/src/typings/theme.d.ts
@@ -1,6 +1,18 @@
 export interface Theme {
     variables: {
-        [index: string]: string;
+        "--background": string;
+        "--background-tooltip": string;
+        "--secondary-background": string;
+        "--color": string;
+        "--points": string;
+        "--locked": string;
+        "--bought": string;
+        "--link": string;
+        "--separator": string;
+        "--border-radius": string;
+        "--danger": string;
+        "--modal-border": string;
+        "--feature-margin": string;
     };
     stackedInfoboxes: boolean;
     floatingTabs: boolean;
diff --git a/src/util/layers.ts b/src/util/layers.ts
index 5bbb2e2..519ca43 100644
--- a/src/util/layers.ts
+++ b/src/util/layers.ts
@@ -1,6 +1,7 @@
 import { hotkeys, layers } from "@/game/layers";
 import player from "@/game/player";
 import { CacheableFunction } from "@/typings/cacheableFunction";
+import { Board, BoardNode, RawBoard } from "@/typings/features/board";
 import { Buyable } from "@/typings/features/buyable";
 import { Challenge } from "@/typings/features/challenge";
 import { Clickable } from "@/typings/features/clickable";
@@ -70,6 +71,20 @@ export function getStartingChallenges(
         : {};
 }
 
+export function getStartingBoards(
+    boards?: Record<string, Board> | Record<string, RawBoard> | undefined
+): Record<string, Array<BoardNode>> {
+    return boards
+        ? Object.keys(boards).reduce((acc: Record<string, Array<BoardNode>>, curr: string): Record<
+              string,
+              Array<BoardNode>
+          > => {
+              acc[curr] = boards[curr].startNodes?.() || [];
+              return acc;
+          }, {})
+        : {};
+}
+
 export function resetLayerData(layer: string, keep: Array<string> = []): void {
     keep.push("unlocked", "forceTooltip", "noRespecConfirm");
     const keptData = keep.reduce((acc: Record<string, any>, curr: string): Record<string, any> => {
diff --git a/src/util/proxies.ts b/src/util/proxies.ts
index 57fa533..83c1c58 100644
--- a/src/util/proxies.ts
+++ b/src/util/proxies.ts
@@ -13,7 +13,6 @@ export function createLayerProxy(object: Record<string, any>): Record<string, an
     return objectProxy;
 }
 
-// TODO cache grid values? Currently they'll be calculated every render they're visible
 export function createGridProxy(object: Record<string, any>): Record<string, any> {
     if (object.isProxy) {
         console.warn(