From 83b952f2c9ac8867932f3b79b1e002d1e5a80c95 Mon Sep 17 00:00:00 2001 From: rshigg <90143161+rshigg@users.noreply.github.com> Date: Fri, 12 Jan 2024 00:40:22 -0330 Subject: [PATCH] feat: add Minesweeper channel (#23) * feat:add Minesweeper channel * refactor createTileCluster to simplify logic * Add credit, store grid state in persistent replicant, grab event name from currentEvent object * Add question marks at random intervals * Change to heart eyes face on donation event * remove tile reveal decay in favor of minimum tile reveal threshold to make tile revealing a bit more realistic --- src/assets/fonts.css | 7 +- src/assets/fonts/Minesweeper.woff2 | Bin 0 -> 6024 bytes src/channels/index.ts | 1 + src/channels/minesweeper/Face.tsx | 29 +++ src/channels/minesweeper/Tile.tsx | 25 ++ src/channels/minesweeper/assets/faces.png | Bin 0 -> 642 bytes src/channels/minesweeper/assets/tiles.png | Bin 0 -> 1222 bytes src/channels/minesweeper/constants.ts | 42 ++++ src/channels/minesweeper/index.tsx | 294 ++++++++++++++++++++++ src/channels/minesweeper/utils.ts | 110 ++++++++ 10 files changed, 507 insertions(+), 1 deletion(-) create mode 100644 src/assets/fonts/Minesweeper.woff2 create mode 100644 src/channels/minesweeper/Face.tsx create mode 100644 src/channels/minesweeper/Tile.tsx create mode 100644 src/channels/minesweeper/assets/faces.png create mode 100644 src/channels/minesweeper/assets/tiles.png create mode 100644 src/channels/minesweeper/constants.ts create mode 100644 src/channels/minesweeper/index.tsx create mode 100644 src/channels/minesweeper/utils.ts 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 0000000000000000000000000000000000000000..732ca9c4f0b881ff9f7762c84d8170ce13dd983f GIT binary patch literal 6024 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 + ); +} 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 0000000000000000000000000000000000000000..7fb4363ce19d2b90a987168057aeab2a7609104e GIT binary patch literal 642 zcmV-|0)737P)qiQ(q3Wr(kCN*v*f;4*rDe=M0SX* zxzvCIn837OilTz@;o4vvT3FY0HA-5R#mw`pg*V0+EPG?!*uuhJ4}+utic8DIo+r5Jz}4 zleF#cogWRbwUz~=un)j?=_GCYN8ktd;1t{+D7R9^aYAEZ-(J49{iE>N0PlfI1_?>a zNtAoL83VB0sr>d_zEnr?MO8GwcBR_(sMREzrpJ4#eXr0Dv8zPiAMX$MRJ*UI0OvR! zRg$V}O(Ng^Jpq>3Px+i}d>xU;_k)jcY zRdNNV3@R-mT8UQ6^z&cWQ-J4rewyi_Ao}|dK;D5w%}M0jkD8{(6v|c468kxR%s)r- z#YGlZ{^zE-BPP)_{f+@p{(3}J+dnn=Y=F60gar@1sB#@o`L&$H*^m1qw)^|$9{aWZ zQQ#1||OP?hxx0RE$?eCeNd`-fK0c^0ZMBA6x*s)Wf z5+`jO8|*7l?|kulu;!2Z+D@v;8I6B3rp><}CCary+y0UHuh;8!(hlzVd{$%M?{`(_ c1HjPyA2*#T=s7narvLx|07*qoM6N<$f^&L29RL6T literal 0 HcmV?d00001 diff --git a/src/channels/minesweeper/assets/tiles.png b/src/channels/minesweeper/assets/tiles.png new file mode 100644 index 0000000000000000000000000000000000000000..a9934b87243d0b48df1e9eb15304a30a8f5b32f8 GIT binary patch literal 1222 zcmV;%1UdVOP)EX>4Tx04R}tkv&MmKpe$iQ>7{uhZYfWh)|s@h>AFB6^c+H)C#RSm|Q=hNkfw2 z;wZQl9Q;_UI=DFN>fkB}f*&9*u1<v=j{EWM-sA2aAk@oDH9N)uRkMs_ zJSL_yt7704-RMUEAq>mR)aN8A0nhPu4L!JM|T%ypVWh+`2;kRU=q6(y8mAxf)8iisrc$2|Olj$a~|Laq`R zITlcX3fb|4|H1EW&HUtqn-q)zoiDciF#-g3fkw@?zmILZaRT_Cfh(=$uhfB=Ptt2G zEpi0(Z37qAElt@2E_Z-|CtWsVNAlAY@_FF>jJ_!Ygl>VJHMh6sK29Hi6m^xj0S*p< z(E??!d%U}=y|;hQH2eDjNSJb#5*s=<00009a7bBm000id000id0mpBsWB>pF2XskI zMF-;v76&pA(eC>$0008jNkl6;kg>Dx2X5wV@$E1X^h%Mj*&2+MX zg^~31BvWCGF;FR5Yak+Mk;mf!E&7#GFmwHP7ujR;5b^B`xB6=xpx63|sCoY5Jgevb zoM)4M>265r!h0LEMgQ#e#{?0ZbO``31NBQ-Sk&d&x?%(UsQ7fKFWD=1?6K<2s0LWG z#wVgCm`B8yzY^W<9RGbT;_YkfeGoyf`&1eIzM~wgdh%?kb)pm};G_eSFmf&(_)fq< ziRBzXcYtcwXtNy07z3dKi;DoU`GUb2tRuPeO=SL(93bb(wUN9cNgN?hq!1!${N+iY zzTlMJZ2X$*_>nDyD7v0MIyGN-Q61E8Q6O-ASHFZUU7Vl4P)a1I?HM^`E|9y_H4Vm{yRlntQa3eXzD{`E}*Ku%+&#r+9yNuzm z-6eYllgTKDaQb6pFXQJTon!b^bSw6O3|_R4HYQa#YRSW<{5~M-+OntdQ}Dj!MjXKA z`&JYPMLyR_ViB*CQ9{Yj_B#$T#3GoB46$)kc{GiS5`HJLi37A6zSj6F3pH{OT&!s& zM;N|GYVadnIfokKT`z3n1u5f{8r~mYYj|$-`@hFHfZsQeBaOc~!A&_pDEF8415(UC z<`}a}JnE>w2h^8De@#g^lmmM>tT;{@oiDt)4 kKcYSk0k-s&?Eqgczdk<{907*qoM6N<$g0O`*HUIzs literal 0 HcmV?d00001 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); +}