diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..600d2d3 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.vscode \ No newline at end of file diff --git a/client/src/components/PlayerBox/PlayerBox.scss b/client/src/components/PlayerBox/PlayerBox.scss new file mode 100644 index 0000000..3ae440f --- /dev/null +++ b/client/src/components/PlayerBox/PlayerBox.scss @@ -0,0 +1,12 @@ +@import 'styles/variables'; + +$background-color: rgb(37, 153, 138); + +.PlayerBox { + border: 2px solid black; + background-color: $background-color; + color: white; + padding: 1rem; + margin: 1rem; + min-width: 10rem; +} diff --git a/client/src/components/PlayerBox/PlayerBox.tsx b/client/src/components/PlayerBox/PlayerBox.tsx new file mode 100644 index 0000000..9c96c7e --- /dev/null +++ b/client/src/components/PlayerBox/PlayerBox.tsx @@ -0,0 +1,23 @@ +import React, { FC } from 'react' + +import './PlayerBox.scss' + +export interface PlayerProps { + name: string + isHost?: boolean + isSelf?: boolean +} + +const Player: FC = props => { + return ( +
+
+

{props.name}

+
+ {props.isHost &&

Host

} + {props.isSelf && '(this is you)'} +
+ ) +} + +export default Player diff --git a/client/src/components/PlayerBox/index.ts b/client/src/components/PlayerBox/index.ts new file mode 100644 index 0000000..74f12c9 --- /dev/null +++ b/client/src/components/PlayerBox/index.ts @@ -0,0 +1,2 @@ +export * from './PlayerBox' +export { default } from './PlayerBox' diff --git a/client/src/pages/Index.tsx b/client/src/pages/Index.tsx index 8907a58..846003c 100644 --- a/client/src/pages/Index.tsx +++ b/client/src/pages/Index.tsx @@ -1,5 +1,5 @@ import React, { FC } from 'react' -import axios from 'axios' +import axios, { AxiosResponse } from 'axios' import { RouteComponentProps, navigate } from '@reach/router' import Button from '../components/Button' @@ -9,11 +9,10 @@ import { Game } from '../types/Game' export interface IndexProps extends RouteComponentProps {} const Index: FC = () => { - const createGame = () => { - axios.post('/games').then(response => { - const game = response.data as Game - navigate(`/lobby/${game.id}`) - }) + const createGame = async () => { + const postGameResponse: AxiosResponse = await axios.post(`/games`) + const game = postGameResponse.data + navigate(`/lobby/${game.id}`) } return ( diff --git a/client/src/pages/Lobby/Lobby.scss b/client/src/pages/Lobby/Lobby.scss index a1f37e4..078d7e2 100644 --- a/client/src/pages/Lobby/Lobby.scss +++ b/client/src/pages/Lobby/Lobby.scss @@ -4,7 +4,7 @@ $background-color: #f8de96; .Players { margin-top: 1rem; display: flex; - justify-content: space-evenly; + justify-content: flex-start; flex-wrap: wrap; .Player { display: flex; diff --git a/client/src/pages/Lobby/Lobby.tsx b/client/src/pages/Lobby/Lobby.tsx index 4f43c6a..1f42e77 100644 --- a/client/src/pages/Lobby/Lobby.tsx +++ b/client/src/pages/Lobby/Lobby.tsx @@ -1,10 +1,13 @@ import React, { FC, useEffect, useState } from 'react' -import axios from 'axios' +import axios, { AxiosResponse } from 'axios' import { RouteComponentProps } from '@reach/router' import { Game } from '../../types/Game' import * as moment from 'moment' +import connectToGameHub from '../../utils/signalrConnector' + import './Lobby.scss' +import PlayerBox from '../../components/PlayerBox' export interface LobbyProps extends RouteComponentProps { id?: string @@ -12,22 +15,37 @@ export interface LobbyProps extends RouteComponentProps { const Lobby: FC = (props: LobbyProps) => { const [game, setGame] = useState() - + const [currentPlayerId, setCurrentPlayerId] = useState() + useEffect(() => { - axios.get(`/games/${props.id}`).then(response => { - setGame(response.data as Game) - }) - }, [props.id]) + const loadGame = async () => { + const getGameResponse: AxiosResponse = await axios.get( + `/games/${props.id}` + ) + return getGameResponse.data + } + const onLoad = async () => { + var game = await loadGame() + setGame(game) - const getHostName = (game: Game) => { - const host = game.players.find(player => player.id === game.hostId) - return host ? host.name : 'host unknown' - } + if (game.status === 'PENDING_START') { + const hubConnection = connectToGameHub(game.id) + hubConnection.start().then(() => { + hubConnection.connectionId && + setCurrentPlayerId(hubConnection.connectionId) + }) + hubConnection.on('refreshGame', () => { + loadGame().then(game => setGame(game)) + }) + } + } + + onLoad() + }, [props.id]) - const getSubtitle = (game: Game) => { - return `Créé par ${getHostName(game)} ${moment - .utc(game.creationDate) - .fromNow()}` + // The oldest member of the lobby is the host + const isGameHost = (game: Game, playerId: string | undefined) => { + return game.players && game.players[0].id === playerId } return ( @@ -35,15 +53,15 @@ const Lobby: FC = (props: LobbyProps) => { {game && ( <>

Details de la partie ({game.id})

-

{getSubtitle(game)}

-

Statut {game.status}

-

-

Joueurs

+

{`Créé ${moment.utc(game.creationDate).fromNow()}`}

{game.players.map(player => ( -
- {player.name} -
+ ))}
diff --git a/client/src/utils/signalrConnector.ts b/client/src/utils/signalrConnector.ts new file mode 100644 index 0000000..c029955 --- /dev/null +++ b/client/src/utils/signalrConnector.ts @@ -0,0 +1,19 @@ +import * as SignalR from '@microsoft/signalr' +import { HubConnection } from '@microsoft/signalr' + +const API_URL: string = 'https://localhost:5001' + +const connectToGameHub = ( + gameId: number, + onStart: any = null +): HubConnection => { + const connection = new SignalR.HubConnectionBuilder() + .withUrl(`${API_URL}/gameHub?gameId=${gameId}`) + .withAutomaticReconnect() + .configureLogging(SignalR.LogLevel.Debug) + .build() + + return connection +} + +export default connectToGameHub diff --git a/server/.gitignore b/server/.gitignore index 82cc558..ac53393 100644 --- a/server/.gitignore +++ b/server/.gitignore @@ -3,7 +3,6 @@ *.user *.userosscache *.sln.docstates -.vscode # Build results [Dd]ebug/ diff --git a/server/DTO/GameDTOs.cs b/server/DTO/GameDTOs.cs index 6679aeb..431e5b5 100644 --- a/server/DTO/GameDTOs.cs +++ b/server/DTO/GameDTOs.cs @@ -19,7 +19,7 @@ public GameDTO(Game game) hostId = game.HostId; status = game.Status.ToString(); creationDate = game.CreationDate.ToString("o", CultureInfo.InvariantCulture); - players = game.Players.Select(p => new PlayerDTO(p)).ToArray(); + players = game.Players.OrderBy(p => p.CreationDate).Select(player => new PlayerDTO(player)).ToArray(); } public int id { get; set; } public string hostId { get; set; } diff --git a/server/Data/random-names.txt b/server/Data/random-names.txt deleted file mode 100644 index 3257ec1..0000000 --- a/server/Data/random-names.txt +++ /dev/null @@ -1,50 +0,0 @@ -Rubie -Shiloh -Michelina -Alise -Hilda -Aurea -Rodrigo -Dalton -Elfreda -Taunya -Shavonne -Emmy -Caron -Lin -Andres -Lon -Elwood -Mildred -Johanne -Loriann -Aileen -Leann -Charissa -Denita -Pierre -Margot -Anne -Argentina -Birgit -Warren -Clayton -Earlean -Princess -Dawn -Velia -Kareen -Tova -Misti -Harvey -Johanna -Sheilah -Laquita -Raina -Minnie -Remedios -Luvenia -Cherise -Keren -Clint -Marilou \ No newline at end of file diff --git a/server/Data/video-game-characters.txt b/server/Data/video-game-characters.txt new file mode 100644 index 0000000..098424f --- /dev/null +++ b/server/Data/video-game-characters.txt @@ -0,0 +1,50 @@ +Ryu Hayabusa +Dirk The Daring +Donkey Kong +The Horned Reaper +Vault Boy +Marcus Fenix +Leon Kennedy +HK-47 +Sam & Max +Pyramid Head +Dr Fred Eddison +Mr. X +Dante +Pac Man +Big Daddy +Prince of Persia +Alucard +The Announcer +Miner Willy +Kane +Manny Cavalera +Garrett +Harman Smith +Ryu +Samus Aran +Arthas Menethil +Sabre Man +Bowser +Nathan Drake +Agent 47 +Duke Nukem +Solid Snake +American McGee's Alice +Illidan Stormrage +Brucie +Kratos +Sonic +Cloud Strife +GLaDOS +Minsc & Boo +Sephiroth +The Lemmings +Master Chief +Guybrush Threepwood +Link +Lara Croft +The Nameless One +Shodan +Mario +Gordon Freeman \ No newline at end of file diff --git a/server/Hubs/GameHub.cs b/server/Hubs/GameHub.cs index 14847b9..ef1d645 100644 --- a/server/Hubs/GameHub.cs +++ b/server/Hubs/GameHub.cs @@ -1,13 +1,61 @@ +using Api.DTO; +using Api.Services; using Microsoft.AspNetCore.SignalR; +using System; +using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; namespace Api.Hubs { - public class GameHub : Hub + public class GameHub : Hub + { + private readonly IGameService _gameService; + + public GameHub(IGameService gameService) + { + _gameService = gameService; + } + + private string GetPlayerId() + { + return Context.ConnectionId; + } + + private int GetGameId() { - public async Task SendMessage() - { - await Clients.All.SendAsync("ReceiveMessage"); - } + int gameId = 0; + if (!int.TryParse(Context.GetHttpContext().Request.Query["gameId"], out gameId)) + throw new InvalidOperationException("Game id is missing from query string"); + + return gameId; + } + + public override Task OnConnectedAsync() + { + string playerId = GetPlayerId(); + int gameId = GetGameId(); + string groupName = "game-" + gameId; + + _gameService.AddPlayer(gameId, playerId); + + Groups.AddToGroupAsync(Context.ConnectionId, groupName); + Clients.Group(groupName).SendAsync("refreshGame"); + + return base.OnConnectedAsync(); + } + public override Task OnDisconnectedAsync(Exception exception) + { + string playerId = GetPlayerId(); + int gameId = GetGameId(); + string groupName = "game-" + gameId; + + Groups.RemoveFromGroupAsync(Context.ConnectionId, groupName); + _gameService.RemovePlayer(playerId); + + Clients.Group(groupName).SendAsync("refreshGame"); + + return base.OnDisconnectedAsync(exception); } + } } \ No newline at end of file diff --git a/server/Readme.md b/server/Readme.md index 0cb8388..eca999c 100644 --- a/server/Readme.md +++ b/server/Readme.md @@ -2,7 +2,7 @@ This project is a REST Api made with dotnet core 3.0 ## Prerequisites -* [Dotnet Core 3.0 SDK](https://dotnet.microsoft.com/download). +- [Dotnet Core 3.0 SDK](https://dotnet.microsoft.com/download). ## Running the project @@ -18,21 +18,23 @@ If using Postman, deactivate SSL certificate verification. ## Switching environments -From a Powershell terminal, you can change environments with the following commands: +From a Powershell terminal, you can change environments with the following commands: + - `$Env:ASPNETCORE_ENVIRONMENT = "Development"` - `$Env:ASPNETCORE_ENVIRONMENT = "Production"` ## Database setup -* Get Sql Server installer from link above from [here](https://go.microsoft.com/fwlink/?linkid=853017). -* Launch installer. -* Click on Download media/ LocalDB (45mo) and install. -* Database is created on Api project startup. +- Get Sql Server installer from link above from [here](https://go.microsoft.com/fwlink/?linkid=853017). +- Launch installer. +- Click on Download media/ LocalDB (45mo) and install. +- Database is created on Api project startup. To run ef commands you need the install dotnet-ef globally. Due to [this bug](https://github.com/aspnet/EntityFrameworkCore/issues/18977) you need to specify the version. `dotnet tool install --global dotnet-ef --version 3.0.0` ### Gotchas + [CREATE FILE encountered operating system error 5](https://github.com/aspnet/EntityFrameworkCore/issues/11329) ## Updating the database @@ -63,13 +65,16 @@ We will use the naming conventions from [here](https://restfulapi.net/resource-n ```json { - "id": 3, - "hostId": "b76ce637-1549-45e0-8268-551d39f91ae5", - "status": "PENDING_START", - "creationDate": "2019-11-25T22:43:14.2937504Z", - "playerIds": [ - "b76ce637-1549-45e0-8268-551d39f91ae5" - ] + "id": 3, + "hostId": "b76ce637-1549-45e0-8268-551d39f91ae5", + "status": "PENDING_START", + "creationDate": "2019-11-25T22:43:14.2937504Z", + "playerIds": [ + { + "id": "7739c775-11db-4bb4-9e22-3d1ccf47ca8a", + "name": "Clint" + } + ] } ``` @@ -77,16 +82,19 @@ We will use the naming conventions from [here](https://restfulapi.net/resource-n ```json { - "id": 3, - "hostId": "b76ce637-1549-45e0-8268-551d39f91ae5", - "status": "IN_PROGRESS", - "creationDate": "2019-11-25T22:43:14.2937504", - "playerIds": [ - "1a706910-b52f-45f4-a374-91633986c652", - "6be25ae1-68b5-4609-92cc-966391fd1141", - "70da2d92-313c-47bb-9ed0-9402d3b301ea", - "ad91f360-a4a9-43d3-9a84-d29fcfcb3acc", - "b76ce637-1549-45e0-8268-551d39f91ae5" + "id": 2, + "hostId": "63a4cc39-fa9d-4096-87b9-bb302fa15449", + "status": "PENDING_START", + "creationDate": "2019-12-01T12:05:22.5023072", + "players": [ + { + "id": "63a4cc39-fa9d-4096-87b9-bb302fa15449", + "name": "Lin" + }, + { + "id": "7739c775-11db-4bb4-9e22-3d1ccf47ca8a", + "name": "Clint" + } ] } ``` @@ -95,6 +103,7 @@ We will use the naming conventions from [here](https://restfulapi.net/resource-n ```json { - "playerId": "ad91f360-a4a9-43d3-9a84-d29fcfcb3acc" + "id": "ac2e5cad-d5a4-422f-b1de-8002d1fb25f2", + "name": "Emmy" } -``` \ No newline at end of file +``` diff --git a/server/Services/GameService.cs b/server/Services/GameService.cs index 2530d60..7d49bc7 100644 --- a/server/Services/GameService.cs +++ b/server/Services/GameService.cs @@ -13,6 +13,7 @@ public interface IGameService Game Create(); Game Get(int gameId); Player AddPlayer(int gameId, string playerId = null); + void RemovePlayer(string playerId); } public class GameService : IGameService @@ -34,8 +35,6 @@ public Game Create() _dbContext.Games.Add(game); _dbContext.SaveChanges(); - this.AddPlayer(game.Id, game.HostId); - return game; } @@ -66,10 +65,19 @@ public Player AddPlayer(int gameId, string playerId = null) return player; } + public void RemovePlayer(string playerId) + { + var player = _dbContext.Players.Find(playerId); + if (player == null) + throw new ArgumentException($"The playerId {playerId} was not found."); + _dbContext.Players.Remove(player); + _dbContext.SaveChanges(); + } + private string GetRandomName() { Random random = new Random(); - string filePath = Path.Combine(Environment.CurrentDirectory, "Data/random-names.txt"); + string filePath = Path.Combine(Environment.CurrentDirectory, "Data/video-game-characters.txt"); List names = File.ReadLines(filePath).ToList(); int randomInt = random.Next(0, names.Count); string randomName = names[randomInt];