diff options
Diffstat (limited to 'sw-ui/src')
56 files changed, 2046 insertions, 0 deletions
diff --git a/sw-ui/src/@types/reflexbox.d.ts b/sw-ui/src/@types/reflexbox.d.ts new file mode 100644 index 00000000..802bc5f3 --- /dev/null +++ b/sw-ui/src/@types/reflexbox.d.ts @@ -0,0 +1,37 @@ +declare module 'reflexbox' { + + import { HTMLAttributes } from 'react'; + import * as React from 'react' + + export interface BoxProps { + w?: number | string, + h?: number | string, + + flex?: boolean, + wrap?: boolean, + column?: boolean, + auto?: boolean, + order?: number, + align?: "flex-start" | "flex-end" | "center" | "stretch" | "baseline", + justify?: "flex-start" | "flex-end" | "center" | "space-between" | "space-around" | "space-evenly", + + m?: number | string, + mx?: number | string, + my?: number | string, + mt?: number | string, + mb?: number | string, + ml?: number | string, + mr?: number | string, + + p?: number | string, + px?: number | string, + py?: number | string, + pt?: number | string, + pb?: number | string, + pl?: number | string, + pr?: number | string, + } + + export class Flex extends React.Component<HTMLAttributes & BoxProps> { } + export class Box extends React.Component<HTMLAttributes & BoxProps> { } +} diff --git a/sw-ui/src/api/model.ts b/sw-ui/src/api/model.ts new file mode 100644 index 00000000..2796a6d3 --- /dev/null +++ b/sw-ui/src/api/model.ts @@ -0,0 +1,187 @@ +export type ApiErrorDetail = { + message: string +}; + +export type ApiError = { + message: string, + details: ApiErrorDetail[] +}; + +export type ApiPlayer = { + username: string, + displayName: string, + index: number, + gameOwner: boolean, + user: boolean, +}; + +export type ApiWonderSidePickMethod = "EACH_RANDOM" | "ALL_A" | "ALL_B" | "SAME_RANDOM_FOR_ALL"; + +export type ApiSettings = { + randomSeedForTests: number, + timeLimitInSeconds: number, + wonderSidePickMethod: ApiWonderSidePickMethod, + initialGold: number, + discardedCardGold: number, + defaultTradingCost: number, + pointsPer3Gold: number, + lostPointsPerDefeat: number, + wonPointsPerVictoryPerAge: Map<number, number> +}; + +export type ApiGameState = "LOBBY" | "PLAYING"; + +export type ApiLobby = { + id: number, + name: string, + owner: string, + players: ApiPlayer[], + settings: ApiSettings, + state: ApiGameState +}; + +export type ApiScience = { + jokers: number, + nbWheels: number, + nbCompasses: number, + nbTablets: number, +} + +export type ApiMilitary = { + nbShields: number, + totalPoints: number, + nbDefeatTokens: number, +} + +export type ApiResourceType = "WOOD" | "STONE" | "ORE" | "CLAY" | "GLASS" | "PAPYRUS" | "LOOM"; + +export type ApiResources = { + quantities: Map<ApiResourceType, number>, +}; + +export type ApiRequirements = { + gold: number, + resources: ApiResources +} + +export type ApiCardBack = { + image: string, +}; + +export type ApiWonderStage = { + cardBack: ApiCardBack | null, + isBuilt: boolean, + requirements: ApiRequirements, + builtDuringLastMove: boolean, +} + +export type ApiWonderBuildability = { + buildable: boolean +} + +export type ApiWonder = { + name: string, + initialResource: ApiResourceType, + stages: ApiWonderStage[], + image: string, + nbBuiltStages: number, + buildability: ApiWonderBuildability, +} + +export type Color = 'BLUE' | 'GREEN' | 'RED' | 'BROWN' | 'GREY' | 'PURPLE' | 'YELLOW'; + +export type ApiProvider = "LEFT_NEIGHBOUR" | "RIGHT_NEIGHBOUR"; + +export type ApiCountedResource = { + type: ApiResourceType, + count: number, +} + +export type ApiProduction = { + fixedResources: ApiCountedResource[], + alternativeResources: ApiResourceType[][], +} + +export type ApiBoughtResources = { + provider: ApiProvider, + resources: ApiResources, +}; + +export type ApiCard = { + name: string, + color: Color, + requirements: ApiRequirements, + chainParent: String | null, + chainChildren: String[], + image: string, + back: ApiCardBack +}; + +export type ApiTableCard = ApiCard & { + playedDuringLastMove: boolean, +}; + +export type ApiBoard = { + playerIndex: number, + wonder: ApiWonder, + production: ApiProduction, + publicProduction: ApiProduction, + science: ApiScience, + military: ApiMilitary, + playedCards: ApiTableCard[][], + gold: number, +}; + +export type HandRotationDirection = 'LEFT' | 'RIGHT'; + +export type ApiMoveType = "PLAY" | "PLAY_FREE" | "UPGRADE_WONDER" | "DISCARD" | "COPY_GUILD"; + +export type ApiPlayedMove = { + playerIndex: number, + type: ApiMoveType, + card: ApiTableCard, + boughtResources: ApiBoughtResources[], +}; + +export type ApiTable = { + boards: ApiBoard[], + currentAge: number, + handRotationDirection: HandRotationDirection, + lastPlayedMoves: ApiPlayedMove[], + nbPlayers: number, +}; + +export type ApiAction = 'PLAY' | 'PLAY_2' | 'PLAY_LAST' | 'PICK_NEIGHBOR_GUILD' | 'WAIT'; + +export type ApiPlayability = { + playable: boolean, + chainable: boolean, + minPrice: number, +}; + +export type ApiHandCard = ApiCard & { + playability: ApiPlayability, +}; + +export type ApiPreparedCard = { + player: ApiPlayer, + cardBack: ApiCardBack, +}; + +export type ApiPlayerTurnInfo = { + playerIndex: number, + table: ApiTable, + currentAge: number, + action: ApiAction, + hand: ApiHandCard[], + playedMove: ApiPlayedMove | null, + neighbourGuildCards: ApiTableCard[], + message: string, + wonderBuildability: ApiWonderBuildability, +}; + +export type ApiPlayerMove = { + type: ApiMoveType, + cardName: string, + boughtResources: ApiBoughtResources[], +}; diff --git a/sw-ui/src/api/sevenWondersApi.ts b/sw-ui/src/api/sevenWondersApi.ts new file mode 100644 index 00000000..4f76a677 --- /dev/null +++ b/sw-ui/src/api/sevenWondersApi.ts @@ -0,0 +1,104 @@ +import { + ApiError, + ApiLobby, + ApiPlayer, + ApiPlayerMove, + ApiPlayerTurnInfo, + ApiPreparedCard, + ApiSettings, + ApiTable, +} from './model'; +import { JsonStompClient, SubscribeFn } from './websocket'; +import { createJsonStompClient } from './websocket'; + +const WS_URL = '/seven-wonders-websocket'; + +export class SevenWondersSession { + client: JsonStompClient; + + constructor(client: JsonStompClient) { + this.client = client; + } + + watchErrors(): SubscribeFn<ApiError> { + return this.client.subscriber('/user/queue/errors'); + } + + watchNameChoice(): SubscribeFn<ApiPlayer> { + return this.client.subscriber('/user/queue/nameChoice'); + } + + chooseName(displayName: string): void { + this.client.send('/app/chooseName', { playerName: displayName }); + } + + watchGames(): SubscribeFn<ApiLobby[]> { + return this.client.subscriber('/topic/games'); + } + + watchLobbyJoined(): SubscribeFn<Object> { + return this.client.subscriber('/user/queue/lobby/joined'); + } + + createGame(gameName: string): void { + this.client.send('/app/lobby/create', { gameName }); + } + + joinGame(gameId: number): void { + this.client.send('/app/lobby/join', { gameId }); + } + + watchLobbyUpdated(currentGameId: number): SubscribeFn<Object> { + return this.client.subscriber(`/topic/lobby/${currentGameId}/updated`); + } + + watchGameStarted(currentGameId: number): SubscribeFn<Object> { + return this.client.subscriber(`/topic/lobby/${currentGameId}/started`); + } + + leave(): void { + this.client.send('/app/lobby/leave'); + } + + reorderPlayers(orderedPlayers: Array<string>): void { + this.client.send('/app/lobby/reorderPlayers', { orderedPlayers }); + } + + updateSettings(settings: ApiSettings): void { + this.client.send('/app/lobby/updateSettings', { settings }); + } + + startGame(): void { + this.client.send('/app/lobby/startGame'); + } + + watchPlayerReady(currentGameId: number): SubscribeFn<string> { + return this.client.subscriber(`/topic/game/${currentGameId}/playerReady`); + } + + watchTableUpdates(currentGameId: number): SubscribeFn<ApiTable> { + return this.client.subscriber(`/topic/game/${currentGameId}/tableUpdates`); + } + + watchPreparedCards(currentGameId: number): SubscribeFn<ApiPreparedCard> { + return this.client.subscriber(`/topic/game/${currentGameId}/prepared`); + } + + watchTurnInfo(): SubscribeFn<ApiPlayerTurnInfo> { + return this.client.subscriber('/user/queue/game/turn'); + } + + sayReady(): void { + this.client.send('/app/game/sayReady'); + } + + prepareMove(move: ApiPlayerMove): void { + this.client.send('/app/game/prepareMove', { move }); + } +} + +export async function connectToGame(): Promise<SevenWondersSession> { + const jsonStompClient: JsonStompClient = createJsonStompClient(WS_URL); + await jsonStompClient.connect(); + return new SevenWondersSession(jsonStompClient); +} diff --git a/sw-ui/src/api/websocket.ts b/sw-ui/src/api/websocket.ts new file mode 100644 index 00000000..e9393836 --- /dev/null +++ b/sw-ui/src/api/websocket.ts @@ -0,0 +1,60 @@ +import SockJS from 'sockjs-client'; +import { Client, Frame, Message, Options, Subscription } from 'webstomp-client'; +import * as Stomp from 'webstomp-client'; + +const DEFAULT_DEBUG_OPTIONS = { + debug: process.env.NODE_ENV !== 'production', +}; + +export type Callback<T> = (value: T) => void; +export type UnsubscribeFn = () => void; +export type SubscribeFn<T> = (callback: Callback<T>) => UnsubscribeFn; + +export class JsonStompClient { + client: Client; + + constructor(client: Client) { + this.client = client; + } + + connect(headers: Stomp.ConnectionHeaders = {}): Promise<Frame | void> { + return new Promise((resolve, reject) => { + this.client.connect(headers, resolve, reject); + }); + } + + subscribe<T>(path: string, callback: Callback<T>): UnsubscribeFn { + const socketSubscription: Subscription = this.client.subscribe(path, (message: Message) => { + // not all frames have a JSON body + const value: T | void = message && JsonStompClient.parseBody(message); + callback(value || {} as T); + }); + return () => socketSubscription.unsubscribe(); + } + + static parseBody<T>(message: Message): T | void { + try { + return message.body ? JSON.parse(message.body) : undefined; + } catch (jsonParseError) { + throw new Error('Cannot parse websocket message as JSON: ' + jsonParseError.message); + } + } + + subscriber<T>(path: string): SubscribeFn<T> { + return (callback: Callback<T>) => this.subscribe(path, callback); + } + + send(url: string, body?: Object) { + const strBody = body ? JSON.stringify(body) : ''; + this.client.send(url, strBody); + } +} + +function createStompClient(url: string, options: Options = {}): Client { + const optionsWithDebug = Object.assign({}, DEFAULT_DEBUG_OPTIONS, options); + return Stomp.over(new SockJS(url), optionsWithDebug); +} + +export function createJsonStompClient(url: string, options: Options = {}): JsonStompClient { + return new JsonStompClient(createStompClient(url, options)); +} diff --git a/sw-ui/src/components/Application.tsx b/sw-ui/src/components/Application.tsx new file mode 100644 index 00000000..e0ec604d --- /dev/null +++ b/sw-ui/src/components/Application.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { Redirect, Route, Switch } from 'react-router-dom'; +import { GameBrowser } from './game-browser/GameBrowser'; +import { GameScene } from './game/GameScene'; +import { Lobby } from './lobby/Lobby'; +import { Home } from './home/Home'; + +export const Application = () => ( + <Switch> + <Route path="/game" component={GameScene} /> + <Route path="/games" component={GameBrowser} /> + <Route path="/lobby" component={Lobby} /> + <Route path="/" component={Home} /> + <Redirect to="/" /> + </Switch> +); diff --git a/sw-ui/src/components/game-browser/GameBrowser.tsx b/sw-ui/src/components/game-browser/GameBrowser.tsx new file mode 100644 index 00000000..a6367d5e --- /dev/null +++ b/sw-ui/src/components/game-browser/GameBrowser.tsx @@ -0,0 +1,56 @@ +import { Button, Classes, InputGroup, Intent } from '@blueprintjs/core'; +import React, { ChangeEvent, Component, SyntheticEvent } from 'react'; +import { connect } from 'react-redux'; +import { Flex } from 'reflexbox'; +import { actions } from '../../redux/actions/lobby'; +import { GameList } from './GameList'; +import { PlayerInfo } from './PlayerInfo'; + +type GameBrowserProps = { + createGame: (gameName: string) => void, +} + +class GameBrowserPresenter extends Component<GameBrowserProps> { + + _gameName: string | void = undefined; + + createGame = (e: SyntheticEvent<any>): void => { + e.preventDefault(); + if (this._gameName !== undefined) { + this.props.createGame(this._gameName); + } + }; + + render() { + return ( + <div> + <Flex align="center" justify='space-between' p={1}> + <form onSubmit={this.createGame}> + <InputGroup + placeholder="Game name" + name="game_name" + onChange={(e: ChangeEvent<HTMLInputElement>) => (this._gameName = e.target.value)} + rightElement={<CreateGameButton createGame={this.createGame}/>} + /> + </form> + <PlayerInfo /> + </Flex> + <GameList /> + </div> + ); + } +} + +type CreateGameButtonProps = { + createGame: (e: SyntheticEvent<any>) => void +} + +const CreateGameButton = ({createGame}: CreateGameButtonProps) => ( + <Button className={Classes.MINIMAL} intent={Intent.PRIMARY} icon='add' onClick={createGame} /> +); + +const mapDispatchToProps = { + createGame: actions.requestCreateGame, +}; + +export const GameBrowser = connect(null, mapDispatchToProps)(GameBrowserPresenter); diff --git a/sw-ui/src/components/game-browser/GameList.css b/sw-ui/src/components/game-browser/GameList.css new file mode 100644 index 00000000..a04e126c --- /dev/null +++ b/sw-ui/src/components/game-browser/GameList.css @@ -0,0 +1,3 @@ +tr.gameListRow td { + vertical-align: middle; +} diff --git a/sw-ui/src/components/game-browser/GameList.tsx b/sw-ui/src/components/game-browser/GameList.tsx new file mode 100644 index 00000000..1b136940 --- /dev/null +++ b/sw-ui/src/components/game-browser/GameList.tsx @@ -0,0 +1,85 @@ +import { Button, Classes } from '@blueprintjs/core' +import { List } from 'immutable'; +import React from 'react'; +import { connect } from 'react-redux'; +import { ApiLobby } from '../../api/model'; +import { GlobalState } from '../../reducers'; +import { actions } from '../../redux/actions/lobby'; +import { getAllGames } from '../../redux/games'; +import './GameList.css'; +import { GameStatus } from './GameStatus'; +import { PlayerCount } from './PlayerCount'; + +type GameListStateProps = { + games: List<ApiLobby>, +}; + +type GameListDispatchProps = { + joinGame: (gameId: number) => void, +}; + +type GameListProps = GameListStateProps & GameListDispatchProps + +const GameListPresenter = ({ games, joinGame }: GameListProps) => ( + <table className={Classes.HTML_TABLE}> + <thead> + <GameListHeaderRow /> + </thead> + <tbody> + {games.map((game: ApiLobby) => <GameListItemRow key={game.id} game={game} joinGame={joinGame}/>)} + </tbody> + </table> +); + +const GameListHeaderRow = () => ( + <tr> + <th>Name</th> + <th>Status</th> + <th>Nb Players</th> + <th>Join</th> + </tr> +); + +type GameListItemRowProps = { + game: ApiLobby, + joinGame: (gameId: number) => void, +}; + +const GameListItemRow = ({game, joinGame}: GameListItemRowProps) => ( + <tr className="gameListRow"> + <td>{game.name}</td> + <td> + <GameStatus state={game.state} /> + </td> + <td> + <PlayerCount nbPlayers={game.players.length} /> + </td> + <td> + <JoinButton game={game} joinGame={joinGame}/> + </td> + </tr> +); + +type JoinButtonProps = { + game: ApiLobby, + joinGame: (gameId: number) => void, +}; + +const JoinButton = ({game, joinGame}: JoinButtonProps) => { + const disabled = game.state !== 'LOBBY'; + const onClick = () => joinGame(game.id); + return <Button minimal disabled={disabled} icon='arrow-right' title='Join Game' onClick={onClick}/>; +}; + +function mapStateToProps(state: GlobalState): GameListStateProps { + return { + games: getAllGames(state), + }; +} + +const mapDispatchToProps: GameListDispatchProps = { + joinGame: actions.requestJoinGame, +}; + +export const GameList = connect(mapStateToProps, mapDispatchToProps)(GameListPresenter); + diff --git a/sw-ui/src/components/game-browser/GameStatus.tsx b/sw-ui/src/components/game-browser/GameStatus.tsx new file mode 100644 index 00000000..5f237258 --- /dev/null +++ b/sw-ui/src/components/game-browser/GameStatus.tsx @@ -0,0 +1,17 @@ +import { Tag } from '@blueprintjs/core'; +import { Intent } from '@blueprintjs/core'; +import * as React from 'react'; +import { ApiGameState } from '../../api/model'; + +type GameStatusProps = { + state: ApiGameState, +} + +export const GameStatus = ({state}: GameStatusProps) => ( + <Tag minimal intent={statusIntents[state]}>{state}</Tag> +); + +const statusIntents = { + 'LOBBY': Intent.SUCCESS, + 'PLAYING': Intent.WARNING, +}; diff --git a/sw-ui/src/components/game-browser/PlayerCount.css b/sw-ui/src/components/game-browser/PlayerCount.css new file mode 100644 index 00000000..d2f18e50 --- /dev/null +++ b/sw-ui/src/components/game-browser/PlayerCount.css @@ -0,0 +1,3 @@ +.playerCountIcon, .playerCount { + vertical-align: middle; +} diff --git a/sw-ui/src/components/game-browser/PlayerCount.tsx b/sw-ui/src/components/game-browser/PlayerCount.tsx new file mode 100644 index 00000000..64028f68 --- /dev/null +++ b/sw-ui/src/components/game-browser/PlayerCount.tsx @@ -0,0 +1,12 @@ +import { Icon } from '@blueprintjs/core'; +import * as React from 'react'; +import './PlayerCount.css'; + +type PlayerCountProps = { + nbPlayers: number, +} + +export const PlayerCount = ({nbPlayers}: PlayerCountProps) => <div title='Number of players'> + <Icon className="playerCountIcon" icon="people" title={false} /> + <span className="playerCount"> {nbPlayers}</span> +</div>; diff --git a/sw-ui/src/components/game-browser/PlayerInfo.tsx b/sw-ui/src/components/game-browser/PlayerInfo.tsx new file mode 100644 index 00000000..4afed671 --- /dev/null +++ b/sw-ui/src/components/game-browser/PlayerInfo.tsx @@ -0,0 +1,27 @@ +import { Text } from '@blueprintjs/core'; +import React from 'react'; +import { connect } from 'react-redux'; +import { GlobalState } from '../../reducers'; +import { User } from '../../redux/user'; +import { getCurrentUser } from '../../redux/user'; + +type PlayerInfoProps = { + user: User | null, +} + +const PlayerInfoPresenter = ({user}: PlayerInfoProps) => ( + <Text> + <b>Username:</b> + {' '} + {user && user.displayName} + </Text> +); + +const mapStateToProps = (state: GlobalState): PlayerInfoProps => ({ + user: getCurrentUser(state), +}); + +const mapDispatchToProps = { +}; + +export const PlayerInfo = connect(mapStateToProps, mapDispatchToProps)(PlayerInfoPresenter); diff --git a/sw-ui/src/components/game/Board.css b/sw-ui/src/components/game/Board.css new file mode 100644 index 00000000..0600bd14 --- /dev/null +++ b/sw-ui/src/components/game/Board.css @@ -0,0 +1,38 @@ +.board { + width: 100vw +} + +.cards { + display: flex; + height: 40vh; + width: 100vw; +} + +.card-column { + height: 40vh; + margin: auto; + position: relative; + width: 15vw; +} + +.card { + position: absolute; + /* dynamic positioning in JS */ +} + +.table-card-img { + max-width: 10vw; + max-height: 25vh; +} + +.wonder { + width: 100vw; + text-align: center; +} + +.wonder-img { + border-radius: 0.5%/1.5%; + box-shadow: 0.2rem 0.2rem 0.5rem black; + max-height: 30vh; + max-width: 95vw; +} diff --git a/sw-ui/src/components/game/Board.tsx b/sw-ui/src/components/game/Board.tsx new file mode 100644 index 00000000..98298a1f --- /dev/null +++ b/sw-ui/src/components/game/Board.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { ApiBoard, ApiTableCard, ApiWonder } from '../../api/model'; +import './Board.css' +import { CardImage } from './CardImage'; + +// card offsets in % of their size when displayed in columns +const xOffset = 20; +const yOffset = 21; + +type BoardProps = { + board: ApiBoard, +} + +export const Board = ({board}: BoardProps) => { + return <div className='board'> + <TableCards cardColumns={board.playedCards}/> + <Wonder wonder={board.wonder}/> + </div>; +}; + +type TableCardsProps = { + cardColumns: ApiTableCard[][], +} + +const TableCards = ({cardColumns}: TableCardsProps) => { + return <div className="cards"> + {cardColumns.map(column => <TableCardColumn key={column[0].color} cards={column}/>)} + </div> +}; + +type TableCardColumnProps = { + cards: ApiTableCard[] +} + +const TableCardColumn = ({cards}: TableCardColumnProps) => { + return <div className="card-column"> + {cards.map((c, i) => <TableCard key={c.name} card={c} indexInColumn={i}/>)} + </div> +}; + +type TableCardProps = { + card: ApiTableCard, + indexInColumn: number, +} + +const TableCard = ({card, indexInColumn}: TableCardProps) => { + let style = { + transform: `translate(${indexInColumn * xOffset}%, ${indexInColumn * yOffset}%)`, + zIndex: indexInColumn, + }; + return <div className="card" style={style}> + <CardImage card={card} otherClasses="table-card-img"/> + </div> +}; + +type WonderProps = { + wonder: ApiWonder, +} + +const Wonder = ({wonder}: WonderProps) => { + return <div className="wonder"> + <img src={`/images/wonders/${wonder.image}`} + title={wonder.name} + alt={`Wonder ${wonder.name}`} + className="wonder-img"/> + </div> +}; diff --git a/sw-ui/src/components/game/CardImage.css b/sw-ui/src/components/game/CardImage.css new file mode 100644 index 00000000..795c1503 --- /dev/null +++ b/sw-ui/src/components/game/CardImage.css @@ -0,0 +1,4 @@ +.card-img { + border-radius: 5%; + box-shadow: 2px 2px 5px black; +} diff --git a/sw-ui/src/components/game/CardImage.tsx b/sw-ui/src/components/game/CardImage.tsx new file mode 100644 index 00000000..a37595ad --- /dev/null +++ b/sw-ui/src/components/game/CardImage.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { ApiCard } from '../../api/model'; +import './CardImage.css' + +type CardImageProps = { + card: ApiCard, + otherClasses: string, + highlightColor?: string +} + +export const CardImage = ({card, otherClasses, highlightColor}: CardImageProps) => { + const style = highlightStyle(highlightColor); + return <img src={`/images/cards/${card.image}`} + title={card.name} + alt={'Card ' + card.name} + className={`card-img ${otherClasses}`} + style={style}/> +}; + +function highlightStyle(highlightColor?: string) { + if (highlightColor) { + return { boxShadow: `0 0 1rem 0.1rem ${highlightColor}` }; + } else { + return {}; + } +} diff --git a/sw-ui/src/components/game/GameScene.css b/sw-ui/src/components/game/GameScene.css new file mode 100644 index 00000000..3417459b --- /dev/null +++ b/sw-ui/src/components/game/GameScene.css @@ -0,0 +1,13 @@ +.gameSceneRoot { + background: url('background-papyrus.jpg') center no-repeat; + background-size: cover; +} + +.fullscreen { + position: fixed; + top: 0; + left: 0; + bottom: 0; + right: 0; + overflow: hidden; +} diff --git a/sw-ui/src/components/game/GameScene.tsx b/sw-ui/src/components/game/GameScene.tsx new file mode 100644 index 00000000..465d0840 --- /dev/null +++ b/sw-ui/src/components/game/GameScene.tsx @@ -0,0 +1,77 @@ +import { Button, Classes, Intent, NonIdealState } from '@blueprintjs/core'; +import { List } from 'immutable'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { ApiPlayer, ApiPlayerMove, ApiPlayerTurnInfo } from '../../api/model'; +import { GlobalState } from '../../reducers'; +import { actions } from '../../redux/actions/game'; +import { getCurrentTurnInfo } from '../../redux/currentGame'; +import { getCurrentGame } from '../../redux/games'; +import { Board } from './Board'; +import './GameScene.css' +import { Hand } from './Hand'; +import { ProductionBar } from './ProductionBar'; + +type GameSceneStateProps = { + players: List<ApiPlayer>, + turnInfo: ApiPlayerTurnInfo | null, +} + +type GameSceneDispatchProps = { + sayReady: () => void, + prepareMove: (move: ApiPlayerMove) => void, +} + +type GameSceneProps = GameSceneStateProps & GameSceneDispatchProps + +class GameScenePresenter extends Component<GameSceneProps> { + + render() { + return ( + <div className='gameSceneRoot fullscreen'> + {!this.props.turnInfo && <GamePreStart onReadyClicked={this.props.sayReady}/>} + {this.props.turnInfo && this.turnInfoScene(this.props.turnInfo)} + </div> + ); + } + + turnInfoScene(turnInfo: ApiPlayerTurnInfo) { + let board = turnInfo.table.boards[turnInfo.playerIndex]; + return <div> + <p>{turnInfo.message}</p> + <Board board={board}/> + <Hand cards={turnInfo.hand} + wonderUpgradable={turnInfo.wonderBuildability.buildable} + prepareMove={this.props.prepareMove}/> + <ProductionBar gold={board.gold} production={board.production}/> + </div> + } +} + +type GamePreStartProps = { + onReadyClicked: () => void +} +const GamePreStart = ({onReadyClicked}: GamePreStartProps) => <NonIdealState + description={<p>Click "ready" when you are</p>} + action={<Button text="READY" className={Classes.LARGE} intent={Intent.PRIMARY} icon='play' + onClick={() => onReadyClicked()}/>} +/>; + +function mapStateToProps(state: GlobalState): GameSceneStateProps { + const game = getCurrentGame(state); + console.info(game); + + return { + players: game ? List(game.players) : List(), + turnInfo: getCurrentTurnInfo(state), + }; +} + +function mapDispatchToProps(): GameSceneDispatchProps { + return { + sayReady: actions.sayReady, + prepareMove: actions.prepareMove, + } +} + +export const GameScene = connect(mapStateToProps, mapDispatchToProps)(GameScenePresenter); diff --git a/sw-ui/src/components/game/Hand.css b/sw-ui/src/components/game/Hand.css new file mode 100644 index 00000000..8e7d93c5 --- /dev/null +++ b/sw-ui/src/components/game/Hand.css @@ -0,0 +1,50 @@ +.hand { + align-items: center; + bottom: 0; + display: flex; + height: 345px; /* can hold enhanced cards */ + left: 50%; + max-height: 25vw; + position: absolute; + transform: translate(-50%, 55%); + transition: 0.5s; + z-index: 30; +} +.hand:hover { + bottom: 4rem; + transform: translate(-50%, 0%); +} + +.hand-card { + align-items: flex-end; + display: grid; + margin: 0.2rem; +} + +.hand-card .hand-card-img { + grid-row: 1; + grid-column: 1; + max-width: 13vw; + max-height: 60vh; + transition: 0.1s; + width: 11rem; +} +.hand-card.unplayable .hand-card-img { + filter: grayscale(50%) contrast(50%); +} +.hand-card:hover .hand-card-img { + box-shadow: 0 10px 40px black; + width: 14rem; + max-width: 15vw; + max-height: 90vh; +} + +.hand-card .action-buttons { + align-items: flex-end; + display: none; + grid-row: 1; + grid-column: 1; +} +.hand-card:hover .action-buttons { + display: flex; +} diff --git a/sw-ui/src/components/game/Hand.tsx b/sw-ui/src/components/game/Hand.tsx new file mode 100644 index 00000000..744c45cc --- /dev/null +++ b/sw-ui/src/components/game/Hand.tsx @@ -0,0 +1,44 @@ +import { Button, ButtonGroup, Classes, Intent } from '@blueprintjs/core'; +import React from 'react'; +import { ApiHandCard, ApiPlayerMove } from '../../api/model'; +import './Hand.css' +import { CardImage } from './CardImage'; + +type HandProps = { + cards: ApiHandCard[], + wonderUpgradable: boolean, + prepareMove: (move: ApiPlayerMove) => void +} + +export const Hand = ({cards, wonderUpgradable, prepareMove}: HandProps) => { + return <div className='hand'>{cards.map((c, i) => <HandCard key={i} card={c} + wonderUpgradable={wonderUpgradable} + prepareMove={prepareMove}/>)}</div>; +}; + +type HandCardProps = { + card: ApiHandCard, + wonderUpgradable: boolean, + prepareMove: (move: ApiPlayerMove) => void +} + +const HandCard = ({card, wonderUpgradable, prepareMove}: HandCardProps) => { + let playableClass = card.playability.playable ? '' : 'unplayable'; + return <div className={`hand-card ${playableClass}`}> + <CardImage card={card} otherClasses="hand-card-img"/> + <ActionButtons card={card} wonderUpgradable={wonderUpgradable} prepareMove={prepareMove} /> + </div> +}; + +type ActionButtonsProps = HandCardProps + +const ActionButtons = ({card, wonderUpgradable, prepareMove}: ActionButtonsProps) => <ButtonGroup className="action-buttons"> + <Button title="PLAY" className={Classes.LARGE} intent={Intent.SUCCESS} icon='play' + disabled={!card.playability.playable} + onClick={() => prepareMove({type: 'PLAY', cardName: card.name, boughtResources: []})}/> + <Button title="BUILD WONDER" className={Classes.LARGE} intent={Intent.PRIMARY} icon='key-shift' + disabled={!wonderUpgradable} + onClick={() => prepareMove({type: 'UPGRADE_WONDER', cardName: card.name, boughtResources: []})}/> + <Button title="DISCARD" className={Classes.LARGE} intent={Intent.DANGER} icon='cross' + onClick={() => prepareMove({type: 'DISCARD', cardName: card.name, boughtResources: []})}/> +</ButtonGroup>; diff --git a/sw-ui/src/components/game/ProductionBar.css b/sw-ui/src/components/game/ProductionBar.css new file mode 100644 index 00000000..77a3b8fc --- /dev/null +++ b/sw-ui/src/components/game/ProductionBar.css @@ -0,0 +1,50 @@ +.production-bar { + align-items: center; + background: lightgray; + bottom: 0; + border-top: 1px #8b8b8b solid; + background: linear-gradient(#eaeaea, #888 7%); + box-shadow: 0 0 15px 0 #747474; + display: flex; + height: 3.5rem; + position: fixed; + width: 100vw; + z-index: 99; +} + +.fixed-resources { + margin: auto; + display: flex; +} +.alternative-resources { + margin: auto; + display: flex; +} + +.resource-with-count { + margin-left: 1rem +} +.resource-choice { + margin-left: 1.5rem; +} + +.choice-separator { + font-size: 2rem; + vertical-align: middle; + margin: 5px; + color: #c29929; + text-shadow: 0 0 1px black; +} + +.token-img { + height: 3rem; + vertical-align: middle; + width: 3rem; +} + +.token-count { + font-family: fantasy; + font-size: 1.5rem; + margin-left: 0.2rem; + vertical-align: middle; +} diff --git a/sw-ui/src/components/game/ProductionBar.tsx b/sw-ui/src/components/game/ProductionBar.tsx new file mode 100644 index 00000000..3e5c6d34 --- /dev/null +++ b/sw-ui/src/components/game/ProductionBar.tsx @@ -0,0 +1,87 @@ +import React from 'react'; +import { ApiCountedResource, ApiProduction, ApiResourceType } from '../../api/model'; +import './ProductionBar.css' + +type ProductionBarProps = { + gold: number, + production: ApiProduction, +} + +export const ProductionBar = ({gold, production}: ProductionBarProps) => { + return <div className='production-bar'> + <GoldIndicator amount={gold}/> + <FixedResources resources={production.fixedResources}/> + <AlternativeResources resources={production.alternativeResources}/> + </div>; +}; + +type GoldIndicatorProps = { + amount: number, +} +const GoldIndicator = ({amount}: GoldIndicatorProps) => { + return <TokenWithCount tokenName="coin" count={amount} otherClasses="gold-indicator"/> +}; + +type FixedResourcesProps = { + resources: ApiCountedResource[], +} +const FixedResources = ({resources}: FixedResourcesProps) => { + return <div className="fixed-resources"> + {resources.map(r => <TokenWithCount key={r.type} + tokenName={getTokenName(r.type)} + count={r.count} + otherClasses="resource-with-count"/>)} + </div> +}; + +type AlternativeResourcesProps = { + resources: ApiResourceType[][], +} +const AlternativeResources = ({resources}: AlternativeResourcesProps) => { + return <div className="alternative-resources"> + {resources.map((types, i) => <ResourceChoice key={i} types={types}/>)} + </div> +}; + +type ResourceChoiceProps = { + types: ApiResourceType[], +} +const ResourceChoice = ({types}: ResourceChoiceProps) => { + let typeImages = types.map(type => <TokenImage key={type} tokenName={getTokenName(type)}/>); + let separator = <span className="choice-separator">∕</span>; + return <div className="resource-choice"> + {intersperce(typeImages, separator)} + </div> +}; + +function intersperce<T>(array: T[], separator: T): T[] { + let result = array.reduce((acc: T[], elt: T) => acc.concat(elt, separator), []); + return result.slice(0, -1); // remove extra separator at the end +} + +type TokenWithCountProps = { + tokenName: string, + count: number, + otherClasses?: string, +} +const TokenWithCount = ({tokenName, count, otherClasses = ""}: TokenWithCountProps) => { + return <div className={`token-with-count ${otherClasses}`}> + <TokenImage tokenName={tokenName}/> + <span className="token-count">× {count}</span> + </div> +}; + +type TokenImageProps = { + tokenName: string, +} +const TokenImage = ({tokenName}: TokenImageProps) => { + return <img src={getTokenImagePath(tokenName)} title={tokenName} alt={tokenName} className="token-img"/> +}; + +function getTokenImagePath(tokenName: string): string { + return `/images/tokens/${tokenName}.png`; +} + +function getTokenName(resourceType: ApiResourceType): string { + return `resources/${resourceType.toLowerCase()}`; +} diff --git a/sw-ui/src/components/game/background-papyrus.jpg b/sw-ui/src/components/game/background-papyrus.jpg Binary files differnew file mode 100644 index 00000000..57bdffcf --- /dev/null +++ b/sw-ui/src/components/game/background-papyrus.jpg diff --git a/sw-ui/src/components/home/ChooseNameForm.tsx b/sw-ui/src/components/home/ChooseNameForm.tsx new file mode 100644 index 00000000..8292150b --- /dev/null +++ b/sw-ui/src/components/home/ChooseNameForm.tsx @@ -0,0 +1,42 @@ +import { Button, Classes, InputGroup, Intent } from '@blueprintjs/core'; +import React, { ChangeEvent, Component, SyntheticEvent } from 'react'; +import { connect } from 'react-redux'; +import { actions } from '../../redux/actions/user'; + +type ChooseNameFormPresenterProps = { + chooseUsername: (username: string) => void, +} + +class ChooseNameFormPresenter extends Component<ChooseNameFormPresenterProps> { + _username = ''; + + play = (e: SyntheticEvent<any>) => { + e.preventDefault(); + if (this._username !== undefined) { + this.props.chooseUsername(this._username); + } + }; + + render() { + return ( + <form onSubmit={this.play}> + <InputGroup + className={Classes.LARGE} + placeholder="Username" + onChange={(e: ChangeEvent<HTMLInputElement>) => (this._username = e.target.value)} + rightElement={this.renderSubmit()} + /> + </form> + ); + } + + renderSubmit = () => ( + <Button className={Classes.MINIMAL} onClick={this.play} intent={Intent.PRIMARY} icon="arrow-right" /> + ); +} + +const mapDispatchToProps = { + chooseUsername: actions.chooseUsername, +}; + +export const ChooseNameForm = connect(null, mapDispatchToProps)(ChooseNameFormPresenter); diff --git a/sw-ui/src/components/home/Home.css b/sw-ui/src/components/home/Home.css new file mode 100644 index 00000000..7d9a96de --- /dev/null +++ b/sw-ui/src/components/home/Home.css @@ -0,0 +1,13 @@ +.homeRoot { + background: url('background-zeus-temple.jpg') center no-repeat; + background-size: cover; +} + +.fullscreen { + position: fixed; + top: 0; + left: 0; + bottom: 0; + right: 0; + overflow: hidden; +} diff --git a/sw-ui/src/components/home/Home.tsx b/sw-ui/src/components/home/Home.tsx new file mode 100644 index 00000000..094db658 --- /dev/null +++ b/sw-ui/src/components/home/Home.tsx @@ -0,0 +1,12 @@ +import * as React from 'react'; +import { Flex } from 'reflexbox'; +import { ChooseNameForm } from './ChooseNameForm'; +import './Home.css' +import logo from './logo-7-wonders.png'; + +export const Home = () => ( + <Flex className='homeRoot fullscreen' column align='center' justify='center'> + <img src={logo} alt="Seven Wonders"/> + <ChooseNameForm/> + </Flex> +); diff --git a/sw-ui/src/components/home/background-zeus-temple.jpg b/sw-ui/src/components/home/background-zeus-temple.jpg Binary files differnew file mode 100644 index 00000000..5a28e933 --- /dev/null +++ b/sw-ui/src/components/home/background-zeus-temple.jpg diff --git a/sw-ui/src/components/home/logo-7-wonders.png b/sw-ui/src/components/home/logo-7-wonders.png Binary files differnew file mode 100644 index 00000000..96974d3e --- /dev/null +++ b/sw-ui/src/components/home/logo-7-wonders.png diff --git a/sw-ui/src/components/lobby/Lobby.tsx b/sw-ui/src/components/lobby/Lobby.tsx new file mode 100644 index 00000000..3594af65 --- /dev/null +++ b/sw-ui/src/components/lobby/Lobby.tsx @@ -0,0 +1,56 @@ +import { Button, Classes, Intent } from '@blueprintjs/core'; +import { List } from 'immutable'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { ApiLobby, ApiPlayer } from '../../api/model'; +import { GlobalState } from '../../reducers'; +import { actions } from '../../redux/actions/lobby'; +import { getCurrentGame } from '../../redux/games'; +import { getCurrentPlayer } from '../../redux/user'; +import { RadialPlayerList } from './RadialPlayerList'; + +export type LobbyStateProps = { + currentGame: ApiLobby | null, + currentPlayer: ApiPlayer | null, + players: List<ApiPlayer>, +} + +export type LobbyDispatchProps = { + startGame: () => void, +} + +export type LobbyProps = LobbyStateProps & LobbyDispatchProps + +class LobbyPresenter extends Component<LobbyProps> { + + render() { + const {currentGame, currentPlayer, players, startGame} = this.props; + if (!currentGame || !currentPlayer) { + return <div>Error: no current game.</div> + } + return ( + <div> + <h2>{currentGame.name + ' — Lobby'}</h2> + <RadialPlayerList players={players}/> + {currentPlayer.gameOwner && <Button text="START" className={Classes.LARGE} intent={Intent.PRIMARY} icon='play' + onClick={startGame} disabled={players.size < 3}/>} + </div> + ); + } +} + +function mapStateToProps(state: GlobalState): LobbyStateProps { + const game = getCurrentGame(state); + console.info(game); + return { + currentGame: game, + currentPlayer: getCurrentPlayer(state), + players: game ? List(game.players) : List(), + }; +} + +const mapDispatchToProps = { + startGame: actions.requestStartGame, +}; + +export const Lobby = connect(mapStateToProps, mapDispatchToProps)(LobbyPresenter); diff --git a/sw-ui/src/components/lobby/PlayerList.tsx b/sw-ui/src/components/lobby/PlayerList.tsx new file mode 100644 index 00000000..bfc3a56c --- /dev/null +++ b/sw-ui/src/components/lobby/PlayerList.tsx @@ -0,0 +1,41 @@ +import { Classes, Icon } from '@blueprintjs/core' +import { List } from 'immutable'; +import * as React from 'react'; +import { Flex } from 'reflexbox'; +import { ApiPlayer } from '../../api/model'; + +type PlayerListItemProps = { + player: ApiPlayer, + isOwner: boolean, + isUser: boolean, +}; + +const PlayerListItem = ({player, isOwner, isUser}: PlayerListItemProps) => ( + <tr> + <td> + <Flex align='center'> + {isOwner && <Icon icon='badge' title='Game owner'/>} + {isUser && <Icon icon='user' title='This is you'/>} + </Flex> + </td> + <td>{player.displayName}</td> + <td>{player.username}</td> + </tr> +); + +type PlayerListProps = { + players: List<ApiPlayer>, + owner: string, + currentPlayer: ApiPlayer, +}; + +export const PlayerList = ({players, owner, currentPlayer}: PlayerListProps) => ( + <table className={Classes.HTML_TABLE}> + <tbody> + {players.map((player: ApiPlayer) => <PlayerListItem key={player.username} + player={player} + isOwner={player.username === owner} + isUser={player.username === currentPlayer.username}/>)} + </tbody> + </table> +); diff --git a/sw-ui/src/components/lobby/RadialPlayerList.tsx b/sw-ui/src/components/lobby/RadialPlayerList.tsx new file mode 100644 index 00000000..88db55fc --- /dev/null +++ b/sw-ui/src/components/lobby/RadialPlayerList.tsx @@ -0,0 +1,69 @@ +import { Icon, IconName, Intent } from '@blueprintjs/core'; +import { List } from 'immutable'; +import * as React from 'react'; +import { ReactNode } from 'react'; +import { Flex } from 'reflexbox'; +import { ApiPlayer } from '../../api/model'; +import { RadialList } from './radial-list/RadialList'; +import roundTable from './round-table.png'; + +type PlayerItemProps = { + player: ApiPlayer +}; + +const PlayerItem = ({player}: PlayerItemProps) => ( + <Flex column align='center'> + <UserIcon isOwner={player.gameOwner} isUser={player.user} title={player.gameOwner ? 'Game owner' : null}/> + <h5 style={{margin: 0}}>{player.displayName}</h5> + </Flex> +); + +const PlayerPlaceholder = () => ( + <Flex column align='center' style={{opacity: 0.3}}> + <UserIcon isOwner={false} isUser={false} title='Waiting for player...'/> + <h5 style={{margin: 0}}>?</h5> + </Flex> +); + +type UserIconProps = { + isUser: boolean, + isOwner: boolean, + title: string | null, +}; + +const UserIcon = ({isUser, isOwner, title}: UserIconProps) => { + const icon: IconName = isOwner ? 'badge' : 'user'; + const intent: Intent = isUser ? Intent.WARNING : Intent.NONE; + return <Icon icon={icon} iconSize={50} intent={intent} title={title}/>; +}; + +type RadialPlayerListProps = { + players: List<ApiPlayer> +}; + +export const RadialPlayerList = ({players}: RadialPlayerListProps) => { + const orderedPlayers = placeUserFirst(players.toArray()); + const playerItems = orderedPlayers.map(player => <PlayerItem key={player.username} player={player}/>); + const tableImg = <img src={roundTable} alt='Round table' style={{width: 200, height: 200}}/>; + return <RadialList items={completeWithPlaceholders(playerItems)} + centerElement={tableImg} + radius={175} + offsetDegrees={180} + itemWidth={120} + itemHeight={100}/>; +}; + +function placeUserFirst(players: ApiPlayer[]): ApiPlayer[] { + while (!players[0].user) { + players.push(players.shift()!); + } + return players; +} + +function completeWithPlaceholders(playerItems: Array<ReactNode>): Array<ReactNode> { + while (playerItems.length < 3) { + playerItems.push(<PlayerPlaceholder/>); + } + return playerItems; +} + diff --git a/sw-ui/src/components/lobby/radial-list/RadialList.css b/sw-ui/src/components/lobby/radial-list/RadialList.css new file mode 100644 index 00000000..3b0f3a79 --- /dev/null +++ b/sw-ui/src/components/lobby/radial-list/RadialList.css @@ -0,0 +1,23 @@ +.radial-list-container { + margin: 0; + padding: 0; + position: relative; +} + +.radial-list { + margin: 0; + padding: 0; + transition: all 500ms ease-in-out; + z-index: 1; +} + +.radial-list-center { + z-index: 0; +} + +.absolute-center { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); +} diff --git a/sw-ui/src/components/lobby/radial-list/RadialList.tsx b/sw-ui/src/components/lobby/radial-list/RadialList.tsx new file mode 100644 index 00000000..806cdd08 --- /dev/null +++ b/sw-ui/src/components/lobby/radial-list/RadialList.tsx @@ -0,0 +1,64 @@ +import React, { ReactNode } from 'react'; +import { CartesianCoords, RadialConfig } from './radial-math'; +import { offsetsFromCenter, CLOCKWISE, COUNTERCLOCKWISE } from './radial-math'; +import './RadialList.css'; +import { RadialListItem } from './RadialListItem'; + +type RadialListProps = { + items: Array<ReactNode>, + centerElement?: ReactNode, + radius?: number, // 120px by default + offsetDegrees?: number, // defaults to 0 = 12 o'clock + arc?: number, // defaults to 360 (full circle) + clockwise?: boolean, // defaults to true + itemWidth?: number, + itemHeight?: number, +}; + +export const RadialList = ({items, centerElement, radius = 120, offsetDegrees = 0, arc = 360, clockwise = true, itemWidth = 20, itemHeight = 20}: RadialListProps) => { + const diameter = radius * 2; + const containerStyle = { + width: diameter + itemWidth, + height: diameter + itemHeight, + }; + const direction = clockwise ? CLOCKWISE : COUNTERCLOCKWISE; + const radialConfig: RadialConfig = {radius, arc, offsetDegrees, direction}; + + return <div className='radial-list-container' style={containerStyle}> + <RadialListItems items={items} radialConfig={radialConfig}/> + <RadialListCenter centerElement={centerElement}/> + </div>; +}; + +type RadialListItemsProps = { + items: Array<React.ReactNode>, + radialConfig: RadialConfig, +}; + +const RadialListItems = ({items, radialConfig}: RadialListItemsProps) => { + const diameter = radialConfig.radius * 2; + const ulStyle = { + width: diameter, + height: diameter, + }; + const itemOffsets: Array<CartesianCoords> = offsetsFromCenter(items.length, radialConfig); + + return <ul className='radial-list absolute-center' style={ulStyle}> + {items.map((item, i) => (<RadialListItem + key={i} + item={item} + offsets={itemOffsets[i]} + />))} + </ul>; +}; + +type RadialListCenterProps = { + centerElement?: ReactNode, +}; + +const RadialListCenter = ({centerElement}: RadialListCenterProps) => { + if (!centerElement) { + return null; + } + return <div className='radial-list-center absolute-center'>{centerElement}</div>; +}; diff --git a/sw-ui/src/components/lobby/radial-list/RadialListItem.css b/sw-ui/src/components/lobby/radial-list/RadialListItem.css new file mode 100644 index 00000000..65bb9661 --- /dev/null +++ b/sw-ui/src/components/lobby/radial-list/RadialListItem.css @@ -0,0 +1,11 @@ +.radial-list-item { + display: block; + position: absolute; + top: 50%; + left: 50%; + margin: 0; + padding: 0; + list-style: unset; + transition: all 500ms ease-in-out; + z-index: 1; +} diff --git a/sw-ui/src/components/lobby/radial-list/RadialListItem.tsx b/sw-ui/src/components/lobby/radial-list/RadialListItem.tsx new file mode 100644 index 00000000..19a27638 --- /dev/null +++ b/sw-ui/src/components/lobby/radial-list/RadialListItem.tsx @@ -0,0 +1,18 @@ +import * as React from 'react'; +import { ReactNode } from 'react'; +import { CartesianCoords } from './radial-math'; +import './RadialListItem.css'; + +type RadialListItemProps = { + item: ReactNode, + offsets: CartesianCoords, +}; + +export const RadialListItem = ({item, offsets}: RadialListItemProps) => { + // Y-axis points down, hence the minus sign + const liStyle = { + transform: `translate(${offsets.x}px, ${-offsets.y}px) translate(-50%, -50%)`, + }; + + return <li className='radial-list-item' style={liStyle}>{item}</li>; +}; diff --git a/sw-ui/src/components/lobby/radial-list/radial-math.ts b/sw-ui/src/components/lobby/radial-list/radial-math.ts new file mode 100644 index 00000000..f0f411f5 --- /dev/null +++ b/sw-ui/src/components/lobby/radial-list/radial-math.ts @@ -0,0 +1,48 @@ +export type CartesianCoords = { + x: number, + y: number, +} +type PolarCoords = { + radius: number, + angleDeg: number, +} + +const toRad = (deg: number) => deg * (Math.PI / 180); +const roundedProjection = (radius: number, thetaRad: number, trigFn: (angle: number) => number) => Math.round(radius * trigFn(thetaRad)); +const xProjection = (radius: number, thetaRad: number) => roundedProjection(radius, thetaRad, Math.cos); +const yProjection = (radius: number, thetaRad: number) => roundedProjection(radius, thetaRad, Math.sin); + +const toCartesian = ({radius, angleDeg}: PolarCoords): CartesianCoords => ({ + x: xProjection(radius, toRad(angleDeg)), + y: yProjection(radius, toRad(angleDeg)), +}); + +export type Direction = -1 | 1; +export const CLOCKWISE: Direction = -1; +export const COUNTERCLOCKWISE: Direction = 1; + +export type RadialConfig = { + radius: number, + arc: number, + offsetDegrees: number, + direction: Direction, +} +const DEFAULT_CONFIG: RadialConfig = { + radius: 120, + arc: 360, + offsetDegrees: 0, + direction: CLOCKWISE, +}; + +const DEFAULT_START = 90; // Up + +export function offsetsFromCenter(nbItems: number, radialConfig: RadialConfig = DEFAULT_CONFIG): Array<CartesianCoords> { + return Array.from({length: nbItems}, (v, i) => itemCartesianOffsets(i, nbItems, radialConfig)); +} + +function itemCartesianOffsets(index: number, nbItems: number, {radius, arc, direction, offsetDegrees}: RadialConfig): CartesianCoords { + const startAngle = DEFAULT_START + direction * offsetDegrees; + const angleStep = arc / nbItems; + const itemAngle = startAngle + direction * angleStep * index; + return toCartesian({radius, angleDeg: itemAngle}); +} diff --git a/sw-ui/src/components/lobby/round-table.png b/sw-ui/src/components/lobby/round-table.png Binary files differnew file mode 100644 index 00000000..f277376d --- /dev/null +++ b/sw-ui/src/components/lobby/round-table.png diff --git a/sw-ui/src/global-styles.css b/sw-ui/src/global-styles.css new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/sw-ui/src/global-styles.css diff --git a/sw-ui/src/index.tsx b/sw-ui/src/index.tsx new file mode 100644 index 00000000..fce90915 --- /dev/null +++ b/sw-ui/src/index.tsx @@ -0,0 +1,21 @@ +import '@blueprintjs/core/lib/css/blueprint.css'; +import React from 'react'; +import ReactDOM from 'react-dom'; +import { Provider } from 'react-redux'; +import { ConnectedRouter } from 'react-router-redux'; +import { Application } from './components/Application'; +import { INITIAL_STATE } from './reducers'; +import { configureStore } from './store'; + +const { store, history } = configureStore(INITIAL_STATE); + +const rootElement = document.getElementById('root'); +if (rootElement) { + ReactDOM.render(<Provider store={store}> + <ConnectedRouter history={history}> + <Application/> + </ConnectedRouter> + </Provider>, rootElement); +} else { + console.error('Element with ID "root" was not found, cannot bootstrap react app'); +} diff --git a/sw-ui/src/react-app-env.d.ts b/sw-ui/src/react-app-env.d.ts new file mode 100644 index 00000000..6431bc5f --- /dev/null +++ b/sw-ui/src/react-app-env.d.ts @@ -0,0 +1 @@ +/// <reference types="react-scripts" /> diff --git a/sw-ui/src/reducers.ts b/sw-ui/src/reducers.ts new file mode 100644 index 00000000..f885f642 --- /dev/null +++ b/sw-ui/src/reducers.ts @@ -0,0 +1,31 @@ +import { routerReducer } from 'react-router-redux'; +import { combineReducers } from 'redux'; +import { ApiPlayer } from './api/model'; +import { CurrentGameState, EMPTY_CURRENT_GAME } from './redux/currentGame'; +import { createCurrentGameReducer } from './redux/currentGame'; +import { EMPTY_GAMES, GamesState } from './redux/games'; +import { createGamesReducer } from './redux/games'; +import { currentUserReducer } from './redux/user'; + +export type GlobalState = { + currentGame: CurrentGameState; + currentUser: ApiPlayer | null; + games: GamesState; + routing: any; +} + +export const INITIAL_STATE: GlobalState = { + currentGame: EMPTY_CURRENT_GAME, + currentUser: null, + games: EMPTY_GAMES, + routing: null, +}; + +export function createReducer() { + return combineReducers({ + currentGame: createCurrentGameReducer(), + currentUser: currentUserReducer, + games: createGamesReducer(), + routing: routerReducer, + }); +} diff --git a/sw-ui/src/redux/actions/all.ts b/sw-ui/src/redux/actions/all.ts new file mode 100644 index 00000000..57d2a443 --- /dev/null +++ b/sw-ui/src/redux/actions/all.ts @@ -0,0 +1,5 @@ +import { GameAction } from './game'; +import { LobbyAction } from './lobby'; +import { PlayerAction } from './user'; + +export type Action = PlayerAction | LobbyAction | GameAction diff --git a/sw-ui/src/redux/actions/game.ts b/sw-ui/src/redux/actions/game.ts new file mode 100644 index 00000000..b67ea1dc --- /dev/null +++ b/sw-ui/src/redux/actions/game.ts @@ -0,0 +1,32 @@ +import { ApiPlayerMove, ApiPlayerTurnInfo, ApiPreparedCard, ApiTable } from '../../api/model'; + +export const REQUEST_SAY_READY = 'GAME/REQUEST_SAY_READY'; +export const REQUEST_PREPARE_MOVE = 'GAME/REQUEST_PREPARE_MOVE'; +export const PLAYER_READY_RECEIVED = 'GAME/PLAYER_READY_RECEIVED'; +export const TABLE_UPDATE_RECEIVED = 'GAME/TABLE_UPDATE_RECEIVED'; +export const PREPARED_CARD_RECEIVED = 'GAME/PREPARED_CARD_RECEIVED'; +export const TURN_INFO_RECEIVED = 'GAME/TURN_INFO_RECEIVED'; + +export type SayReadyAction = { type: typeof REQUEST_SAY_READY }; +export type PrepareMoveAction = { type: typeof REQUEST_PREPARE_MOVE, move: ApiPlayerMove }; +export type PlayerReadyEvent = { type: typeof PLAYER_READY_RECEIVED, username: string }; +export type TableUpdateEvent = { type: typeof TABLE_UPDATE_RECEIVED, table: ApiTable }; +export type PreparedCardEvent = { type: typeof PREPARED_CARD_RECEIVED, card: ApiPreparedCard }; +export type TurnInfoEvent = { type: typeof TURN_INFO_RECEIVED, turnInfo: ApiPlayerTurnInfo }; + +export type GameAction = + SayReadyAction + | PrepareMoveAction + | PlayerReadyEvent + | TableUpdateEvent + | PreparedCardEvent + | TurnInfoEvent; + +export const actions = { + sayReady: () => ({ type: REQUEST_SAY_READY }), + prepareMove: (move: ApiPlayerMove) => ({ type: REQUEST_PREPARE_MOVE, move }), + receivePlayerReady: (username: string) => ({ type: PLAYER_READY_RECEIVED, username }), + receiveTableUpdate: (table: ApiTable) => ({ type: TABLE_UPDATE_RECEIVED, table }), + receivePreparedCard: (card: ApiPreparedCard) => ({ type: PREPARED_CARD_RECEIVED, card }), + receiveTurnInfo: (turnInfo: ApiPlayerTurnInfo) => ({ type: TURN_INFO_RECEIVED, turnInfo }), +}; diff --git a/sw-ui/src/redux/actions/lobby.ts b/sw-ui/src/redux/actions/lobby.ts new file mode 100644 index 00000000..c121b022 --- /dev/null +++ b/sw-ui/src/redux/actions/lobby.ts @@ -0,0 +1,32 @@ +import { ApiLobby } from '../../api/model'; + +export const UPDATE_GAMES = 'GAMES/UPDATE_GAMES'; +export const REQUEST_CREATE_GAME = 'GAMES/REQUEST_CREATE_GAME'; +export const REQUEST_JOIN_GAME = 'GAMES/REQUEST_JOIN_GAME'; +export const REQUEST_START_GAME = 'GAMES/REQUEST_START_GAME'; +export const ENTER_LOBBY = 'GAMES/ENTER_LOBBY'; +export const ENTER_GAME = 'GAMES/ENTER_GAME'; + +export type UpdateGamesAction = { type: typeof UPDATE_GAMES, games: ApiLobby[]}; +export type RequestCreateGameAction = { type: typeof REQUEST_CREATE_GAME, gameName: string }; +export type RequestJoinGameAction = { type: typeof REQUEST_JOIN_GAME, gameId: number }; +export type RequestStartGameAction = { type: typeof REQUEST_START_GAME }; +export type EnterLobbyAction = { type: typeof ENTER_LOBBY, gameId: number }; +export type EnterGameAction = { type: typeof ENTER_GAME, gameId: number }; + +export type LobbyAction = + | UpdateGamesAction + | RequestCreateGameAction + | RequestJoinGameAction + | RequestStartGameAction + | EnterLobbyAction + | EnterGameAction; + +export const actions = { + updateGames: (games: ApiLobby[]): UpdateGamesAction => ({ type: UPDATE_GAMES, games }), + requestJoinGame: (gameId: number): RequestJoinGameAction => ({ type: REQUEST_JOIN_GAME, gameId }), + requestCreateGame: (gameName: string): RequestCreateGameAction => ({ type: REQUEST_CREATE_GAME, gameName }), + requestStartGame: (): RequestStartGameAction => ({ type: REQUEST_START_GAME }), + enterLobby: (gameId: number): EnterLobbyAction => ({ type: ENTER_LOBBY, gameId }), + enterGame: (gameId: number): EnterGameAction => ({ type: ENTER_GAME, gameId }), +}; diff --git a/sw-ui/src/redux/actions/user.ts b/sw-ui/src/redux/actions/user.ts new file mode 100644 index 00000000..29c85707 --- /dev/null +++ b/sw-ui/src/redux/actions/user.ts @@ -0,0 +1,17 @@ +import { Map } from 'immutable'; +import { ApiPlayer } from '../../api/model'; + +export const REQUEST_CHOOSE_USERNAME = 'USER/REQUEST_CHOOSE_USERNAME'; +export const SET_CURRENT_PLAYER = 'USER/SET_CURRENT_PLAYER'; +export const UPDATE_PLAYERS = 'USER/UPDATE_PLAYERS'; + +export type RequestChooseUsernameAction = { type: typeof REQUEST_CHOOSE_USERNAME, username: string }; +export type SetCurrentPlayerAction = { type: typeof SET_CURRENT_PLAYER, player: ApiPlayer }; +export type UpdatePlayersAction = { type: typeof UPDATE_PLAYERS, players: Map<string, ApiPlayer> }; + +export type PlayerAction = RequestChooseUsernameAction | SetCurrentPlayerAction | UpdatePlayersAction; + +export const actions = { + chooseUsername: (username: string): RequestChooseUsernameAction => ({ type: REQUEST_CHOOSE_USERNAME, username }), + setCurrentPlayer: (player: ApiPlayer): SetCurrentPlayerAction => ({ type: SET_CURRENT_PLAYER, player }), +}; diff --git a/sw-ui/src/redux/currentGame.ts b/sw-ui/src/redux/currentGame.ts new file mode 100644 index 00000000..5e015d60 --- /dev/null +++ b/sw-ui/src/redux/currentGame.ts @@ -0,0 +1,46 @@ +import { combineReducers } from 'redux'; +import { ApiPlayerTurnInfo, ApiTable } from '../api/model'; +import { GlobalState } from '../reducers'; +import { Action } from './actions/all'; +import { TABLE_UPDATE_RECEIVED, TURN_INFO_RECEIVED } from './actions/game'; + +export type CurrentGameState = { + turnInfo: ApiPlayerTurnInfo | null; + table: ApiTable | null; +} + +export const EMPTY_CURRENT_GAME: CurrentGameState = { + turnInfo: null, + table: null, +}; + +export function createCurrentGameReducer() { + return combineReducers({ + turnInfo: turnInfoReducer, + table: tableUpdatesReducer, + }); +} + +const turnInfoReducer = (state: ApiPlayerTurnInfo | null = null, action: Action) => { + switch (action.type) { + case TURN_INFO_RECEIVED: + return action.turnInfo; + case TABLE_UPDATE_RECEIVED: + return null; + default: + return state; + } +}; + +const tableUpdatesReducer = (state: ApiTable | null = null, action: Action) => { + switch (action.type) { + case TURN_INFO_RECEIVED: + return action.turnInfo.table; + case TABLE_UPDATE_RECEIVED: + return action.table; + default: + return state; + } +}; + +export const getCurrentTurnInfo = (state: GlobalState): ApiPlayerTurnInfo | null => state.currentGame.turnInfo; diff --git a/sw-ui/src/redux/games.ts b/sw-ui/src/redux/games.ts new file mode 100644 index 00000000..4df2f1da --- /dev/null +++ b/sw-ui/src/redux/games.ts @@ -0,0 +1,56 @@ +import { List, Map } from 'immutable'; +import { combineReducers } from 'redux'; +import { ApiLobby } from '../api/model'; +import { GlobalState } from '../reducers'; +import { Action } from './actions/all'; +import { ENTER_LOBBY, UPDATE_GAMES } from './actions/lobby'; + +export type GamesState = { + all: Map<string, ApiLobby>, + current: string | null +}; + +export const EMPTY_GAMES: GamesState = { + all: Map(), + current: null, +}; + +export const createGamesReducer = () => { + return combineReducers({ + all: allGamesReducer, + current: currentGameIdReducer + }) +}; + +export const allGamesReducer = (state: Map<string, ApiLobby> = Map(), action: Action) => { + switch (action.type) { + case UPDATE_GAMES: + const newGames = mapify(action.games); + return state.merge(newGames); + default: + return state; + } +}; + +function mapify(games: ApiLobby[]): Map<string, ApiLobby> { + let newGames: {[id:string]:ApiLobby} = {}; + games.forEach(g => newGames[`${g.id}`] = g); + return Map(newGames); +} + +export const currentGameIdReducer = (state: string | null = null, action: Action) => { + switch (action.type) { + case ENTER_LOBBY: + return `${action.gameId}`; + default: + return state; + } +}; + +export const getAllGames = (state: GlobalState): List<ApiLobby> => state.games.all.toList(); +export const getCurrentGame = (state: GlobalState): ApiLobby | null => { + if (state.games.current == null) { + return null; + } + return state.games.all.get(state.games.current) || null; +}; diff --git a/sw-ui/src/redux/user.ts b/sw-ui/src/redux/user.ts new file mode 100644 index 00000000..2cc25cc0 --- /dev/null +++ b/sw-ui/src/redux/user.ts @@ -0,0 +1,43 @@ +import { ApiPlayer } from '../api/model'; +import { GlobalState } from '../reducers'; +import { Action } from './actions/all'; +import { SET_CURRENT_PLAYER } from './actions/user'; +import { getCurrentGame } from './games'; + +export type User = { + username: string, + displayName: string, +} + +export const currentUserReducer = (state: User | null = null, action: Action) => { + switch (action.type) { + case SET_CURRENT_PLAYER: + return { + username: action.player.username, + displayName: action.player.displayName + }; + default: + return state; + } +}; + +export function getCurrentUser(state: GlobalState): User | null { + return state.currentUser +} + +export function getCurrentPlayer(state: GlobalState): ApiPlayer | null { + if (state.currentUser == null) { + return null; + } + let game = getCurrentGame(state); + if (game == null) { + return null; + } + for (let i = 0; i < game.players.length; i++) { + let player = game.players[i]; + if (player.username === state.currentUser.username) { + return player; + } + } + return null; +} diff --git a/sw-ui/src/sagas.ts b/sw-ui/src/sagas.ts new file mode 100644 index 00000000..03c71b63 --- /dev/null +++ b/sw-ui/src/sagas.ts @@ -0,0 +1,23 @@ +import { SagaIterator } from 'redux-saga'; +import { call, fork } from 'redux-saga/effects'; +import { connectToGame, SevenWondersSession } from './api/sevenWondersApi'; +import { errorHandlingSaga } from './sagas/errors'; +import { gameSaga } from './sagas/game'; +import { gameBrowserSaga } from './sagas/gameBrowser'; +import { homeSaga } from './sagas/home'; +import { lobbySaga } from './sagas/lobby'; + +export function* rootSaga(): SagaIterator { + let sevenWondersSession: SevenWondersSession; + try { + sevenWondersSession = yield call(connectToGame); + } catch (error) { + console.error('Could not connect to socket', error); + return; + } + yield fork(errorHandlingSaga, sevenWondersSession); + yield fork(homeSaga, sevenWondersSession); + yield fork(gameBrowserSaga, sevenWondersSession); + yield fork(lobbySaga, sevenWondersSession); + yield fork(gameSaga, sevenWondersSession); +} diff --git a/sw-ui/src/sagas/errors.ts b/sw-ui/src/sagas/errors.ts new file mode 100644 index 00000000..b27dfa95 --- /dev/null +++ b/sw-ui/src/sagas/errors.ts @@ -0,0 +1,36 @@ +import {Toaster} from '@blueprintjs/core'; +import {Channel, eventChannel} from 'redux-saga'; +import {apply, cancelled, take} from 'redux-saga/effects'; +import {ApiError} from '../api/model'; +import {SevenWondersSession} from '../api/sevenWondersApi'; + +const ErrorToaster = Toaster.create(); + +export function* errorHandlingSaga(session: SevenWondersSession): any { + const errorChannel: Channel<ApiError> = yield eventChannel(session.watchErrors()); + try { + while (true) { + const error: ApiError = yield take(errorChannel); + yield* handleOneError(error); + } + } finally { + if (yield cancelled()) { + console.log('Error management saga cancelled'); + yield apply(errorChannel, errorChannel.close); + } + } +} + +function* handleOneError(err: ApiError): any { + console.error('Error received on web socket channel', err); + const msg = buildMsg(err); + yield apply(ErrorToaster, ErrorToaster.show, [{ intent: 'danger', icon: 'error', message: msg }]); +} + +function buildMsg(err: ApiError): string { + if (err.details.length > 0) { + return err.details.map(d => d.message).join('\n'); + } else { + return err.message; + } +} diff --git a/sw-ui/src/sagas/game.ts b/sw-ui/src/sagas/game.ts new file mode 100644 index 00000000..a60ab2d3 --- /dev/null +++ b/sw-ui/src/sagas/game.ts @@ -0,0 +1,81 @@ +import { eventChannel, SagaIterator } from 'redux-saga'; +import { apply, call, put, take } from 'redux-saga/effects'; +import { ApiPlayerTurnInfo, ApiPreparedCard, ApiTable } from '../api/model'; +import { SevenWondersSession } from '../api/sevenWondersApi'; +import { actions, REQUEST_PREPARE_MOVE, REQUEST_SAY_READY } from '../redux/actions/game'; +import { ENTER_GAME } from '../redux/actions/lobby'; + +function* watchPlayerReady(session: SevenWondersSession, gameId: number) { + const channel = yield eventChannel(session.watchPlayerReady(gameId)); + try { + while (true) { + const username = yield take(channel); + yield put(actions.receivePlayerReady(username)); + } + } finally { + yield apply(channel, channel.close); + } +} + +function* watchTableUpdates(session: SevenWondersSession, gameId: number) { + const channel = yield eventChannel(session.watchTableUpdates(gameId)); + try { + while (true) { + const table: ApiTable = yield take(channel); + yield put(actions.receiveTableUpdate(table)); + } + } finally { + yield apply(channel, channel.close); + } +} + +function* watchPreparedCards(session: SevenWondersSession, gameId: number) { + const channel = yield eventChannel(session.watchPreparedCards(gameId)); + try { + while (true) { + const preparedCard: ApiPreparedCard = yield take(channel); + yield put(actions.receivePreparedCard(preparedCard)); + } + } finally { + yield apply(channel, channel.close); + } +} + +function* sayReady(session: SevenWondersSession): SagaIterator { + while (true) { + yield take(REQUEST_SAY_READY); + yield apply(session, session.sayReady); + } +} + +function* prepareMove(session: SevenWondersSession): SagaIterator { + while (true) { + let action = yield take(REQUEST_PREPARE_MOVE); + yield apply(session, session.prepareMove, [action.move]); + } +} + +function* watchTurnInfo(session: SevenWondersSession) { + const channel = yield eventChannel(session.watchTurnInfo()); + try { + while (true) { + const turnInfo: ApiPlayerTurnInfo = yield take(channel); + yield put(actions.receiveTurnInfo(turnInfo)); + } + } finally { + yield apply(channel, channel.close); + } +} + +export function* gameSaga(session: SevenWondersSession): SagaIterator { + const { gameId } = yield take(ENTER_GAME); + console.log('Entered game!', gameId); + yield [ + call(watchPlayerReady, session, gameId), + call(watchTableUpdates, session, gameId), + call(watchPreparedCards, session, gameId), + call(sayReady, session), + call(prepareMove, session), + call(watchTurnInfo, session) + ]; +} diff --git a/sw-ui/src/sagas/gameBrowser.ts b/sw-ui/src/sagas/gameBrowser.ts new file mode 100644 index 00000000..868ec471 --- /dev/null +++ b/sw-ui/src/sagas/gameBrowser.ts @@ -0,0 +1,55 @@ +import { push } from 'react-router-redux'; +import { eventChannel, SagaIterator } from 'redux-saga'; +import { all, apply, call, put, take } from 'redux-saga/effects'; +import { ApiLobby } from '../api/model'; +import { SevenWondersSession } from '../api/sevenWondersApi'; +import { actions as gameActions, REQUEST_CREATE_GAME, REQUEST_JOIN_GAME } from '../redux/actions/lobby'; + +function* watchGames(session: SevenWondersSession): any { + const gamesChannel = yield eventChannel(session.watchGames()); + try { + while (true) { + const gameList = yield take(gamesChannel); + yield put(gameActions.updateGames(gameList)); + } + } finally { + yield apply(gamesChannel, gamesChannel.close); + } +} + +function* watchLobbyJoined(session: SevenWondersSession): any { + const joinedLobbyChannel = yield eventChannel(session.watchLobbyJoined()); + try { + const joinedLobby: ApiLobby = yield take(joinedLobbyChannel); + yield put(gameActions.updateGames([joinedLobby])); + yield put(gameActions.enterLobby(joinedLobby.id)); + yield put(push(`/lobby/${joinedLobby.id}`)); + } finally { + yield apply(joinedLobbyChannel, joinedLobbyChannel.close); + } +} + +function* createGame(session: SevenWondersSession): SagaIterator { + while (true) { + const { gameName } = yield take(REQUEST_CREATE_GAME); + // $FlowFixMe + yield apply(session, session.createGame, [gameName]); + } +} + +function* joinGame(session: SevenWondersSession): SagaIterator { + while (true) { + const { gameId } = yield take(REQUEST_JOIN_GAME); + // $FlowFixMe + yield apply(session, session.joinGame, [gameId]); + } +} + +export function* gameBrowserSaga(session: SevenWondersSession): SagaIterator { + yield all([ + call(watchGames, session), + call(watchLobbyJoined, session), + call(createGame, session), + call(joinGame, session), + ]); +} diff --git a/sw-ui/src/sagas/home.ts b/sw-ui/src/sagas/home.ts new file mode 100644 index 00000000..585c536e --- /dev/null +++ b/sw-ui/src/sagas/home.ts @@ -0,0 +1,28 @@ +import { push } from 'react-router-redux'; +import { eventChannel, SagaIterator } from 'redux-saga'; +import { all, apply, call, put, take } from 'redux-saga/effects'; +import { ApiPlayer } from '../api/model'; +import { SevenWondersSession } from '../api/sevenWondersApi'; +import { actions, REQUEST_CHOOSE_USERNAME } from '../redux/actions/user'; + +function* sendUsername(session: SevenWondersSession): SagaIterator { + while (true) { + const { username } = yield take(REQUEST_CHOOSE_USERNAME); + // $FlowFixMe + yield apply(session, session.chooseName, [username]); + } +} + +function* validateUsername(session: SevenWondersSession): any { + const usernameChannel = yield eventChannel(session.watchNameChoice()); + while (true) { + const user: ApiPlayer = yield take(usernameChannel); + yield put(actions.setCurrentPlayer(user)); + yield apply(usernameChannel, usernameChannel.close); + yield put(push('/games')); + } +} + +export function* homeSaga(session: SevenWondersSession): SagaIterator { + yield all([call(sendUsername, session), call(validateUsername, session)]); +} diff --git a/sw-ui/src/sagas/lobby.ts b/sw-ui/src/sagas/lobby.ts new file mode 100644 index 00000000..09360b02 --- /dev/null +++ b/sw-ui/src/sagas/lobby.ts @@ -0,0 +1,44 @@ +import { push } from 'react-router-redux'; +import { Channel, eventChannel, SagaIterator } from 'redux-saga'; +import { all, apply, call, put, take } from 'redux-saga/effects'; +import { SevenWondersSession } from '../api/sevenWondersApi'; +import { actions as gameActions, ENTER_LOBBY, REQUEST_START_GAME } from '../redux/actions/lobby'; + +function* watchLobbyUpdates(session: SevenWondersSession, lobbyId: number): any { + const lobbyUpdatesChannel: Channel<Object> = yield eventChannel(session.watchLobbyUpdated(lobbyId)); + try { + while (true) { + const lobby = yield take(lobbyUpdatesChannel); + yield put(gameActions.updateGames([lobby])); + } + } finally { + yield apply(lobbyUpdatesChannel, lobbyUpdatesChannel.close); + } +} + +function* watchGameStart(session: SevenWondersSession, lobbyId: number): any { + const gameStartedChannel = yield eventChannel(session.watchGameStarted(lobbyId)); + try { + yield take(gameStartedChannel); + yield put(gameActions.enterGame(lobbyId)); + yield put(push(`/game/${lobbyId}`)); + } finally { + yield apply(gameStartedChannel, gameStartedChannel.close); + } +} + +function* startGame(session: SevenWondersSession): SagaIterator { + while (true) { + yield take(REQUEST_START_GAME); + yield apply(session, session.startGame); + } +} + +export function* lobbySaga(session: SevenWondersSession): SagaIterator { + const { gameId } = yield take(ENTER_LOBBY); + yield all([ + call(watchLobbyUpdates, session, gameId), + call(watchGameStart, session, gameId), + call(startGame, session) + ]); +} diff --git a/sw-ui/src/setupProxy.js b/sw-ui/src/setupProxy.js new file mode 100644 index 00000000..88831e6e --- /dev/null +++ b/sw-ui/src/setupProxy.js @@ -0,0 +1,8 @@ +const proxy = require('http-proxy-middleware'); + +module.exports = function(app) { + app.use(proxy('/seven-wonders-websocket', { + "target": "http://localhost:8080", + "ws": true + })); +}; diff --git a/sw-ui/src/store.ts b/sw-ui/src/store.ts new file mode 100644 index 00000000..54a65509 --- /dev/null +++ b/sw-ui/src/store.ts @@ -0,0 +1,27 @@ +import createHistory from 'history/createBrowserHistory'; +import { routerMiddleware } from 'react-router-redux'; +import { applyMiddleware, createStore } from 'redux'; +import { composeWithDevTools } from 'redux-devtools-extension'; +import createSagaMiddleware from 'redux-saga'; +import { GlobalState } from './reducers'; +import { createReducer } from './reducers'; +import { rootSaga } from './sagas'; + +export function configureStore(initialState: GlobalState) { + const sagaMiddleware = createSagaMiddleware(); + + const history = createHistory(); + + const middlewares = [sagaMiddleware, routerMiddleware(history)]; + + const enhancers = [applyMiddleware(...middlewares)]; + + const store = createStore(createReducer(), initialState, composeWithDevTools(...enhancers)); + + sagaMiddleware.run(rootSaga); + + return { + store, + history, + }; +} |