summaryrefslogtreecommitdiff
path: root/sw-ui/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'sw-ui/src/components')
-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
33 files changed, 1072 insertions, 0 deletions
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
bgstack15