Skip to content

Commit

Permalink
Support random boards & improve new board UX (#70)
Browse files Browse the repository at this point in the history
  • Loading branch information
RheingoldRiver authored Nov 2, 2023
1 parent 7920966 commit e7672b0
Show file tree
Hide file tree
Showing 16 changed files with 735 additions and 137 deletions.
389 changes: 358 additions & 31 deletions package-lock.json

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
},
"dependencies": {
"@heroicons/react": "^2.0.18",
"@radix-ui/react-dialog": "^1.0.4",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-toolbar": "^1.0.4",
"@types/html2canvas": "^1.0.0",
Expand Down
18 changes: 9 additions & 9 deletions src/components/AppStateProvider/AppStateProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,6 @@
import { createContext, ReactNode, useState } from "react";
import { DEFAULT_DISPLAY_COLORS } from "../../constants";
import { AppPreferences } from "./appConstants";

const DEFAULT_APP_PREFERENCES: AppPreferences = {
pentominoSize: 12,
displayColors: DEFAULT_DISPLAY_COLORS,
numVisibleColors: 3,
copyImage: false,
showCdot: false,
};
import { AppPreferences, DEFAULT_APP_PREFERENCES } from "./appConstants";

interface AppState {
appPreferences: AppPreferences;
Expand All @@ -21,18 +13,24 @@ interface AppState {
) => void;
darkMode: boolean;
updateDarkMode: (newIsDark: boolean) => void;
settingsOpen: boolean;
setSettingsOpen: (newVal: boolean) => void;
}

const DEFAULT_APP_STATE: AppState = {
appPreferences: DEFAULT_APP_PREFERENCES,
updateAppPreferences: () => {},
darkMode: false,
updateDarkMode: () => {},
settingsOpen: false,
setSettingsOpen: () => {},
};

export const AppStateContext = createContext(DEFAULT_APP_STATE);

export default function AppStateProvider({ children }: { children: ReactNode }) {
const [settingsOpen, setSettingsOpen] = useState<boolean>(false);

const [appPreferences, setAppPreferences] = useState<AppPreferences>(() => {
const localColors = window.localStorage.getItem("colors");
const displayColors = [...DEFAULT_DISPLAY_COLORS];
Expand Down Expand Up @@ -86,6 +84,8 @@ export default function AppStateProvider({ children }: { children: ReactNode })
updateAppPreferences,
darkMode,
updateDarkMode,
settingsOpen,
setSettingsOpen,
}}
>
{children}
Expand Down
117 changes: 105 additions & 12 deletions src/components/BottomToolbar/BottomToolbar.tsx
Original file line number Diff line number Diff line change
@@ -1,36 +1,118 @@
import { CSSProperties, ReactInstance, RefObject, forwardRef, useContext, useState } from "react";
import { CSSProperties, ReactInstance, ReactNode, RefObject, forwardRef, useContext, useState } from "react";
import * as Toolbar from "@radix-ui/react-toolbar";
import { GameStateContext } from "../GameStateProvider/GameStateProvider";
import { ToolbarButton } from "../Button/Button";
import { exportComponentAsPNG } from "react-component-export-image";
import { AppStateContext } from "../AppStateProvider/AppStateProvider";
import html2canvas from "html2canvas";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import { HamburgerMenuIcon } from "@radix-ui/react-icons";
import clsx from "clsx";
import { EMPTY_GRID, PlacedPentomino, SOLVE_AREA } from "../../constants";
import { random, range } from "lodash";
import { PRESET_SIZES, RANDOM_TERRAIN_ALLOWED, invalidSolve } from "./bottomToolbar.utils";
import { PENTOMINOES } from "../../pentominoes";

const BottomToolbar = forwardRef(({ style }: { style: CSSProperties }, ref) => {
const { clearGrid, grid, surface } = useContext(GameStateContext);
const { appPreferences } = useContext(AppStateContext);
const { clearGrid, newGrid, grid, surface } = useContext(GameStateContext);
const { appPreferences, setSettingsOpen: updateSettingsOpen } = useContext(AppStateContext);
const [copied, setCopied] = useState(false);
const [newBoardOpen, setNewBoardOpen] = useState(false);
return (
<Toolbar.Root
style={style as CSSProperties}
className="pl-2 space-x-3 my-2 w-full flex justify-start"
aria-label="Game controls"
>
<ToolbarButton
onClick={() => {
clearGrid(false);
}}
aria-label="Clear All"
>
Clear All
</ToolbarButton>
<DropdownMenu.Root open={newBoardOpen} onOpenChange={setNewBoardOpen}>
<DropdownMenu.Trigger asChild>
<button
className={clsx(
"cursor-pointer p-2 rounded mb-2",
"shadow-sm shadow-zinc-900 dark:shadow-none dark:border dark:border-zinc-500",
"flex flex-row items-center gap-2",
"IconButton"
)}
aria-label="New board"
>
New board
<HamburgerMenuIcon />
</button>
</DropdownMenu.Trigger>

<DropdownMenu.Portal>
<DropdownMenu.Content
className={clsx(
"shadow-sm shadow-zinc-900 dark:shadow-none dark:border dark:border-zinc-500",
"DropdownMenuContent",
"text-black dark:text-gray-50",
"bg-gray-100 dark:bg-gray-900 dark:text-gray-50",
"rounded py-2",
"text-right"
)}
align="start"
>
<DropdownItem
onClick={(e) => {
e.preventDefault();
clearGrid(false);
setNewBoardOpen(false);
}}
>
Same Dimensions
</DropdownItem>
<DropdownItem
onClick={() => {
let nextGrid: PlacedPentomino[][] = [];
do {
nextGrid = EMPTY_GRID(RANDOM_TERRAIN_ALLOWED.height, RANDOM_TERRAIN_ALLOWED.width);
range(grid.length * grid[0].length, SOLVE_AREA).forEach(() => {
let x = random(RANDOM_TERRAIN_ALLOWED.height - 1);
let y = random(RANDOM_TERRAIN_ALLOWED.width - 1);
while (nextGrid[x][y].pentomino.name !== PENTOMINOES.None.name) {
x = random(RANDOM_TERRAIN_ALLOWED.height);
y = random(RANDOM_TERRAIN_ALLOWED.width);
}
nextGrid[x][y].pentomino = PENTOMINOES.R;
});
} while (invalidSolve(nextGrid));
newGrid(nextGrid);
setNewBoardOpen(false);
}}
aria-label="Randomize terrain"
>
8x8 (random terrain)
</DropdownItem>
{PRESET_SIZES.map((size, i) => (
<DropdownItem
onClick={(e) => {
e.preventDefault();
newGrid(EMPTY_GRID(size.width, size.height));
setNewBoardOpen(false);
}}
key={i}
>{`${size.width}x${size.height}`}</DropdownItem>
))}
<DropdownItem
onClick={(e) => {
e.preventDefault();
updateSettingsOpen(true);
setNewBoardOpen(false);
}}
>
Custom Dimensions
</DropdownItem>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>

<ToolbarButton
onClick={() => {
clearGrid(true);
}}
aria-label="Clear Solve"
>
Clear Solve
Clear solve
</ToolbarButton>
<ToolbarButton
onClick={() => {
Expand Down Expand Up @@ -61,4 +143,15 @@ const BottomToolbar = forwardRef(({ style }: { style: CSSProperties }, ref) => {
);
});

const DropdownItem = ({ children, ...rest }: { children: ReactNode } & DropdownMenu.DropdownMenuItemProps) => {
return (
<DropdownMenu.Item
className={clsx("DropdownMenuItem", "px-2 cursor-pointer hover:bg-blue-300 dark:hover:bg-blue-950")}
{...rest}
>
{children}
</DropdownMenu.Item>
);
};

export default BottomToolbar;
99 changes: 99 additions & 0 deletions src/components/BottomToolbar/bottomToolbar.utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { expect, test } from "vitest";
import { EMPTY_GRID } from "../../constants";
import { PENTOMINOES } from "../../pentominoes";
import { getAdjacentArea, invalidSolve } from "./bottomToolbar.utils";

test("negative control", () => {
const grid = EMPTY_GRID(8, 8);
expect(getAdjacentArea(grid)).toBe(64);
});

test("works with some random tiles", () => {
const grid = EMPTY_GRID(8, 8);
grid[0][1].pentomino = PENTOMINOES.R;
grid[5][6].pentomino = PENTOMINOES.R;
grid[2][3].pentomino = PENTOMINOES.R;
grid[7][7].pentomino = PENTOMINOES.R;
expect(getAdjacentArea(grid)).toBe(60);
expect(invalidSolve(grid)).toBe(false);
});

test("validating a corner properly: trivial case", () => {
const grid = EMPTY_GRID(8, 8);
grid[0][1].pentomino = PENTOMINOES.R;
grid[1][0].pentomino = PENTOMINOES.R;
expect(invalidSolve(grid)).toBe(true);
});

test("validating a corner properly: trivial case", () => {
const grid = EMPTY_GRID(8, 8);
grid[6][0].pentomino = PENTOMINOES.R;
grid[7][1].pentomino = PENTOMINOES.R;
expect(invalidSolve(grid)).toBe(true);
});

test("validating a corner properly: rectangle case", () => {
const grid = EMPTY_GRID(8, 8);
grid[0][1].pentomino = PENTOMINOES.R;
grid[1][1].pentomino = PENTOMINOES.R;
grid[2][0].pentomino = PENTOMINOES.R;
expect(invalidSolve(grid)).toBe(true);
});

test("validating a corner properly: rectangle case 2", () => {
const grid = EMPTY_GRID(8, 8);
grid[1][0].pentomino = PENTOMINOES.R;
grid[1][1].pentomino = PENTOMINOES.R;
grid[1][2].pentomino = PENTOMINOES.R;
grid[0][3].pentomino = PENTOMINOES.R;
expect(invalidSolve(grid)).toBe(true);
});

test("validating a diagonal corner properly", () => {
const grid = EMPTY_GRID(8, 8);
grid[0][2].pentomino = PENTOMINOES.R;
grid[1][1].pentomino = PENTOMINOES.R;
grid[2][0].pentomino = PENTOMINOES.R;
expect(invalidSolve(grid)).toBe(true);

const grid2 = EMPTY_GRID(8, 8);
grid2[3][0].pentomino = PENTOMINOES.R;
grid2[2][1].pentomino = PENTOMINOES.R;
grid2[1][2].pentomino = PENTOMINOES.R;
grid2[0][3].pentomino = PENTOMINOES.R;
expect(invalidSolve(grid2)).toBe(true);
});

test("validating a center properly", () => {
const grid = EMPTY_GRID(8, 8);
grid[4][5].pentomino = PENTOMINOES.R;
grid[4][3].pentomino = PENTOMINOES.R;
grid[3][4].pentomino = PENTOMINOES.R;
grid[5][4].pentomino = PENTOMINOES.R;
expect(invalidSolve(grid)).toBe(true);
});

test("validating a center properly", () => {
const grid = EMPTY_GRID(8, 8);
grid[0][3].pentomino = PENTOMINOES.R;
grid[0][1].pentomino = PENTOMINOES.R;
grid[1][2].pentomino = PENTOMINOES.R;
grid[7][7].pentomino = PENTOMINOES.R;
expect(invalidSolve(grid)).toBe(true);
});

test("validating a corner 2x2 square", () => {
const grid = EMPTY_GRID(8, 8);
grid[0][2].pentomino = PENTOMINOES.R;
grid[1][2].pentomino = PENTOMINOES.R;
grid[2][0].pentomino = PENTOMINOES.R;
grid[2][1].pentomino = PENTOMINOES.R;
expect(invalidSolve(grid)).toBe(true);

const grid2 = EMPTY_GRID(8, 8);
grid2[0][5].pentomino = PENTOMINOES.R;
grid2[1][5].pentomino = PENTOMINOES.R;
grid2[2][7].pentomino = PENTOMINOES.R;
grid2[2][6].pentomino = PENTOMINOES.R;
expect(invalidSolve(grid2)).toBe(true);
});
74 changes: 74 additions & 0 deletions src/components/BottomToolbar/bottomToolbar.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { cloneDeep } from "lodash";
import { Dimensions, PlacedPentomino } from "../../constants";
import { Coordinates, PENTOMINOES } from "../../pentominoes";

export const PRESET_SIZES: Dimensions[] = [
{
height: 8,
width: 8,
},
{
height: 5,
width: 12,
},
{
height: 6,
width: 10,
},
{
height: 12,
width: 5,
},
{
height: 10,
width: 6,
},
];

export const RANDOM_TERRAIN_ALLOWED: Dimensions = {
height: 8,
width: 8,
};

const adjacentCells: Coordinates[] = [
{ x: -1, y: 0 },
{ x: 1, y: 0 },
{ x: 0, y: 1 },
{ x: 0, y: -1 },
];

export function invalidSolve(gridToCheck: PlacedPentomino[][]) {
const area = getAdjacentArea(gridToCheck);
return area % 5 !== 0;
}

export function getAdjacentArea(gridToCheck: PlacedPentomino[][]) {
const grid = cloneDeep(gridToCheck);
const tileQueue: PlacedPentomino[] = [];
let x = 0;
let firstCell: PlacedPentomino;
do {
firstCell = grid[x][0];
x++;
} while (firstCell.pentomino.name !== PENTOMINOES.None.name);
tileQueue.push(firstCell);
let area = 0;
firstCell.pentomino = PENTOMINOES.Found;
// eslint-disable-next-line no-constant-condition
while (true) {
const curCell = tileQueue.pop();
if (curCell === undefined) break;
area++;
adjacentCells.forEach(({ x, y }) => {
const curX = curCell.coordinates.x + x;
const curY = curCell.coordinates.y + y;
if (curX < 0 || curX >= grid.length) return;
if (curY < 0 || curY >= grid[0].length) return;
const nextCell = grid[curX][curY];
if (nextCell.pentomino.name !== PENTOMINOES.None.name) return;
nextCell.pentomino = PENTOMINOES.Found;
tileQueue.push(nextCell);
});
}
return area;
}
2 changes: 1 addition & 1 deletion src/components/Game/Game.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ const GameContent = () => {
return (
<div
className={clsx(
"min-h-screen grid py-4 w-full grid-areas-game 2xl:grid-areas-game2xl",
"min-h-screen grid py-4 w-full grid-areas-game xl:grid-areas-gamexl 2xl:grid-areas-game2xl",
"bg-gray-50 dark:bg-gray-950 dark:text-gray-50",
"grid-rows-game 2xl:grid-rows-game2xl grid-cols-game"
)}
Expand Down
Loading

0 comments on commit e7672b0

Please sign in to comment.