diff --git a/frontend/package.json b/frontend/package.json index d37122b9637a12b4f4a7eb120d61bf53f1b1f60d..665306053ef786781725cb0fe2bb35655837a288 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,6 +22,7 @@ "myzod": "^1.0.0-alpha.9", "react": "^16.13.1", "react-dom": "^16.13.1", + "react-fast-compare": "^3.2.0", "react-hook-form": "^5.6.3", "react-scripts": "^3.4.1", "react-use-websocket": "^2.0.1", diff --git a/frontend/src/app.tsx b/frontend/src/app.tsx index 7a02930b7b7c44e9c6c5a5e741a92170f177a7eb..946e4160e7b2a3ca5add063f9d5c4f489919e2d1 100644 --- a/frontend/src/app.tsx +++ b/frontend/src/app.tsx @@ -1,13 +1,15 @@ import querystring from 'querystring'; import * as React from 'react'; -import { ServerTimeProvider } from './hooks/useServerTime'; +import { ServerTimeProvider } from './hooks'; import { Game, GameProps } from './pages/game'; import { Login } from './pages/login'; import { StaticView } from './pages/staticView'; export const App = (_props: {}) => { const [gameProps, setGameProps] = React.useState(); + const leave = React.useCallback(() => setGameProps(undefined), []); + const onLogin = React.useCallback((roomID, nickname) => setGameProps({ roomID, nickname, leave }), [leave]); if (process.env.NODE_ENV === 'development') { const query = querystring.parse(window.location.search.substring(1)); @@ -24,9 +26,5 @@ export const App = (_props: {}) => { ); } - return ( - setGameProps({ roomID, nickname, leave: () => setGameProps(undefined) })} - /> - ); + return ; }; diff --git a/frontend/src/components/board.tsx b/frontend/src/components/board.tsx index 2d38bb7cccc2dd8b3e02b3b0f6d301523bc09038..f5a69d3607197da63efa231a1250abc9b219d8b0 100644 --- a/frontend/src/components/board.tsx +++ b/frontend/src/components/board.tsx @@ -2,8 +2,9 @@ import { Button, createStyles, makeStyles, Theme, Typography } from '@material-u import { grey, orange, red } from '@material-ui/core/colors'; import { Fireworks } from 'fireworks/lib/react'; import * as React from 'react'; +import isEqual from 'react-fast-compare'; -import { isDefined } from '../common'; +import { isDefined, noop } from '../common'; import { StateBoard, StateTile } from '../protocol'; import { TeamHue, teamSpecs } from '../teams'; import { AspectDiv } from './aspectDiv'; @@ -85,20 +86,37 @@ const useTileStyles = makeStyles((theme: Theme) => }) ); +const fireworksProps = { + interval: 0, + colors: [red[700], orange[800], grey[500]], + x: 0, + y: 0, +}; + interface TileProps { + row: number; + col: number; + onClick: (row: number, col: number) => void; tile: StateTile; - onClick: () => void; spymaster: boolean; myTurn: boolean; winner: boolean; } -const Tile = ({ tile, onClick, spymaster, myTurn, winner }: TileProps) => { +const Tile = React.memo(function Tile({ row, col, onClick, tile, spymaster, myTurn, winner }: TileProps) { const classes = useTileStyles(); const bombRevealed = !!(tile.revealed && tile.view?.bomb); const alreadyExploded = React.useRef(bombRevealed); const explode = bombRevealed && !alreadyExploded.current; + const disabled = spymaster || !myTurn || winner || tile.revealed; + + const reveal = React.useMemo(() => { + if (disabled) { + return noop; + } + return () => onClick(row, col); + }, [disabled, row, col, onClick]); return ( @@ -106,9 +124,9 @@ const Tile = ({ tile, onClick, spymaster, myTurn, winner }: TileProps) => { type="button" variant="contained" className={classes.button} - onClick={onClick} + onClick={reveal} style={tileStyle(tile, spymaster)} - disabled={spymaster || !myTurn || winner || tile.revealed} + disabled={disabled} > {tile.word} @@ -117,20 +135,13 @@ const Tile = ({ tile, onClick, spymaster, myTurn, winner }: TileProps) => { {explode ? (
- +
) : null}
); -}; +}, isEqual); export interface BoardProps { words: StateBoard; @@ -154,7 +165,7 @@ const useStyles = makeStyles((theme: Theme) => }) ); -export const Board = (props: BoardProps) => { +export const Board = React.memo(function Board(props: BoardProps) { const classes = useStyles(props); return ( @@ -163,8 +174,10 @@ export const Board = (props: BoardProps) => { arr.map((tile, col) => (
props.onClick(row, col)} spymaster={props.spymaster} myTurn={props.myTurn} winner={props.winner} @@ -174,4 +187,4 @@ export const Board = (props: BoardProps) => { )}
); -}; +}, isEqual); diff --git a/frontend/src/hooks/useServerTime.tsx b/frontend/src/hooks/index.tsx similarity index 77% rename from frontend/src/hooks/useServerTime.tsx rename to frontend/src/hooks/index.tsx index 4d9e4a2f6c0532185933ee98d8291496ee12bb26..3544ed07749d346e222a95fdee9a2de3b93168b4 100644 --- a/frontend/src/hooks/useServerTime.tsx +++ b/frontend/src/hooks/index.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import { v4 } from 'uuid'; export interface ServerTime { setOffset: (v: number) => void; @@ -16,3 +17,9 @@ export const ServerTimeProvider = (props: React.PropsWithChildren<{}>) => { export function useServerTime() { return React.useContext(Context); } + +export function useStableUUID(): string { + const id = React.useRef(); + id.current = id.current ?? v4(); + return id.current; +} diff --git a/frontend/src/pages/game.tsx b/frontend/src/pages/game.tsx index 34ed58acbf728197225b8c1a5790c2c88846d36d..b65a0346f90a15812e9d73ffecc16c85ac988006 100644 --- a/frontend/src/pages/game.tsx +++ b/frontend/src/pages/game.tsx @@ -1,10 +1,10 @@ import { fail } from 'assert'; import * as React from 'react'; import useWebSocket from 'react-use-websocket'; -import { v4 } from 'uuid'; +import { DeepReadonly } from 'ts-essentials'; import { assertIsDefined, assertNever, noop, reloadOutdatedPage, websocketUrl } from '../common'; -import { useServerTime } from '../hooks/useServerTime'; +import { useServerTime, useStableUUID } from '../hooks'; import { version as codiesVersion } from '../metadata.json'; import { ClientNote, PartialClientNote, ServerNote, State, StatePlayer, TimeResponse, WordPack } from '../protocol'; import { GameView, Sender } from './gameView'; @@ -175,8 +175,8 @@ export interface GameProps { leave: () => void; } -export const Game = (props: GameProps) => { - const [playerID] = React.useState(v4); +export const Game = (props: DeepReadonly) => { + const playerID = useStableUUID(); const nickname = React.useRef(props.nickname); // Preserve a nickname for use in reconnects. const syncTime = useSyncedServerTime(); @@ -184,6 +184,7 @@ export const Game = (props: GameProps) => { const reducer = useStateReducer(sendJsonMessage); const [state, dispatch] = React.useReducer(reducer, undefined); + const player = usePlayer(playerID, state); const send = useSender(dispatch); React.useEffect(() => { @@ -202,8 +203,6 @@ export const Game = (props: GameProps) => { } }, [lastJsonMessage]); - const player = usePlayer(playerID, state); - if (!state) { return ; } diff --git a/frontend/src/pages/gameView.tsx b/frontend/src/pages/gameView.tsx index 2b12a118921fb12d3e8a9522f1458488e591a993..31e813d4414cd9df2db09901751bd39abb7dc689 100644 --- a/frontend/src/pages/gameView.tsx +++ b/frontend/src/pages/gameView.tsx @@ -38,7 +38,7 @@ import { Controller, useForm } from 'react-hook-form'; import { isDefined, nameofFactory, noComplete } from '../common'; import { Board } from '../components/board'; import { ClipboardButton } from '../components/clipboard'; -import { useServerTime } from '../hooks/useServerTime'; +import { useServerTime } from '../hooks'; import { State, StatePlayer, StateTimer, WordPack } from '../protocol'; import { teamSpecs } from '../teams'; @@ -511,10 +511,11 @@ const Sidebar = ({ send, state, pState, pTeam }: GameViewProps) => { const Board2 = ({ send, state, pState, pTeam }: GameViewProps) => { const myTurn = state.turn === pTeam; + return ( myTurn && !pState.spymaster && send.reveal(row, col)} + onClick={send.reveal} spymaster={pState.spymaster} myTurn={myTurn} winner={isDefined(state.winner)} @@ -637,6 +638,15 @@ const useStyles = makeStyles((theme: Theme) => sidebar: { gridArea: 'sidebar', }, + leaveWrapper: { + position: 'absolute', + top: 0, + left: 0, + margin: '0.5rem', + }, + leaveButton: { + marginRight: '0.5rem', + }, }) ); @@ -645,15 +655,8 @@ export const GameView = (props: GameViewProps) => { return (
-
-