Implemented basic Pong lobby system
This commit is contained in:
7
PongGame/Hubs/GameState.cs
Normal file
7
PongGame/Hubs/GameState.cs
Normal file
@ -0,0 +1,7 @@
|
||||
namespace PongGame;
|
||||
|
||||
public enum GameState {
|
||||
WaitingForPlayers,
|
||||
InProgress,
|
||||
Finished,
|
||||
}
|
6
PongGame/Hubs/IPongClient.cs
Normal file
6
PongGame/Hubs/IPongClient.cs
Normal file
@ -0,0 +1,6 @@
|
||||
namespace PongGame.Hubs;
|
||||
|
||||
public interface IPongClient {
|
||||
Task GameStateChanged(GameState state);
|
||||
Task UsernameChanged(string value);
|
||||
}
|
61
PongGame/Hubs/PongHub.cs
Normal file
61
PongGame/Hubs/PongHub.cs
Normal file
@ -0,0 +1,61 @@
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace PongGame.Hubs;
|
||||
|
||||
public class PongHub : Hub<IPongClient> {
|
||||
private const string PLAYER_KEY = "PLAYER";
|
||||
private readonly PongLobby Lobby;
|
||||
private readonly ILogger<PongHub> Logger;
|
||||
|
||||
public PongHub(PongLobby lobby, ILogger<PongHub> logger) : base() {
|
||||
Lobby = lobby;
|
||||
Logger = logger;
|
||||
}
|
||||
|
||||
private PongPlayer Player {
|
||||
get => (Context.Items.TryGetValue(PLAYER_KEY, out var player) ? player as PongPlayer : null)
|
||||
?? throw new InvalidProgramException("Player was not assigned at connection start!");
|
||||
set => Context.Items[PLAYER_KEY] = value;
|
||||
}
|
||||
|
||||
public override Task OnConnectedAsync() {
|
||||
Player = Lobby.CreatePlayer();
|
||||
Player.Client = Clients.Client(Context.ConnectionId);
|
||||
Player.Username = "Anon";
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void AssertNotInRoom() {
|
||||
if (Player.ConnectedRoom is PongRoom currentRoom)
|
||||
throw new HubException($"User is already connected to room [{currentRoom}]");
|
||||
}
|
||||
|
||||
public Task<string> CreateRoom() {
|
||||
AssertNotInRoom();
|
||||
var room = Lobby.CreateRoom(Player);
|
||||
return Task.FromResult(room.ID);
|
||||
}
|
||||
|
||||
public Task<string> JoinRoom(string roomId) {
|
||||
AssertNotInRoom();
|
||||
var room = Lobby.JoinRoom(Player, roomId);
|
||||
return Task.FromResult(room.ID);
|
||||
}
|
||||
|
||||
public Task LeaveRoom() {
|
||||
Lobby.LeaveRoom(Player);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task RequestUsernameChange(string username) {
|
||||
// TOOD: check this
|
||||
Logger.LogInformation($"Player {Player} requested username change to [{username}]");
|
||||
Player.Username = username;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public override Task OnDisconnectedAsync(Exception? exception) {
|
||||
Lobby.RemovePlayer(Player);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
65
PongGame/Hubs/PongLobbyCollection.cs
Normal file
65
PongGame/Hubs/PongLobbyCollection.cs
Normal file
@ -0,0 +1,65 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace PongGame.Hubs;
|
||||
|
||||
public class PongLobby {
|
||||
private readonly HashSet<PongPlayer> connectedPlayers = new();
|
||||
private readonly Dictionary<string, PongRoom> PongRooms = new();
|
||||
|
||||
public PongLobby(ILogger<PongLobby> logger)
|
||||
=> Logger = logger;
|
||||
|
||||
public const int ROOM_ID_LENGTH = 4;
|
||||
|
||||
public PongPlayer CreatePlayer() {
|
||||
var player = new PongPlayer();
|
||||
lock (connectedPlayers)
|
||||
connectedPlayers.Add(player);
|
||||
return player;
|
||||
}
|
||||
|
||||
public void RemovePlayer(PongPlayer player) {
|
||||
if (player.ConnectedRoom is PongRoom room)
|
||||
room.Leave(player);
|
||||
lock (connectedPlayers)
|
||||
_ = connectedPlayers.Remove(player);
|
||||
}
|
||||
|
||||
public PongRoom CreateRoom(PongPlayer player) {
|
||||
PongRoom room;
|
||||
lock (PongRooms) {
|
||||
room = new(GenerateRoomId(), Logger);
|
||||
PongRooms.Add(room.ID, room);
|
||||
}
|
||||
room.Join(player);
|
||||
return room;
|
||||
}
|
||||
|
||||
public PongRoom JoinRoom(PongPlayer player, string roomId) {
|
||||
PongRoom? room;
|
||||
lock (PongRooms) {
|
||||
room = PongRooms.GetValueOrDefault(roomId);
|
||||
}
|
||||
if (room is null) throw new HubException($"Room [{roomId}] not found!");
|
||||
room.Join(player);
|
||||
return room;
|
||||
}
|
||||
|
||||
public void LeaveRoom(PongPlayer player) {
|
||||
if (player.ConnectedRoom is PongRoom room)
|
||||
room.Leave(player);
|
||||
}
|
||||
|
||||
private readonly Random random = new();
|
||||
private readonly ILogger<PongLobby> Logger;
|
||||
private const string ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||
|
||||
private string GenerateRoomId() {
|
||||
string id;
|
||||
do {
|
||||
id = string.Concat(Enumerable.Range(0, ROOM_ID_LENGTH).Select(_ => ALPHABET[random.Next(ALPHABET.Length)]));
|
||||
} while (PongRooms.ContainsKey(id));
|
||||
return id;
|
||||
}
|
||||
}
|
22
PongGame/Hubs/PongPlayer.cs
Normal file
22
PongGame/Hubs/PongPlayer.cs
Normal file
@ -0,0 +1,22 @@
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace PongGame.Hubs;
|
||||
|
||||
[DebuggerDisplay($"{{{nameof(ToString)}(),nq}}")]
|
||||
public class PongPlayer {
|
||||
private string username = default!;
|
||||
|
||||
public PongRoom? ConnectedRoom { get; internal set; }
|
||||
public string Username {
|
||||
get => username;
|
||||
internal set {
|
||||
if (username != value) {
|
||||
username = value;
|
||||
Task.Run(() => Client.UsernameChanged(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
public IPongClient Client { get; internal set; } = default!;
|
||||
|
||||
public override string ToString() => $"[{Username}]";
|
||||
}
|
65
PongGame/Hubs/PongRoom.cs
Normal file
65
PongGame/Hubs/PongRoom.cs
Normal file
@ -0,0 +1,65 @@
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace PongGame.Hubs;
|
||||
|
||||
public class PongRoom {
|
||||
public string ID { get; }
|
||||
private readonly ILogger Logger;
|
||||
|
||||
public PongRoom(string id, ILogger logger) {
|
||||
ID = id;
|
||||
Logger = logger;
|
||||
}
|
||||
|
||||
public PongPlayer? Player1 { get; private set; }
|
||||
public PongPlayer? Player2 { get; private set; }
|
||||
public GameState State { get; private set; } = GameState.WaitingForPlayers;
|
||||
|
||||
public void Join(PongPlayer player) {
|
||||
// TODO: synchronize this
|
||||
if (Player1 is null) {
|
||||
Player1 = player;
|
||||
Logger.LogInformation($"[{ID}] {player} joined pong room as player 1!");
|
||||
} else if (Player2 is null) {
|
||||
Player2 = player;
|
||||
Logger.LogInformation($"[{ID}] {player} joined pong room as player 2!");
|
||||
} else
|
||||
throw new HubException($"Lobby [{ID}] is already full!");
|
||||
_ = Task.Run(PlayersChanged);
|
||||
player.ConnectedRoom = this;
|
||||
}
|
||||
|
||||
public void Leave(PongPlayer player) {
|
||||
if (Player1 == player) {
|
||||
Player1 = null;
|
||||
Logger.LogInformation($"[{ID}] Player 1 {player} left pong room!");
|
||||
} else if (Player2 == player) {
|
||||
Player2 = null;
|
||||
Logger.LogInformation($"[{ID}] Player 2 {player} left pong room!");
|
||||
}
|
||||
player.ConnectedRoom = null;
|
||||
_ = Task.Run(PlayersChanged);
|
||||
}
|
||||
|
||||
private Task PlayersChanged() {
|
||||
if (Player1 is PongPlayer player1 && Player2 is PongPlayer player2) {
|
||||
ResumeGame(player1, player2);
|
||||
} else if (Player1 is null && Player2 is null) {
|
||||
CloseRoom();
|
||||
} else
|
||||
PauseGame();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void ResumeGame(PongPlayer player1, PongPlayer player2) {
|
||||
Logger.LogInformation($"[{ID}] Pong game started: {player1} vs. {player2}");
|
||||
State = GameState.InProgress;
|
||||
player1.Client.GameStateChanged(State);
|
||||
player2.Client.GameStateChanged(State);
|
||||
}
|
||||
|
||||
private void PauseGame() => State = GameState.WaitingForPlayers;
|
||||
private void CloseRoom() => State = GameState.Finished;
|
||||
|
||||
public override string ToString() => $"[{ID}]";
|
||||
}
|
@ -6,4 +6,5 @@
|
||||
|
||||
<div class="text-center">
|
||||
<h1 class="display-4">Welcome</h1>
|
||||
<a asp-area="" asp-page="/Pong">Pong</a>
|
||||
</div>
|
||||
|
@ -9,7 +9,7 @@ public class IndexModel : PageModel {
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public void OnGet() {
|
||||
|
||||
public IActionResult OnGet() {
|
||||
return RedirectToPage("Pong");
|
||||
}
|
||||
}
|
||||
|
27
PongGame/Pages/Pong.cshtml
Normal file
27
PongGame/Pages/Pong.cshtml
Normal file
@ -0,0 +1,27 @@
|
||||
@page
|
||||
@model PongModel
|
||||
@{
|
||||
ViewData["Title"] = "Pong";
|
||||
}
|
||||
|
||||
<div class="text-center">
|
||||
<h1 class="display-4">Pong</h1>
|
||||
<h3 id="connection">Connection Status</h3>
|
||||
|
||||
<button id="createlobby" class="btn btn-primary mb-3">Create</button>
|
||||
<button id="leavelobby" class="btn btn-primary mb-3">Leave</button>
|
||||
|
||||
<div class="input-group mb-3">
|
||||
<input id="roomid" type="text" class="form-control" placeholder="Room ID" aria-label="Room ID" aria-describedby="joinroom">
|
||||
<button id="joinroom" class="btn btn-outline-secondary" type="button">Button</button>
|
||||
</div>
|
||||
|
||||
<div class="input-group mb-3">
|
||||
<input id="username" type="text" class="form-control" placeholder="Username" aria-label="Username" aria-describedby="setusername">
|
||||
<button id="setusername" class="btn btn-outline-secondary" type="button">Set Username</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="~/js/signalr/signalr.min.js"></script>
|
||||
<script src="~/js/signalr/signalr-protocol-msgpack.min.js"></script>
|
||||
<script src="~/js/pong.js"></script>
|
15
PongGame/Pages/Pong.cshtml.cs
Normal file
15
PongGame/Pages/Pong.cshtml.cs
Normal file
@ -0,0 +1,15 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
||||
namespace PongGame.Pages;
|
||||
public class PongModel : PageModel {
|
||||
private readonly ILogger<PongModel> _logger;
|
||||
|
||||
public PongModel(ILogger<PongModel> logger) {
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public void OnGet() {
|
||||
|
||||
}
|
||||
}
|
@ -8,6 +8,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.MessagePack" Version="6.0.10" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.17.0" />
|
||||
</ItemGroup>
|
||||
|
||||
|
@ -1,7 +1,14 @@
|
||||
using MessagePack;
|
||||
using PongGame.Hubs;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Add services to the container.
|
||||
builder.Services.AddRazorPages();
|
||||
builder.Services.AddSingleton<PongLobby>(services
|
||||
=> new(services.GetRequiredService<ILogger<PongLobby>>()));
|
||||
builder.Services.AddSignalR()
|
||||
.AddMessagePackProtocol();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
@ -15,6 +22,9 @@ app.UseRouting();
|
||||
|
||||
app.UseAuthorization();
|
||||
|
||||
app.MapRazorPages();
|
||||
app.UseEndpoints(endpoints => {
|
||||
endpoints.MapRazorPages();
|
||||
endpoints.MapHub<PongHub>("/pong/hub");
|
||||
});
|
||||
|
||||
app.Run();
|
||||
|
20
PongGame/libman.json
Normal file
20
PongGame/libman.json
Normal file
@ -0,0 +1,20 @@
|
||||
{
|
||||
"version": "1.0",
|
||||
"defaultProvider": "jsdelivr",
|
||||
"libraries": [
|
||||
{
|
||||
"library": "@microsoft/signalr-protocol-msgpack@6.0.10",
|
||||
"destination": "wwwroot/js/"
|
||||
},
|
||||
{
|
||||
"library": "@microsoft/signalr@6.0.10",
|
||||
"destination": "wwwroot/js/signalr/",
|
||||
"files": [
|
||||
"dist/browser/signalr.js",
|
||||
"dist/browser/signalr.js.map",
|
||||
"dist/browser/signalr.min.js",
|
||||
"dist/browser/signalr.min.js.map"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
88
PongGame/wwwroot/js/pong.js
Normal file
88
PongGame/wwwroot/js/pong.js
Normal file
@ -0,0 +1,88 @@
|
||||
"use strict";
|
||||
|
||||
console.log("Pong script was run!");
|
||||
|
||||
const connection = new signalR.HubConnectionBuilder()
|
||||
.withUrl("/pong/hub")
|
||||
.withAutomaticReconnect()
|
||||
.withHubProtocol(new signalR.protocols.msgpack.MessagePackHubProtocol())
|
||||
.build();
|
||||
|
||||
function getElement(id) {
|
||||
return document.getElementById(id) ?? console.error(`Element #${id} not found!`)
|
||||
}
|
||||
|
||||
const connectionStatus = getElement("connection");
|
||||
const createlobby = getElement("createlobby");
|
||||
const roomid = getElement("roomid");
|
||||
const joinroom = getElement("joinroom");
|
||||
const usernameinput = getElement("username");
|
||||
const setusername = getElement("setusername");
|
||||
const leavelobby = getElement("leavelobby");
|
||||
|
||||
connection.onclose(function (error) {
|
||||
if (error) {
|
||||
connectionStatus.textContent = "Unexpected error!";
|
||||
return console.error(`Connection aborted: ${error.message}`);
|
||||
}
|
||||
console.info("Disconnected!");
|
||||
connectionStatus.textContent = "Closed!";
|
||||
});
|
||||
|
||||
connection.onreconnecting(function (error) {
|
||||
if (error) {
|
||||
connectionStatus.textContent = "Reconnecting!";
|
||||
return console.error(`Connection reconnecting: ${error.message}`);
|
||||
}
|
||||
console.info("Reconnecting!");
|
||||
connectionStatus.textContent = "Reconnecting!";
|
||||
});
|
||||
|
||||
connection.onreconnected(function (connectionId) {
|
||||
console.info(`Connected as ${connectionId}!`);
|
||||
connectionStatus.textContent = "Connected!";
|
||||
});
|
||||
|
||||
createlobby.addEventListener("click", function (event) {
|
||||
connection.invoke("CreateRoom").catch(function (err) {
|
||||
return console.error(err.toString());
|
||||
});
|
||||
event.preventDefault();
|
||||
});
|
||||
|
||||
connection.on("GameStateChanged", function (state) {
|
||||
console.info(`Game is now in state ${state}`);
|
||||
});
|
||||
connection.on("UsernameChanged", function (username) {
|
||||
console.info(`Username is now ${username}`);
|
||||
usernameinput.value = username;
|
||||
});
|
||||
|
||||
joinroom.addEventListener("click", function (event) {
|
||||
connection.invoke("JoinRoom", roomid.value).catch(function (err) {
|
||||
return console.error(err.toString());
|
||||
});
|
||||
event.preventDefault();
|
||||
});
|
||||
|
||||
setusername.addEventListener("click", function (event) {
|
||||
connection.invoke("RequestUsernameChange", usernameinput.value).catch(function (err) {
|
||||
return console.error(err.toString());
|
||||
});
|
||||
event.preventDefault();
|
||||
});
|
||||
|
||||
leavelobby.addEventListener("click", function (event) {
|
||||
connection.invoke("LeaveRoom").catch(function (err) {
|
||||
return console.error(err.toString());
|
||||
});
|
||||
event.preventDefault();
|
||||
});
|
||||
|
||||
connection.start().then(function () {
|
||||
console.info(`Connected!`);
|
||||
connectionStatus.textContent = "Connected!";
|
||||
}).catch(function (err) {
|
||||
connectionStatus.textContent = "Connection failed!";
|
||||
return console.error(err.toString());
|
||||
});
|
2077
PongGame/wwwroot/js/signalr/signalr-protocol-msgpack.js
Normal file
2077
PongGame/wwwroot/js/signalr/signalr-protocol-msgpack.js
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
2
PongGame/wwwroot/js/signalr/signalr-protocol-msgpack.min.js
vendored
Normal file
2
PongGame/wwwroot/js/signalr/signalr-protocol-msgpack.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
3115
PongGame/wwwroot/js/signalr/signalr.js
Normal file
3115
PongGame/wwwroot/js/signalr/signalr.js
Normal file
File diff suppressed because it is too large
Load Diff
1
PongGame/wwwroot/js/signalr/signalr.js.map
Normal file
1
PongGame/wwwroot/js/signalr/signalr.js.map
Normal file
File diff suppressed because one or more lines are too long
2
PongGame/wwwroot/js/signalr/signalr.min.js
vendored
Normal file
2
PongGame/wwwroot/js/signalr/signalr.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
PongGame/wwwroot/js/signalr/signalr.min.js.map
Normal file
1
PongGame/wwwroot/js/signalr/signalr.min.js.map
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user