From 7e0ba5431ad02dab66dfefd05c20fd4462cab402 Mon Sep 17 00:00:00 2001 From: snen Date: Fri, 24 Sep 2021 06:51:17 -0400 Subject: [PATCH] Implement the socket protocol...mostly --- client/game/Board.tsx | 2 - client/game/BoardSlot.tsx | 26 +++++ client/game/Game.tsx | 134 +++++++++++++++------- client/game/GameClient.tsx | 153 ++++++++++++++++++++++++++ client/game/cards/Card.tsx | 20 ++-- client/game/cards/Hand.tsx | 18 --- client/game/cards/cards.ts | 119 +++----------------- client/game/cards/getCardKey.ts | 5 - client/game/selection.tsx | 31 ++++++ client/game/types.ts | 119 +++++++++++++++++++- client/game/useServerSocket.ts | 189 ++++++++++++++++++++++++++++++++ client/game/useSocket.ts | 133 ---------------------- client/pages/AppPage.tsx | 6 +- client/styles.css | 32 +++++- 14 files changed, 666 insertions(+), 321 deletions(-) delete mode 100644 client/game/Board.tsx create mode 100644 client/game/BoardSlot.tsx create mode 100644 client/game/GameClient.tsx delete mode 100644 client/game/cards/Hand.tsx delete mode 100644 client/game/cards/getCardKey.ts create mode 100644 client/game/selection.tsx create mode 100644 client/game/useServerSocket.ts delete mode 100644 client/game/useSocket.ts diff --git a/client/game/Board.tsx b/client/game/Board.tsx deleted file mode 100644 index aca1895..0000000 --- a/client/game/Board.tsx +++ /dev/null @@ -1,2 +0,0 @@ - -export default function Board() {} diff --git a/client/game/BoardSlot.tsx b/client/game/BoardSlot.tsx new file mode 100644 index 0000000..eff63e9 --- /dev/null +++ b/client/game/BoardSlot.tsx @@ -0,0 +1,26 @@ +import React from 'react' + +import type {Card} from './types.ts' +import CardToken from './cards/Card.tsx' + +const EMPTY_SPACE = '-' + +interface CardTokenProps { + card: Card | null + onSelect: () => void +} + +export default function BoardSlot(props: CardTokenProps): JSX.Element { + const {onSelect, card} = props + + if (card == null) { + return ( + + ) + } + return ( + + ) +} diff --git a/client/game/Game.tsx b/client/game/Game.tsx index a0169a4..ad3db88 100644 --- a/client/game/Game.tsx +++ b/client/game/Game.tsx @@ -1,51 +1,103 @@ -import React, { useEffect } from 'react' +import React from 'react' -import useSocket from './useSocket.ts' -import {CardInstance} from './types.ts' +import type { GameHandle, Selection } from './types.ts' +import BoardSlot from './BoardSlot.tsx' +import CardToken from './cards/Card.tsx' +import {isAttackSelection, isMoveSelection, isPlayCardSelection} from './selection.tsx' -interface GameActionsContextValue {} +type GameProps = GameHandle -const GameActionsContext = React.createContext(null) +export default function Game({ + team, + board, + player, + deck, + enemyLife, + enemyDeckSize, + enemyHandSize, + currentTurn, + canDraw, + hasDrawn, + gameStatus, + startTurn, + endTurn, + startDraw, + commitDraw, + attackCard, + moveCard, + playCard, +}: GameProps): JSX.Element { + const [selection, setSelection] = React.useState(null) -interface GameClientState { - player_id: string - match_id: string - result: string -} + function selectCard(nextSelection: Selection): void { + if (!selection) { + setSelection(nextSelection) + } else { + if (isAttackSelection(selection, nextSelection)) { + attackCard(selection.index, nextSelection.index) + } else if (isMoveSelection(selection, nextSelection)) { + moveCard(selection.index, nextSelection.index) + } else if (isPlayCardSelection(selection, nextSelection)) { + playCard(selection.index, nextSelection.index) + } + setSelection(null) + } + } -interface BoardPosition { - card: CardInstance | null -} + function selectTurnButton(): void { + } -interface PlayerBoard { - 0: BoardPosition - 1: BoardPosition - 2: BoardPosition - 3: BoardPosition -} - -interface GameState { - self_board: PlayerBoard - self_hand: CardInstance[] - enemy_board: PlayerBoard - enemy_hand: CardInstance[] -} - -interface GameProps { - -} - -export default function Game(props: GameProps): JSX.Element { - const [state, setState] = React.useState() - - // ensure this is stable wrt state so that onMessage does not have to be constantly reattached - // const onMessage = React.useCallback(() => {}, []) - - // const handle = useSocket() + const enemyBoard = team === 1 ? board.scourge : board.sentinal + const allyBoard = team === 1 ? board.sentinal : board.scourge return ( - -
Hello world!
-
+
+
+
+

Opponent

+

Life: {enemyLife}

+

Deck: {enemyDeckSize}

+
+

Turn: {currentTurn}

+ {canDraw &&

Drawing phase...

} + {hasDrawn &&

Action phase...

} + +
+

Life: {player.life}

+

Deck: {deck.length}

+
+
+
+
+ {Array(enemyHandSize).fill(null).map(() => ( + + ))} +
+
+ {enemyBoard.map((card, index) => ( + selectCard({target: 'opponent', type: 'board', index})} + /> + ))} +
+
+ {allyBoard.map((card, index) => ( + selectCard({target: 'ally', type: 'board', index})} + /> + ))} +
+
+ {player.hand.map((card, index) => ( + selectCard({target: 'ally', type: 'hand', index})} + /> + ))} +
+
+
) } diff --git a/client/game/GameClient.tsx b/client/game/GameClient.tsx new file mode 100644 index 0000000..4d38b4b --- /dev/null +++ b/client/game/GameClient.tsx @@ -0,0 +1,153 @@ +import React from 'react' + +import assertNever from '~/common/assertNever.ts' + +import Loading from '../components/Loading.tsx' + +import { + AsyncHandle, + FighterArea, + GameState, + GameAction, + GameCommandAPI, +} from './types.ts' +import useServerSocket from './useServerSocket.ts' +import Game from './Game.tsx' + +function reducer(state: GameState, action: GameAction): GameState { + switch (action.type) { + case 'update-state': { + return {...action.state, team: state.team} + } + case 'set-player': { + return {...state, team: action.team} + } + default: return state + } +} + +const initialState: GameState = { + board: { + sentinal: Array(4).fill(undefined) as FighterArea, + scourge: Array(4).fill(undefined) as FighterArea, + }, + player: { + name: '', + id: 0, + hand: [], + life: 0, + ready: false, + }, + deck: [], + team: 1, + enemyLife: 0, + enemyDeckSize: 0, + enemyHandSize: 0, + currentTurn: 1, + canDraw: false, + hasDrawn: false, + gameStatus: 0, +} + +export default function GameClient(): JSX.Element { + const [state, dispatch] = React.useReducer(reducer, initialState) + + const handleGameUpdate = React.useCallback((data: Record) => { + console.log(data) + }, []) + + const socketHandle = useServerSocket(handleGameUpdate) + + const gameHandle = React.useMemo>( + () => { + if (socketHandle.status !== 'connected') return socketHandle + return { + status: 'connected', + handle: { + readyPlayer: () => { + if (socketHandle.status !== 'connected') return + socketHandle.handle.sendGameCommand({ + type: 's', + cmd: 'b', + }) + }, + startTurn: () => { + if (socketHandle.status !== 'connected') return + socketHandle.handle.sendGameCommand({ + type: 's', + cmd: 's', + }) + }, + endTurn: () => { + if (socketHandle.status !== 'connected') return + socketHandle.handle.sendGameCommand({ + type: 's', + cmd: 'e', + }) + }, + getView: () => { + if (socketHandle.status !== 'connected') return + socketHandle.handle.sendGameCommand({ + type: 's', + cmd: 'g', + }) + }, + startDraw: () => { + if (socketHandle.status !== 'connected') return + socketHandle.handle.sendGameCommand({ + type: 'a', + cmd: 's', + }) + }, + commitDraw: (cardIndex: number) => { + if (socketHandle.status !== 'connected') return + socketHandle.handle.sendGameCommand({ + type: 'a', + cmd: `d ${cardIndex}`, + }) + }, + playCard: (handIndex: number, positionIndex: number) => { + if (socketHandle.status !== 'connected') return + socketHandle.handle.sendGameCommand({ + type: 'a', + cmd: `p ${handIndex} ${positionIndex}`, + }) + }, + moveCard: (positionFrom: number, positionTo: number) => { + if (socketHandle.status !== 'connected') return + socketHandle.handle.sendGameCommand({ + type: 'a', + cmd: `m ${positionFrom} ${positionTo}`, + }) + }, + attackCard: (positionFrom: number, positionTo: number) => { + if (socketHandle.status !== 'connected') return + socketHandle.handle.sendGameCommand({ + type: 'a', + cmd: `a ${positionFrom} ${positionTo}`, + }) + }, + }, + } + }, + [socketHandle, state], + ) + + React.useEffect(() => { + if (gameHandle.status !== 'connected') return + gameHandle.handle.readyPlayer() + if (state?.team === 1) gameHandle.handle.startTurn() + }, [gameHandle, state?.team]) + + switch (gameHandle.status) { + case 'connected': { + if (!state) return + return + } + case 'not-connected': + case 'connecting': { + return + } + default: return assertNever(gameHandle) + } +} diff --git a/client/game/cards/Card.tsx b/client/game/cards/Card.tsx index 0228c5f..2906339 100644 --- a/client/game/cards/Card.tsx +++ b/client/game/cards/Card.tsx @@ -1,28 +1,32 @@ import React from 'react' -import {CardKey, getCardSrc} from './cards.ts' +import type {CardKey} from '../types.ts' + +import {getCardAlt, getCardSrc} from './cards.ts' const EMPTY_SPACE = '-' interface CardTokenProps { cardKey: CardKey | null - onClick?: () => void + onSelect?: () => void } export default function CardToken(props: CardTokenProps): JSX.Element { - const {onClick, cardKey} = props + const {onSelect, cardKey} = props if (cardKey == null) { return ( - + ) } - const card = getCardSrc(cardKey) return ( - ) } diff --git a/client/game/cards/Hand.tsx b/client/game/cards/Hand.tsx deleted file mode 100644 index 138fb1e..0000000 --- a/client/game/cards/Hand.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import React from 'react' - -import {CardInstance} from '../types.ts' - -import Card from './Card.tsx' - -interface HandProps { - cards: CardInstance[] -} - -export default function Hand(props: HandProps): JSX.Element { - const {cards} = props - return ( - <> - {cards.map((card) => )} - - ) -} diff --git a/client/game/cards/cards.ts b/client/game/cards/cards.ts index 4db7272..00c27d6 100644 --- a/client/game/cards/cards.ts +++ b/client/game/cards/cards.ts @@ -1,112 +1,21 @@ -export type CardKey = - | 'cl_2' - | 'cl_3' - | 'cl_4' - | 'cl_5' - | 'cl_6' - | 'cl_7' - | 'cl_8' - // | 'cl_9' - // | 'cl_10' - // | 'cl_11' - // | 'cl_12' - // | 'cl_13' - | 'cl_14' - | 'di_2' - | 'di_3' - | 'di_4' - | 'di_5' - | 'di_6' - | 'di_7' - | 'di_8' - // | 'di_9' - // | 'di_10' - // | 'di_11' - // | 'di_12' - // | 'di_13' - | 'di_14' - // | 'hr_2' - // | 'hr_3' - // | 'hr_4' - // | 'hr_5' - // | 'hr_6' - // | 'hr_7' - // | 'hr_8' - // | 'hr_9' - // | 'hr_10' - // | 'hr_11' - // | 'hr_12' - // | 'hr_13' - // | 'hr_14' - | 'sp_2' - | 'sp_3' - | 'sp_4' - | 'sp_5' - | 'sp_6' - | 'sp_7' - | 'sp_8' - // | 'sp_9' - // | 'sp_10' - // | 'sp_11' - // | 'sp_12' - // | 'sp_13' - | 'sp_14' +import type {CardKey} from '../types.ts' const cardPaths: Record = { - cl_2: 'cl_2.png', - cl_3: 'cl_3.png', - cl_4: 'cl_4.png', - cl_5: 'cl_5.png', - cl_6: 'cl_6.png', - cl_7: 'cl_7.png', - cl_8: 'cl_8.png', - // cl_9: 'cl_9.png', - // cl_10: 'cl_10.png', - // cl_11: 'cl_11.png', - // cl_12: 'cl_12.png', - // cl_13: 'cl_13.png', - cl_14: 'cl_14.png', - di_2: 'di_2.png', - di_3: 'di_3.png', - di_4: 'di_4.png', - di_5: 'di_5.png', - di_6: 'di_6.png', - di_7: 'di_7.png', - di_8: 'di_8.png', - // di_9: 'di_9.png', - // di_10: 'di_10.png', - // di_11: 'di_11.png', - // di_12: 'di_12.png', - // di_13: 'di_13.png', - di_14: 'di_14.png', - // hr_2: 'hr_2.png', - // hr_3: 'hr_3.png', - // hr_4: 'hr_4.png', - // hr_5: 'hr_5.png', - // hr_6: 'hr_6.png', - // hr_7: 'hr_7.png', - // hr_8: 'hr_8.png', - // hr_9: 'hr_9.png', - // hr_10: 'hr_10.png', - // hr_11: 'hr_11.png', - // hr_12: 'hr_12.png', - // hr_13: 'hr_13.png', - // hr_14: 'hr_14.png', - sp_2: 'sp_2.png', - sp_3: 'sp_3.png', - sp_4: 'sp_4.png', - sp_5: 'sp_5.png', - sp_6: 'sp_6.png', - sp_7: 'sp_7.png', - sp_8: 'sp_8.png', - // sp_9: 'sp_9.png', - // sp_10: 'sp_10.png', - // sp_11: 'sp_11.png', - // sp_12: 'sp_12.png', - // sp_13: 'sp_13.png', - sp_14: 'sp_14.png', + 0: 'joker.png', + 1: 'sp_2.png', + 2: 'sp_14.png', + 3: 'sp_3.png', + 4: 'sp_4.png', + 5: 'sp_5.png', + 6: 'sp_6.png', + 7: 'sp_7.png', + 8: 'sp_8.png', } export function getCardSrc(cardKey: CardKey): string { return `/assets/${cardPaths[cardKey]}` } + +export function getCardAlt(cardKey: CardKey): string { + return cardPaths[cardKey] +} diff --git a/client/game/cards/getCardKey.ts b/client/game/cards/getCardKey.ts deleted file mode 100644 index 052144d..0000000 --- a/client/game/cards/getCardKey.ts +++ /dev/null @@ -1,5 +0,0 @@ -import {CardKey} from './cards.ts' - -export default function getCardKey(suit: string, value: string): CardKey { - return `${suit}_${value}` as CardKey -} diff --git a/client/game/selection.tsx b/client/game/selection.tsx new file mode 100644 index 0000000..89ffda5 --- /dev/null +++ b/client/game/selection.tsx @@ -0,0 +1,31 @@ +import type {Selection} from './types.ts' + +function isAlly(selection: Selection): boolean { + return selection.target === 'ally' +} + +function isHand(selection: Selection): boolean { + return selection.type === 'hand' +} + +function isBoard(selection: Selection): boolean { + return selection.type === 'board' +} + +export function isPlayCardSelection(first: Selection, second: Selection): boolean { + const isMyHand = isAlly(first) && isHand(first) + const targetsMyBoard = isAlly(second) && isBoard(second) + return isMyHand && targetsMyBoard +} + +export function isAttackSelection(first: Selection, second: Selection): boolean { + const isMyBoard = isAlly(first) && isBoard(first) + const targetsEnemyBoard = !isAlly(second) && isBoard(second) + return isMyBoard && targetsEnemyBoard +} + +export function isMoveSelection(first: Selection, second: Selection): boolean { + const isMyBoard = isAlly(first) && isBoard(first) + const isMyOtherBoard = isAlly(second) && isBoard(second) && first.index !== second.index + return isMyBoard && isMyOtherBoard +} diff --git a/client/game/types.ts b/client/game/types.ts index 6ab097d..eb397e1 100644 --- a/client/game/types.ts +++ b/client/game/types.ts @@ -1,6 +1,121 @@ -import {CardKey} from './cards/cards' - export interface CardInstance { key: CardKey // other relevant modifiers to show user } + +export type AsyncHandle = + | { status: 'not-connected' } + | { status: 'connecting' } + | { status: 'connected'; handle: T} + +export type CardKey = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 + +export interface Card { + type: number + basePower: number + power: number + id: CardKey + counters: number + owner: number + position: number + effects: {id: number}[] + sick: boolean + spell: boolean +} + +export type FighterSlot = Card | undefined +export type FighterArea = [FighterSlot, FighterSlot, FighterSlot, FighterSlot] +export interface Board { + sentinal: FighterArea + scourge: FighterArea +} +export interface Player { + name: string + id: number + hand: Card[] + life: number + ready: boolean +} + +interface TeamEnumMap { + 1: 'sentinal' + 2: 'scourge' +} + +interface GameStatusMap { + 0: 'lobby' + 1: 'ready' + 2: 'playing' + 3: 'stop' + 4: 'sentinal-win' + 5: 'scourage-win' + 6: 'draw' +} + +export interface GameState { + board: Board + player: Player + deck: Card[] + team: 1 | 2 + enemyLife: number + enemyDeckSize: number + enemyHandSize: number + currentTurn: number + canDraw: boolean + hasDrawn: boolean + gameStatus: keyof GameStatusMap +} + +export type GameAction = + | { type: 'set-player'; team: 1 | 2} + | { type: 'update-state'; state: Omit} + +export interface GameCommandAPI { + readyPlayer: () => void + startTurn: () => void + endTurn: () => void + getView: () => void + startDraw: () => void + commitDraw: (cardIndex: number) => void + playCard: (handIndex: number, positionIndex: number) => void + moveCard: (positionFrom: number, positionTo: number) => void + attackCard: (positionFrom: number, positionTo: number) => void +} + +export type GameHandle = GameState & GameCommandAPI + +// TODO +export type GameCommandEnum = 'a' | 's' + +export interface CommandVariantMap { + // "state" commands + s: 'b' | 's' | 'e' | 'g' + // "action" commands + a: 's' | 'd' | 'p' | 'm' | 'a' +} + +export interface CommandVariantParamMap { + s: Record + a: { + s: never + d: number + p: [number, number] + m: [number, number] + a: [number, number] + } +} + +export interface GameCommand { + playerId: string + type: GameCommandEnum, + cmd: string // " ..." +} + +type SelectionTarget = 'ally' | 'opponent' +type SelectionType = 'hand' | 'board' + +export interface Selection { + target: SelectionTarget + type: SelectionType + index: number +} diff --git a/client/game/useServerSocket.ts b/client/game/useServerSocket.ts new file mode 100644 index 0000000..59060da --- /dev/null +++ b/client/game/useServerSocket.ts @@ -0,0 +1,189 @@ +import React from 'react' +import useWebSocket from 'react-use-websocket' + +import assertNever from '~/common/assertNever.ts' + +import { useUser } from '../user.tsx' + +import { AsyncHandle, GameCommand, GameAction } from './types.ts' + +const MY_ID = (function(){ + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { + var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8); + return v.toString(16); + }) +})() + +function shouldReconnect() { + console.log('Reconnecting...') + return true +} + +// session commands +// query, join, leave, play (game command), poll +interface SessionCommandAPI { + query: () => void + join: () => void + leave: () => void + play: () => void + poll: () => void +} + +interface SocketMessage { + command: keyof SessionCommandAPI + // required unless command === 'query' + match_id?: string + player_id?: string + // only present if command === 'play' + game_command?: GameCommand +} + +type GameSocketSessionState = + | { status: 'not-connected' } + | { status: 'connecting' } + | { status: 'finding-game' } + | { status: 'joining-game'; playerId: string; matchId: string } + | { status: 'in-game'; playerId: string; matchId: string } + +type Action = + | { type: 'open' } + | { type: 'close' } + | { type: 'find-game'; playerId: string; matchId: string } + | { type: 'join-game' } + +function reducer(state: GameSocketSessionState, action: Action): GameSocketSessionState { + switch (action.type) { + case 'open': { + if (state.status !== 'connecting') return state + return { status: 'finding-game' } + } + case 'find-game': { + if (state.status !== 'finding-game') return state + return { status: 'joining-game', matchId: action.matchId, playerId: action.playerId } + } + case 'join-game': { + if (state.status !== 'joining-game') return state + return { ...state, status: 'in-game' } + } + case 'close': { + return { status: 'not-connected' } + } + default: return assertNever(action) + } +} + +const initialState: GameSocketSessionState = {status: 'connecting'} + +interface SocketHandle { + sendGameCommand: (command: Omit) => void +} + +export default function useServerSocket( + onUpdate: (action: GameAction) => void +): AsyncHandle { + const profile = useUser() + const [state, dispatch] = React.useReducer(reducer, initialState) + + // prep socket + + const onMessage = React.useCallback((message: WebSocketEventMap['message']) => { + const data = JSON.parse(message.data) + switch (data.result) { + case 'found': { + dispatch({ + type: 'find-game', + matchId: data.match_id, + playerId: data.player_id, + }) + return + } + case 'joined p1': { + onUpdate({ type: 'set-player', team: 1 }) + dispatch ({type: 'join-game'}) + return + } + case 'joined p2': { + onUpdate({ type: 'set-player', team: 2 }) + dispatch ({type: 'join-game' }) + return + } + default: break; + } + onUpdate(data) + }, [onUpdate]) + + const onOpen = React.useCallback(() => { + console.log('socket opened') + dispatch({type: 'open'}) + }, []) + + const onError = React.useCallback((event: WebSocketEventMap['error']) => { + console.error(event) + }, []) + + const onClose = React.useCallback((_event: WebSocketEventMap['close']) => { + console.log('socket closed') + dispatch({type: 'close'}) + }, []) + + const url = React.useMemo( + () => `ws://localhost:7636/ws`, + [profile], + ) + + const {sendJsonMessage} = useWebSocket( + url, + {onMessage, onOpen, onError, onClose, shouldReconnect}, + ) + + // convenient type-safe wrapper + const sendJson = React.useCallback((message: SocketMessage) => { + sendJsonMessage(message) + }, [sendJsonMessage]) + + const sendGameCommand = React.useCallback((gameCommand: Omit) => { + if (state.status !== 'in-game') return + sendJson({ + player_id: MY_ID, + match_id: state.matchId, + command: 'play', + game_command: { + ...gameCommand, + playerId: state.playerId, + }, + }) + }, [state, sendJson]) + + // effects to push the coordinator along + + React.useEffect(() => { + if (state.status !== 'finding-game') return + sendJson({command: 'query', player_id: MY_ID}) + }, [sendJson, state.status]) + + React.useEffect(() => { + if (state.status !== 'joining-game') return + sendJson({command: 'join', player_id: state.playerId, match_id: state.matchId}) + }, [sendJson, state]) + + // return game command handler in wrapper + + const handle = React.useMemo>(() => { + switch (state.status) { + case 'in-game': { + return {status: 'connected', handle: {sendGameCommand}} + } + case 'finding-game': + case 'joining-game': { + return {status: 'connecting'} + } + case 'connecting': + case 'not-connected': { + return state + } + default: return assertNever(state) + } + }, [sendGameCommand]) + + return handle +} diff --git a/client/game/useSocket.ts b/client/game/useSocket.ts deleted file mode 100644 index 2d9cc5b..0000000 --- a/client/game/useSocket.ts +++ /dev/null @@ -1,133 +0,0 @@ -import React from 'react' -import useWebSocket from 'react-use-websocket' - -import assertNever from '~/common/assertNever.ts' - -import { useUser } from '../user.tsx' - -function shouldReconnect() { - console.log('Reconnecting...') - return true -} - -type AsyncHandle = - | { status: 'not-connected' } - | { status: 'connecting' } - | { status: 'connected'; handle: T} - -interface SessionCommandAPI { - query: () => void - join: () => void - leave: () => void - play: () => void - poll: () => void -} - -interface GameCommandAPI { - act: () => void, - getState: () => void, - debug: () => void, -} - -// TODO -type GameCommandEnum = 'a' | 's' | 'd' - -interface SocketMessage { - playerId?: string - matchId?: string - command: keyof SessionCommandAPI - gameCommand?: { - playerId: string - type: GameCommandEnum, - cmd: unknown - } -} - -type State = - | { status: 'not-connected' } - | { status: 'connecting' } - | { status: 'finding-game' } - | { status: 'in-game'; playerId: string; matchId: string } - -type Action = - | { type: 'open' } - | { type: 'close' } - | { type: 'error' } - | { type: 'game-found'; matchId: string; playerId: string } - -function reducer(_state: State, action: Action): State { - switch (action.type) { - case 'open': return { status: 'finding-game' } - case 'game-found': return { status: 'in-game', matchId: action.matchId, playerId: action.playerId } - case 'close': return { status: 'not-connected' } - case 'error': return { status: 'connecting' } - default: return assertNever(action) - } -} - -const initialState: State = {status: 'not-connected'} - -export default function useSocket(): AsyncHandle { - const profile = useUser() - const [state, dispatch] = React.useReducer(reducer, initialState) - - const onMessage = React.useCallback((message: WebSocketEventMap['message']) => { - const data = JSON.parse(message.data) - dispatch({ type: 'update', ...data }) - }, []) - - const onOpen = React.useCallback(() => { - console.log('socket opened') - dispatch({type: 'open'}) - }, []) - - const onError = React.useCallback((event: WebSocketEventMap['error']) => { - console.error(event) - dispatch({type: 'error'}) - }, []) - - const onClose = React.useCallback((_event: WebSocketEventMap['close']) => { - console.log('socket closed') - dispatch({type: 'close'}) - }, []) - - const url = React.useMemo( - // () => `ws://arcade.saintnet.tech:7636/ws?name=${profile.displayName}`, - () => `ws://arcade.saintnet.tech:7636/ws`, - [profile], - ) - const socket = useWebSocket( - url, - {onMessage, onOpen, onError, onClose, shouldReconnect}, - ) - - const sendJson = React.useCallback((message: SocketMessage) => { - socket.send(JSON.stringify(message)) - }, [socket]) - - const handle = React.useMemo(() => ({ - // session commands - query: () => {}, - join: () => {}, - leave: () => {}, - play: () => {}, - poll: () => {}, - // game commands - act: () => {}, - getState: () => {}, - debug: () => {}, - }), [sendJson]) - - switch (state.status) { - case 'in-game': { - return {status: 'connected', handle} - } - case 'finding-game': { - return {status: 'connecting'} - } - case 'connecting': - case 'not-connected': - return state - default: return assertNever(state) - } -} diff --git a/client/pages/AppPage.tsx b/client/pages/AppPage.tsx index 98d8d20..f5fc1d4 100644 --- a/client/pages/AppPage.tsx +++ b/client/pages/AppPage.tsx @@ -4,7 +4,7 @@ import assertNever from '~/common/assertNever.ts' import IntroPage from '../components/IntroPage.tsx' import Page from '../components/Page.tsx' -import Game from '../game/Game.tsx' +import GameClient from '../game/GameClient.tsx' type MenuState = 'menu' | 'play' @@ -15,7 +15,7 @@ export default function AppPage() { case 'menu': { return ( - @@ -24,7 +24,7 @@ export default function AppPage() { case 'play': { return ( - + ) } diff --git a/client/styles.css b/client/styles.css index 4bae0e4..5028a39 100644 --- a/client/styles.css +++ b/client/styles.css @@ -1,3 +1,7 @@ +* { + font-family: sans-serif; +} + h1 { font-size: 3em; } @@ -12,8 +16,6 @@ h2 { display: flex; flex-direction: column; - font-family: sans-serif; - background-color: #ad9885; } @@ -93,6 +95,17 @@ h2 { overflow: hidden; } +.menu-button { + font-size: 2em; + height: 2em; + width: 4em; + + background-color: #ffe7d1; + border: 0.1em solid #b85900; + border-radius: 0.5em; + box-shadow: -2px 2px 0 rgba(144, 90, 39, 0.25); +} + /* Common pages */ .header { @@ -136,12 +149,23 @@ h2 { } .game-sidebar { - flex: 0; - width: 200px; + flex: 0 0 auto; + width: 10em; display: flex; flex-direction: column; justify-content: space-between; + text-align: center; + + background: #942911; + color: #ddf6fd; +} + +.player-info { + margin: 1em; + padding: 1em; + background-color: #c4c4c4; + color: #000000; } .game-board {