diff options
Diffstat (limited to 'sw-ui/src/components')
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 Binary files differnew file mode 100644 index 00000000..57bdffcf --- /dev/null +++ b/sw-ui/src/components/game/background-papyrus.jpg diff --git a/sw-ui/src/components/home/ChooseNameForm.tsx b/sw-ui/src/components/home/ChooseNameForm.tsx new file mode 100644 index 00000000..8292150b --- /dev/null +++ b/sw-ui/src/components/home/ChooseNameForm.tsx @@ -0,0 +1,42 @@ +import { Button, Classes, InputGroup, Intent } from '@blueprintjs/core'; +import React, { ChangeEvent, Component, SyntheticEvent } from 'react'; +import { connect } from 'react-redux'; +import { actions } from '../../redux/actions/user'; + +type ChooseNameFormPresenterProps = { + chooseUsername: (username: string) => void, +} + +class ChooseNameFormPresenter extends Component<ChooseNameFormPresenterProps> { + _username = ''; + + play = (e: SyntheticEvent<any>) => { + e.preventDefault(); + if (this._username !== undefined) { + this.props.chooseUsername(this._username); + } + }; + + render() { + return ( + <form onSubmit={this.play}> + <InputGroup + className={Classes.LARGE} + placeholder="Username" + onChange={(e: ChangeEvent<HTMLInputElement>) => (this._username = e.target.value)} + rightElement={this.renderSubmit()} + /> + </form> + ); + } + + renderSubmit = () => ( + <Button className={Classes.MINIMAL} onClick={this.play} intent={Intent.PRIMARY} icon="arrow-right" /> + ); +} + +const mapDispatchToProps = { + chooseUsername: actions.chooseUsername, +}; + +export const ChooseNameForm = connect(null, mapDispatchToProps)(ChooseNameFormPresenter); diff --git a/sw-ui/src/components/home/Home.css b/sw-ui/src/components/home/Home.css new file mode 100644 index 00000000..7d9a96de --- /dev/null +++ b/sw-ui/src/components/home/Home.css @@ -0,0 +1,13 @@ +.homeRoot { + background: url('background-zeus-temple.jpg') center no-repeat; + background-size: cover; +} + +.fullscreen { + position: fixed; + top: 0; + left: 0; + bottom: 0; + right: 0; + overflow: hidden; +} diff --git a/sw-ui/src/components/home/Home.tsx b/sw-ui/src/components/home/Home.tsx new file mode 100644 index 00000000..094db658 --- /dev/null +++ b/sw-ui/src/components/home/Home.tsx @@ -0,0 +1,12 @@ +import * as React from 'react'; +import { Flex } from 'reflexbox'; +import { ChooseNameForm } from './ChooseNameForm'; +import './Home.css' +import logo from './logo-7-wonders.png'; + +export const Home = () => ( + <Flex className='homeRoot fullscreen' column align='center' justify='center'> + <img src={logo} alt="Seven Wonders"/> + <ChooseNameForm/> + </Flex> +); diff --git a/sw-ui/src/components/home/background-zeus-temple.jpg b/sw-ui/src/components/home/background-zeus-temple.jpg Binary files differnew file mode 100644 index 00000000..5a28e933 --- /dev/null +++ b/sw-ui/src/components/home/background-zeus-temple.jpg diff --git a/sw-ui/src/components/home/logo-7-wonders.png b/sw-ui/src/components/home/logo-7-wonders.png Binary files differnew file mode 100644 index 00000000..96974d3e --- /dev/null +++ b/sw-ui/src/components/home/logo-7-wonders.png diff --git a/sw-ui/src/components/lobby/Lobby.tsx b/sw-ui/src/components/lobby/Lobby.tsx new file mode 100644 index 00000000..3594af65 --- /dev/null +++ b/sw-ui/src/components/lobby/Lobby.tsx @@ -0,0 +1,56 @@ +import { Button, Classes, Intent } from '@blueprintjs/core'; +import { List } from 'immutable'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { ApiLobby, ApiPlayer } from '../../api/model'; +import { GlobalState } from '../../reducers'; +import { actions } from '../../redux/actions/lobby'; +import { getCurrentGame } from '../../redux/games'; +import { getCurrentPlayer } from '../../redux/user'; +import { RadialPlayerList } from './RadialPlayerList'; + +export type LobbyStateProps = { + currentGame: ApiLobby | null, + currentPlayer: ApiPlayer | null, + players: List<ApiPlayer>, +} + +export type LobbyDispatchProps = { + startGame: () => void, +} + +export type LobbyProps = LobbyStateProps & LobbyDispatchProps + +class LobbyPresenter extends Component<LobbyProps> { + + render() { + const {currentGame, currentPlayer, players, startGame} = this.props; + if (!currentGame || !currentPlayer) { + return <div>Error: no current game.</div> + } + return ( + <div> + <h2>{currentGame.name + ' — Lobby'}</h2> + <RadialPlayerList players={players}/> + {currentPlayer.gameOwner && <Button text="START" className={Classes.LARGE} intent={Intent.PRIMARY} icon='play' + onClick={startGame} disabled={players.size < 3}/>} + </div> + ); + } +} + +function mapStateToProps(state: GlobalState): LobbyStateProps { + const game = getCurrentGame(state); + console.info(game); + return { + currentGame: game, + currentPlayer: getCurrentPlayer(state), + players: game ? List(game.players) : List(), + }; +} + +const mapDispatchToProps = { + startGame: actions.requestStartGame, +}; + +export const Lobby = connect(mapStateToProps, mapDispatchToProps)(LobbyPresenter); diff --git a/sw-ui/src/components/lobby/PlayerList.tsx b/sw-ui/src/components/lobby/PlayerList.tsx new file mode 100644 index 00000000..bfc3a56c --- /dev/null +++ b/sw-ui/src/components/lobby/PlayerList.tsx @@ -0,0 +1,41 @@ +import { Classes, Icon } from '@blueprintjs/core' +import { List } from 'immutable'; +import * as React from 'react'; +import { Flex } from 'reflexbox'; +import { ApiPlayer } from '../../api/model'; + +type PlayerListItemProps = { + player: ApiPlayer, + isOwner: boolean, + isUser: boolean, +}; + +const PlayerListItem = ({player, isOwner, isUser}: PlayerListItemProps) => ( + <tr> + <td> + <Flex align='center'> + {isOwner && <Icon icon='badge' title='Game owner'/>} + {isUser && <Icon icon='user' title='This is you'/>} + </Flex> + </td> + <td>{player.displayName}</td> + <td>{player.username}</td> + </tr> +); + +type PlayerListProps = { + players: List<ApiPlayer>, + owner: string, + currentPlayer: ApiPlayer, +}; + +export const PlayerList = ({players, owner, currentPlayer}: PlayerListProps) => ( + <table className={Classes.HTML_TABLE}> + <tbody> + {players.map((player: ApiPlayer) => <PlayerListItem key={player.username} + player={player} + isOwner={player.username === owner} + isUser={player.username === currentPlayer.username}/>)} + </tbody> + </table> +); diff --git a/sw-ui/src/components/lobby/RadialPlayerList.tsx b/sw-ui/src/components/lobby/RadialPlayerList.tsx new file mode 100644 index 00000000..88db55fc --- /dev/null +++ b/sw-ui/src/components/lobby/RadialPlayerList.tsx @@ -0,0 +1,69 @@ +import { Icon, IconName, Intent } from '@blueprintjs/core'; +import { List } from 'immutable'; +import * as React from 'react'; +import { ReactNode } from 'react'; +import { Flex } from 'reflexbox'; +import { ApiPlayer } from '../../api/model'; +import { RadialList } from './radial-list/RadialList'; +import roundTable from './round-table.png'; + +type PlayerItemProps = { + player: ApiPlayer +}; + +const PlayerItem = ({player}: PlayerItemProps) => ( + <Flex column align='center'> + <UserIcon isOwner={player.gameOwner} isUser={player.user} title={player.gameOwner ? 'Game owner' : null}/> + <h5 style={{margin: 0}}>{player.displayName}</h5> + </Flex> +); + +const PlayerPlaceholder = () => ( + <Flex column align='center' style={{opacity: 0.3}}> + <UserIcon isOwner={false} isUser={false} title='Waiting for player...'/> + <h5 style={{margin: 0}}>?</h5> + </Flex> +); + +type UserIconProps = { + isUser: boolean, + isOwner: boolean, + title: string | null, +}; + +const UserIcon = ({isUser, isOwner, title}: UserIconProps) => { + const icon: IconName = isOwner ? 'badge' : 'user'; + const intent: Intent = isUser ? Intent.WARNING : Intent.NONE; + return <Icon icon={icon} iconSize={50} intent={intent} title={title}/>; +}; + +type RadialPlayerListProps = { + players: List<ApiPlayer> +}; + +export const RadialPlayerList = ({players}: RadialPlayerListProps) => { + const orderedPlayers = placeUserFirst(players.toArray()); + const playerItems = orderedPlayers.map(player => <PlayerItem key={player.username} player={player}/>); + const tableImg = <img src={roundTable} alt='Round table' style={{width: 200, height: 200}}/>; + return <RadialList items={completeWithPlaceholders(playerItems)} + centerElement={tableImg} + radius={175} + offsetDegrees={180} + itemWidth={120} + itemHeight={100}/>; +}; + +function placeUserFirst(players: ApiPlayer[]): ApiPlayer[] { + while (!players[0].user) { + players.push(players.shift()!); + } + return players; +} + +function completeWithPlaceholders(playerItems: Array<ReactNode>): Array<ReactNode> { + while (playerItems.length < 3) { + playerItems.push(<PlayerPlaceholder/>); + } + return playerItems; +} + diff --git a/sw-ui/src/components/lobby/radial-list/RadialList.css b/sw-ui/src/components/lobby/radial-list/RadialList.css new file mode 100644 index 00000000..3b0f3a79 --- /dev/null +++ b/sw-ui/src/components/lobby/radial-list/RadialList.css @@ -0,0 +1,23 @@ +.radial-list-container { + margin: 0; + padding: 0; + position: relative; +} + +.radial-list { + margin: 0; + padding: 0; + transition: all 500ms ease-in-out; + z-index: 1; +} + +.radial-list-center { + z-index: 0; +} + +.absolute-center { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); +} diff --git a/sw-ui/src/components/lobby/radial-list/RadialList.tsx b/sw-ui/src/components/lobby/radial-list/RadialList.tsx new file mode 100644 index 00000000..806cdd08 --- /dev/null +++ b/sw-ui/src/components/lobby/radial-list/RadialList.tsx @@ -0,0 +1,64 @@ +import React, { ReactNode } from 'react'; +import { CartesianCoords, RadialConfig } from './radial-math'; +import { offsetsFromCenter, CLOCKWISE, COUNTERCLOCKWISE } from './radial-math'; +import './RadialList.css'; +import { RadialListItem } from './RadialListItem'; + +type RadialListProps = { + items: Array<ReactNode>, + centerElement?: ReactNode, + radius?: number, // 120px by default + offsetDegrees?: number, // defaults to 0 = 12 o'clock + arc?: number, // defaults to 360 (full circle) + clockwise?: boolean, // defaults to true + itemWidth?: number, + itemHeight?: number, +}; + +export const RadialList = ({items, centerElement, radius = 120, offsetDegrees = 0, arc = 360, clockwise = true, itemWidth = 20, itemHeight = 20}: RadialListProps) => { + const diameter = radius * 2; + const containerStyle = { + width: diameter + itemWidth, + height: diameter + itemHeight, + }; + const direction = clockwise ? CLOCKWISE : COUNTERCLOCKWISE; + const radialConfig: RadialConfig = {radius, arc, offsetDegrees, direction}; + + return <div className='radial-list-container' style={containerStyle}> + <RadialListItems items={items} radialConfig={radialConfig}/> + <RadialListCenter centerElement={centerElement}/> + </div>; +}; + +type RadialListItemsProps = { + items: Array<React.ReactNode>, + radialConfig: RadialConfig, +}; + +const RadialListItems = ({items, radialConfig}: RadialListItemsProps) => { + const diameter = radialConfig.radius * 2; + const ulStyle = { + width: diameter, + height: diameter, + }; + const itemOffsets: Array<CartesianCoords> = offsetsFromCenter(items.length, radialConfig); + + return <ul className='radial-list absolute-center' style={ulStyle}> + {items.map((item, i) => (<RadialListItem + key={i} + item={item} + offsets={itemOffsets[i]} + />))} + </ul>; +}; + +type RadialListCenterProps = { + centerElement?: ReactNode, +}; + +const RadialListCenter = ({centerElement}: RadialListCenterProps) => { + if (!centerElement) { + return null; + } + return <div className='radial-list-center absolute-center'>{centerElement}</div>; +}; diff --git a/sw-ui/src/components/lobby/radial-list/RadialListItem.css b/sw-ui/src/components/lobby/radial-list/RadialListItem.css new file mode 100644 index 00000000..65bb9661 --- /dev/null +++ b/sw-ui/src/components/lobby/radial-list/RadialListItem.css @@ -0,0 +1,11 @@ +.radial-list-item { + display: block; + position: absolute; + top: 50%; + left: 50%; + margin: 0; + padding: 0; + list-style: unset; + transition: all 500ms ease-in-out; + z-index: 1; +} diff --git a/sw-ui/src/components/lobby/radial-list/RadialListItem.tsx b/sw-ui/src/components/lobby/radial-list/RadialListItem.tsx new file mode 100644 index 00000000..19a27638 --- /dev/null +++ b/sw-ui/src/components/lobby/radial-list/RadialListItem.tsx @@ -0,0 +1,18 @@ +import * as React from 'react'; +import { ReactNode } from 'react'; +import { CartesianCoords } from './radial-math'; +import './RadialListItem.css'; + +type RadialListItemProps = { + item: ReactNode, + offsets: CartesianCoords, +}; + +export const RadialListItem = ({item, offsets}: RadialListItemProps) => { + // Y-axis points down, hence the minus sign + const liStyle = { + transform: `translate(${offsets.x}px, ${-offsets.y}px) translate(-50%, -50%)`, + }; + + return <li className='radial-list-item' style={liStyle}>{item}</li>; +}; diff --git a/sw-ui/src/components/lobby/radial-list/radial-math.ts b/sw-ui/src/components/lobby/radial-list/radial-math.ts new file mode 100644 index 00000000..f0f411f5 --- /dev/null +++ b/sw-ui/src/components/lobby/radial-list/radial-math.ts @@ -0,0 +1,48 @@ +export type CartesianCoords = { + x: number, + y: number, +} +type PolarCoords = { + radius: number, + angleDeg: number, +} + +const toRad = (deg: number) => deg * (Math.PI / 180); +const roundedProjection = (radius: number, thetaRad: number, trigFn: (angle: number) => number) => Math.round(radius * trigFn(thetaRad)); +const xProjection = (radius: number, thetaRad: number) => roundedProjection(radius, thetaRad, Math.cos); +const yProjection = (radius: number, thetaRad: number) => roundedProjection(radius, thetaRad, Math.sin); + +const toCartesian = ({radius, angleDeg}: PolarCoords): CartesianCoords => ({ + x: xProjection(radius, toRad(angleDeg)), + y: yProjection(radius, toRad(angleDeg)), +}); + +export type Direction = -1 | 1; +export const CLOCKWISE: Direction = -1; +export const COUNTERCLOCKWISE: Direction = 1; + +export type RadialConfig = { + radius: number, + arc: number, + offsetDegrees: number, + direction: Direction, +} +const DEFAULT_CONFIG: RadialConfig = { + radius: 120, + arc: 360, + offsetDegrees: 0, + direction: CLOCKWISE, +}; + +const DEFAULT_START = 90; // Up + +export function offsetsFromCenter(nbItems: number, radialConfig: RadialConfig = DEFAULT_CONFIG): Array<CartesianCoords> { + return Array.from({length: nbItems}, (v, i) => itemCartesianOffsets(i, nbItems, radialConfig)); +} + +function itemCartesianOffsets(index: number, nbItems: number, {radius, arc, direction, offsetDegrees}: RadialConfig): CartesianCoords { + const startAngle = DEFAULT_START + direction * offsetDegrees; + const angleStep = arc / nbItems; + const itemAngle = startAngle + direction * angleStep * index; + return toCartesian({radius, angleDeg: itemAngle}); +} diff --git a/sw-ui/src/components/lobby/round-table.png b/sw-ui/src/components/lobby/round-table.png Binary files differnew file mode 100644 index 00000000..f277376d --- /dev/null +++ b/sw-ui/src/components/lobby/round-table.png |