summaryrefslogtreecommitdiff
path: root/sw-ui/src
diff options
context:
space:
mode:
authorJoffrey BION <joffrey.bion@gmail.com>2019-05-16 23:48:38 +0200
committerJoffrey BION <joffrey.bion@gmail.com>2019-05-16 23:48:38 +0200
commit2382a452456e4bdef4584e1046925e372624cb79 (patch)
tree0e49b2e5d81facb55fb8b08228abeb218a27d466 /sw-ui/src
parentRemove GRADLE_METADATA feature to avoid breaking frontend build (diff)
downloadseven-wonders-2382a452456e4bdef4584e1046925e372624cb79.tar.gz
seven-wonders-2382a452456e4bdef4584e1046925e372624cb79.tar.bz2
seven-wonders-2382a452456e4bdef4584e1046925e372624cb79.zip
Rationalize module names
Diffstat (limited to 'sw-ui/src')
-rw-r--r--sw-ui/src/@types/reflexbox.d.ts37
-rw-r--r--sw-ui/src/api/model.ts187
-rw-r--r--sw-ui/src/api/sevenWondersApi.ts104
-rw-r--r--sw-ui/src/api/websocket.ts60
-rw-r--r--sw-ui/src/components/Application.tsx16
-rw-r--r--sw-ui/src/components/game-browser/GameBrowser.tsx56
-rw-r--r--sw-ui/src/components/game-browser/GameList.css3
-rw-r--r--sw-ui/src/components/game-browser/GameList.tsx85
-rw-r--r--sw-ui/src/components/game-browser/GameStatus.tsx17
-rw-r--r--sw-ui/src/components/game-browser/PlayerCount.css3
-rw-r--r--sw-ui/src/components/game-browser/PlayerCount.tsx12
-rw-r--r--sw-ui/src/components/game-browser/PlayerInfo.tsx27
-rw-r--r--sw-ui/src/components/game/Board.css38
-rw-r--r--sw-ui/src/components/game/Board.tsx67
-rw-r--r--sw-ui/src/components/game/CardImage.css4
-rw-r--r--sw-ui/src/components/game/CardImage.tsx26
-rw-r--r--sw-ui/src/components/game/GameScene.css13
-rw-r--r--sw-ui/src/components/game/GameScene.tsx77
-rw-r--r--sw-ui/src/components/game/Hand.css50
-rw-r--r--sw-ui/src/components/game/Hand.tsx44
-rw-r--r--sw-ui/src/components/game/ProductionBar.css50
-rw-r--r--sw-ui/src/components/game/ProductionBar.tsx87
-rw-r--r--sw-ui/src/components/game/background-papyrus.jpgbin0 -> 100272 bytes
-rw-r--r--sw-ui/src/components/home/ChooseNameForm.tsx42
-rw-r--r--sw-ui/src/components/home/Home.css13
-rw-r--r--sw-ui/src/components/home/Home.tsx12
-rw-r--r--sw-ui/src/components/home/background-zeus-temple.jpgbin0 -> 571089 bytes
-rw-r--r--sw-ui/src/components/home/logo-7-wonders.pngbin0 -> 301442 bytes
-rw-r--r--sw-ui/src/components/lobby/Lobby.tsx56
-rw-r--r--sw-ui/src/components/lobby/PlayerList.tsx41
-rw-r--r--sw-ui/src/components/lobby/RadialPlayerList.tsx69
-rw-r--r--sw-ui/src/components/lobby/radial-list/RadialList.css23
-rw-r--r--sw-ui/src/components/lobby/radial-list/RadialList.tsx64
-rw-r--r--sw-ui/src/components/lobby/radial-list/RadialListItem.css11
-rw-r--r--sw-ui/src/components/lobby/radial-list/RadialListItem.tsx18
-rw-r--r--sw-ui/src/components/lobby/radial-list/radial-math.ts48
-rw-r--r--sw-ui/src/components/lobby/round-table.pngbin0 -> 18527 bytes
-rw-r--r--sw-ui/src/global-styles.css0
-rw-r--r--sw-ui/src/index.tsx21
-rw-r--r--sw-ui/src/react-app-env.d.ts1
-rw-r--r--sw-ui/src/reducers.ts31
-rw-r--r--sw-ui/src/redux/actions/all.ts5
-rw-r--r--sw-ui/src/redux/actions/game.ts32
-rw-r--r--sw-ui/src/redux/actions/lobby.ts32
-rw-r--r--sw-ui/src/redux/actions/user.ts17
-rw-r--r--sw-ui/src/redux/currentGame.ts46
-rw-r--r--sw-ui/src/redux/games.ts56
-rw-r--r--sw-ui/src/redux/user.ts43
-rw-r--r--sw-ui/src/sagas.ts23
-rw-r--r--sw-ui/src/sagas/errors.ts36
-rw-r--r--sw-ui/src/sagas/game.ts81
-rw-r--r--sw-ui/src/sagas/gameBrowser.ts55
-rw-r--r--sw-ui/src/sagas/home.ts28
-rw-r--r--sw-ui/src/sagas/lobby.ts44
-rw-r--r--sw-ui/src/setupProxy.js8
-rw-r--r--sw-ui/src/store.ts27
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
new file mode 100644
index 00000000..57bdffcf
--- /dev/null
+++ b/sw-ui/src/components/game/background-papyrus.jpg
Binary files differ
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
new file mode 100644
index 00000000..5a28e933
--- /dev/null
+++ b/sw-ui/src/components/home/background-zeus-temple.jpg
Binary files differ
diff --git a/sw-ui/src/components/home/logo-7-wonders.png b/sw-ui/src/components/home/logo-7-wonders.png
new file mode 100644
index 00000000..96974d3e
--- /dev/null
+++ b/sw-ui/src/components/home/logo-7-wonders.png
Binary files differ
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
new file mode 100644
index 00000000..f277376d
--- /dev/null
+++ b/sw-ui/src/components/lobby/round-table.png
Binary files differ
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,
+ };
+}
bgstack15