diff --git a/src/components/GameStateProvider/GameStateProvider.tsx b/src/components/GameStateProvider/GameStateProvider.tsx index 3f4dfca..4e4ca26 100644 --- a/src/components/GameStateProvider/GameStateProvider.tsx +++ b/src/components/GameStateProvider/GameStateProvider.tsx @@ -39,8 +39,17 @@ import { RotationDirection, orientationReducer, } from "./currentPentominoReducer"; -import { DEFAULT_GAME_PREFERENCES } from "./gameConstants"; +import { + DEFAULT_GAME_PREFERENCES, + DEFAULT_HOTKEYS, + DEFAULT_HOTKEY_MAP, + HotkeyMap, + HotkeyableAction, + Hotkeys, +} from "./gameConstants"; import { produce } from "immer"; +import useHotkeyMap from "../../hooks/use-hotkey-map"; +import { deserializeHotkeys, serializeHotkeys } from "./hotkeyMapState"; interface GameState { grid: PlacedPentomino[][]; @@ -68,6 +77,9 @@ interface GameState { updateDefaultRandomColors: (newDefault: boolean) => void; defaultAddTerrain: boolean; updateDefaultAddTerrain: (newDefault: boolean) => void; + hotkeys: Hotkeys; + hotkeyMap: HotkeyMap; + updateHotkeyMap: (nextMap: HotkeyMap) => void; } const DEFAULT_GAME_STATE: GameState = { @@ -96,6 +108,9 @@ const DEFAULT_GAME_STATE: GameState = { updateDefaultRandomColors: () => {}, defaultAddTerrain: true, updateDefaultAddTerrain: () => {}, + hotkeys: DEFAULT_HOTKEYS, + hotkeyMap: [], + updateHotkeyMap: () => {}, }; export const GameStateContext = createContext(DEFAULT_GAME_STATE); @@ -343,39 +358,6 @@ export default function GameStateProvider({ children }: { children: ReactNode }) setActionHistory(nextActionHistory); }); - useHotkey(undefined, "ArrowLeft", () => { - setShowKeyboardIndicators(true); - updateGridCoords("y", -1); - }); - - useHotkey(undefined, "ArrowUp", () => { - setShowKeyboardIndicators(true); - updateGridCoords("x", -1); - }); - - useHotkey(undefined, "ArrowRight", () => { - setShowKeyboardIndicators(true); - updateGridCoords("y", 1); - }); - - useHotkey(undefined, "ArrowDown", () => { - setShowKeyboardIndicators(true); - updateGridCoords("x", 1); - }); - - useHotkey(undefined, "A", () => { - orientationDispatch({ type: OrientationActionType.rotate, direction: RotationDirection.Left }); - }); - useHotkey(undefined, "D", () => { - orientationDispatch({ type: OrientationActionType.rotate, direction: RotationDirection.Right }); - }); - useHotkey(undefined, "S", () => { - orientationDispatch({ type: OrientationActionType.reflect, direction: ReflectionDirection.X }); - }); - useHotkey(undefined, "W", () => { - orientationDispatch({ type: OrientationActionType.reflect, direction: ReflectionDirection.Y }); - }); - function updateToolbarPentomino(increment: number) { const curIndex = ALL_PENTOMINO_NAMES.indexOf(currentPentomino.name); const nextPentomino = @@ -388,19 +370,93 @@ export default function GameStateProvider({ children }: { children: ReactNode }) orientationDispatch({ type: OrientationActionType.replace }); } - useHotkey(undefined, "E", () => { - setShowKeyboardIndicators(true); - updateToolbarPentomino(1); + const [hotkeyMap, setHotkeyMap] = useState(() => { + const cachedData = window.localStorage.getItem("hotkeys"); + if (!cachedData) return cloneDeep(DEFAULT_HOTKEY_MAP); + return deserializeHotkeys(cachedData); }); - useHotkey(undefined, "Q", () => { - setShowKeyboardIndicators(true); - updateToolbarPentomino(-1); - }); + const updateHotkeyMap = (nextMap: HotkeyMap) => { + setHotkeyMap(nextMap); + window.localStorage.setItem("hotkeys", serializeHotkeys(nextMap)); + }; - useHotkey(undefined, "Enter", () => { - clickBoard(currentGridCoords.x, currentGridCoords.y); - }); + const hotkeys = { + [HotkeyableAction.ReflectY]: { + action: () => { + orientationDispatch({ type: OrientationActionType.reflect, direction: ReflectionDirection.Y }); + }, + text: "Reflect horizontally", + }, + [HotkeyableAction.ReflectX]: { + action: () => { + orientationDispatch({ type: OrientationActionType.reflect, direction: ReflectionDirection.X }); + }, + text: "Reflect vertically", + }, + [HotkeyableAction.RotateLeft]: { + action: () => { + orientationDispatch({ type: OrientationActionType.rotate, direction: RotationDirection.Left }); + }, + text: "Rotate left (counter-clockwise)", + }, + [HotkeyableAction.RotateRight]: { + action: () => { + orientationDispatch({ type: OrientationActionType.rotate, direction: RotationDirection.Right }); + }, + text: "Rotate right (clockwise)", + }, + [HotkeyableAction.TilePrev]: { + action: () => { + setShowKeyboardIndicators(true); + updateToolbarPentomino(-1); + }, + text: "Select previous tile", + }, + [HotkeyableAction.TileNext]: { + action: () => { + setShowKeyboardIndicators(true); + updateToolbarPentomino(1); + }, + text: "Select next tile", + }, + [HotkeyableAction.ClickBoard]: { + action: () => { + clickBoard(currentGridCoords.x, currentGridCoords.y); + }, + text: "Place or remove tile", + }, + [HotkeyableAction.GridUp]: { + action: () => { + setShowKeyboardIndicators(true); + updateGridCoords("x", -1); + }, + text: "Move grid cursor up", + }, + [HotkeyableAction.GridRight]: { + action: () => { + setShowKeyboardIndicators(true); + updateGridCoords("y", 1); + }, + text: "Move grid cursor right", + }, + [HotkeyableAction.GridDown]: { + action: () => { + setShowKeyboardIndicators(true); + updateGridCoords("x", 1); + }, + text: "Move grid cursor down", + }, + [HotkeyableAction.GridLeft]: { + action: () => { + setShowKeyboardIndicators(true); + updateGridCoords("y", -1); + }, + text: "Move grid cursor left", + }, + }; + + useHotkeyMap(hotkeyMap, hotkeys); return ( {children} diff --git a/src/components/GameStateProvider/gameConstants.ts b/src/components/GameStateProvider/gameConstants.ts index 9ce6318..c5ed98a 100644 --- a/src/components/GameStateProvider/gameConstants.ts +++ b/src/components/GameStateProvider/gameConstants.ts @@ -8,3 +8,60 @@ export const DEFAULT_GAME_PREFERENCES = { defaultRandomColors: false, defaultAddTerrain: true, }; + +export enum HotkeyableAction { + ReflectY, + ReflectX, + RotateLeft, + RotateRight, + TilePrev, + TileNext, + GridUp, + GridRight, + GridDown, + GridLeft, + ClickBoard, +} + +export type Hotkeys = Record< + HotkeyableAction, + { + action: () => void; + text: string; + } +>; + +export interface HotkeyMapEntry { + keybind: string; + action: HotkeyableAction; +} + +export type HotkeyMap = HotkeyMapEntry[]; + +export const DEFAULT_HOTKEYS: Hotkeys = { + [HotkeyableAction.ReflectY]: { action: () => {}, text: "" }, + [HotkeyableAction.ReflectX]: { action: () => {}, text: "" }, + [HotkeyableAction.RotateLeft]: { action: () => {}, text: "" }, + [HotkeyableAction.RotateRight]: { action: () => {}, text: "" }, + [HotkeyableAction.TilePrev]: { action: () => {}, text: "" }, + [HotkeyableAction.TileNext]: { action: () => {}, text: "" }, + [HotkeyableAction.GridUp]: { action: () => {}, text: "" }, + [HotkeyableAction.GridRight]: { action: () => {}, text: "" }, + [HotkeyableAction.GridDown]: { action: () => {}, text: "" }, + [HotkeyableAction.GridLeft]: { action: () => {}, text: "" }, + [HotkeyableAction.ClickBoard]: { action: () => {}, text: "" }, +}; + +export const DEFAULT_HOTKEY_MAP: HotkeyMap = [ + { keybind: "A", action: HotkeyableAction.RotateLeft }, + { keybind: "D", action: HotkeyableAction.RotateRight }, + { keybind: "S", action: HotkeyableAction.ReflectX }, + { keybind: "W", action: HotkeyableAction.ReflectY }, + { keybind: "ArrowUp", action: HotkeyableAction.GridUp }, + { keybind: "ArrowDown", action: HotkeyableAction.GridDown }, + { keybind: "ArrowLeft", action: HotkeyableAction.GridLeft }, + { keybind: "ArrowRight", action: HotkeyableAction.GridRight }, + { keybind: "Enter", action: HotkeyableAction.ClickBoard }, + { keybind: "Q", action: HotkeyableAction.TilePrev }, + { keybind: "E", action: HotkeyableAction.TileNext }, +]; diff --git a/src/components/GameStateProvider/hotkeyMapState.ts b/src/components/GameStateProvider/hotkeyMapState.ts new file mode 100644 index 0000000..4dd4eab --- /dev/null +++ b/src/components/GameStateProvider/hotkeyMapState.ts @@ -0,0 +1,19 @@ +import { HotkeyMap } from "./gameConstants"; + +const OUTER_SEP = ";;;"; +const INNER_SEP = ":::"; + +export const serializeHotkeys = (hotkeyMap: HotkeyMap): string => { + return hotkeyMap.map((key) => `${key.action}${INNER_SEP}${key.keybind}`).join(OUTER_SEP); +}; + +export const deserializeHotkeys = (val: string): HotkeyMap => { + const t = val.split(OUTER_SEP); + return t.map((s) => { + const [a, k] = s.split(INNER_SEP); + return { + action: parseInt(a), + keybind: k, + }; + }); +}; diff --git a/src/components/Information/Information.tsx b/src/components/Information/Information.tsx index 30e93d0..21fb42b 100644 --- a/src/components/Information/Information.tsx +++ b/src/components/Information/Information.tsx @@ -5,12 +5,12 @@ import { Grid } from "../Grid/Grid"; import { PentominoDisplay } from "../PentominoDisplay/PentominoDisplay"; import { Modal } from "../Modal/Modal"; import { QuestionMarkCircleIcon } from "@heroicons/react/24/outline"; -import { ReactNode, useContext } from "react"; +import { ReactNode, useContext, useState } from "react"; import { AppStateContext } from "../AppStateProvider/AppStateProvider"; import clsx from "clsx"; -import { ArrowDownIcon, ArrowRightIcon, ArrowUpIcon, ArrowLeftIcon } from "@heroicons/react/20/solid"; import { getPaintedBoard } from "../GameStateProvider/paintGrid"; import { InfoGrid } from "../Grid/InfoGrid"; +import { GameStateContext } from "../GameStateProvider/GameStateProvider"; interface GridExample { w: number; @@ -100,99 +100,74 @@ const exampleGrids = gridExampleStructure.map((e) => { }); export const Information = () => { - const { darkMode } = useContext(AppStateContext); + const { darkMode, setSettingsOpen } = useContext(AppStateContext); + const { hotkeyMap, hotkeys } = useContext(GameStateContext); + const [infoOpen, setInfoOpen] = useState(false); return ( - }> + } + > About Pentominoes
-

+

Pentominoes are tiles of area 5. There are 12 distinct pentominoes, up to rotation & reflection, with each tile having somewhere between 2 (the {} tile) and 8 ( {} {}{" "} {} {}{" "} {}) distinct orientations. -

-

+

+
This puzzle game also provides a one-square-unit-area tile that you can use as terrain (the{" "} tile). -

-

+

+
There are several different ways to enjoy Pentominoes, but the common theme is that you will try to fully tile a grid of total area 60 (5x12=60) such that no pentominoes overlap or fall off the edge, and no empty squares remain (other than whatever terrain you choose to place before starting to solve the puzzle). -

-

+

+
Generally, you want to use one of each pentomino to tile the board, but you're welcome to use this app however you like, and there are no prohibitions against using a tile more than once unless you want there to be. One suggestion is to attempt to tile an area with just the {} tile. -

-

+

+
For an added challenge, you can also choose to apply "colorways" to your tiles. Then, constrain yourself to make a solve where the 4 tiles of some color must be pairwise non-adjacent; or must be adjacent; or must be adjacent and form a line spanning the grid area (this last one is especially fun in 8x8 grids with 4 squares of terrain). Setting these colorways is available in the Settings dialog. -

-

+

+
If you're new to pentominoes, feel free to "cheat" in your first few solve attempts and move terrain around, or use one piece twice - this is a single-player puzzle game, so the rules are whatever you make them to be! -

+
Hotkeys + + You can{" "} + { + e.preventDefault(); + setInfoOpen(false); + setSettingsOpen(true); + }} + > + customize hotkeys + {" "} + in the Settings menu. + Ctrl + Z =Undo last action that modified the grid - - W - =Reflect current pentomino along the y-axis (horizontally) - - - S - =Reflect current pentomino along the x-axis (vertically) - - - A - =Rotate current pentomino counter-clockwise - - - D - =Rotate current pentomino clockwise - - - Q - =Select previous pentomino - - - E - =Select next pentomino - - - - - - =Move grid cursor up - - - - - - =Move grid cursor to the right - - - - - - =Move grid cursor down - - - - - - = Move grid cursor to the left - - - Enter - =Add/remove pentomino from board at selected grid location - + {hotkeyMap.map((hotkey, i) => ( + + {hotkey.keybind} + = + {hotkeys[hotkey.action].text} + + ))} Suggested Puzzles
{exampleGrids.map((grid, i) => ( @@ -215,9 +190,9 @@ export const Information = () => { const InformationPentominoDisplay = ({ p }: { p: string }) => { return ( -
+ -
+ ); }; diff --git a/src/components/Settings/Settings.tsx b/src/components/Settings/Settings.tsx index 9c80303..2f818c8 100644 --- a/src/components/Settings/Settings.tsx +++ b/src/components/Settings/Settings.tsx @@ -1,6 +1,6 @@ import * as Dialog from "@radix-ui/react-dialog"; import clsx from "clsx"; -import { range, toNumber } from "lodash"; +import { cloneDeep, range, toNumber } from "lodash"; import { Dispatch, ReactNode, SetStateAction, useContext, useState } from "react"; import { AppStateContext } from "../AppStateProvider/AppStateProvider"; import { @@ -14,7 +14,7 @@ import { } from "../../constants"; import { GameStateContext } from "../GameStateProvider/GameStateProvider"; import { ColorSettings } from "../ColorSettings/ColorSettings"; -import { Cog8ToothIcon } from "@heroicons/react/24/outline"; +import { Cog8ToothIcon, XMarkIcon } from "@heroicons/react/24/outline"; import { Modal } from "../Modal/Modal"; import { Button } from "../Button/Button"; import { CurrentState, DEFAULT_SETTINGS_CONFIG } from "./settingsConstants"; @@ -26,7 +26,11 @@ import { errorWidth, errorHeight, gridChangeNeeded, + duplicateKeybindsAtLetter, + duplicateKeybinds, } from "./validateSettings"; +import { DEFAULT_HOTKEY_MAP, HotkeyMap, HotkeyableAction } from "../GameStateProvider/gameConstants"; +import { produce } from "immer"; function getNumVisibleColors(numVisibleColors: number, defaultRandomColors: boolean, pentominoColors: Colors): number { if (!defaultRandomColors) return numVisibleColors; @@ -56,9 +60,13 @@ export const Settings = () => { updateDefaultRandomColors, defaultAddTerrain, updateDefaultAddTerrain, + hotkeys, + hotkeyMap, + updateHotkeyMap, } = useContext(GameStateContext); const [currentState, setCurrentState] = useState({ ...DEFAULT_SETTINGS_CONFIG }); + const [currentHotkeyMap, setCurrentHotkeyMap] = useState([]); const [showErrors, setShowErrors] = useState(false); const [warnGridReset, setWarnGridReset] = useState(false); @@ -96,6 +104,7 @@ export const Settings = () => { }); setShowErrors(false); setWarnGridReset(false); + setCurrentHotkeyMap(cloneDeep(hotkeyMap)); }} open={settingsOpen} onOpenChange={updateSettingsOpen as Dispatch>} @@ -106,6 +115,7 @@ export const Settings = () => { setShowErrors(true); let returnEarly = false; if (errorConfig(currentState)) returnEarly = true; + if (duplicateKeybinds(currentHotkeyMap)) returnEarly = true; if (gridChangeNeeded(currentState, { height: grid.length, width: grid[0].length })) { if (!warnGridReset) { setWarnGridReset(true); @@ -129,6 +139,7 @@ export const Settings = () => { setSurface(currentState.surface); setPentominoColors(currentState.pentominoColors); setShowKeyboardIndicators(currentState.showKeyboardIndicators); + updateHotkeyMap(currentHotkeyMap); updateDefaultRandomColors(currentState.defaultRandomColors); updateDefaultAddTerrain(currentState.defaultAddTerrain); @@ -380,6 +391,52 @@ export const Settings = () => { />
+ Hotkeys +
+ You can also undo with Ctrl+Z. +
+ {currentHotkeyMap.map((hotkey, i) => { + return ( +
+ + { + e.preventDefault(); + e.stopPropagation(); + if (e.key === hotkey.keybind) return; + setCurrentHotkeyMap( + produce(currentHotkeyMap, (draftHotkeyMap) => { + if (e.key === " ") { + draftHotkeyMap[i].keybind = "Space"; + return; + } + draftHotkeyMap[i].keybind = e.key.length === 1 ? e.key.toUpperCase() : e.key; + }) + ); + }} + onChange={() => {}} + /> + {duplicateKeybindsAtLetter(currentHotkeyMap, hotkey.keybind) && ( + + )} +
+ ); + })} + {/* End of settings area */} {/* Start confirmation area */}
diff --git a/src/components/Settings/validateSettings.test.ts b/src/components/Settings/validateSettings.test.ts index 535fd7c..b063786 100644 --- a/src/components/Settings/validateSettings.test.ts +++ b/src/components/Settings/validateSettings.test.ts @@ -1,7 +1,16 @@ +import { DEFAULT_HOTKEY_MAP } from "./../GameStateProvider/gameConstants"; import { SURFACES } from "./../../constants"; import { expect, test } from "vitest"; import { DEFAULT_SETTINGS_CONFIG } from "./settingsConstants"; -import { errorConfig, errorHeight, errorSphere, gridChangeNeeded, warnDimensions } from "./validateSettings"; +import { + duplicateKeybindsAtLetter, + errorConfig, + errorHeight, + errorSphere, + gridChangeNeeded, + warnDimensions, +} from "./validateSettings"; +import { cloneDeep } from "lodash"; test("default config has no errors", () => { expect(errorConfig({ ...DEFAULT_SETTINGS_CONFIG })).toBe(false); @@ -54,3 +63,11 @@ test("warning grid change is correct & not a real error", () => { expect(gridChangeNeeded(config, { height: 8, width: 10 })).toBe(false); expect(errorConfig(config)).toBe(false); }); + +test("detecting duplicate keybinds correctly", () => { + const hotkeyMap = cloneDeep(DEFAULT_HOTKEY_MAP); + expect(duplicateKeybindsAtLetter(hotkeyMap, "ArrowUp")).toBe(false); + hotkeyMap[0].keybind = "D"; + expect(duplicateKeybindsAtLetter(hotkeyMap, "D")).toBe(true); + expect(duplicateKeybindsAtLetter(hotkeyMap, "ArrowUp")).toBe(false); +}); diff --git a/src/components/Settings/validateSettings.ts b/src/components/Settings/validateSettings.ts index f49a871..2809c97 100644 --- a/src/components/Settings/validateSettings.ts +++ b/src/components/Settings/validateSettings.ts @@ -1,6 +1,7 @@ import { Dimensions, SOLVE_AREA } from "./../../constants"; import { MAX_DIMENSION_SIZE, SURFACES } from "../../constants"; import { CurrentState } from "./settingsConstants"; +import { HotkeyMap } from "../GameStateProvider/gameConstants"; export const errorConfig = (currentState: CurrentState) => { return ( @@ -40,3 +41,22 @@ export const warnDimensions = (draftState: CurrentState) => { export const gridChangeNeeded = (draftState: Dimensions & Partial, prevDimensions: Dimensions) => { return draftState.height !== prevDimensions.height || draftState.width !== prevDimensions.width; }; + +export const duplicateKeybindsAtLetter = (draftKeymap: HotkeyMap, curBind: string) => { + return ( + draftKeymap.reduce((count, val) => { + count += val.keybind === curBind ? 1 : 0; + return count; + }, 0) >= 2 + ); +}; + +export const duplicateKeybinds = (draftKeymap: HotkeyMap) => { + const counts: { [key: string]: number } = {}; + let maxCount = 0; + draftKeymap.forEach((val) => { + counts[val.keybind] = (counts[val.keybind] ?? 0) + 1; + maxCount = Math.max(maxCount, counts[val.keybind]); + }); + return maxCount >= 2; +}; diff --git a/src/hooks/use-hotkey-map.ts b/src/hooks/use-hotkey-map.ts new file mode 100644 index 0000000..71ef0a0 --- /dev/null +++ b/src/hooks/use-hotkey-map.ts @@ -0,0 +1,29 @@ +import { Hotkeys, HotkeyMap, HotkeyableAction } from "./../components/GameStateProvider/gameConstants"; +import { useEffect } from "react"; + +interface HotkeyLookup { + [key: string]: HotkeyableAction; +} + +function useHotkeyMap(hotkeyMap: HotkeyMap, hotkeys: Hotkeys) { + useEffect(() => { + const hotkeyLookup = hotkeyMap.reduce((acc: HotkeyLookup, key) => { + acc[key.keybind.toLocaleLowerCase()] = key.action; + return acc; + }, {}); + function doActionOnKeypress(e: KeyboardEvent) { + if (e.isComposing || e.keyCode === 229) return; + if (hotkeyLookup[e.key.toLocaleLowerCase()] === undefined) return; + e.preventDefault(); + e.stopPropagation(); + hotkeys[hotkeyLookup[e.key.toLocaleLowerCase()]].action(); + } + + window.addEventListener("keydown", doActionOnKeypress); + return () => { + window.removeEventListener("keydown", doActionOnKeypress); + }; + }, [hotkeyMap, hotkeys]); +} + +export default useHotkeyMap;