diff --git a/src/assets/fonts.css b/src/assets/fonts.css index 3f2b627..9bf872e 100644 --- a/src/assets/fonts.css +++ b/src/assets/fonts.css @@ -67,4 +67,9 @@ @font-face { font-family: Reactor7; src: url('fonts/Reactor7.ttf') format('truetype'); -} \ No newline at end of file +} + +@font-face { + font-family: minesweeper; + src: url('fonts/Minesweeper.woff2') format('woff2'); +} diff --git a/src/assets/fonts/Minesweeper.woff2 b/src/assets/fonts/Minesweeper.woff2 new file mode 100644 index 0000000..732ca9c Binary files /dev/null and b/src/assets/fonts/Minesweeper.woff2 differ diff --git a/src/channels/index.ts b/src/channels/index.ts index f55d4bd..8b9056b 100644 --- a/src/channels/index.ts +++ b/src/channels/index.ts @@ -15,5 +15,6 @@ import './dragon-warrior'; import './ff-shop'; import './template'; import './papers-please'; +import './minesweeper'; export * from './channels'; diff --git a/src/channels/minesweeper/Face.tsx b/src/channels/minesweeper/Face.tsx new file mode 100644 index 0000000..0557396 --- /dev/null +++ b/src/channels/minesweeper/Face.tsx @@ -0,0 +1,29 @@ +import { css } from '@emotion/react'; +import { FACE_DIMENSION, FACE_ORDER } from './constants'; + +import faces from './assets/faces.png'; + +export type FaceType = (typeof FACE_ORDER)[number]; + +function getFaceOffset(face: FaceType) { + const faceIndex = FACE_ORDER.indexOf(face); + return faceIndex * FACE_DIMENSION; +} + +type FaceProps = { + face: FaceType; +}; + +export function Face({ face }: FaceProps) { + return ( +
+ ); +} diff --git a/src/channels/minesweeper/Tile.tsx b/src/channels/minesweeper/Tile.tsx new file mode 100644 index 0000000..62c232d --- /dev/null +++ b/src/channels/minesweeper/Tile.tsx @@ -0,0 +1,25 @@ +import { css } from '@emotion/react'; +import { TILE_DIMENSION } from './constants'; + +import tiles from './assets/tiles.png'; + +export type TileData = { + id: string; + tileType: readonly [number, number]; + isMine: boolean; +}; + +export function Tile({ tileType }: TileData) { + const [x, y] = tileType; + + return ( +
+ ); +} diff --git a/src/channels/minesweeper/assets/faces.png b/src/channels/minesweeper/assets/faces.png new file mode 100644 index 0000000..7fb4363 Binary files /dev/null and b/src/channels/minesweeper/assets/faces.png differ diff --git a/src/channels/minesweeper/assets/tiles.png b/src/channels/minesweeper/assets/tiles.png new file mode 100644 index 0000000..a9934b8 Binary files /dev/null and b/src/channels/minesweeper/assets/tiles.png differ diff --git a/src/channels/minesweeper/constants.ts b/src/channels/minesweeper/constants.ts new file mode 100644 index 0000000..038fb6a --- /dev/null +++ b/src/channels/minesweeper/constants.ts @@ -0,0 +1,42 @@ +export const FACE_DIMENSION = 24; +export const FACE_ORDER = ['smile', 'smile_pressed', 'open_mouth', 'sunglasses', 'heart_eyes'] as const; + +export const MINE_CHANCE = 0.17; + +export const GRID_ROWS = 16; +export const GRID_COLUMNS = 67; + +export const TILE_DIMENSION = 16; + +export const TILE_MAP = { + HIDDEN: [0, 0], + ONE: [0, 1], + TWO: [1, 1], + THREE: [2, 1], + FOUR: [3, 1], + FIVE: [4, 1], + SIX: [5, 1], + SEVEN: [6, 1], + EIGHT: [7, 1], + EMPTY: [1, 0], + FLAGGED: [2, 0], + QUESTION_MARK: [3, 0], +} as const; + +export const mineNumberTiles = [ + TILE_MAP.EMPTY, + TILE_MAP.ONE, + TILE_MAP.TWO, + TILE_MAP.THREE, + TILE_MAP.FOUR, + TILE_MAP.FIVE, + TILE_MAP.SIX, + TILE_MAP.SEVEN, + TILE_MAP.EIGHT, +]; + +export const MIN_REVEAL_DONATION = 20; +/** maximum donation amount for determining reveal threshold */ +export const REVEAL_DONATION_CAP = 500; +export const MIN_REVEALED_TILES = 1; +export const MAX_REVEALED_TILES = 25; diff --git a/src/channels/minesweeper/index.tsx b/src/channels/minesweeper/index.tsx new file mode 100644 index 0000000..3f67836 --- /dev/null +++ b/src/channels/minesweeper/index.tsx @@ -0,0 +1,294 @@ +import { useEffect, useReducer, useRef, useState } from 'react'; +import { Global, css } from '@emotion/react'; +import styled from '@emotion/styled'; +import TweenNumber from '@gdq/lib/components/TweenNumber'; +import { ChannelProps, registerChannel } from '../channels'; +import { useListenFor, useReplicant } from 'use-nodecg'; +import type { Event, FormattedDonation, Total } from '@gdq/types/tracker'; + +import { Face, type FaceType } from './Face'; +import { Tile, TileData } from './Tile'; +import { TILE_DIMENSION, GRID_COLUMNS, GRID_ROWS, TILE_MAP, MINE_CHANCE, MIN_REVEAL_DONATION } from './constants'; +import { createTileCluster, getTileRevealThreshold, random, randomFromArray, splitTileIndex } from './utils'; +import { usePreloadedReplicant } from '@gdq/lib/hooks/usePreloadedReplicant'; +import { cloneDeep } from 'lodash'; + +registerChannel('Minesweeper', 132, Minesweeper, { + position: 'bottomRight', + handle: 'rshig', +}); + +type GridState = { + grid: TileData[][]; + mines: string[]; + nonMines: string[]; +}; + +function generateInitialGridState(): GridState { + const state: GridState = { grid: [], mines: [], nonMines: [] }; + for (let i = 0; i < GRID_ROWS; i++) { + state.grid[i] = []; + for (let j = 0; j < GRID_COLUMNS; j++) { + const isMine = Math.random() < MINE_CHANCE; + const id = `${i}:${j}`; + state.grid[i][j] = { + id, + isMine, + tileType: TILE_MAP.HIDDEN, + }; + if (isMine) { + state.mines.push(id); + } else { + state.nonMines.push(id); + } + } + } + return state; +} + +const stateReplicant = nodecg.Replicant('minesweeper-state', { + defaultValue: generateInitialGridState(), + persistent: true, +}); + +const actions = { + RESET: 'reset', + FLAG_TILE: 'flag', + REVEAL_TILES: 'reveal', + QUESTION_TILE: 'question', +} as const; + +type GridAction = + | { type: typeof actions.FLAG_TILE } + | { type: typeof actions.RESET } + | { type: typeof actions.REVEAL_TILES; donationAmount: number } + | { type: typeof actions.QUESTION_TILE }; + +function gridReducer(state: GridState, action: GridAction) { + switch (action.type) { + case actions.RESET: { + const newState = generateInitialGridState(); + stateReplicant.value = newState; + return newState; + } + case actions.FLAG_TILE: { + if (state.mines.length > 0) { + const tileIndexStr = randomFromArray(state.mines); + const [rowIndex, tileIndex] = splitTileIndex(tileIndexStr); + + const newGrid = [...state.grid]; + newGrid[rowIndex][tileIndex].tileType = TILE_MAP.FLAGGED; + + const newMines = state.mines.filter((mineIndex) => mineIndex !== tileIndexStr); + + const newState = { ...state, grid: newGrid, mines: newMines }; + stateReplicant.value = newState; + return newState; + } + return state; + } + case actions.REVEAL_TILES: { + if (state.nonMines.length > 0) { + const revealThreshold = Math.min( + // ensure the threashold doesn't exceed the number of nonMine tiles + getTileRevealThreshold(action.donationAmount), + state.nonMines.length, + ); + + let revealedTiles: TileData[] = []; + let grid = [...state.grid]; + let nonMines = [...state.nonMines]; + + // ensures that bigger donations reveal more tiles + while (revealedTiles.length < revealThreshold) { + const tileIndexStr = randomFromArray(nonMines); + const tilesToReveal = createTileCluster(grid, tileIndexStr); + revealedTiles = [...revealedTiles, ...tilesToReveal]; + + grid = grid.map((row) => { + return row.map((tile) => { + const tileInRevealList = tilesToReveal.find((tileToReveal) => tileToReveal.id === tile.id); + return tileInRevealList || tile; + }); + }); + + // remove any revealed tiles from the nonMines list so they can't be selected again + const revealedTileIds = tilesToReveal.map((revealedTile) => revealedTile.id); + nonMines = nonMines.filter((id) => !revealedTileIds.includes(id)); + } + + const newState = { ...state, grid, nonMines }; + stateReplicant.value = newState; + return newState; + } + return state; + } + case actions.QUESTION_TILE: { + if (state.mines.length > 0) { + const tileIndexStr = randomFromArray(state.mines); + const [rowIndex, tileIndex] = splitTileIndex(tileIndexStr); + + const newGrid = [...state.grid]; + newGrid[rowIndex][tileIndex].tileType = TILE_MAP.QUESTION_MARK; + + const newState = { ...state, grid: newGrid }; + stateReplicant.value = newState; + return newState; + } + return state; + } + default: { + return state; + } + } +} + +export function Minesweeper(props: ChannelProps) { + const [currentEvent] = usePreloadedReplicant('currentEvent'); + const [total] = useReplicant('total', null); + + const [gridState, dispatch] = useReducer(gridReducer, cloneDeep(stateReplicant.value!)); + + const [face, setFace] = useState('smile'); + const faceChangeTimeout = useRef(); + + function changeFace(face: FaceType) { + clearTimeout(faceChangeTimeout.current); + setFace(face); + faceChangeTimeout.current = setTimeout(() => setFace('smile'), 2_500); + } + + useListenFor('donation', (donation: FormattedDonation) => { + changeFace(donation.rawAmount < MIN_REVEAL_DONATION ? 'open_mouth' : 'sunglasses'); + + if (donation.rawAmount < MIN_REVEAL_DONATION && gridState.mines.length > 0) { + dispatch({ type: actions.FLAG_TILE }); + } else { + dispatch({ type: actions.REVEAL_TILES, donationAmount: donation.rawAmount }); + } + }); + + useListenFor('subscription', () => { + changeFace('heart_eyes'); + }); + + useEffect(() => { + if (gridState.nonMines.length === 0) { + // reset the grid when we run out of hidden tiles to reveal + dispatch({ type: actions.RESET }); + } + }, [gridState.nonMines]); + + const flagTimeoutRef = useRef(); + useEffect(() => { + function flagTiles(timeoutMS: number) { + // Add a question mark every 5-10 seconds + const newTimeout = random(5_000, 10_000); + flagTimeoutRef.current = setTimeout(() => { + dispatch({ type: actions.QUESTION_TILE }); + flagTiles(newTimeout); + }, timeoutMS); + } + + flagTiles(5_000); + + return () => { + clearTimeout(flagTimeoutRef.current); + }; + }, []); + + return ( + + + +
+ + {currentEvent.shortname} + + + + + $ + + +
+ + {gridState.grid.map((row) => { + return row.map((cell) => ); + })} + +
+
+ ); +} + +const Container = styled.div` + position: absolute; + width: 100%; + height: 100%; + padding: 0; + margin: 0; + background: #c0c0c0; + border: 1px solid; + border-color: #fff #aca899 #aca899 #fff; +`; + +const Wrapper = styled.div` + position: relative; + display: flex; + gap: 5px; + flex-direction: column; + height: 100%; + background-color: #bdbdbd; + padding: 5px; + border-left: 3px solid #fff; + border-top: 3px solid #fff; +`; + +const Header = styled.header` + display: flex; + justify-content: space-between; + align-items: center; + padding: 0 5px; + height: 50px; + border: 2px solid; + border-color: #7d7d7d #fff #fff #7d7d7d; +`; + +const Grid = styled.div` + flex: 1; + display: grid; + grid-template-rows: repeat(${GRID_ROWS}, ${TILE_DIMENSION}px); + grid-template-columns: repeat(${GRID_COLUMNS}, ${TILE_DIMENSION}px); + place-content: center; + border: 2px solid; + border-color: #7d7d7d #fff #fff #7d7d7d; +`; + +const LCDContainer = styled.div` + flex: 1; + display: flex; +`; + +const LCDText = styled.div` + font-family: minesweeper; + font-size: 32px; + text-transform: uppercase; + color: #ea3323; + background: #000; + border: 1px solid; + border-color: #808080 #fff #fff #808080; + padding: 1px; +`; diff --git a/src/channels/minesweeper/utils.ts b/src/channels/minesweeper/utils.ts new file mode 100644 index 0000000..36bae3d --- /dev/null +++ b/src/channels/minesweeper/utils.ts @@ -0,0 +1,110 @@ +import { TileData } from './Tile'; +import { + REVEAL_DONATION_CAP, + GRID_COLUMNS, + GRID_ROWS, + MAX_REVEALED_TILES, + MIN_REVEAL_DONATION, + MIN_REVEALED_TILES, + mineNumberTiles, +} from './constants'; + +export function random(min: number, max: number) { + return Math.floor(Math.random() * (max - min)) + min; +} + +export function randomFromArray(arr: T[]) { + const randomIndex = random(0, arr.length); + return arr[randomIndex]; +} + +export function splitTileIndex(indexStr: string) { + return indexStr.split(':').map((index) => Number(index)); +} + +export function isTileInbounds(rowIndex: number, tileIndex: number) { + return rowIndex >= 0 && rowIndex < GRID_ROWS && tileIndex >= 0 && tileIndex < GRID_COLUMNS; +} + +function getAdjacentTiles(rowIndex: number, tileIndex: number) { + return [ + [rowIndex - 1, tileIndex], + [rowIndex, tileIndex - 1], + [rowIndex, tileIndex + 1], + [rowIndex + 1, tileIndex], + ]; +} + +function getSurroundingTiles(rowIndex: number, tileIndex: number) { + return [ + [rowIndex - 1, tileIndex - 1], + [rowIndex - 1, tileIndex], + [rowIndex - 1, tileIndex + 1], + [rowIndex, tileIndex - 1], + [rowIndex, tileIndex + 1], + [rowIndex + 1, tileIndex - 1], + [rowIndex + 1, tileIndex], + [rowIndex + 1, tileIndex + 1], + ]; +} + +export function getMineCount(grid: TileData[][], tileId: string) { + const [rowIndex, tileIndex] = splitTileIndex(tileId); + const surroundingTiles = getSurroundingTiles(rowIndex, tileIndex); + const surroundingMineCount = surroundingTiles.reduce((mineCount, [r, t]) => { + if (!isTileInbounds(r, t)) { + return mineCount; + } + return mineCount + (grid[r][t].isMine ? 1 : 0); + }, 0); + return surroundingMineCount; +} + +export function createTileCluster(grid: TileData[][], startingTileId: string) { + const visitedTiles: TileData[] = []; + const [rowIndex, tileIndex] = splitTileIndex(startingTileId); + + function visitTile(rowIndex: number, tileIndex: number) { + const tile = grid[rowIndex]?.[tileIndex] as TileData | undefined; + // stop tile from being revealed (and cascading further) + // when the following conditions are met + if ( + // not a valid tile + !tile || + // the tile is a mine + tile.isMine || + // has already been visisted + visitedTiles.find((visitedTile) => visitedTile.id === tile.id) + ) { + return; + } + + const mineCount = getMineCount(grid, tile.id); + visitedTiles.push({ + ...tile, + tileType: mineNumberTiles[mineCount], + }); + + // stop revealing once we hit a number tile + if (mineCount > 0) { + return; + } + + const adjacentTiles = getAdjacentTiles(rowIndex, tileIndex); + adjacentTiles.forEach(([ajacentRowIndex, adjacentTileIndex]) => visitTile(ajacentRowIndex, adjacentTileIndex)); + } + + visitTile(rowIndex, tileIndex); + + return visitedTiles; +} + +export function getTileRevealThreshold(donationAmount: number) { + // cap the maximum donation amount + const amount = Math.min(donationAmount, REVEAL_DONATION_CAP); + // transforms donation range from $(MIN_DONATION_TO_REVEAL) - $(DONATION_REVEAL_CAP) + // to (MIN_REVEALED_TILES) to (MAX_REVEALED_TILES) revealed tile range + const scale = (MAX_REVEALED_TILES - MIN_REVEALED_TILES) / (REVEAL_DONATION_CAP - MIN_REVEAL_DONATION); + // find donation position in the new scale + return Math.ceil(amount * scale + MIN_REVEALED_TILES); +}