game.tsx

  1import { fail } from 'assert';
  2import * as React from 'react';
  3import useWebSocket from 'react-use-websocket';
  4import { v4 } from 'uuid';
  5
  6import { assertIsDefined, assertNever, noop, websocketUrl } from '../common';
  7import { useServerTime } from '../hooks/useServerTime';
  8import { ClientNote, ServerNote, State, StatePlayer, TimeResponse, WordPack } from '../protocol';
  9import { GameView, Sender } from './gameView';
 10import { Loading } from './loading';
 11
 12const socketUrl = websocketUrl('/api/ws');
 13
 14function useSender(sendNote: (r: ClientNote) => void, version: number): Sender {
 15    return React.useMemo<Sender>(() => {
 16        return {
 17            reveal: (row: number, col: number) =>
 18                sendNote({
 19                    method: 'reveal',
 20                    version,
 21                    params: {
 22                        row,
 23                        col,
 24                    },
 25                }),
 26            newGame: () => sendNote({ method: 'newGame', version, params: {} }),
 27            endTurn: () => sendNote({ method: 'endTurn', version, params: {} }),
 28            changeNickname: (nickname: string) => sendNote({ method: 'changeNickname', version, params: { nickname } }),
 29            changeRole: (spymaster: boolean) => sendNote({ method: 'changeRole', version, params: { spymaster } }),
 30            changeTeam: (team: number) => sendNote({ method: 'changeTeam', version, params: { team } }),
 31            randomizeTeams: () => sendNote({ method: 'randomizeTeams', version, params: {} }),
 32            changePack: (num: number, enable: boolean) =>
 33                sendNote({ method: 'changePack', version, params: { num, enable } }),
 34            changeTurnMode: (timed: boolean) => sendNote({ method: 'changeTurnMode', version, params: { timed } }),
 35            changeTurnTime: (seconds: number) => sendNote({ method: 'changeTurnTime', version, params: { seconds } }),
 36            addPacks: (packs: WordPack[]) => sendNote({ method: 'addPacks', version, params: { packs } }),
 37            removePack: (num: number) => sendNote({ method: 'removePack', version, params: { num } }),
 38            changeHideBomb: (hideBomb: boolean) =>
 39                sendNote({ method: 'changeHideBomb', version, params: { hideBomb } }),
 40        };
 41    }, [sendNote, version]);
 42}
 43
 44function usePlayer(playerID: string, state?: State): { pState: StatePlayer; pTeam: number } | undefined {
 45    return React.useMemo(() => {
 46        if (!state) {
 47            return undefined;
 48        }
 49
 50        for (let i = 0; i < state.teams.length; i++) {
 51            const pState = state.teams[i].find((p) => p.playerID === playerID);
 52            if (pState) {
 53                return { pState, pTeam: i };
 54            }
 55        }
 56
 57        fail('Player not found in any team');
 58    }, [playerID, state]);
 59}
 60
 61const reconnectAttempts = 5;
 62
 63function useWS(roomID: string, playerID: string, nickname: string, dead: () => void, onOpen: () => void) {
 64    const didUnmount = React.useRef(false);
 65    const retry = React.useRef(0);
 66
 67    return useWebSocket(socketUrl, {
 68        queryParams: { roomID, playerID, nickname },
 69        reconnectAttempts,
 70        onMessage: () => {
 71            retry.current = 0;
 72        },
 73        onOpen,
 74        shouldReconnect: () => {
 75            if (didUnmount.current) {
 76                return false;
 77            }
 78
 79            retry.current++;
 80
 81            if (retry.current >= reconnectAttempts) {
 82                dead();
 83                return false;
 84            }
 85
 86            return true;
 87        },
 88    });
 89}
 90
 91function syncTime(setOffset: (offset: number) => void) {
 92    const fn = async () => {
 93        let bestRTT: number | undefined;
 94        let offset = 0;
 95
 96        for (let i = 0; i < 3; i++) {
 97            const before = Date.now();
 98            const resp = await fetch('/api/time');
 99            const after = Date.now();
100
101            const body = await resp.json();
102            if (resp.ok) {
103                const rtt = (after - before) / 2;
104
105                if (bestRTT !== undefined && rtt > bestRTT) {
106                    continue;
107                }
108
109                bestRTT = rtt;
110
111                const t = TimeResponse.parse(body);
112                const serverTime = t.time.getTime() + rtt;
113                offset = serverTime - Date.now();
114            }
115        }
116
117        setOffset(offset);
118    };
119    fn().catch(noop);
120}
121
122export interface GameProps {
123    roomID: string;
124    nickname: string;
125    leave: () => void;
126}
127
128export const Game = (props: GameProps) => {
129    const [playerID] = React.useState(v4);
130    const nickname = React.useRef(props.nickname); // Preserve a nickname for use in reconnects.
131    const [state, setState] = React.useState<State | undefined>();
132    const { setOffset } = useServerTime();
133
134    const { sendJsonMessage, lastJsonMessage } = useWS(props.roomID, playerID, nickname.current, props.leave, () =>
135        syncTime(setOffset)
136    );
137
138    React.useEffect(() => {
139        const interval = window.setInterval(() => {
140            syncTime(setOffset);
141        }, 10 * 60 * 1000);
142        return () => window.clearInterval(interval);
143    }, [setOffset]);
144
145    const send = useSender(sendJsonMessage, state?.version ?? 0);
146
147    React.useEffect(() => {
148        if (!lastJsonMessage) {
149            return;
150        }
151
152        const note = ServerNote.parse(lastJsonMessage);
153
154        switch (note.method) {
155            case 'state':
156                setState(note.params);
157                break;
158            default:
159                assertNever(note.method);
160        }
161    }, [lastJsonMessage]);
162
163    const player = usePlayer(playerID, state);
164
165    if (!state) {
166        return <Loading />;
167    }
168
169    assertIsDefined(player);
170    nickname.current = player.pState.nickname;
171
172    return (
173        <GameView
174            roomID={props.roomID}
175            leave={props.leave}
176            send={send}
177            state={state}
178            pState={player.pState}
179            pTeam={player.pTeam}
180        />
181    );
182};