Profectus grid feature

Profectus has had a Grid feature to mimic TMT’s grid. In 0.7 it got removed due to not really offering a benefit over . If you preferred working with grids though, here’s a 0.7 compatible implementation. It has some improvements over the 0.6 grid, like removing the 100 column limit by storing the cell ID as discrete column and row numbers.

/* eslint-disable @typescript-eslint/no-explicit-any */
import Column from "components/layout/Column.vue";
import Row from "components/layout/Row.vue";
import Clickable from "features/clickables/Clickable.vue";
import { getUniqueID, Visibility } from "features/feature";
import type { Persistent, State } from "game/persistence";
import { persistent } from "game/persistence";
import { isFunction } from "util/common";
import { MaybeGetter, processGetter } from "util/computed";
import { createLazyProxy } from "util/proxies";
import {
    isJSXElement,
    render,
    Renderable,
    VueFeature,
    vueFeatureMixin,
    VueFeatureOptions
} from "util/vue";
import type { CSSProperties, MaybeRef, MaybeRefOrGetter, Ref } from "vue";
import { computed, unref } from "vue";

/** A symbol used to identify {@link Grid} features. */
export const GridType = Symbol("Grid");

/** A type representing a MaybeRefOrGetter value for a cell in the grid. */
export type CellMaybeRefOrGetter<T> =
    | MaybeRefOrGetter<T>
    | ((row: number, col: number, state: State) => T);
export type ProcessedCellRefOrGetter<T> =
    | MaybeRef<T>
    | ((row: number, col: number, state: State) => T);

/**
 * Represents a cell within a grid. These properties will typically be accessed via a cell proxy that calls functions on the grid to get the properties for a specific cell.
 * @see {@link createGridProxy}
 */
export interface GridCell extends VueFeature {
    /** Which roe in the grid this cell is from. */
    row: number;
    /** Which col in the grid this cell is from. */
    col: number;
    /** Whether this cell can be clicked. */
    canClick: boolean;
    /** The initial persistent state of this cell. */
    startState: State;
    /** The persistent state of this cell. */
    state: State;
    /** The main text that appears in the display. */
    display: MaybeGetter<Renderable>;
    /** A function that is called when the cell is clicked. */
    onClick?: (e?: MouseEvent | TouchEvent) => void;
    /** A function that is called when the cell is held down. */
    onHold?: VoidFunction;
}

/**
 * An object that configures a {@link Grid}.
 */
export interface GridOptions extends VueFeatureOptions {
    /** The number of rows in the grid. */
    rows: MaybeRefOrGetter<number>;
    /** The number of columns in the grid. */
    cols: MaybeRefOrGetter<number>;
    /** A getter for the visibility of a cell. */
    getVisibility?: CellMaybeRefOrGetter<Visibility | boolean>;
    /** A getter for if a cell can be clicked. */
    getCanClick?: CellMaybeRefOrGetter<boolean>;
    /** A getter for the initial persistent state of a cell. */
    getStartState: MaybeRefOrGetter<State> | ((row: number, col: number) => State);
    /** A getter for the CSS styles for a cell. */
    getStyle?: CellMaybeRefOrGetter<CSSProperties>;
    /** A getter for the CSS classes for a cell. */
    getClasses?: CellMaybeRefOrGetter<Record<string, boolean>>;
    /** A getter for the display component for a cell. */
    getDisplay:
        | Renderable
        | ((row: number, col: number, state: State) => Renderable)
        | {
              getTitle?: Renderable | ((row: number, col: number, state: State) => Renderable);
              getDescription: Renderable | ((row: number, col: number, state: State) => Renderable);
          };
    /** A function that is called when a cell is clicked. */
    onClick?: (row: number, col: number, state: State, e?: MouseEvent | TouchEvent) => void;
    /** A function that is called when a cell is held down. */
    onHold?: (row: number, col: number, state: State) => void;
}

/** An object that represents a feature that is a grid of cells that all behave according to the same rules. */
export interface Grid extends VueFeature {
    /** A function that is called when a cell is clicked. */
    onClick?: (row: number, col: number, state: State, e?: MouseEvent | TouchEvent) => void;
    /** A function that is called when a cell is held down. */
    onHold?: (row: number, col: number, state: State) => void;
    /** A getter for determine the visibility of a cell. */
    getVisibility?: ProcessedCellRefOrGetter<Visibility | boolean>;
    /** A getter for determine if a cell can be clicked. */
    getCanClick?: ProcessedCellRefOrGetter<boolean>;
    /** The number of rows in the grid. */
    rows: MaybeRef<number>;
    /** The number of columns in the grid. */
    cols: MaybeRef<number>;
    /** A getter for the initial persistent state of a cell. */
    getStartState: MaybeRef<State> | ((row: number, col: number) => State);
    /** A getter for the CSS styles for a cell. */
    getStyle?: ProcessedCellRefOrGetter<CSSProperties>;
    /** A getter for the CSS classes for a cell. */
    getClasses?: ProcessedCellRefOrGetter<Record<string, boolean>>;
    /** A getter for the display component for a cell. */
    getDisplay: Renderable | ((row: number, col: number, state: State) => Renderable);
    /** Get the auto-generated ID for identifying a specific cell of this grid that appears in the DOM. Will not persist between refreshes or updates. */
    getID: (row: number, col: number, state: State) => string;
    /** Get the persistent state of the given cell. */
    getState: (row: number, col: number) => State;
    /** Set the persistent state of the given cell. */
    setState: (row: number, col: number, state: State) => void;
    /** A dictionary of cells within this grid. */
    cells: GridCell[][];
    /** The persistent state of this grid, which is a dictionary of cell states. */
    cellState: Persistent<Record<number, Record<number, State>>>;
    /** A symbol that helps identify features of the same type. */
    type: typeof GridType;
}

function getCellRowHandler(grid: Grid, row: number) {
    return new Proxy({} as GridCell[], {
        get(target, key) {
            if (key === "length") {
                return unref(grid.cols);
            }
            if (typeof key !== "number" && typeof key !== "string") {
                return;
            }

            const keyNum = typeof key === "number" ? key : parseInt(key);
            if (Number.isFinite(keyNum) && keyNum < unref(grid.cols)) {
                if (keyNum in target) {
                    return target[keyNum];
                }
                return (target[keyNum] = getCellHandler(grid, row, keyNum));
            }
        },
        set(target, key, value) {
            console.warn("Cannot set grid cells", target, key, value);
            return false;
        },
        ownKeys() {
            return [...new Array(unref(grid.cols)).fill(0).map((_, i) => "" + i), "length"];
        },
        has(target, key) {
            if (key === "length") {
                return true;
            }
            if (typeof key !== "number" && typeof key !== "string") {
                return false;
            }
            const keyNum = typeof key === "number" ? key : parseInt(key);
            if (!Number.isFinite(keyNum) || keyNum >= unref(grid.cols)) {
                return false;
            }
            return true;
        },
        getOwnPropertyDescriptor(target, key) {
            if (typeof key !== "number" && typeof key !== "string") {
                return;
            }
            const keyNum = typeof key === "number" ? key : parseInt(key);
            if (key !== "length" && (!Number.isFinite(keyNum) || keyNum >= unref(grid.cols))) {
                return;
            }
            return {
                configurable: true,
                enumerable: true,
                writable: false
            };
        }
    });
}

/**
 * Returns traps for a proxy that will get the properties for the specified cell
 * @param id The grid cell ID to get properties from.
 * @see {@link getGridHandler}
 * @see {@link createGridProxy}
 */
function getCellHandler(grid: Grid, row: number, col: number): GridCell {
    const keys = [
        "id",
        "visibility",
        "classes",
        "style",
        "components",
        "wrappers",
        VueFeature,
        "row",
        "col",
        "canClick",
        "startState",
        "state",
        "title",
        "display",
        "onClick",
        "onHold"
    ] as const;
    const cache: Record<string, Ref<unknown>> = {};
    return new Proxy({} as GridCell, {
        // The typing in this function is absolutely atrocious in order to support custom properties
        get(target, key, receiver) {
            switch (key) {
                case "wrappers":
                    return [];
                case VueFeature:
                    return true;
                case "row":
                    return row;
                case "col":
                    return col;
                case "startState": {
                    if (typeof grid.getStartState === "function") {
                        return grid.getStartState(row, col);
                    }
                    return unref(grid.getStartState);
                }
                case "state": {
                    return grid.getState(row, col);
                }
                case "id":
                    return (target.id = target.id ?? getUniqueID("gridcell"));
                case "components":
                    return [
                        computed(() => (
                            <Clickable
                                onClick={receiver.onClick}
                                onHold={receiver.onHold}
                                display={receiver.display}
                                canClick={receiver.canClick}
                            />
                        ))
                    ];
            }

            if (typeof key === "symbol") {
                return (grid as any)[key];
            }

            key = key.slice(0, 1).toUpperCase() + key.slice(1);

            let prop = (grid as any)[`get${key}`];
            if (isFunction(prop)) {
                if (!(key in cache)) {
                    cache[key] = computed(() =>
                        prop.call(receiver, row, col, grid.getState(row, col))
                    );
                }
                return cache[key].value;
            } else if (prop != null) {
                return unref(prop);
            }

            prop = (grid as any)[`on${key}`];
            if (isFunction(prop)) {
                return () => prop.call(receiver, row, col, grid.getState(row, col));
            } else if (prop != null) {
                return prop;
            }

            // Revert key change
            key = key.slice(0, 1).toLowerCase() + key.slice(1);
            prop = (grid as any)[key];

            if (isFunction(prop)) {
                return () => prop.call(receiver, row, col, grid.getState(row, col));
            }

            return (grid as any)[key];
        },
        set(target, key, value) {
            if (typeof key !== "string") {
                return false;
            }
            key = `set${key.slice(0, 1).toUpperCase() + key.slice(1)}`;
            if (key in grid && isFunction((grid as any)[key]) && (grid as any)[key].length <= 3) {
                (grid as any)[key].call(grid, row, col, value);
                return true;
            } else {
                console.warn(`No setter for "${key}".`, target);
                return false;
            }
        },
        ownKeys() {
            return keys;
        },
        has(target, key) {
            return (keys as readonly (string | symbol)[]).includes(key);
        },
        getOwnPropertyDescriptor(target, key) {
            if ((keys as readonly (string | symbol)[]).includes(key)) {
                return {
                    configurable: true,
                    enumerable: true,
                    writable: false
                };
            }
        }
    });
}

function convertCellMaybeRefOrGetter<T>(
    value: NonNullable<CellMaybeRefOrGetter<T>>
): ProcessedCellRefOrGetter<T>;
function convertCellMaybeRefOrGetter<T>(
    value: CellMaybeRefOrGetter<T> | undefined
): ProcessedCellRefOrGetter<T> | undefined;
function convertCellMaybeRefOrGetter<T>(
    value: CellMaybeRefOrGetter<T>
): ProcessedCellRefOrGetter<T> {
    if (typeof value === "function" && value.length > 0) {
        return value;
    }
    return processGetter(value) as MaybeRef<T>;
}

/**
 * Lazily creates a grid with the given options.
 * @param optionsFunc Grid options.
 */
export function createGrid<T extends GridOptions>(optionsFunc: () => T) {
    const cellState = persistent<Record<number, Record<number, State>>>({}, false);
    return createLazyProxy(() => {
        const options = optionsFunc();
        const {
            rows,
            cols,
            getVisibility,
            getCanClick,
            getStartState,
            getStyle,
            getClasses,
            getDisplay: _getDisplay,
            onClick,
            onHold,
            ...props
        } = options;

        let getDisplay;
        if (typeof _getDisplay === "object" && !isJSXElement(_getDisplay)) {
            const { getTitle, getDescription } = _getDisplay;
            getDisplay = function (row: number, col: number, state: State) {
                const title = typeof getTitle === "function" ? getTitle(row, col, state) : getTitle;
                const description =
                    typeof getDescription === "function"
                        ? getDescription(row, col, state)
                        : getDescription;
                return (
                    <>
                        {title}
                        {description}
                    </>
                );
            };
        } else {
            getDisplay = _getDisplay;
        }

        const grid = {
            type: GridType,
            ...(props as Omit<typeof props, keyof VueFeature | keyof GridOptions>),
            ...vueFeatureMixin("grid", options, () => (
                <Column>
                    {new Array(unref(grid.rows)).fill(0).map((_, row) => (
                        <Row>
                            {new Array(unref(grid.cols))
                                .fill(0)
                                .map((_, col) => render(grid.cells[row][col]))}
                        </Row>
                    ))}
                </Column>
            )),
            cellState,
            cells: new Proxy({} as GridCell[][], {
                get(target, key: PropertyKey) {
                    if (key === "length") {
                        return unref(grid.rows);
                    }

                    if (typeof key !== "number" && typeof key !== "string") {
                        return;
                    }

                    const keyNum = typeof key === "number" ? key : parseInt(key);
                    if (Number.isFinite(keyNum) && keyNum < unref(grid.rows)) {
                        if (!(keyNum in target)) {
                            target[keyNum] = getCellRowHandler(grid, keyNum);
                        }
                        return target[keyNum];
                    }
                },
                set(target, key, value) {
                    console.warn("Cannot set grid cells", target, key, value);
                    return false;
                },
                ownKeys(): string[] {
                    return [...new Array(unref(grid.rows)).fill(0).map((_, i) => "" + i), "length"];
                },
                has(target, key) {
                    if (key === "length") {
                        return true;
                    }
                    if (typeof key !== "number" && typeof key !== "string") {
                        return false;
                    }
                    const keyNum = typeof key === "number" ? key : parseInt(key);
                    if (!Number.isFinite(keyNum) || keyNum >= unref(grid.rows)) {
                        return false;
                    }
                    return true;
                },
                getOwnPropertyDescriptor(target, key) {
                    if (typeof key !== "number" && typeof key !== "string") {
                        return;
                    }
                    const keyNum = typeof key === "number" ? key : parseInt(key);
                    if (
                        key !== "length" &&
                        (!Number.isFinite(keyNum) || keyNum >= unref(grid.rows))
                    ) {
                        return;
                    }
                    return {
                        configurable: true,
                        enumerable: true,
                        writable: false
                    };
                }
            }),
            rows: processGetter(rows),
            cols: processGetter(cols),
            getVisibility: convertCellMaybeRefOrGetter(getVisibility ?? true),
            getCanClick: convertCellMaybeRefOrGetter(getCanClick ?? true),
            getStartState:
                typeof getStartState === "function" && getStartState.length > 0
                    ? getStartState
                    : processGetter(getStartState),
            getStyle: convertCellMaybeRefOrGetter(getStyle),
            getClasses: convertCellMaybeRefOrGetter(getClasses),
            getDisplay,
            getID: function (row: number, col: number): string {
                return grid.id + "-" + row + "-" + col;
            },
            getState: function (row: number, col: number): State {
                cellState.value[row] ??= {};
                if (cellState.value[row][col] != null) {
                    return cellState.value[row][col];
                }
                return grid.cells[row][col].startState;
            },
            setState: function (row: number, col: number, state: State) {
                cellState.value[row] ??= {};
                cellState.value[row][col] = state;
            },
            onClick:
                onClick == null
                    ? undefined
                    : function (row, col, state, e) {
                          if (grid.cells[row][col].canClick !== false) {
                              onClick.call(grid, row, col, state, e);
                          }
                      },
            onHold:
                onHold == null
                    ? undefined
                    : function (row, col, state) {
                          if (grid.cells[row][col].canClick !== false) {
                              onHold.call(grid, row, col, state);
                          }
                      }
        } satisfies Grid;

        return grid;
    });
}