Compare commits

..

No commits in common. "82923a311e211be911a841979d18faead52fc791" and "f907be7f895c70e66edfdcfbc3efa0c9bd651648" have entirely different histories.

87 changed files with 3971 additions and 5094 deletions

View File

@ -1,5 +1,5 @@
<component name="ProjectCodeStyleConfiguration"> <component name="ProjectCodeStyleConfiguration">
<state> <state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" /> <option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
</state> </state>
</component> </component>

View File

@ -1,32 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net7.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<IsPackable>false</IsPackable> <IsPackable>false</IsPackable>
<LangVersion>13</LangVersion>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0"/> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.3.2" />
<PackageReference Include="NSubstitute" Version="5.1.0"/> <PackageReference Include="NSubstitute" Version="5.0.0" />
<PackageReference Include="NUnit" Version="4.0.1"/> <PackageReference Include="NUnit" Version="3.13.3" />
<PackageReference Include="NUnit3TestAdapter" Version="4.5.0"/> <PackageReference Include="NUnit3TestAdapter" Version="4.2.1" />
<PackageReference Include="NUnit.Analyzers" Version="3.10.0"> <PackageReference Include="NUnit.Analyzers" Version="3.3.0" />
<PrivateAssets>all</PrivateAssets> <PackageReference Include="coverlet.collector" Version="3.1.2" />
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\pac-man-board-game\pac-man-board-game.csproj"/> <ProjectReference Include="..\pac-man-board-game\pac-man-board-game.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@ -11,10 +11,12 @@ using pacMan.Utils;
namespace BackendTests.Controllers; namespace BackendTests.Controllers;
[TestFixture]
[TestOf(nameof(GameController))]
public class GameControllerTests public class GameControllerTests
{ {
private IActionService _actionService = null!;
private GameController _controller = null!;
private GameService _gameService = null!;
[SetUp] [SetUp]
public void Setup() public void Setup()
{ {
@ -25,10 +27,6 @@ public class GameControllerTests
_controller = new GameController(Substitute.For<ILogger<GameController>>(), _gameService, _actionService); _controller = new GameController(Substitute.For<ILogger<GameController>>(), _gameService, _actionService);
} }
private IActionService _actionService = null!;
private GameController _controller = null!;
private GameService _gameService = null!;
[Test] [Test]
public void Run_ReturnsSame() public void Run_ReturnsSame()
{ {
@ -53,22 +51,4 @@ public class GameControllerTests
else else
Assert.Fail("Result is not an ArraySegment<byte>"); Assert.Fail("Result is not an ArraySegment<byte>");
} }
[Test]
public void DoAction_NegativeAction()
{
const string data = "Nothing happens";
var message = new ActionMessage { Action = (GameAction)(-1), Data = data };
_controller.DoAction(message);
Assert.That(message.Data, Is.EqualTo(data));
}
[Test]
public void DoAction_OutOfBoundsAction()
{
const string data = "Nothing happens";
var message = new ActionMessage { Action = (GameAction)100, Data = data };
_controller.DoAction(message);
Assert.That(message.Data, Is.EqualTo(data));
}
} }

View File

@ -1,9 +1,5 @@
using pacMan.Controllers;
namespace BackendTests.Controllers; namespace BackendTests.Controllers;
[TestFixture]
[TestOf(nameof(PlayerController))]
public class PlayerControllerTests public class PlayerControllerTests
{ {
// TODO // TODO

View File

@ -2,8 +2,6 @@ using pacMan.GameStuff.Items;
namespace BackendTests.Game.Items; namespace BackendTests.Game.Items;
[TestFixture]
[TestOf(nameof(DiceCup))]
public class DiceCupTests public class DiceCupTests
{ {
[Test] [Test]

View File

@ -2,8 +2,6 @@ using pacMan.GameStuff.Items;
namespace BackendTests.Game.Items; namespace BackendTests.Game.Items;
[TestFixture]
[TestOf(nameof(Dice))]
public class DiceTests public class DiceTests
{ {
[Test] [Test]

View File

@ -2,7 +2,6 @@ using System.Text.Json;
using BackendTests.TestUtils; using BackendTests.TestUtils;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using NSubstitute; using NSubstitute;
using pacMan.DTOs;
using pacMan.Exceptions; using pacMan.Exceptions;
using pacMan.GameStuff; using pacMan.GameStuff;
using pacMan.GameStuff.Items; using pacMan.GameStuff.Items;
@ -10,21 +9,8 @@ using pacMan.Services;
namespace BackendTests.Services; namespace BackendTests.Services;
[TestFixture, TestOf(nameof(ActionService))]
public class ActionServiceTests public class ActionServiceTests
{ {
[SetUp]
public void Setup()
{
_spawns = CreateQueue();
_game = new pacMan.Services.Game(_spawns);
_whiteMessage = new ActionMessage { Action = GameAction.JoinGame, Data = SerializeData(_whitePlayer.Username) };
_blackMessage = new ActionMessage { Action = GameAction.JoinGame, Data = SerializeData(_blackPlayer.Username) };
_redMessage = new ActionMessage { Action = GameAction.JoinGame, Data = SerializeData(_redPlayer.Username) };
_gameService = Substitute.For<GameService>(Substitute.For<ILogger<GameService>>());
_service = new ActionService(Substitute.For<ILogger<ActionService>>(), _gameService);
}
private readonly Player _blackPlayer = Players.Create("black"); private readonly Player _blackPlayer = Players.Create("black");
private readonly Player _redPlayer = Players.Create("red"); private readonly Player _redPlayer = Players.Create("red");
private readonly Player _whitePlayer = Players.Create("white"); private readonly Player _whitePlayer = Players.Create("white");
@ -37,6 +23,19 @@ public class ActionServiceTests
private Queue<DirectionalPosition> _spawns = null!; private Queue<DirectionalPosition> _spawns = null!;
private ActionMessage _whiteMessage = null!; private ActionMessage _whiteMessage = null!;
[SetUp]
public void Setup()
{
_spawns = CreateQueue();
_game = new pacMan.Services.Game(_spawns);
_whiteMessage = new ActionMessage { Action = GameAction.JoinGame, Data = SerializeData(_whitePlayer.Username) };
_blackMessage = new ActionMessage { Action = GameAction.JoinGame, Data = SerializeData(_blackPlayer.Username) };
_redMessage = new ActionMessage { Action = GameAction.JoinGame, Data = SerializeData(_redPlayer.Username) };
_gameService = Substitute.For<GameService>(Substitute.For<ILogger<GameService>>());
_service = new ActionService(Substitute.For<ILogger<ActionService>>(), _gameService);
}
private JsonElement SerializeData(string username) => private JsonElement SerializeData(string username) =>
JsonDocument.Parse(JsonSerializer.Serialize( JsonDocument.Parse(JsonSerializer.Serialize(
new JoinGameData { Username = username, GameId = _game.Id } new JoinGameData { Username = username, GameId = _game.Id }
@ -51,6 +50,8 @@ public class ActionServiceTests
new() { At = new Position { X = 9, Y = 9 }, Direction = Direction.Right } new() { At = new Position { X = 9, Y = 9 }, Direction = Direction.Right }
}); });
#region RollDice()
[Test] [Test]
public void RollDice_ReturnsListOfIntegers() public void RollDice_ReturnsListOfIntegers()
{ {
@ -63,6 +64,10 @@ public class ActionServiceTests
}); });
} }
#endregion
#region PlayerInfo(ActionMessage message)
[Test] [Test]
public void PlayerInfo_DataIsNull() public void PlayerInfo_DataIsNull()
{ {
@ -92,6 +97,32 @@ public class ActionServiceTests
Assert.That(new List<Player> { _whitePlayer }, Is.EqualTo(players)); Assert.That(new List<Player> { _whitePlayer }, Is.EqualTo(players));
} }
#endregion
#region DoAction(ActionMessage message)
[Test]
public void DoAction_NegativeAction()
{
const string data = "Nothing happens";
var message = new ActionMessage { Action = (GameAction)(-1), Data = data };
_service.DoAction(message);
Assert.That(message.Data, Is.EqualTo(data));
}
[Test]
public void DoAction_OutOfBoundsAction()
{
const string data = "Nothing happens";
var message = new ActionMessage { Action = (GameAction)100, Data = data };
_service.DoAction(message);
Assert.That(message.Data, Is.EqualTo(data));
}
#endregion
#region Ready()
[Test] [Test]
public void Ready_PlayerIsNull() public void Ready_PlayerIsNull()
{ {
@ -152,11 +183,15 @@ public class ActionServiceTests
Is.EqualTo(_blackPlayer.Username).Or.EqualTo(_whitePlayer.Username)); Is.EqualTo(_blackPlayer.Username).Or.EqualTo(_whitePlayer.Username));
} }
#endregion
#region FindNextPlayer()
[Test] [Test]
public void FindNextPlayer_NoPlayers() public void FindNextPlayer_NoPlayers()
{ {
_service.Game = new pacMan.Services.Game(new Queue<DirectionalPosition>()); _service.Game = new pacMan.Services.Game(new Queue<DirectionalPosition>());
Assert.Throws<PlayerNotFoundException>(() => _service.FindNextPlayer()); Assert.Throws<InvalidOperationException>(() => _service.FindNextPlayer());
} }
[Test] [Test]
@ -186,4 +221,6 @@ public class ActionServiceTests
var second = _service.FindNextPlayer(); var second = _service.FindNextPlayer();
Assert.That(second, Is.EqualTo(_whitePlayer.Username)); Assert.That(second, Is.EqualTo(_whitePlayer.Username));
} }
#endregion
} }

View File

@ -3,14 +3,19 @@ using Microsoft.Extensions.Logging;
using NSubstitute; using NSubstitute;
using pacMan.Exceptions; using pacMan.Exceptions;
using pacMan.GameStuff; using pacMan.GameStuff;
using pacMan.GameStuff.Items;
using pacMan.Services; using pacMan.Services;
namespace BackendTests.Services; namespace BackendTests.Services;
[TestFixture]
[TestOf(nameof(GameService))]
public class GameServiceTests public class GameServiceTests
{ {
private readonly DirectionalPosition _spawn3By3Up = new()
{ At = new Position { X = 3, Y = 3 }, Direction = Direction.Up };
private GameService _service = null!;
private Queue<DirectionalPosition> _spawns = null!;
[SetUp] [SetUp]
public void SetUp() public void SetUp()
{ {
@ -24,11 +29,7 @@ public class GameServiceTests
}); });
} }
private readonly DirectionalPosition _spawn3By3Up = new() #region CreateAndJoin(IPlayer player, Queue<DirectionalPosition> spawns)
{ At = new Position { X = 3, Y = 3 }, Direction = Direction.Up };
private GameService _service = null!;
private Queue<DirectionalPosition> _spawns = null!;
[Test] [Test]
public void CreateAndJoin_WhenEmpty() public void CreateAndJoin_WhenEmpty()
@ -52,11 +53,15 @@ public class GameServiceTests
Assert.Throws<ArgumentException>(() => _service.CreateAndJoin(player, _spawns)); Assert.Throws<ArgumentException>(() => _service.CreateAndJoin(player, _spawns));
} }
#endregion
#region JoinbyId(Guid id)
[Test] [Test]
public void JoinById_WhenIdNotExists() public void JoinById_WhenIdNotExists()
{ {
var player = Players.Create("white"); var player = Players.Create("white");
_service.Games.Add(new pacMan.Services.Game(_spawns) { Players = [player] }); _service.Games.Add(new pacMan.Services.Game(_spawns) { Players = new List<Player> { player } });
Assert.Throws<GameNotFoundException>(() => _service.JoinById(Guid.NewGuid(), player)); Assert.Throws<GameNotFoundException>(() => _service.JoinById(Guid.NewGuid(), player));
} }
@ -65,7 +70,7 @@ public class GameServiceTests
public void JoinById_WhenIdExists() public void JoinById_WhenIdExists()
{ {
var player = Players.Create("white"); var player = Players.Create("white");
var game = new pacMan.Services.Game(_spawns) { Players = [player] }; var game = new pacMan.Services.Game(_spawns) { Players = new List<Player> { player } };
_service.Games.Add(game); _service.Games.Add(game);
@ -79,4 +84,6 @@ public class GameServiceTests
Assert.That(_service.Games, Has.Count.EqualTo(1)); Assert.That(_service.Games, Has.Count.EqualTo(1));
}); });
} }
#endregion
} }

View File

@ -6,23 +6,8 @@ using pacMan.Utils;
namespace BackendTests.Services; namespace BackendTests.Services;
[TestFixture, TestOf(nameof(pacMan.Services.Game))]
public class GameTests public class GameTests
{ {
[SetUp]
public void Setup()
{
_spawns = new Queue<DirectionalPosition>(
new[] { _spawn3By3Up, _spawn7By7Left, _spawn7By7Down, _spawn7By7Right });
_game = new pacMan.Services.Game(_spawns);
_redPlayer = Players.Create("red");
_bluePlayer = Players.Create("blue");
_yellowPlayer = Players.Create("yellow");
_greenPlayer = Players.Create("green");
_purplePlayer = Players.Create("purple");
}
private readonly DirectionalPosition _spawn3By3Up = new() private readonly DirectionalPosition _spawn3By3Up = new()
{ At = new Position { X = 3, Y = 3 }, Direction = Direction.Up }; { At = new Position { X = 3, Y = 3 }, Direction = Direction.Up };
@ -44,6 +29,20 @@ public class GameTests
private Queue<DirectionalPosition> _spawns = null!; private Queue<DirectionalPosition> _spawns = null!;
private Player _yellowPlayer = null!; private Player _yellowPlayer = null!;
[SetUp]
public void Setup()
{
_spawns = new Queue<DirectionalPosition>(
new[] { _spawn3By3Up, _spawn7By7Left, _spawn7By7Down, _spawn7By7Right });
_game = new pacMan.Services.Game(_spawns);
_redPlayer = Players.Create("red");
_bluePlayer = Players.Create("blue");
_yellowPlayer = Players.Create("yellow");
_greenPlayer = Players.Create("green");
_purplePlayer = Players.Create("purple");
}
private void AddFullParty() private void AddFullParty()
{ {
_game.AddPlayer(_bluePlayer); _game.AddPlayer(_bluePlayer);
@ -52,12 +51,18 @@ public class GameTests
_game.AddPlayer(_greenPlayer); _game.AddPlayer(_greenPlayer);
} }
#region NextPlayer()
[Test] [Test]
public void NextPlayer_WhenEmpty() public void NextPlayer_WhenEmpty()
{ {
Assert.Throws<PlayerNotFoundException>(() => _game.NextPlayer()); Assert.Throws<InvalidOperationException>(() => _game.NextPlayer());
} }
#endregion
#region IsGameStarted
[Test] [Test]
public void IsGameStarted_WhenEmpty() public void IsGameStarted_WhenEmpty()
{ {
@ -96,6 +101,10 @@ public class GameTests
Assert.That(_game.IsGameStarted, Is.True); Assert.That(_game.IsGameStarted, Is.True);
} }
#endregion
#region AddPlayer(Player player)
[Test] [Test]
public void AddPlayer_WhenEmpty() public void AddPlayer_WhenEmpty()
{ {
@ -148,6 +157,10 @@ public class GameTests
Assert.Throws<GameNotPlayableException>(() => _game.AddPlayer(_greenPlayer)); Assert.Throws<GameNotPlayableException>(() => _game.AddPlayer(_greenPlayer));
} }
#endregion
#region Sendtoall(ArraySegment<byte> segment)
[Test] [Test]
public void SendToAll_WhenConnectionsIsNull() public void SendToAll_WhenConnectionsIsNull()
{ {
@ -158,6 +171,7 @@ public class GameTests
public void SendToAll_WhenConnectionsIsNotNull() public void SendToAll_WhenConnectionsIsNotNull()
{ {
var counter = 0; var counter = 0;
async Task Send(ArraySegment<byte> segment) => await Task.Run(() => counter++);
_game.Connections += Send; _game.Connections += Send;
_game.Connections += Send; _game.Connections += Send;
@ -168,11 +182,12 @@ public class GameTests
while (counter < 2) { } while (counter < 2) { }
Assert.That(counter, Is.EqualTo(2)); Assert.That(counter, Is.EqualTo(2));
return;
async Task Send(ArraySegment<byte> segment) => await Task.Run(() => counter++);
} }
#endregion
#region SetReady(Player player)
[Test] [Test]
public void SetReady_ReturnsAllPlayers() public void SetReady_ReturnsAllPlayers()
{ {
@ -207,6 +222,10 @@ public class GameTests
Assert.Throws<PlayerNotFoundException>(() => _game.SetReady(_redPlayer.Username)); Assert.Throws<PlayerNotFoundException>(() => _game.SetReady(_redPlayer.Username));
} }
#endregion
#region SetAllIngame()
[Test] [Test]
public void SetAllInGame_SetsStateToInGame() public void SetAllInGame_SetsStateToInGame()
{ {
@ -238,6 +257,10 @@ public class GameTests
Assert.That(_game.Players, Is.Empty); Assert.That(_game.Players, Is.Empty);
} }
#endregion
#region IsGameStarted()
[Test] [Test]
public void IsGameStarted_AllWaiting() public void IsGameStarted_AllWaiting()
{ {
@ -252,4 +275,6 @@ public class GameTests
_game.Players.ForEach(player => player.State = State.InGame); _game.Players.ForEach(player => player.State = State.InGame);
Assert.That(_game.IsGameStarted, Is.True); Assert.That(_game.IsGameStarted, Is.True);
} }
#endregion
} }

View File

@ -1,22 +1,23 @@
using System.Net.WebSockets; using System.Net.WebSockets;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using NSubstitute; using NSubstitute;
using pacMan.Interfaces;
using pacMan.Services; using pacMan.Services;
using pacMan.Utils; using pacMan.Utils;
namespace BackendTests.Services; namespace BackendTests.Services;
[TestFixture]
[TestOf(nameof(WebSocketService))]
public class WebSocketServiceTests public class WebSocketServiceTests
{ {
private IWebSocketService _service = null!;
[SetUp] [SetUp]
public void SetUp() public void SetUp()
{ {
_service = new WebSocketService(Substitute.For<ILogger<WebSocketService>>()); _service = new WebSocketService(Substitute.For<ILogger<WebSocketService>>());
} }
private IWebSocketService _service = null!; #region Send(Websocket, ArraySegment<byte>)
[Test] [Test]
public void Send_OpenWebsocket() public void Send_OpenWebsocket()
@ -47,6 +48,10 @@ public class WebSocketServiceTests
webSocket.Received().SendAsync(segment, WebSocketMessageType.Text, true, CancellationToken.None); webSocket.Received().SendAsync(segment, WebSocketMessageType.Text, true, CancellationToken.None);
} }
#endregion
#region Receive(Websocket, byte[])
[Test] [Test]
public void Receive_ExactBuffer() public void Receive_ExactBuffer()
{ {
@ -85,6 +90,10 @@ public class WebSocketServiceTests
webSocket.ReceivedWithAnyArgs().ReceiveAsync(default, CancellationToken.None); webSocket.ReceivedWithAnyArgs().ReceiveAsync(default, CancellationToken.None);
} }
#endregion
#region Close(Websocket, WebSocketCloseStatus, string?)
[Test] [Test]
public void Close_OpenWebsocket() public void Close_OpenWebsocket()
{ {
@ -115,4 +124,6 @@ public class WebSocketServiceTests
webSocket.ReceivedWithAnyArgs().CloseAsync(default, default, CancellationToken.None); webSocket.ReceivedWithAnyArgs().CloseAsync(default, default, CancellationToken.None);
} }
#endregion
} }

View File

@ -3,15 +3,9 @@ using pacMan.Utils;
namespace BackendTests.Utils; namespace BackendTests.Utils;
[TestFixture]
[TestOf(nameof(Extensions))]
public class ExtensionsTests public class ExtensionsTests
{ {
[SetUp] #region ToArraySegment(this object obj)
public void Setup()
{
_bytes = "Hello World!"u8.ToArray();
}
[Test] [Test]
public void ToArraySegmentValidObject() public void ToArraySegmentValidObject()
@ -30,8 +24,18 @@ public class ExtensionsTests
Assert.That(segment, Has.Count.EqualTo(4)); Assert.That(segment, Has.Count.EqualTo(4));
} }
#endregion
#region GetString(this byte[] bytes, int length)
private byte[] _bytes = null!; private byte[] _bytes = null!;
[SetUp]
public void Setup()
{
_bytes = "Hello World!"u8.ToArray();
}
[Test] [Test]
public void GetString_ValidByteArray() public void GetString_ValidByteArray()
{ {
@ -61,4 +65,6 @@ public class ExtensionsTests
{ {
Assert.That(_bytes.GetString(_bytes.Length / 2), Is.EqualTo("Hello ")); Assert.That(_bytes.GetString(_bytes.Length / 2), Is.EqualTo("Hello "));
} }
#endregion
} }

View File

@ -1,11 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net7.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<RootNamespace>DAL</RootNamespace> <RootNamespace>DAL</RootNamespace>
<LangVersion>13</LangVersion>
</PropertyGroup> </PropertyGroup>
</Project> </Project>

View File

@ -4,8 +4,8 @@ namespace DAL.Database.Service;
public class UserService public class UserService
{ {
private readonly List<User> _users = private readonly List<User> _users = new()
[ {
new User new User
{ {
Username = "Firefox", Username = "Firefox",
@ -18,10 +18,10 @@ public class UserService
Password = "Chrome", Password = "Chrome",
Colour = "blue" Colour = "blue"
} }
]; };
public Task<User?> Login(string username, string password) public async Task<User?> Login(string username, string password)
{ {
return Task.Run(() => _users.FirstOrDefault(x => x.Username == username && x.Password == password)); return await Task.Run(() => _users.FirstOrDefault(x => x.Username == username && x.Password == password));
} }
} }

View File

@ -11,11 +11,6 @@ cd ..
dotnet run dotnet run
``` ```
### Log in
For testing there are currently two users available:
- Username: `Firefox`, Password: `Firefox`
- Username: `Chrome`, Password: `Chrome`
## Stack ## Stack
- Frontend - Frontend

View File

@ -1,6 +1,4 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation"> <wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:Boolean x:Key="/Default/Dpa/ExcludeFilters/=_002A_003B_002A_003BpacMan_002EControllers_002EGameController_003BConnect/@EntryIndexedValue">True</s:Boolean> <s:String x:Key="/Default/Environment/UnitTesting/UnitTestSessionStore/Sessions/=4f001339_002D2d48_002D46c8_002D91bc_002D45608c0ab446/@EntryIndexedValue">&lt;SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from &amp;lt;BackendTests&amp;gt;" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"&gt;
<s:Boolean x:Key="/Default/Environment/UnitTesting/CreateUnitTestDialog/ShowAdvancedOptions/@EntryValue">True</s:Boolean> &lt;Project Location="/home/martin/Git/Csharp/pac-man-board-game/BackendTests" Presentation="&amp;lt;BackendTests&amp;gt;" /&gt;
<s:String x:Key="/Default/Environment/UnitTesting/CreateUnitTestDialog/TestProjectMapping/=60072632_002DA16F_002D4007_002D8A97_002DAC74B7E6703B/@EntryIndexedValue">35336347-32EB-4764-A28E-3F8FF6CA54C4</s:String> &lt;/SessionState&gt;</s:String></wpf:ResourceDictionary>
<s:String x:Key="/Default/Environment/UnitTesting/CreateUnitTestDialog/TestTemplateMapping/=NUnit3x/@EntryIndexedValue">db4927dd-2e12-48a7-9a84-2b7e3e31b9c8</s:String>
</wpf:ResourceDictionary>

View File

@ -1,9 +0,0 @@
{
"tabWidth": 2,
"semi": false,
"singleQuote": false,
"arrowParens": "avoid",
"bracketSpacing": true,
"bracketSameLine": true,
"printWidth": 120
}

View File

@ -2,37 +2,34 @@
"name": "pac_man_board_game", "name": "pac_man_board_game",
"version": "0.1.1", "version": "0.1.1",
"private": true, "private": true,
"type": "module",
"dependencies": { "dependencies": {
"@emotion/react": "^11.11.3", "@emotion/react": "^11.11.1",
"@headlessui/react": "^1.7.18", "@headlessui/react": "^1.7.17",
"@heroicons/react": "^2.1.1", "@heroicons/react": "^2.0.18",
"jotai": "^2.6.1", "jotai": "^2.3.1",
"jotai-devtools": "^0.7.1", "jotai-devtools": "^0.6.2",
"oidc-client": "^1.11.5", "oidc-client": "^1.11.5",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-router-dom": "^6.21.1", "react-router-dom": "^6.15.0",
"web-vitals": "^3.5.1" "web-vitals": "^3.4.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20.10.8", "@types/react": "^18.2.33",
"@types/react": "^18.2.47", "@types/react-dom": "^18.2.14",
"@types/react-dom": "^18.2.18", "@vitejs/plugin-react": "^4.1.0",
"@vitejs/plugin-react": "^4.2.1", "@vitest/coverage-c8": "^0.33.0",
"@vitest/coverage-v8": "^1.1.3",
"autoprefixer": "^10.4.16", "autoprefixer": "^10.4.16",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"happy-dom": "^12.10.3", "happy-dom": "^12.9.1",
"postcss": "^8.4.33", "postcss": "^8.4.31",
"prettier": "^3.1.1", "tailwindcss": "^3.3.3",
"tailwindcss": "^3.4.1", "typescript": "^5.1.6",
"typescript": "^5.3.3", "vite": "^4.4.9",
"vite": "^5.0.11", "vite-plugin-node-polyfills": "^0.15.0",
"vite-plugin-node-polyfills": "^0.19.0", "vite-plugin-svgr": "^3.2.0",
"vite-plugin-svgr": "^4.2.0", "vite-tsconfig-paths": "^4.2.0",
"vite-tsconfig-paths": "^4.2.3", "vitest": "^0.34.6"
"vitest": "^1.1.3"
}, },
"resolutions": { "resolutions": {
"css-what": "^5.0.1", "css-what": "^5.0.1",
@ -44,8 +41,7 @@
"build": "vite build", "build": "vite build",
"serve": "vite preview", "serve": "vite preview",
"test": "cross-env CI=true vitest", "test": "cross-env CI=true vitest",
"coverage": "vitest run --coverage", "coverage": "vitest run --coverage"
"format": "prettier --write \"src/**/*.{js,jsx,ts,tsx,json,css,md}\""
}, },
"browserslist": { "browserslist": {
"production": [ "production": [

File diff suppressed because it is too large Load Diff

View File

@ -1,21 +1,21 @@
import React, { FC } from "react" import React, {FC} from "react";
import { Navigate, Route, Routes } from "react-router-dom" import {Navigate, Route, Routes} from "react-router-dom";
import Layout from "./components/layout" import Layout from "./components/layout";
import AppRoutes from "./AppRoutes" import AppRoutes from "./AppRoutes";
import "./index.css" import "./index.css";
import { useAtomValue } from "jotai" import {useAtomValue} from "jotai";
import { thisPlayerAtom } from "./utils/state" import {thisPlayerAtom} from "./utils/state";
export const App: FC = () => ( export const App: FC = () => (
<Layout> <Layout>
<Routes> <Routes>
{AppRoutes.map((route, index) => { {AppRoutes.map((route, index) => {
const { element, secured = false, ...rest } = route const {element, secured = false, ...rest} = route;
return <Route key={index} {...rest} element={<Secured secured={secured}>{element}</Secured>} /> return <Route key={index} {...rest} element={<Secured secured={secured}>{element}</Secured>}/>;
})} })}
</Routes> </Routes>
</Layout> </Layout>
) );
/** /**
* This component is used to redirect the user to the login page if they are not logged in and the page is secured. * This component is used to redirect the user to the login page if they are not logged in and the page is secured.
@ -23,15 +23,13 @@ export const App: FC = () => (
* @param secured Whether or not the page is secured. * @param secured Whether or not the page is secured.
* @constructor The Secured component. * @constructor The Secured component.
*/ */
const Secured: FC< const Secured: FC<{
{ secured: boolean
secured: boolean } & ChildProps> = ({children, secured}) => {
} & ChildProps const player = useAtomValue(thisPlayerAtom);
> = ({ children, secured }) => {
const player = useAtomValue(thisPlayerAtom)
if (secured && player === undefined) { if (secured && player === undefined) {
return <Navigate to={"/login"} replace /> return <Navigate to={"/login"} replace/>
} }
return <>{children}</> return <>{children}</>

View File

@ -1,37 +1,37 @@
import React from "react" import React from "react";
import { Counter } from "./pages/counter" import {Counter} from "./pages/counter";
import GamePage from "./pages/game" import GamePage from "./pages/game";
import LobbyPage from "./pages/lobby" import LobbyPage from "./pages/lobby";
import LoginPage from "./pages/login" import LoginPage from "./pages/login";
import HomePage from "./pages/home" import HomePage from "./pages/home";
const AppRoutes = [ const AppRoutes = [
{ {
index: true, index: true,
element: <HomePage />, element: <HomePage/>
}, },
{ {
path: "/counter", path: "/counter",
element: <Counter />, element: <Counter/>
}, },
{ {
path: "/game/:id", path: "/game/:id",
element: <GamePage />, element: <GamePage/>,
secured: true, secured: true
}, },
{ {
path: "/lobby", path: "/lobby",
element: <LobbyPage />, element: <LobbyPage/>,
secured: true, secured: true
}, },
{ {
path: "/login", path: "/login",
element: <LoginPage />, element: <LoginPage/>
}, },
{ {
path: "*", path: "*",
element: <p>Page not found</p>, element: <p>Page not found</p>
}, }
] ];
export default AppRoutes export default AppRoutes;

View File

@ -1,15 +1,16 @@
import React, { FC } from "react" import React, {FC} from "react";
export const Button: FC<ButtonProps> = ({ export const Button: FC<ButtonProps> = (
className, {
onClick, className,
style, onClick,
title, style,
id, title,
disabled = false, id,
children, disabled = false,
type = "button", children,
}) => { type = "button",
}) => {
return ( return (
<button <button
id={id} id={id}
@ -22,4 +23,4 @@ export const Button: FC<ButtonProps> = ({
{children} {children}
</button> </button>
) )
} }

View File

@ -1,34 +1,36 @@
import React, { FC } from "react" import React, {FC} from "react";
import useToggle from "../hooks/useToggle" import useToggle from "../hooks/useToggle";
import { BugAntIcon } from "@heroicons/react/20/solid" import {BugAntIcon} from "@heroicons/react/20/solid";
import { selectedMapAtom } from "../utils/state" import {selectedMapAtom} from "../utils/state";
import { useAtom } from "jotai" import {useAtom} from "jotai";
const DebugMenu: FC = () => { const DebugMenu: FC = () => {
const [open, toggleOpen] = useToggle()
const [open, toggleOpen] = useToggle();
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
return ( return (
<div> <div>
{open && <DebugOptions />} {open && <DebugOptions/>}
<button <button className={"fixed bottom-2 right-2 bg-gray-800 text-white p-2 z-50 rounded-full"}
className={"fixed bottom-2 right-2 bg-gray-800 text-white p-2 z-50 rounded-full"} title={"Debug menu"}
title={"Debug menu"} onClick={() => toggleOpen()}>
onClick={() => toggleOpen()}> <BugAntIcon className={"w-8 m-1"}/>
<BugAntIcon className={"w-8 m-1"} />
</button> </button>
</div> </div>
)
);
} }
} }
export default DebugMenu export default DebugMenu;
const DebugOptions: FC = () => { const DebugOptions: FC = () => {
const [map, setMap] = useAtom(selectedMapAtom)
const [map, setMap] = useAtom(selectedMapAtom);
function clearSessionStorage(): void { function clearSessionStorage(): void {
sessionStorage.clear() sessionStorage.clear();
} }
function restartGame(): void { function restartGame(): void {

View File

@ -1,49 +1,56 @@
import React, { FC } from "react" import React, {FC} from "react";
import { useAtom, useAtomValue } from "jotai" import {useAtom, useAtomValue} from "jotai";
import { selectedDiceAtom, thisPlayerAtom } from "../utils/state" import {selectedDiceAtom, thisPlayerAtom,} from "../utils/state";
import { Button } from "./button" import {Button} from "./button";
export const AllDice: FC<{ values?: number[] } & ComponentProps> = ({ className, values }) => { export const AllDice: FC<{ values?: number[] } & ComponentProps> = (
const [selectedDice, setSelectedDice] = useAtom(selectedDiceAtom) {
className,
values,
}) => {
const [selectedDice, setSelectedDice] = useAtom(selectedDiceAtom);
function handleClick(dice: SelectedDice): void { function handleClick(dice: SelectedDice): void {
setSelectedDice(dice) setSelectedDice(dice);
} }
return ( return (
<div className={"flex gap-5 justify-center"}> <div className={"flex gap-5 justify-center"}>
{values?.map((value, index) => ( {values?.map((value, index) =>
<Dice <Dice key={index}
key={index} className={`${selectedDice?.index === index ? "border-2 border-black" : ""} ${className}`}
className={`${selectedDice?.index === index ? "border-2 border-black" : ""} ${className}`} value={value}
value={value} onClick={(value) => handleClick({index, value})}/>)}
onClick={value => handleClick({ index, value })}
/>
))}
</div> </div>
) );
} };
interface DiceProps extends ComponentProps { interface DiceProps extends ComponentProps {
value?: number value?: number,
onClick?: (value: number) => void onClick?: (value: number) => void,
} }
export const Dice: FC<DiceProps> = ({ className, value, onClick }) => { export const Dice: FC<DiceProps> = (
const thisPlayer = useAtomValue(thisPlayerAtom) {
className,
value,
onClick,
}) => {
const thisPlayer = useAtomValue(thisPlayerAtom);
function handleClick() { function handleClick() {
if (onClick && value) { if (onClick && value) {
onClick(value) onClick(value);
} }
} }
return ( return (
<Button <Button className={`text-2xl bg-gray-400 px-4 m-1 ${className}`}
className={`text-2xl bg-gray-400 px-4 m-1 ${className}`} disabled={!thisPlayer?.isTurn()}
disabled={!thisPlayer?.isTurn()} onClick={handleClick}>
onClick={handleClick}>
{value?.toString()} {value?.toString()}
</Button> </Button>
) );
} };

View File

@ -1,17 +1,24 @@
import React, { forwardRef, ReactEventHandler } from "react" import React, {forwardRef, ReactEventHandler} from "react";
export interface DropdownProps extends ComponentProps { export interface DropdownProps extends ComponentProps {
options?: string[] options?: string[],
onSelect?: ReactEventHandler<HTMLSelectElement> onSelect?: ReactEventHandler<HTMLSelectElement>,
} }
const Dropdown: FRComponent<DropdownProps, HTMLSelectElement> = forwardRef(({ className, options, onSelect }, ref) => ( const Dropdown: FRComponent<DropdownProps, HTMLSelectElement> = forwardRef((
<select {
ref={ref} className,
className={"border-2 border-gray-300 rounded-md py-1 px-2 bg-white " + className} options,
onSelect={onSelect}> onSelect
{options?.map((option, index) => <option key={index}>{option}</option>)} }, ref) => (
<select ref={ref} className={"border-2 border-gray-300 rounded-md py-1 px-2 bg-white " + className}
onSelect={onSelect}>
{
options?.map((option, index) => (
<option key={index}>{option}</option>
))
}
</select> </select>
)) ));
export default Dropdown export default Dropdown;

View File

@ -1,138 +1,147 @@
import React, { FC, Fragment, useEffect, useState } from "react" import React, {FC, Fragment, useEffect, useState} from "react";
import { Character } from "../game/character" import {Character} from "../game/character";
import findPossiblePositions from "../game/possibleMovesAlgorithm" import findPossiblePositions from "../game/possibleMovesAlgorithm";
import { GameTile } from "./gameTile" import {GameTile} from "./gameTile";
import { TileType } from "../game/tileType" import {TileType} from "../game/tileType";
import { atom, useAtom, useAtomValue, useSetAtom } from "jotai" import {atom, useAtom, useAtomValue, useSetAtom} from "jotai";
import { allCharactersAtom, currentPlayerAtom, playersAtom, selectedDiceAtom } from "../utils/state" import {allCharactersAtom, currentPlayerAtom, playersAtom, selectedDiceAtom} from "../utils/state";
import { Dialog, Transition } from "@headlessui/react" import {Dialog, Transition} from "@headlessui/react";
interface BoardProps extends ComponentProps { interface BoardProps extends ComponentProps {
onMove?: Action<Position[]> onMove?: Action<Position[]>,
map: GameMap map: GameMap
} }
const modalOpenAtom = atom(false) const modalOpenAtom = atom(false);
const Board: FC<BoardProps> = ({ className, onMove, map }) => { const Board: FC<BoardProps> = (
const currentPlayer = useAtomValue(currentPlayerAtom) {
const characters = useAtomValue(allCharactersAtom) className,
const selectedDice = useAtomValue(selectedDiceAtom) onMove,
const [selectedCharacter, setSelectedCharacter] = useState<Character>() map
const [possiblePositions, setPossiblePositions] = useState<Path[]>([]) }) => {
const [hoveredPosition, setHoveredPosition] = useState<Path>()
const setModalOpen = useSetAtom(modalOpenAtom) const currentPlayer = useAtomValue(currentPlayerAtom);
const characters = useAtomValue(allCharactersAtom);
const selectedDice = useAtomValue(selectedDiceAtom);
const [selectedCharacter, setSelectedCharacter] = useState<Character>();
const [possiblePositions, setPossiblePositions] = useState<Path[]>([]);
const [hoveredPosition, setHoveredPosition] = useState<Path>();
const setModalOpen = useSetAtom(modalOpenAtom);
function handleSelectCharacter(character: Character): void { function handleSelectCharacter(character: Character): void {
if (character.isPacMan() && currentPlayer?.pacMan.colour !== character.colour) { if (character.isPacMan() && currentPlayer?.pacMan.colour !== character.colour) {
return return;
} }
setSelectedCharacter(character) setSelectedCharacter(character);
} }
function handleShowPath(path: Path): void { function handleShowPath(path: Path): void {
setHoveredPosition(path) setHoveredPosition(path);
} }
function handleMoveCharacter(destination: Path): void { function handleMoveCharacter(destination: Path): void {
if (selectedCharacter) { if (selectedCharacter) {
setHoveredPosition(undefined) setHoveredPosition(undefined);
if (selectedCharacter.isGhost()) { if (selectedCharacter.isGhost()) {
tryMovePacManToSpawn(destination) tryMovePacManToSpawn(destination);
} }
selectedCharacter.follow(destination) selectedCharacter.follow(destination);
const positions = pickUpPellets(destination) const positions = pickUpPellets(destination);
onMove?.(positions) onMove?.(positions);
setSelectedCharacter(undefined) setSelectedCharacter(undefined);
} }
} }
function tryMovePacManToSpawn(destination: Path): void { function tryMovePacManToSpawn(destination: Path): void {
const takenChar = characters.find(c => c.isPacMan() && c.isAt(destination.end)) const takenChar = characters.find(c => c.isPacMan() && c.isAt(destination.end));
if (takenChar) { if (takenChar) {
takenChar.moveToSpawn() takenChar.moveToSpawn();
stealFromPlayer() stealFromPlayer();
} }
} }
function stealFromPlayer(): void { function stealFromPlayer(): void {
setModalOpen(true) setModalOpen(true);
} }
function pickUpPellets(destination: Path): Position[] { function pickUpPellets(destination: Path): Position[] {
const positions: Position[] = [] const positions: Position[] = [];
if (selectedCharacter?.isPacMan()) { if (selectedCharacter?.isPacMan()) {
for (const tile of [...(destination.path ?? []), destination.end]) {
const currentTile = map[tile.y][tile.x] for (const tile of [...destination.path ?? [], destination.end]) {
const currentTile = map[tile.y][tile.x];
function updateTileAndPlayerBox(isPowerPellet = false): void { function updateTileAndPlayerBox(isPowerPellet = false): void {
if (isPowerPellet) { if (isPowerPellet) {
currentPlayer?.addPowerPellet() currentPlayer?.addPowerPellet();
} else { } else {
currentPlayer?.addPellet() currentPlayer?.addPellet();
} }
map[tile.y][tile.x] = TileType.empty map[tile.y][tile.x] = TileType.empty;
positions.push(tile) positions.push(tile);
} }
if (currentTile === TileType.pellet) { if (currentTile === TileType.pellet) {
updateTileAndPlayerBox() updateTileAndPlayerBox();
} else if (currentTile === TileType.powerPellet) { } else if (currentTile === TileType.powerPellet) {
updateTileAndPlayerBox(true) updateTileAndPlayerBox(true);
} }
} }
} }
return positions return positions;
} }
useEffect(() => { useEffect(() => {
if (selectedCharacter && selectedDice) { if (selectedCharacter && selectedDice) {
const possiblePaths = findPossiblePositions(map, selectedCharacter, selectedDice.value, characters) const possiblePaths = findPossiblePositions(map, selectedCharacter, selectedDice.value, characters);
setPossiblePositions(possiblePaths) setPossiblePositions(possiblePaths);
} else { } else {
setPossiblePositions([]) setPossiblePositions([]);
} }
}, [selectedCharacter, selectedDice]) }, [selectedCharacter, selectedDice]);
return ( return (
<div className={`w-fit ${className}`}> <div className={`w-fit ${className}`}>
<SelectPlayerModal /> <SelectPlayerModal/>
{map.map((row, rowIndex) => ( {
<div key={rowIndex} className={"flex"}> map.map((row, rowIndex) =>
{row.map((tile, colIndex) => ( <div key={rowIndex} className={"flex"}>
<GameTile {
key={colIndex + rowIndex * colIndex} row.map((tile, colIndex) =>
type={tile} <GameTile
possiblePath={possiblePositions.find(p => p.end.x === colIndex && p.end.y === rowIndex)} key={colIndex + rowIndex * colIndex}
character={characters.find(c => c.isAt({ x: colIndex, y: rowIndex }))} type={tile}
isSelected={selectedCharacter?.isAt({ x: colIndex, y: rowIndex })} possiblePath={possiblePositions.find(p => p.end.x === colIndex && p.end.y === rowIndex)}
showPath={hoveredPosition?.path?.find(pos => pos.x === colIndex && pos.y === rowIndex) !== undefined} character={characters.find(c => c.isAt({x: colIndex, y: rowIndex}))}
handleMoveCharacter={handleMoveCharacter} isSelected={selectedCharacter?.isAt({x: colIndex, y: rowIndex})}
handleSelectCharacter={handleSelectCharacter} showPath={hoveredPosition?.path?.find(pos => pos.x === colIndex && pos.y === rowIndex) !== undefined}
handleStartShowPath={handleShowPath} handleMoveCharacter={handleMoveCharacter}
handleStopShowPath={() => setHoveredPosition(undefined)} handleSelectCharacter={handleSelectCharacter}
/> handleStartShowPath={handleShowPath}
))} handleStopShowPath={() => setHoveredPosition(undefined)}/>
</div> )
))} }
</div>)
}
</div> </div>
) );
} };
export default Board export default Board;
const SelectPlayerModal: FC = () => { const SelectPlayerModal: FC = () => {
const [isOpen, setIsOpen] = useAtom(modalOpenAtom) const [isOpen, setIsOpen] = useAtom(modalOpenAtom);
const currentPlayer = useAtomValue(currentPlayerAtom) const currentPlayer = useAtomValue(currentPlayerAtom);
const allPlayers = useAtomValue(playersAtom).filter(p => p !== currentPlayer) const allPlayers = useAtomValue(playersAtom).filter(p => p !== currentPlayer);
if (currentPlayer === undefined) return null if (currentPlayer === undefined) return null;
function close(): void { function close(): void {
setIsOpen(false) setIsOpen(false);
} }
return ( return (
@ -146,8 +155,9 @@ const SelectPlayerModal: FC = () => {
enterTo="opacity-100" enterTo="opacity-100"
leave="ease-in duration-200" leave="ease-in duration-200"
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0"> leaveTo="opacity-0"
<div className="fixed inset-0 bg-black bg-opacity-25" /> >
<div className="fixed inset-0 bg-black bg-opacity-25"/>
</Transition.Child> </Transition.Child>
<div className="fixed inset-0 overflow-y-auto"> <div className="fixed inset-0 overflow-y-auto">
@ -159,8 +169,10 @@ const SelectPlayerModal: FC = () => {
enterTo="opacity-100 scale-100" enterTo="opacity-100 scale-100"
leave="ease-in duration-200" leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100" leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"> leaveTo="opacity-0 scale-95"
<Dialog.Panel className="w-full max-w-md transform overflow-hidden rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all"> >
<Dialog.Panel
className="w-full max-w-md transform overflow-hidden rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all">
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-gray-900"> <Dialog.Title as="h3" className="text-lg font-medium leading-6 text-gray-900">
Steal from player Steal from player
</Dialog.Title> </Dialog.Title>
@ -170,29 +182,29 @@ const SelectPlayerModal: FC = () => {
</Dialog.Description> </Dialog.Description>
</div> </div>
{allPlayers.map(player => ( {
<div key={player.username} className={"border-b pb-1"}> allPlayers.map(player =>
<span className={"mx-2"}> <div key={player.username} className={"border-b pb-1"}>
{player.username} has {player.box.pellets} pellets <span className={"mx-2"}>{player.username} has {player.box.pellets} pellets</span>
</span> <button className={"text-blue-500 enabled:cursor-pointer disabled:text-gray-500"}
<button style={{background: "none"}}
className={"text-blue-500 enabled:cursor-pointer disabled:text-gray-500"} disabled={player.box.pellets === 0}
style={{ background: "none" }} onClick={() => {
disabled={player.box.pellets === 0} currentPlayer?.stealFrom(player);
onClick={() => { close();
currentPlayer?.stealFrom(player) }}>
close() Steal
}}> </button>
Steal </div>
</button> )
</div> }
))}
<div className="mt-4"> <div className="mt-4">
<button <button
type="button" type="button"
className="inline-flex justify-center rounded-md border border-transparent bg-blue-100 px-4 py-2 text-sm font-medium text-blue-900 hover:bg-blue-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2" className="inline-flex justify-center rounded-md border border-transparent bg-blue-100 px-4 py-2 text-sm font-medium text-blue-900 hover:bg-blue-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
onClick={close}> onClick={close}
>
Don't steal from anyone Don't steal from anyone
</button> </button>
</div> </div>

View File

@ -1,36 +1,33 @@
import React, { FC, MouseEventHandler } from "react" import React, {FC, MouseEventHandler} from "react";
import { State } from "../game/player" import {State} from "../game/player";
import { currentPlayerAtom, playersAtom, rollDiceButtonAtom, thisPlayerAtom } from "../utils/state" import {currentPlayerAtom, playersAtom, rollDiceButtonAtom, thisPlayerAtom} from "../utils/state";
import { useAtomValue } from "jotai" import {useAtomValue} from "jotai";
import { Button } from "./button" import {Button} from "./button";
import rules from "../game/rules" import rules from "../game/rules";
interface GameButtonProps extends ComponentProps { interface GameButtonProps extends ComponentProps {
onReadyClick?: MouseEventHandler onReadyClick?: MouseEventHandler,
onRollDiceClick?: MouseEventHandler onRollDiceClick?: MouseEventHandler
} }
const GameButton: FC<GameButtonProps> = ({ onReadyClick, onRollDiceClick }) => { const GameButton: FC<GameButtonProps> = (
const currentPlayer = useAtomValue(currentPlayerAtom) {
const thisPlayer = useAtomValue(thisPlayerAtom) onReadyClick,
const players = useAtomValue(playersAtom) onRollDiceClick,
const activeRollDiceButton = useAtomValue(rollDiceButtonAtom) }) => {
if ( const currentPlayer = useAtomValue(currentPlayerAtom);
players.length >= rules.minPlayers && const thisPlayer = useAtomValue(thisPlayerAtom);
(currentPlayer === undefined || currentPlayer.state === State.waitingForPlayers) const players = useAtomValue(playersAtom);
) { const activeRollDiceButton = useAtomValue(rollDiceButtonAtom);
return <Button onClick={onReadyClick}>Ready</Button>
}
if (!thisPlayer?.isTurn()) {
// TODO also show when waiting for other players
return <Button disabled>Please wait</Button>
}
return (
<Button onClick={onRollDiceClick} disabled={!activeRollDiceButton}>
Roll dice
</Button>
)
}
export default GameButton if (players.length >= rules.minPlayers && (currentPlayer === undefined || currentPlayer.state === State.waitingForPlayers)) {
return <Button onClick={onReadyClick}>Ready</Button>;
}
if (!thisPlayer?.isTurn()) { // TODO also show when waiting for other players
return <Button disabled>Please wait</Button>;
}
return <Button onClick={onRollDiceClick} disabled={!activeRollDiceButton}>Roll dice</Button>;
};
export default GameButton;

View File

@ -1,46 +1,39 @@
import React, { FC, useEffect } from "react" import React, {FC, useEffect} from "react";
import { AllDice } from "./dice" import {AllDice} from "./dice";
import { doAction, GameAction } from "../utils/actions" import {doAction, GameAction} from "../utils/actions";
import GameBoard from "./gameBoard" import GameBoard from "./gameBoard";
import WebSocketService from "../websockets/WebSocketService" import WebSocketService from "../websockets/WebSocketService";
import Player from "../game/player" import Player from "../game/player";
import PlayerStats from "../components/playerStats" import PlayerStats from "../components/playerStats";
import { useAtom, useAtomValue, useSetAtom } from "jotai" import {useAtom, useAtomValue, useSetAtom} from "jotai";
import { diceAtom, ghostsAtom, playersAtom, rollDiceButtonAtom, selectedDiceAtom } from "../utils/state" import {diceAtom, ghostsAtom, playersAtom, rollDiceButtonAtom, selectedDiceAtom} from "../utils/state";
import GameButton from "./gameButton" import GameButton from "./gameButton";
import { Button } from "./button" import {Button} from "./button";
import { useNavigate, useParams } from "react-router-dom" import {useNavigate, useParams} from "react-router-dom";
import { getData } from "../utils/api" import {getData} from "../utils/api";
const wsService = new WebSocketService(import.meta.env.VITE_API_WS) const wsService = new WebSocketService(import.meta.env.VITE_API_WS);
/** export const GameComponent: FC<{ player: Player, map: GameMap }> = ({player, map}) => {
* Represents the main game component.
* @component
* @param player - The current player.
* @param map - The current game map.
*
* @returns The rendered game component.
*/
export const GameComponent: FC<{ player: Player; map: GameMap }> = ({ player, map }) => {
const players = useAtomValue(playersAtom)
const dice = useAtomValue(diceAtom)
const [selectedDice, setSelectedDice] = useAtom(selectedDiceAtom)
const setActiveRollDiceButton = useSetAtom(rollDiceButtonAtom)
const ghosts = useAtomValue(ghostsAtom)
const navigate = useNavigate() const players = useAtomValue(playersAtom);
const { id } = useParams() const dice = useAtomValue(diceAtom);
const [selectedDice, setSelectedDice] = useAtom(selectedDiceAtom);
const setActiveRollDiceButton = useSetAtom(rollDiceButtonAtom);
const ghosts = useAtomValue(ghostsAtom);
const navigate = useNavigate();
const {id} = useParams();
/** /**
* Rolls the dice for the current player's turn. * Rolls the dice for the current player's turn.
*/ */
function rollDice(): void { function rollDice(): void {
if (!player.isTurn()) return if (!player.isTurn()) return;
setSelectedDice(undefined) setSelectedDice(undefined);
wsService.send({ action: GameAction.rollDice }) wsService.send({action: GameAction.rollDice});
setActiveRollDiceButton(false) setActiveRollDiceButton(false);
} }
/** /**
@ -49,22 +42,22 @@ export const GameComponent: FC<{ player: Player; map: GameMap }> = ({ player, ma
*/ */
function onCharacterMove(eatenPellets: Position[]): void { function onCharacterMove(eatenPellets: Position[]): void {
if (dice && selectedDice) { if (dice && selectedDice) {
dice.splice(selectedDice.index, 1) dice.splice(selectedDice.index, 1);
} }
setSelectedDice(undefined) setSelectedDice(undefined);
const data: ActionMessage = { const data: ActionMessage = {
action: GameAction.moveCharacter, action: GameAction.moveCharacter,
data: { data: {
dice: dice?.length ?? 0 > 0 ? dice : null, dice: dice?.length ?? 0 > 0 ? dice : null,
players: players, players: players,
ghosts: ghosts, ghosts: ghosts,
eatenPellets: eatenPellets, eatenPellets: eatenPellets
}, }
} };
wsService.send(data) wsService.send(data);
if (dice?.length === 0) { if (dice?.length === 0) {
endTurn() endTurn();
} }
} }
@ -77,15 +70,15 @@ export const GameComponent: FC<{ player: Player; map: GameMap }> = ({ player, ma
data: { data: {
username: player.username, username: player.username,
gameId: id, gameId: id,
} as JoinGameData, } as JoinGameData
}) });
} }
/** /**
* Sends a ready action to the WebSocket service. * Sends a ready action to the WebSocket service.
*/ */
function sendReady(): void { function sendReady(): void {
wsService.send({ action: GameAction.ready }) wsService.send({action: GameAction.ready});
} }
/** /**
@ -93,40 +86,44 @@ export const GameComponent: FC<{ player: Player; map: GameMap }> = ({ player, ma
* to advance to the next player in the game. * to advance to the next player in the game.
*/ */
function endTurn(): void { function endTurn(): void {
wsService.send({ action: GameAction.nextPlayer }) wsService.send({action: GameAction.nextPlayer});
} }
/** /**
* Leaves the current game and navigates to the lobby. * Leaves the current game and navigates to the lobby.
*/ */
function leaveGame(): void { function leaveGame(): void {
wsService.send({ action: GameAction.disconnect }) wsService.send({action: GameAction.disconnect});
navigate("/lobby") navigate("/lobby");
} }
useEffect(() => { useEffect(() => {
getData(`/game/exists/${id}`).then(res => {
if (!res.ok) {
return navigate("/lobby")
}
wsService.onReceive = doAction
wsService.open()
wsService.waitForOpen().then(() => joinGame()) getData(`/game/exists/${id}`)
}) .then(res => {
if (!res.ok) {
return navigate("/lobby");
}
wsService.onReceive = doAction;
wsService.open();
return () => wsService.close() wsService.waitForOpen().then(() => joinGame());
}, []) })
return () => wsService.close();
}, []);
return ( return (
<> <>
<Button onClick={leaveGame}>Leave game</Button> <Button onClick={leaveGame}>Leave game</Button>
<div className={"flex justify-center"}>{players?.map(p => <PlayerStats key={p.username} player={p} />)}</div> <div className={"flex justify-center"}>
<div className={"flex-center"}> {players?.map(p => <PlayerStats key={p.username} player={p}/>)}
<GameButton onReadyClick={sendReady} onRollDiceClick={rollDice} />
</div> </div>
<AllDice values={dice} /> <div className={"flex-center"}>
<GameBoard className={"mx-auto my-2"} onMove={onCharacterMove} map={map} /> <GameButton onReadyClick={sendReady} onRollDiceClick={rollDice}/>
</div>
<AllDice values={dice}/>
<GameBoard className={"mx-auto my-2"} onMove={onCharacterMove} map={map}/>
</> </>
) );
} };

View File

@ -1,151 +1,165 @@
import React, { FC, useEffect, useState } from "react" import React, {FC, useEffect, useState} from "react";
import { TileType } from "../game/tileType" import {TileType} from "../game/tileType";
import { Character, Dummy } from "../game/character" import {Character, Dummy} from "../game/character";
import { Direction } from "../game/direction" import {Direction} from "../game/direction";
import { Colour } from "../game/colour" import {Colour} from "../game/colour";
interface TileWithCharacterProps extends ComponentProps { interface TileWithCharacterProps extends ComponentProps {
possiblePath?: Path possiblePath?: Path,
character?: Character character?: Character,
type?: TileType type?: TileType,
handleMoveCharacter?: Action<Path> handleMoveCharacter?: Action<Path>,
handleSelectCharacter?: Action<Character> handleSelectCharacter?: Action<Character>,
handleStartShowPath?: Action<Path> handleStartShowPath?: Action<Path>,
handleStopShowPath?: VoidFunction handleStopShowPath?: VoidFunction,
isSelected?: boolean isSelected?: boolean,
showPath?: boolean showPath?: boolean
} }
export const GameTile: FC<TileWithCharacterProps> = ({ export const GameTile: FC<TileWithCharacterProps> = (
possiblePath, {
character, possiblePath,
type, character,
handleMoveCharacter, type,
handleSelectCharacter, handleMoveCharacter,
handleStartShowPath, handleSelectCharacter,
handleStopShowPath, handleStartShowPath,
isSelected = false, handleStopShowPath,
showPath = false, isSelected = false,
}) => ( showPath = false
<Tile }) => (
className={`${possiblePath?.end && "border-4 border-white"}`} <Tile className={`${possiblePath?.end ? "border-4 border-white" : ""}`}
type={type} type={type}
onClick={possiblePath ? () => handleMoveCharacter?.(possiblePath) : undefined} onClick={possiblePath ? () => handleMoveCharacter?.(possiblePath) : undefined}
onMouseEnter={possiblePath ? () => handleStartShowPath?.(possiblePath) : undefined} onMouseEnter={possiblePath ? () => handleStartShowPath?.(possiblePath) : undefined}
onMouseLeave={handleStopShowPath}> onMouseLeave={handleStopShowPath}>
<> <>
{character && ( {character &&
<div className={"flex-center wh-full"}> <div className={"flex-center wh-full"}>
<CharacterComponent <CharacterComponent
character={character} character={character}
onClick={handleSelectCharacter} onClick={handleSelectCharacter}
className={isSelected ? "animate-bounce" : ""} className={isSelected ? "animate-bounce" : ""}/>
/> </div>
</div> }
)} {showPath && <Circle/>}
{showPath && <Circle />} <AddDummy path={possiblePath}/>
<AddDummy path={possiblePath} />
</> </>
</Tile> </Tile>
) );
const Circle: FC<{ colour?: Colour } & ComponentProps> = ({ colour = Colour.white, className }) => ( const Circle: FC<{ colour?: Colour } & ComponentProps> = ({colour = Colour.white, className}) => (
<div className={`flex-center w-full h-full ${className}`}> <div className={`flex-center w-full h-full ${className}`}>
<div className={`w-1/2 h-1/2 rounded-full`} style={{ backgroundColor: colour }} /> <div className={`w-1/2 h-1/2 rounded-full`}
style={{backgroundColor: colour}}/>
</div> </div>
) );
interface TileProps extends ChildProps { interface TileProps extends ChildProps {
type?: TileType type?: TileType,
onClick?: VoidFunction onClick?: VoidFunction,
onMouseEnter?: VoidFunction onMouseEnter?: VoidFunction,
onMouseLeave?: VoidFunction onMouseLeave?: VoidFunction,
character?: Character character?: Character,
onCharacterClick?: Action<Character> onCharacterClick?: Action<Character>,
characterClass?: string characterClass?: string,
} }
const Tile: FC<TileProps> = ({ type = TileType.empty, onClick, onMouseEnter, onMouseLeave, className, children }) => { const Tile: FC<TileProps> = (
const [tileSize, setTileSize] = useState(2) {
type = TileType.empty,
onClick,
onMouseEnter,
onMouseLeave,
className,
children
}) => {
const [tileSize, setTileSize] = useState(2);
function setColor(): string { function setColor(): string {
switch (type) { switch (type) {
case TileType.wall: case TileType.wall:
return "bg-blue-500" return "bg-blue-500";
case TileType.ghostSpawn: case TileType.ghostSpawn:
return "bg-red-500" return "bg-red-500";
case TileType.pacmanSpawn: case TileType.pacmanSpawn:
return "bg-green-500" // TODO should be the colour of the player return "bg-green-500"; // TODO should be the colour of the player
default: default:
return "bg-black" return "bg-black";
} }
} }
useEffect(() => { useEffect(() => {
function handleResize(): void { function handleResize(): void {
const newSize = Math.floor(window.innerWidth / 16) const newSize = Math.floor(window.innerWidth / 16);
setTileSize(newSize) setTileSize(newSize);
} }
handleResize() handleResize();
window.addEventListener("resize", handleResize) window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize) return () => window.removeEventListener("resize", handleResize);
}, []) }, []);
return ( return (
<div <div className={`${setColor()} hover:border relative max-w-[75px] max-h-[75px] ${className}`}
className={`${setColor()} hover:border relative max-w-[75px] max-h-[75px] ${className}`} style={{width: `${tileSize}px`, height: `${tileSize}px`}}
style={{ width: `${tileSize}px`, height: `${tileSize}px` }} onClick={onClick}
onClick={onClick} onMouseEnter={onMouseEnter}
onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}>
onMouseLeave={onMouseLeave}>
{children} {children}
{type === TileType.pellet && <Circle colour={Colour.yellow} />} {type === TileType.pellet && <Circle colour={Colour.yellow}/>}
{type === TileType.powerPellet && <Circle colour={Colour.red} />} {type === TileType.powerPellet && <Circle colour={Colour.red}/>}
</div> </div>
) );
} };
const AddDummy: FC<{ path?: Path } & ComponentProps> = ({ path }) => ( const AddDummy: FC<{ path?: Path } & ComponentProps> = ({path}) => (
<> <>
{path && ( {path &&
<div className={"flex-center wh-full"}> <div className={"flex-center wh-full"}>
<CharacterComponent character={new Dummy(path)} /> <CharacterComponent character={new Dummy(path)}/>
</div> </div>
)} }
</> </>
) );
interface CharacterComponentProps extends ComponentProps { interface CharacterComponentProps extends ComponentProps {
character?: Character character?: Character,
onClick?: Action<Character> onClick?: Action<Character>,
} }
const CharacterComponent: FC<CharacterComponentProps> = ({ character, onClick, className }) => { const CharacterComponent: FC<CharacterComponentProps> = (
{
character,
onClick,
className
}) => {
function getSide() { function getSide() {
switch (character?.position?.direction) { switch (character?.position?.direction) {
case Direction.up: case Direction.up:
return "right-1/4 top-0" return "right-1/4 top-0";
case Direction.down: case Direction.down:
return "right-1/4 bottom-0" return "right-1/4 bottom-0";
case Direction.left: case Direction.left:
return "left-0 top-1/4" return "left-0 top-1/4";
case Direction.right: case Direction.right:
return "right-0 top-1/4" return "right-0 top-1/4";
} }
} }
if (character === undefined) return null if (character === undefined) return null;
return ( return (
<div <div className={`rounded-full w-4/5 h-4/5 cursor-pointer hover:border border-black relative ${className}`}
className={`rounded-full w-4/5 h-4/5 cursor-pointer hover:border border-black relative ${className}`} style={{backgroundColor: `${character.colour}`}}
style={{ backgroundColor: `${character.colour}` }} onClick={() => onClick?.(character)}>
onClick={() => onClick?.(character)}>
<div> <div>
<div className={`absolute ${getSide()} w-1/2 h-1/2 rounded-full bg-black`} /> <div className={`absolute ${getSide()} w-1/2 h-1/2 rounded-full bg-black`}/>
</div> </div>
</div> </div>
) );
} };

View File

@ -1,18 +1,23 @@
import React, { forwardRef } from "react" import React, {forwardRef} from "react";
const Input: FRComponent<InputProps, HTMLInputElement> = forwardRef( const Input: FRComponent<InputProps, HTMLInputElement> = forwardRef((
({ type = "text", className, id, placeholder, required = false, name, autoComplete = "off" }, ref) => ( {
<input type = "text",
type={type} className,
autoComplete={autoComplete} id,
ref={ref} placeholder,
id={id} required = false,
name={name} name,
className={"border-2 border-gray-300 rounded-md p-1 " + className} autoComplete = "off",
placeholder={placeholder} }, ref) => (
required={required} <input type={type}
/> autoComplete={autoComplete}
), ref={ref}
) id={id}
name={name}
className={"border-2 border-gray-300 rounded-md p-1 " + className}
placeholder={placeholder}
required={required}/>
));
export default Input export default Input;

View File

@ -1,11 +1,13 @@
import React, { FC } from "react" import React, {FC} from "react";
import NavMenu from "./navMenu" import NavMenu from "./navMenu";
const Layout: FC<ChildProps> = ({ children }) => ( const Layout: FC<ChildProps> = ({children}) => (
<div> <div>
<NavMenu /> <NavMenu/>
<main>{children}</main> <main>
{children}
</main>
</div> </div>
) );
export default Layout export default Layout;

View File

@ -1,89 +1,84 @@
import React, { FC, useEffect } from "react" import React, {FC, useEffect} from "react";
import { Link, useNavigate } from "react-router-dom" import {Link, useNavigate} from "react-router-dom";
import { useAtom, useAtomValue } from "jotai" import {useAtom, useAtomValue} from "jotai";
import { thisPlayerAtom } from "../utils/state" import {thisPlayerAtom} from "../utils/state";
import { UserCircleIcon } from "@heroicons/react/24/outline" import {UserCircleIcon} from "@heroicons/react/24/outline";
import useToggle from "../hooks/useToggle" import useToggle from "../hooks/useToggle";
const NavMenu: FC = () => { const NavMenu: FC = () => {
const player = useAtomValue(thisPlayerAtom) const player = useAtomValue(thisPlayerAtom);
return ( return (
<header className={"z-10"}> <header className={"z-10"}>
<nav className="mb-3 flex justify-between border-b-2"> <nav className="mb-3 flex justify-between border-b-2">
<Link to="/"> <Link to="/"><h2 className={"m-1"}>Pac-Man Board Game</h2></Link>
<h2 className={"m-1"}>Pac-Man Board Game</h2>
</Link>
<ul className="inline-flex gap-2 items-center mr-5 relative"> <ul className="inline-flex gap-2 items-center mr-5 relative">
<NavItem to="/">Home</NavItem> <NavItem to="/">Home</NavItem>
{player === undefined ? ( {
<NavItem className={"mx-2"} to={"/login"}> player === undefined ?
Login <NavItem className={"mx-2"} to={"/login"}>Login</NavItem>
</NavItem> :
) : ( <>
<> <NavItem to={"/lobby"}>Lobby</NavItem>
<NavItem to={"/lobby"}>Lobby</NavItem> <ProfileDropdown className={"mx-2"}/>
<ProfileDropdown className={"mx-2"} /> </>
</> }
)}
</ul> </ul>
</nav> </nav>
</header> </header>
) );
} };
export default NavMenu export default NavMenu;
const NavItem: FC<LinkProps> = ({ to, children, className }) => ( const NavItem: FC<LinkProps> = ({to, children, className}) => (
<li> <li>
<Link className={`hover:underline ${className}`} to={to}> <Link className={`hover:underline ${className}`} to={to}>{children}</Link>
{children}
</Link>
</li> </li>
) );
const ProfileDropdown: FC<ComponentProps> = ({ className }) => { const ProfileDropdown: FC<ComponentProps> = ({className}) => {
const [player, setPlayer] = useAtom(thisPlayerAtom) const [player, setPlayer] = useAtom(thisPlayerAtom);
const [isOpened, toggleOpen] = useToggle() const [isOpened, toggleOpen] = useToggle();
const navigate = useNavigate() const navigate = useNavigate();
async function logout(): Promise<void> { async function logout(): Promise<void> {
setPlayer(undefined) setPlayer(undefined);
navigate("/login") navigate("/login");
} }
useEffect(() => { useEffect(() => {
if (isOpened) { if (isOpened) {
function closeIfOutsideButton(e: MouseEvent): void { function closeIfOutsideButton(e: MouseEvent): void {
if (isOpened && e.target instanceof HTMLElement) { if (isOpened && e.target instanceof HTMLElement) {
if (e.target.closest("#profile-dropdown") === null) { if (e.target.closest("#profile-dropdown") === null) {
toggleOpen(false) toggleOpen(false);
} }
} }
} }
document.addEventListener("click", closeIfOutsideButton) document.addEventListener("click", closeIfOutsideButton);
return () => document.removeEventListener("click", closeIfOutsideButton) return () => document.removeEventListener("click", closeIfOutsideButton);
} }
}, [isOpened])
}, [isOpened]);
return ( return (
<> <>
<li <li id={"profile-dropdown"}
id={"profile-dropdown"} className={`inline-flex-center cursor-pointer hover:bg-gray-100 h-full px-2 ${className}`}
className={`inline-flex-center cursor-pointer hover:bg-gray-100 h-full px-2 ${className}`} onClick={() => toggleOpen()}>
onClick={() => toggleOpen()}> <UserCircleIcon className={"w-7"}/>
<UserCircleIcon className={"w-7"} />
<span>{player?.username}</span> <span>{player?.username}</span>
</li> </li>
{isOpened && ( {
<div className={"absolute right-2 border rounded-b -bottom-9 px-5"}> isOpened &&
<button onClick={logout} className={"hover:underline py-1"}> <div className={"absolute right-2 border rounded-b -bottom-9 px-5"}>
Logout <button onClick={logout} className={"hover:underline py-1"}>Logout</button>
</button> </div>
</div> }
)}
</> </>
) )
} }

View File

@ -1,27 +1,29 @@
import React, { FC } from "react" import React, {FC} from "react";
import Player, { State } from "../game/player" import Player, {State} from "../game/player";
import { useAtomValue } from "jotai" import {useAtomValue} from "jotai";
import { currentPlayerNameAtom } from "../utils/state" import {currentPlayerNameAtom} from "../utils/state";
const PlayerStats: FC<{ player: Player } & ComponentProps> = ({ player, className, id }) => { const PlayerStats: FC<{ player: Player } & ComponentProps> = (
const currentPlayerName = useAtomValue(currentPlayerNameAtom) {
player,
className,
id
}) => {
const currentPlayerName = useAtomValue(currentPlayerNameAtom);
return ( return (
<div <div key={player.colour}
key={player.colour} className={`w-fit m-2 ${player.state === State.disconnected ? "text-gray-500" : ""} ${className}`} id={id}>
className={`w-fit m-2 ${player.state === State.disconnected ? "text-gray-500" : ""} ${className}`}
id={id}>
<p className={player.username === currentPlayerName ? "underline" : ""}>Player: {player.username}</p> <p className={player.username === currentPlayerName ? "underline" : ""}>Player: {player.username}</p>
<p>Colour: {player.colour}</p> <p>Colour: {player.colour}</p>
{player.state === State.inGame || player.state === State.disconnected ? ( {player.state === State.inGame || player.state === State.disconnected ?
<> <>
<p>Pellets: {player.box.pellets}</p> <p>Pellets: {player.box.pellets}</p>
<p>PowerPellets: {player.box.powerPellets}</p> <p>PowerPellets: {player.box.powerPellets}</p>
</> </>
) : ( :
<p>{player.state === State.waitingForPlayers ? "Waiting" : "Ready"}</p> <p>{player.state === State.waitingForPlayers ? "Waiting" : "Ready"}</p>}
)}
</div> </div>
) );
} };
export default PlayerStats export default PlayerStats;

View File

@ -1,31 +1,32 @@
export default class Box implements BoxProps { export default class Box implements BoxProps {
public readonly colour public readonly colour;
public pellets public pellets;
public powerPellets public powerPellets;
public constructor({ colour, pellets = 0, powerPellets = 0 }: BoxProps) { public constructor({colour, pellets = 0, powerPellets = 0}: BoxProps) {
this.colour = colour this.colour = colour;
this.pellets = pellets this.pellets = pellets;
this.powerPellets = powerPellets this.powerPellets = powerPellets;
} }
public addPellet(): void { public addPellet(): void {
this.pellets++ this.pellets++;
} }
public removePellet(): boolean { public removePellet(): boolean {
if (this.pellets <= 0) return false if (this.pellets <= 0) return false;
this.pellets-- this.pellets--;
return true return true;
} }
public addPowerPellet(): void { public addPowerPellet(): void {
this.powerPellets++ this.powerPellets++;
} }
public removePowerPellet(): boolean { public removePowerPellet(): boolean {
if (this.powerPellets <= 0) return false if (this.powerPellets <= 0) return false;
this.powerPellets-- this.powerPellets--;
return true return true;
} }
} }

View File

@ -1,5 +1,5 @@
import { Direction } from "./direction" import {Direction} from "./direction";
import { Colour } from "./colour" import {Colour} from "./colour";
export enum CharacterType { export enum CharacterType {
pacMan, pacMan,
@ -8,106 +8,89 @@ export enum CharacterType {
} }
export class Character implements CharacterProps { export class Character implements CharacterProps {
public readonly colour public readonly colour;
public position public position;
public isEatable public isEatable;
public readonly spawnPosition public readonly spawnPosition;
public readonly type public readonly type;
public constructor({ public constructor(
colour, {
position = null, colour,
type = CharacterType.dummy, position = null,
isEatable = type === CharacterType.pacMan, type = CharacterType.dummy,
spawnPosition = null, isEatable = type === CharacterType.pacMan,
}: CharacterProps) { spawnPosition = null
this.colour = colour }: CharacterProps) {
this.isEatable = isEatable this.colour = colour;
this.spawnPosition = spawnPosition this.isEatable = isEatable;
this.spawnPosition = spawnPosition;
if (position) { if (position) {
this.position = position this.position = position;
} else { } else {
this.position = spawnPosition this.position = spawnPosition ? {
? { end: spawnPosition!.at,
end: spawnPosition!.at, direction: spawnPosition!.direction
direction: spawnPosition!.direction, } : null;
}
: null
} }
this.type = type this.type = type;
} }
public follow(path: Path): void { public follow(path: Path): void {
if (!this.position) { if (!this.position) {
this.position = path this.position = path;
} else { } else {
this.position.end = path.end this.position.end = path.end;
this.position.direction = path.direction this.position.direction = path.direction;
this.position.path = undefined this.position.path = undefined;
} }
} }
public isPacMan(): boolean { public isPacMan(): boolean {
return this.type === CharacterType.pacMan return this.type === CharacterType.pacMan;
} }
public isGhost(): boolean { public isGhost(): boolean {
return this.type === CharacterType.ghost return this.type === CharacterType.ghost;
} }
public moveToSpawn(): void { public moveToSpawn(): void {
if (!this.spawnPosition) return if (!this.spawnPosition) return;
this.follow({ this.follow({end: this.spawnPosition.at, direction: this.spawnPosition.direction});
end: this.spawnPosition.at,
direction: this.spawnPosition.direction,
})
} }
public isAt(position: Position): boolean { public isAt(position: Position): boolean {
return this.position !== null && this.position.end.x === position.x && this.position.end.y === position.y return this.position !== null && this.position.end.x === position.x && this.position.end.y === position.y;
} }
} }
export class PacMan extends Character implements CharacterProps { export class PacMan extends Character implements CharacterProps {
public constructor({
colour, public constructor({colour, position, isEatable = true, spawnPosition, type = CharacterType.pacMan}: CharacterProps) {
position, super({colour: colour, position: position, isEatable: isEatable, spawnPosition: spawnPosition, type: type});
isEatable = true,
spawnPosition,
type = CharacterType.pacMan,
}: CharacterProps) {
super({
colour: colour,
position: position,
isEatable: isEatable,
spawnPosition: spawnPosition,
type: type,
})
} }
} }
export class Ghost extends Character implements CharacterProps { export class Ghost extends Character implements CharacterProps {
public constructor({ colour, position, isEatable, spawnPosition, type = CharacterType.ghost }: CharacterProps) {
super({ public constructor({colour, position, isEatable, spawnPosition, type = CharacterType.ghost}: CharacterProps) {
colour: colour, super({colour: colour, position: position, isEatable: isEatable, spawnPosition: spawnPosition, type: type});
position: position,
isEatable: isEatable,
spawnPosition: spawnPosition,
type: type,
})
} }
} }
export class Dummy extends Character implements CharacterProps { export class Dummy extends Character implements CharacterProps {
public constructor(position: Path) { public constructor(position: Path) {
super({ super({
colour: Colour.grey, colour: Colour.grey,
position: position, position: position,
isEatable: false, isEatable: false,
spawnPosition: { at: { x: 0, y: 0 }, direction: Direction.up }, spawnPosition: {at: {x: 0, y: 0}, direction: Direction.up},
type: CharacterType.dummy, type: CharacterType.dummy,
}) });
} }
} }

View File

@ -8,4 +8,4 @@ export enum Colour {
grey = "grey", grey = "grey",
} }
export const getColours = (): Colour[] => Object.values(Colour) export const getColours = (): Colour[] => Object.values(Colour);

View File

@ -2,7 +2,8 @@ export enum Direction {
left, left,
up, up,
right, right,
down, down
} }
export const getDirections = () => Object.values(Direction).filter(d => !isNaN(Number(d))) as Direction[] export const getDirections = () => Object.values(Direction)
.filter(d => !isNaN(Number(d))) as Direction[];

View File

@ -1,5 +1,5 @@
import { CharacterType } from "./character" import {CharacterType} from "./character";
import { Direction } from "./direction" import {Direction} from "./direction";
/** /**
* 0 = empty * 0 = empty
@ -25,32 +25,29 @@ export const customMap: GameMap = [
[1, 2, 1, 0, 1, 2, 1, 0, 1, 2, 1, 0, 1, 2, 1], [1, 2, 1, 0, 1, 2, 1, 0, 1, 2, 1, 0, 1, 2, 1],
[1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1], [1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1],
[1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1],
] ];
export function getCharacterSpawns(map: GameMap): { type: CharacterType; position: DirectionalPosition }[] { export function getCharacterSpawns(map: GameMap): { type: CharacterType, position: DirectionalPosition }[] {
const result: { type: CharacterType; position: DirectionalPosition }[] = []
const result: { type: CharacterType, position: DirectionalPosition }[] = [];
for (let row = 0; row < map.length; row++) { for (let row = 0; row < map.length; row++) {
for (let col = 0; col < map.length; col++) { for (let col = 0; col < map.length; col++) {
// TODO find direction // TODO find direction
if (map[row][col] === 4) { if (map[row][col] === 4) {
result.push({ result.push({type: CharacterType.ghost, position: {at: {x: col, y: row}, direction: Direction.up}});
type: CharacterType.ghost,
position: { at: { x: col, y: row }, direction: Direction.up },
})
} else if (map[row][col] === 5) { } else if (map[row][col] === 5) {
result.push({ result.push({
type: CharacterType.pacMan, type: CharacterType.pacMan, position: {at: {x: col, y: row}, direction: Direction.up}
position: { at: { x: col, y: row }, direction: Direction.up }, });
})
} }
} }
} }
return result return result;
} }
export function getPacManSpawns(map: GameMap): DirectionalPosition[] { export function getPacManSpawns(map: GameMap): DirectionalPosition[] {
return getCharacterSpawns(map) return getCharacterSpawns(map)
.filter(s => s.type === CharacterType.pacMan) .filter(s => s.type === CharacterType.pacMan)
.map(s => s.position) .map(s => s.position)
} }

View File

@ -1,62 +1,54 @@
import { Character, CharacterType } from "./character" import {Character, CharacterType} from "./character";
import Box from "./box" import Box from "./box";
import { getDefaultStore } from "jotai" import {getDefaultStore} from "jotai";
import { currentPlayerNameAtom, playersAtom } from "../utils/state" import {currentPlayerNameAtom, playersAtom} from "../utils/state";
import rules from "./rules" import rules from "./rules";
/**
* Represents the different states of a game.
*
* @enum {number}
*/
export enum State { export enum State {
waitingForPlayers, waitingForPlayers,
ready, ready,
inGame, inGame,
disconnected, disconnected
} }
export default class Player implements PlayerProps { export default class Player implements PlayerProps {
private static store = getDefaultStore() private static store = getDefaultStore();
public readonly username public readonly username;
public readonly pacMan public readonly pacMan;
public readonly colour public readonly colour;
public readonly box public readonly box;
public state public state;
constructor(props: PlayerProps) { constructor(props: PlayerProps) {
this.username = props.username this.username = props.username;
this.colour = props.colour this.colour = props.colour;
this.box = new Box(props.box ?? { colour: props.colour }) this.box = new Box(props.box ?? {colour: props.colour});
this.pacMan = new Character( this.pacMan = new Character(props.pacMan ?? {
props.pacMan ?? { colour: props.colour,
colour: props.colour, type: CharacterType.pacMan
type: CharacterType.pacMan, });
}, this.state = props.state ?? State.waitingForPlayers;
)
this.state = props.state ?? State.waitingForPlayers
} }
public isTurn(): boolean { public isTurn(): boolean {
return Player.store.get(currentPlayerNameAtom) === this.username return Player.store.get(currentPlayerNameAtom) === this.username;
} }
public addPellet(): void { public addPellet(): void {
this.box.addPellet() this.box.addPellet();
} }
public addPowerPellet(): void { public addPowerPellet(): void {
this.box.addPowerPellet() this.box.addPowerPellet();
} }
public stealFrom(other: Player): void { public stealFrom(other: Player): void {
for (let i = 0; i < rules.maxStealPellets; i++) { for (let i = 0; i < rules.maxStealPellets; i++) {
const removed = other.box.removePellet() const removed = other.box.removePellet();
if (removed) this.box.addPellet() if (removed)
this.box.addPellet();
} }
Player.store.set( Player.store.set(playersAtom, Player.store.get(playersAtom).map(player => player));
playersAtom,
Player.store.get(playersAtom).map(player => player),
)
} }
} }

View File

@ -1,6 +1,6 @@
import { TileType } from "./tileType" import {TileType} from "./tileType";
import { Character } from "./character" import {Character} from "./character";
import { Direction, getDirections } from "./direction" import {Direction, getDirections} from "./direction";
/** /**
* Finds all the possible positions for the character to move to * Finds all the possible positions for the character to move to
@ -10,15 +10,10 @@ import { Direction, getDirections } from "./direction"
* @param characters All the characters on the map * @param characters All the characters on the map
* @returns An array of paths the character can move to * @returns An array of paths the character can move to
*/ */
export default function findPossiblePositions( export default function findPossiblePositions(board: GameMap, character: Character, steps: number, characters: Character[]): Path[] {
board: GameMap, if (!character.position || !character.spawnPosition) throw new Error("Character has no position or spawn position");
character: Character, return findPossibleRecursive(board, character.position, steps, character, characters);
steps: number, };
characters: Character[],
): Path[] {
if (!character.position || !character.spawnPosition) throw new Error("Character has no position or spawn position")
return findPossibleRecursive(board, character.position, steps, character, characters)
}
/** /**
* Uses recursion to move through the map and find all the possible positions * Uses recursion to move through the map and find all the possible positions
@ -29,40 +24,38 @@ export default function findPossiblePositions(
* @param characters All the characters on the map * @param characters All the characters on the map
* @returns {Path[]} An array of paths the character can move to * @returns {Path[]} An array of paths the character can move to
*/ */
function findPossibleRecursive( function findPossibleRecursive(map: GameMap, currentPath: Path, steps: number, character: Character, characters: Character[]): Path[] {
map: GameMap,
currentPath: Path, const paths: Path[] = [];
steps: number,
character: Character,
characters: Character[],
): Path[] {
const paths: Path[] = []
if (isOutsideBoard(currentPath, map.length)) { if (isOutsideBoard(currentPath, map.length)) {
if (character.isPacMan()) { if (character.isPacMan()) {
return addTeleportationTiles(map, currentPath, steps, character, characters) return addTeleportationTiles(map, currentPath, steps, character, characters);
} }
} else if (!isWall(map, currentPath)) { } else if (!isWall(map, currentPath)) {
if (!characterHitsAnotherCharacter(character, currentPath, characters)) { if (!characterHitsAnotherCharacter(character, currentPath, characters)) {
if (steps <= 0) { if (steps <= 0) {
if (!(isSpawn(map, currentPath) && !isOwnSpawn(currentPath, character))) { if (!(isSpawn(map, currentPath) && !isOwnSpawn(currentPath, character))) {
paths.push(currentPath) paths.push(currentPath);
} }
} else {
tryAddToPath(currentPath)
steps-- } else {
tryAddToPath(currentPath);
steps--;
for (const direction of getDirections()) { for (const direction of getDirections()) {
paths.push(...tryMove(map, currentPath, direction, steps, character, characters)) paths.push(...tryMove(map, currentPath, direction, steps, character, characters));
} }
} }
} else { } else {
const pacMan = ghostHitsPacMan(character, currentPath, characters) const pacMan = ghostHitsPacMan(character, currentPath, characters);
if (pacMan instanceof Character && !isCharactersSpawn(currentPath, pacMan)) { if (pacMan instanceof Character && !isCharactersSpawn(currentPath, pacMan)) {
paths.push(currentPath) paths.push(currentPath);
} }
} }
} }
return paths return paths;
} }
/** /**
@ -72,7 +65,7 @@ function findPossibleRecursive(
* @returns {boolean} True if the character is on its spawn, otherwise false * @returns {boolean} True if the character is on its spawn, otherwise false
*/ */
function isCharactersSpawn(currentPath: Path, character: Character): boolean { function isCharactersSpawn(currentPath: Path, character: Character): boolean {
return character.spawnPosition?.at.x === currentPath.end.x && character.spawnPosition.at.y === currentPath.end.y return character.spawnPosition?.at.x === currentPath.end.x && character.spawnPosition.at.y === currentPath.end.y;
} }
/** /**
@ -82,12 +75,8 @@ function isCharactersSpawn(currentPath: Path, character: Character): boolean {
* @param characters All the characters on the board * @param characters All the characters on the board
* @returns {boolean} True if the character is a ghost and hits Pac-Man, otherwise false * @returns {boolean} True if the character is a ghost and hits Pac-Man, otherwise false
*/ */
function ghostHitsPacMan( function ghostHitsPacMan(character: Character, currentPath: Path, characters: Character[]): Character | undefined | false {
character: Character, return character.isGhost() && characters.find(c => c.isPacMan() && c.isAt(currentPath.end));
currentPath: Path,
characters: Character[],
): Character | undefined | false {
return character.isGhost() && characters.find(c => c.isPacMan() && c.isAt(currentPath.end))
} }
/** /**
@ -98,7 +87,7 @@ function ghostHitsPacMan(
* @returns {boolean} True if the character hits another character, otherwise false * @returns {boolean} True if the character hits another character, otherwise false
*/ */
function characterHitsAnotherCharacter(character: Character, currentPath: Path, characters: Character[]): boolean { function characterHitsAnotherCharacter(character: Character, currentPath: Path, characters: Character[]): boolean {
return characters.find(c => c !== character && c.isAt(currentPath.end)) !== undefined return characters.find(c => c !== character && c.isAt(currentPath.end)) !== undefined;
} }
/** /**
@ -107,9 +96,9 @@ function characterHitsAnotherCharacter(character: Character, currentPath: Path,
*/ */
function tryAddToPath(currentPos: Path): void { function tryAddToPath(currentPos: Path): void {
if (!currentPos.path) { if (!currentPos.path) {
currentPos.path = [] currentPos.path = [];
} else if (!currentPos.path.includes(currentPos.end)) { } else if (!currentPos.path.includes(currentPos.end)) {
currentPos.path = [...currentPos.path, currentPos.end] currentPos.path = [...currentPos.path, currentPos.end];
} }
} }
@ -124,53 +113,39 @@ function tryAddToPath(currentPos: Path): void {
* @param characters All the characters on the board * @param characters All the characters on the board
* @returns An array of paths the character can move to * @returns An array of paths the character can move to
*/ */
function tryMove( function tryMove(board: GameMap, path: Path, direction: Direction, steps: number, character: Character, characters: Character[]): Path[] {
board: GameMap,
path: Path,
direction: Direction,
steps: number,
character: Character,
characters: Character[],
): Path[] {
function getNewPosition(): Position { function getNewPosition(): Position {
switch (direction) { switch (direction) {
case Direction.left: case Direction.left:
return { return {
x: path.end.x - 1, x: path.end.x - 1,
y: path.end.y, y: path.end.y
} };
case Direction.up: case Direction.up:
return { return {
x: path.end.x, x: path.end.x,
y: path.end.y - 1, y: path.end.y - 1
} };
case Direction.right: case Direction.right:
return { return {
x: path.end.x + 1, x: path.end.x + 1,
y: path.end.y, y: path.end.y
} };
case Direction.down: case Direction.down:
return { return {
x: path.end.x, x: path.end.x,
y: path.end.y + 1, y: path.end.y + 1
} };
} }
} }
if (path.direction !== (direction + 2) % 4) { if (path.direction !== (direction + 2) % 4) {
return findPossibleRecursive( return findPossibleRecursive(board, {
board, end: getNewPosition(), direction: direction, path: path.path
{ }, steps, character, characters);
end: getNewPosition(),
direction: direction,
path: path.path,
},
steps,
character,
characters,
)
} }
return [] return [];
} }
/** /**
@ -182,26 +157,22 @@ function tryMove(
* @param characters All the characters on the map * @param characters All the characters on the map
* @returns {Path[]} An array of paths the character can move to * @returns {Path[]} An array of paths the character can move to
*/ */
function addTeleportationTiles( function addTeleportationTiles(board: GameMap, currentPath: Path, steps: number, character: Character, characters: Character[]): Path[] {
board: GameMap, const possiblePositions = findTeleportationTiles(board);
currentPath: Path, const paths: Path[] = [];
steps: number,
character: Character,
characters: Character[],
): Path[] {
const possiblePositions = findTeleportationTiles(board)
const paths: Path[] = []
for (const pos of possiblePositions) { for (const pos of possiblePositions) {
function inInterval(coordinate: "x" | "y"): boolean { function inInterval(coordinate: "x" | "y"): boolean {
return pos.end[coordinate] !== interval(0, board.length - 1, currentPath.end[coordinate]) return pos.end[coordinate] !== interval(0, board.length - 1, currentPath.end[coordinate])
} }
if (inInterval("x") || inInterval("y")) { if (inInterval("x") || inInterval("y")) {
pos.path = currentPath.path
paths.push(...findPossibleRecursive(board, pos, steps, character, characters)) pos.path = currentPath.path;
paths.push(...findPossibleRecursive(board, pos, steps, character, characters));
} }
} }
return paths return paths;
} }
/** /**
@ -212,7 +183,7 @@ function addTeleportationTiles(
* @returns {number} The value if it's between the lower and upper bounds, otherwise it returns the lower or upper bound * @returns {number} The value if it's between the lower and upper bounds, otherwise it returns the lower or upper bound
*/ */
function interval(lower: number, upper: number, value: number): number { function interval(lower: number, upper: number, value: number): number {
return Math.max(Math.min(value, upper), lower) return Math.max(Math.min(value, upper), lower);
} }
/** /**
@ -221,17 +192,18 @@ function interval(lower: number, upper: number, value: number): number {
* @returns An array of paths containing the teleportation tiles * @returns An array of paths containing the teleportation tiles
*/ */
function findTeleportationTiles(map: GameMap): Path[] { function findTeleportationTiles(map: GameMap): Path[] {
const possiblePositions: Path[] = [] const possiblePositions: Path[] = [];
const edge = [0, map.length - 1] const edge = [0, map.length - 1];
for (const e of edge) { for (const e of edge) {
for (let i = 0; i < map[e].length; i++) { for (let i = 0; i < map[e].length; i++) {
pushPath(map, possiblePositions, i, e)
pushPath(map, possiblePositions, e, i) pushPath(map, possiblePositions, i, e);
pushPath(map, possiblePositions, e, i);
} }
} }
return possiblePositions return possiblePositions;
} }
/** /**
@ -243,10 +215,7 @@ function findTeleportationTiles(map: GameMap): Path[] {
*/ */
function pushPath(board: GameMap, possiblePositions: Path[], x: number, y: number): void { function pushPath(board: GameMap, possiblePositions: Path[], x: number, y: number): void {
if (board[y] && board[y][x] !== TileType.wall) { if (board[y] && board[y][x] !== TileType.wall) {
possiblePositions.push({ possiblePositions.push({end: {x: x, y: y}, direction: findDirection(x, y, board.length)});
end: { x: x, y: y },
direction: findDirection(x, y, board.length),
})
} }
} }
@ -257,17 +226,17 @@ function pushPath(board: GameMap, possiblePositions: Path[], x: number, y: numbe
* @param boardSize The length of the board * @param boardSize The length of the board
*/ */
function findDirection(x: number, y: number, boardSize: number): Direction { function findDirection(x: number, y: number, boardSize: number): Direction {
let direction: Direction let direction: Direction;
if (x === 0) { if (x === 0) {
direction = Direction.right direction = Direction.right;
} else if (y === 0) { } else if (y === 0) {
direction = Direction.down direction = Direction.down;
} else if (x === boardSize - 1) { } else if (x === boardSize - 1) {
direction = Direction.left direction = Direction.left;
} else { } else {
direction = Direction.up direction = Direction.up;
} }
return direction return direction;
} }
/** /**
@ -276,8 +245,8 @@ function findDirection(x: number, y: number, boardSize: number): Direction {
* @param boardSize The size of the board * @param boardSize The size of the board
*/ */
function isOutsideBoard(currentPos: Path, boardSize: number): boolean { function isOutsideBoard(currentPos: Path, boardSize: number): boolean {
const pos = currentPos.end const pos = currentPos.end;
return pos.x < 0 || pos.x >= boardSize || pos.y < 0 || pos.y >= boardSize return pos.x < 0 || pos.x >= boardSize || pos.y < 0 || pos.y >= boardSize;
} }
/** /**
@ -286,8 +255,8 @@ function isOutsideBoard(currentPos: Path, boardSize: number): boolean {
* @param currentPos The current position of the character * @param currentPos The current position of the character
*/ */
function isWall(board: GameMap, currentPos: Path): boolean { function isWall(board: GameMap, currentPos: Path): boolean {
const pos = currentPos.end const pos = currentPos.end;
return board[pos.y][pos.x] === TileType.wall // Shouldn't work, but it does return board[pos.y][pos.x] === TileType.wall; // Shouldn't work, but it does
} }
/** /**
@ -296,8 +265,8 @@ function isWall(board: GameMap, currentPos: Path): boolean {
* @param currentPos The current position of the character * @param currentPos The current position of the character
*/ */
function isSpawn(board: GameMap, currentPos: Path) { function isSpawn(board: GameMap, currentPos: Path) {
const pos = currentPos.end const pos = currentPos.end;
return board[pos.y][pos.x] === TileType.pacmanSpawn || board[pos.y][pos.x] === TileType.ghostSpawn return board[pos.y][pos.x] === TileType.pacmanSpawn || board[pos.y][pos.x] === TileType.ghostSpawn;
} }
/** /**
@ -306,7 +275,8 @@ function isSpawn(board: GameMap, currentPos: Path) {
* @param character The current character * @param character The current character
*/ */
function isOwnSpawn(currentPos: Path, character: Character): boolean { function isOwnSpawn(currentPos: Path, character: Character): boolean {
const pos = currentPos.end const pos = currentPos.end;
const charPos = character.spawnPosition!.at const charPos = character.spawnPosition!.at;
return charPos.x === pos.x && charPos.y === pos.y return charPos.x === pos.x && charPos.y === pos.y;
} }

View File

@ -4,4 +4,4 @@ const rules = {
maxStealPellets: 2, maxStealPellets: 2,
} }
export default rules export default rules;

View File

@ -5,4 +5,4 @@ export enum TileType {
powerPellet, powerPellet,
ghostSpawn, ghostSpawn,
pacmanSpawn, pacmanSpawn,
} }

View File

@ -1,4 +1,4 @@
import { useState } from "react" import {useState} from "react";
/** /**
* A hook that returns a boolean value and a function to toggle it. The function can optionally be passed a boolean * A hook that returns a boolean value and a function to toggle it. The function can optionally be passed a boolean
@ -6,7 +6,7 @@ import { useState } from "react"
* @returns A tuple containing the boolean value and a function to toggle it. * @returns A tuple containing the boolean value and a function to toggle it.
*/ */
export default function useToggle(defaultValue = false): [boolean, (value?: boolean) => void] { export default function useToggle(defaultValue = false): [boolean, (value?: boolean) => void] {
const [value, setValue] = useState(defaultValue) const [value, setValue] = useState(defaultValue);
const toggleValue = (newValue?: boolean) => (newValue ? setValue(newValue) : setValue(!value)) const toggleValue = (newValue?: boolean) => newValue ? setValue(newValue) : setValue(!value);
return [value, toggleValue] return [value, toggleValue];
} }

View File

@ -3,37 +3,35 @@
@tailwind utilities; @tailwind utilities;
.debug { .debug {
@apply border border-red-500; @apply border border-red-500;
@apply after:content-['debug'] after:absolute; @apply after:content-['debug'] after:absolute;
} }
.flex-center { .flex-center {
@apply flex justify-center items-center; @apply flex justify-center items-center;
} }
.inline-flex-center { .inline-flex-center {
@apply inline-flex justify-center items-center; @apply inline-flex justify-center items-center;
} }
.wh-full { .wh-full {
@apply w-full h-full; @apply w-full h-full;
} }
h1 { h1 {
@apply text-4xl; @apply text-4xl;
} }
h2 { h2 {
@apply text-3xl; @apply text-3xl;
} }
br { br {
@apply my-2; @apply my-2;
} }
.button-default, .button-default, button[type=submit], input[type=submit] {
button[type="submit"], @apply bg-blue-500 hover:bg-blue-600 text-white font-bold py-2 px-4 rounded;
input[type="submit"] { @apply disabled:bg-gray-500;
@apply bg-blue-500 hover:bg-blue-600 text-white font-bold py-2 px-4 rounded;
@apply disabled:bg-gray-500;
} }

View File

@ -1,26 +1,25 @@
import React from "react" import React from 'react';
import { createRoot } from "react-dom/client" import {createRoot} from 'react-dom/client';
import { BrowserRouter } from "react-router-dom" import {BrowserRouter} from 'react-router-dom';
import { App } from "./App" import {App} from './App';
// @ts-ignore // @ts-ignore
import reportWebVitals from "./reportWebVitals" import reportWebVitals from './reportWebVitals';
import { DevTools } from "jotai-devtools" import {DevTools} from "jotai-devtools";
import DebugMenu from "./components/debugMenu" import DebugMenu from "./components/debugMenu";
const baseUrl = document.getElementsByTagName("base")[0].getAttribute("href") const baseUrl = document.getElementsByTagName('base')[0].getAttribute('href');
const rootElement = document.getElementById("root") const rootElement = document.getElementById('root');
if (rootElement === null) throw new Error("Root element is null") if (rootElement === null) throw new Error("Root element is null");
const root = createRoot(rootElement) const root = createRoot(rootElement);
root.render( root.render(
<BrowserRouter basename={baseUrl ?? undefined}> <BrowserRouter basename={baseUrl ?? undefined}>
<DevTools /> <DevTools/>
<DebugMenu /> <DebugMenu/>
<App /> <App/>
</BrowserRouter>, </BrowserRouter>);
)
// If you want to start measuring performance in your app, pass a function // If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log)) // to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals() reportWebVitals();

View File

@ -1,27 +1,29 @@
import React, { FC } from "react" import React, {FC} from "react";
import WebSocketService from "../websockets/WebSocketService" import WebSocketService from "../websockets/WebSocketService";
const ws = new WebSocketService("wss://localhost:3000/api/ws") const ws = new WebSocketService("wss://localhost:3000/api/ws");
export const Counter: FC = () => { export const Counter: FC = () => {
const [currentCount, setCurrentCount] = React.useState(0)
function incrementCounterAndSend() { const [currentCount, setCurrentCount] = React.useState(0);
async function incrementCounterAndSend() {
if (ws.isOpen()) { if (ws.isOpen()) {
ws.send((currentCount + 1).toString()) await ws.send((currentCount + 1).toString());
} }
} }
function receiveMessage(data: MessageEvent<string>) { function receiveMessage(data: MessageEvent<string>) {
const count = parseInt(data.data) const count = parseInt(data.data);
if (!isNaN(count)) setCurrentCount(count) if (!isNaN(count))
setCurrentCount(count);
} }
React.useEffect(() => { React.useEffect(() => {
ws.onReceive = receiveMessage ws.onReceive = receiveMessage;
ws.open() ws.open();
return () => ws.close() return () => ws.close();
}, []) }, []);
return ( return (
<div> <div>
@ -29,13 +31,9 @@ export const Counter: FC = () => {
<p>This is a simple example of a React component.</p> <p>This is a simple example of a React component.</p>
<p aria-live="polite"> <p aria-live="polite">Current count: <strong>{currentCount}</strong></p>
Current count: <strong>{currentCount}</strong>
</p>
<button className="btn btn-primary" onClick={incrementCounterAndSend}> <button className="btn btn-primary" onClick={incrementCounterAndSend}>Increment</button>
Increment
</button>
</div> </div>
) );
} };

View File

@ -1,16 +1,17 @@
import React, { FC } from "react" import React, {FC} from "react";
import { GameComponent } from "../components/gameComponent" import {GameComponent} from "../components/gameComponent";
import { useAtomValue } from "jotai" import {useAtomValue} from "jotai";
import { selectedMapAtom, thisPlayerAtom } from "../utils/state" import {selectedMapAtom, thisPlayerAtom} from "../utils/state";
const GamePage: FC = () => { const GamePage: FC = () => {
const player = useAtomValue(thisPlayerAtom) const player = useAtomValue(thisPlayerAtom);
const map = useAtomValue(selectedMapAtom) const map = useAtomValue(selectedMapAtom);
if (player && map) { if (player && map) {
return <GameComponent player={player} map={map} /> return <GameComponent player={player} map={map}/>;
} else {
return null;
} }
return null };
}
export default GamePage export default GamePage;

View File

@ -1,35 +1,27 @@
import React, { FC } from "react" import React, {FC} from "react";
import { Link } from "react-router-dom" import {Link} from "react-router-dom";
import { useAtomValue } from "jotai" import {useAtomValue} from "jotai";
import { thisPlayerAtom } from "../utils/state" import {thisPlayerAtom} from "../utils/state";
const HomePage: FC = () => { const HomePage: FC = () => {
const player = useAtomValue(thisPlayerAtom) const player = useAtomValue(thisPlayerAtom);
return ( return (
<div className={"container max-w-[800px] mx-auto px-2"}> <div className={"container max-w-[800px] mx-auto px-2"}>
<h1 className={"w-fit mx-auto"}>Hello {player?.username ?? "Player"}. Do you want to play a game?</h1> <h1 className={"w-fit mx-auto"}>Hello {player?.username ?? "Player"}. Do you want to play a game?</h1>
<p className={"text-center mt-5"}> <p className={"text-center mt-5"}>
{!player ? ( {!player ?
<> <>Start by {" "}
Start by{" "} <Link to={"/login"} className={"text-blue-600"}>logging in</Link>.
<Link to={"/login"} className={"text-blue-600"}>
logging in
</Link>
.
</> </>
) : ( :
<> <>Go to the {" "}
Go to the{" "} <Link to={"/lobby"} className={"text-blue-600"}>lobby</Link> to select a game.
<Link to={"/lobby"} className={"text-blue-600"}>
lobby
</Link>{" "}
to select a game.
</> </>
)} }
</p> </p>
</div> </div>
) );
} };
export default HomePage export default HomePage;

View File

@ -1,65 +1,66 @@
import React, { FC, Suspense } from "react" import React, {FC, Suspense} from "react";
import { atom, useAtomValue } from "jotai" import {atom, useAtomValue} from "jotai";
import { Button } from "../components/button" import {Button} from "../components/button";
import { selectedMapAtom, thisPlayerAtom } from "../utils/state" import {selectedMapAtom, thisPlayerAtom} from "../utils/state";
import { getData, postData } from "../utils/api" import {getData, postData} from "../utils/api";
import { getPacManSpawns } from "../game/map" import {getPacManSpawns} from "../game/map";
import { useNavigate } from "react-router-dom" import {useNavigate} from "react-router-dom";
const fetchAtom = atom(async () => { const fetchAtom = atom(async () => {
const response = await getData("/game/all") const response = await getData("/game/all");
return (await response.json()) as Game[] return await response.json() as Game[];
}) });
const LobbyPage: FC = () => { const LobbyPage: FC = () => {
const thisPlayer = useAtomValue(thisPlayerAtom)
const navigate = useNavigate() const thisPlayer = useAtomValue(thisPlayerAtom);
const map = useAtomValue(selectedMapAtom) const navigate = useNavigate();
const map = useAtomValue(selectedMapAtom);
async function createGame(): Promise<void> { async function createGame(): Promise<void> {
const response = await postData("/game/create", { const response = await postData("/game/create", {
body: { body: {player: thisPlayer, spawns: getPacManSpawns(map)} as CreateGameData
player: thisPlayer, });
spawns: getPacManSpawns(map),
} as CreateGameData,
})
if (response.ok) { if (response.ok) {
const data = await response.json() const data = await response.json();
navigate("/game/" + data.id) navigate("/game/" + data.id)
} else { } else {
const data = await response.text() const data = await response.text();
console.error("Error: ", data) console.error("Error: ", data);
// TODO display error // TODO display error
} }
} }
return ( return (
<> <>
<Button onClick={createGame}>New game</Button> <Button onClick={createGame}>New game</Button>
<Suspense fallback={"Please wait"}> <Suspense fallback={"Please wait"}>
<GameTable className={"mx-auto"} /> <GameTable className={"mx-auto"}/>
</Suspense> </Suspense>
</> </>
) );
} }
export default LobbyPage export default LobbyPage;
const GameTable: FC<ComponentProps> = ({ className }) => { const GameTable: FC<ComponentProps> = ({className}) => {
const data = useAtomValue(fetchAtom)
const thisPlayer = useAtomValue(thisPlayerAtom) const data = useAtomValue(fetchAtom);
const navigate = useNavigate() const thisPlayer = useAtomValue(thisPlayerAtom);
const navigate = useNavigate();
async function joinGame(gameId: string): Promise<void> { async function joinGame(gameId: string): Promise<void> {
if (thisPlayer === undefined) throw new Error("Player is undefined") if (thisPlayer === undefined) throw new Error("Player is undefined");
const result = await postData("/game/join/" + gameId, { body: thisPlayer }) const result = await postData("/game/join/" + gameId, {body: thisPlayer});
if (result.ok) { if (result.ok) {
navigate("/game/" + gameId) navigate("/game/" + gameId);
} else { } else {
console.error("Failed to join game " + gameId, await result.text()) console.error("Failed to join game " + gameId, await result.text());
// TODO show error message // TODO show error message
} }
} }
@ -67,34 +68,33 @@ const GameTable: FC<ComponentProps> = ({ className }) => {
return ( return (
<table className={`rounded overflow-hidden ${className}`}> <table className={`rounded overflow-hidden ${className}`}>
<thead className={"bg-gray-500 text-white"}> <thead className={"bg-gray-500 text-white"}>
<tr className={"my-5"}> <tr className={"my-5"}>
<th className={"p-2"}>Id</th> <th className={"p-2"}>Id</th>
<th className={"p-2"}>Count</th> <th className={"p-2"}>Count</th>
<th className={"p-2"}>State</th> <th className={"p-2"}>State</th>
<th className={"p-2"}>Join</th> <th className={"p-2"}>Join</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{data?.map(game => ( {data?.map(game =>
<tr key={game.id} className={"even:bg-gray-200"}> <tr key={game.id} className={"even:bg-gray-200"}>
<td className={"p-2"}>{game.id}</td> <td className={"p-2"}>{game.id}</td>
<td className={"text-center"}>{game.count}</td> <td className={"text-center"}>{game.count}</td>
<td>{game.isGameStarted ? "Closed" : "Open"}</td> <td>{game.isGameStarted ? "Closed" : "Open"}</td>
<td className={"p-2"}> <td className={"p-2"}>
<Button disabled={game.isGameStarted} onClick={() => joinGame(game.id)}> <Button disabled={game.isGameStarted} onClick={() => joinGame(game.id)}>
Join Join
</Button> </Button>
</td> </td>
</tr> </tr>
))} )}
{data?.length === 0 && ( {
data?.length === 0 &&
<tr> <tr>
<td colSpan={4} className={"text-center"}> <td colSpan={4} className={"text-center"}>No games found</td>
No games found
</td>
</tr> </tr>
)} }
</tbody> </tbody>
</table> </table>
) );
} }

View File

@ -1,22 +1,23 @@
import React, { FC, FormEvent, useState } from "react" import React, {FC, FormEvent, useState} from "react";
import { Button } from "../components/button" import {Button} from "../components/button";
import Input from "../components/input" import Input from "../components/input";
import { useSetAtom } from "jotai" import {useSetAtom} from "jotai";
import { thisPlayerAtom } from "../utils/state" import {thisPlayerAtom} from "../utils/state";
import Player from "../game/player" import Player from "../game/player";
import { useNavigate } from "react-router-dom" import {useNavigate} from "react-router-dom";
import { postData } from "../utils/api" import {postData} from "../utils/api";
const LoginPage: FC = () => { const LoginPage: FC = () => {
const setThisPlayer = useSetAtom(thisPlayerAtom)
const navigate = useNavigate() const setThisPlayer = useSetAtom(thisPlayerAtom);
const [error, setError] = useState<string | undefined>() const navigate = useNavigate();
const [error, setError] = useState<string | undefined>();
async function handleLogin(e: FormEvent<HTMLFormElement>): Promise<void> { async function handleLogin(e: FormEvent<HTMLFormElement>): Promise<void> {
e.preventDefault() e.preventDefault();
const fields = e.currentTarget.querySelectorAll("input") const fields = e.currentTarget.querySelectorAll("input");
let user: User = { username: "", password: "" } let user: User = {username: "", password: ""};
for (const field of fields) { for (const field of fields) {
user = { user = {
...user, ...user,
@ -25,41 +26,36 @@ const LoginPage: FC = () => {
} }
const response = await postData("/player/login", { const response = await postData("/player/login", {
body: { username: user.username, password: user.password } as User, body: {username: user.username, password: user.password} as User
}) })
if (response.ok) { if (response.ok) {
const data = (await response.json()) as PlayerProps const data = await response.json() as PlayerProps;
setThisPlayer(new Player(data)) setThisPlayer(new Player(data));
navigate("/lobby") navigate("/lobby");
} else { } else {
const data = await response.text() const data = await response.text();
console.error(data) console.error(data);
setError(data) setError(data);
} }
} }
const username = "username", const username = "username", password = "password";
password = "password"
return ( return (
<form onSubmit={handleLogin} className={"container w-fit mx-auto flex flex-col gap-2"}> <form onSubmit={handleLogin} className={"container w-fit mx-auto flex flex-col gap-2"}>
<h1 className={"my-5"}>Login</h1> <h1 className={"my-5"}>Login</h1>
{error && <p className={"text-red-500"}>{error}</p>} {error && <p className={"text-red-500"}>{error}</p>}
<label htmlFor={username}>Username:</label> <label htmlFor={username}>Username:</label>
<Input id={username} name={username} placeholder={"Username"} autoComplete={"username"} required /> <Input id={username} name={username} placeholder={"Username"} autoComplete={"username"} required/>
<label htmlFor={password}>Password:</label> <label htmlFor={password}>Password:</label>
<Input <Input id={password} name={password} type={"password"} placeholder={"Password"}
id={password} autoComplete={"current-password"} required/>
name={password}
type={"password"}
placeholder={"Password"}
autoComplete={"current-password"}
required
/>
<Button type={"submit"}>Login</Button> <Button type={"submit"}>Login</Button>
</form> </form>
) );
} }
export default LoginPage export default LoginPage;

View File

@ -1,13 +1,13 @@
const reportWebVitals = onPerfEntry => { const reportWebVitals = (onPerfEntry) => {
if (onPerfEntry && onPerfEntry instanceof Function) { if (onPerfEntry && onPerfEntry instanceof Function) {
import("web-vitals").then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry) getCLS(onPerfEntry);
getFID(onPerfEntry) getFID(onPerfEntry);
getFCP(onPerfEntry) getFCP(onPerfEntry);
getLCP(onPerfEntry) getLCP(onPerfEntry);
getTTFB(onPerfEntry) getTTFB(onPerfEntry);
}) });
} }
} };
export default reportWebVitals export default reportWebVitals;

View File

@ -1,27 +1,25 @@
type FRComponent<T = ComponentProps, HTML extends HTMLElement = HTMLElement> = React.ForwardRefExoticComponent< type FRComponent<T = ComponentProps, HTML extends HTMLElement = HTMLElement> = React.ForwardRefExoticComponent<React.PropsWithoutRef<T> & React.RefAttributes<HTML>>;
React.PropsWithoutRef<T> & React.RefAttributes<HTML>
>
interface ComponentProps { interface ComponentProps {
className?: string className?: string,
style?: React.CSSProperties style?: React.CSSProperties,
id?: string id?: string,
title?: string title?: string,
} }
interface ChildProps extends ComponentProps { interface ChildProps extends ComponentProps {
children?: React.JSX.Element | string children?: React.JSX.Element | string,
} }
interface LinkProps extends ChildProps { interface LinkProps extends ChildProps {
to: string to: string,
newTab?: boolean newTab?: boolean,
} }
interface ButtonProps extends ChildProps { interface ButtonProps extends ChildProps {
onClick?: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void onClick?: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void,
disabled?: boolean disabled?: boolean,
type?: "button" | "submit" | "reset" type?: "button" | "submit" | "reset",
} }
interface InputProps extends ComponentProps { interface InputProps extends ComponentProps {
@ -33,23 +31,23 @@ interface InputProps extends ComponentProps {
} }
interface CharacterProps { interface CharacterProps {
colour: import("../game/colour").Colour colour: import("../game/colour").Colour,
position?: Path | null position?: Path | null,
isEatable?: boolean isEatable?: boolean,
spawnPosition?: DirectionalPosition | null spawnPosition?: DirectionalPosition | null,
type?: import("../game/character").CharacterType type?: import("../game/character").CharacterType,
} }
interface BoxProps { interface BoxProps {
pellets?: number pellets?: number,
powerPellets?: number powerPellets?: number,
readonly colour: import("../game/colour").Colour readonly colour: import("../game/colour").Colour,
} }
interface PlayerProps { interface PlayerProps {
readonly username: string readonly username: string,
readonly pacMan?: CharacterProps readonly pacMan?: CharacterProps,
readonly colour: import("../game/colour").Colour readonly colour: import("../game/colour").Colour,
readonly box?: BoxProps readonly box?: BoxProps,
state?: import("../game/player").State state?: import("../game/player").State,
} }

View File

@ -1,68 +1,68 @@
type MessageEventFunction<T = any> = (data: MessageEvent<T>) => void type MessageEventFunction<T = any> = (data: MessageEvent<T>) => void;
type Setter<T> = React.Dispatch<React.SetStateAction<T>> type Setter<T> = React.Dispatch<React.SetStateAction<T>>;
type GUID = `${string}-${string}-${string}-${string}-${string}` type GUID = `${string}-${string}-${string}-${string}-${string}`;
type WebSocketData = string | ArrayBufferLike | Blob | ArrayBufferView type WebSocketData = string | ArrayBufferLike | Blob | ArrayBufferView;
type ActionMessage<T = any> = { type ActionMessage<T = any> = {
readonly action: import("../utils/actions").GameAction readonly action: import("../utils/actions").GameAction,
readonly data?: T readonly data?: T
} }
type Action<T> = (obj: T) => void type Action<T> = (obj: T) => void;
type BiAction<T1, T2> = (obj1: T1, obj2: T2) => void type BiAction<T1, T2> = (obj1: T1, obj2: T2) => void;
type Predicate<T> = (obj: T) => boolean type Predicate<T> = (obj: T) => boolean;
type SelectedDice = { type SelectedDice = {
value: number value: number,
index: number index: number
} };
type Position = { x: number; y: number } type Position = { x: number, y: number };
type GameMap = number[][] type GameMap = number[][];
type DirectionalPosition = { type DirectionalPosition = {
at: Position at: Position,
direction: import("../game/direction").Direction direction: import("../game/direction").Direction
} }
type Path = { type Path = {
path?: Position[] | null path?: Position[] | null,
// TODO replace with DirectionalPosition // TODO replace with DirectionalPosition
end: Position end: Position,
direction: import("../game/direction").Direction direction: import("../game/direction").Direction
} }
type Game = { type Game = {
readonly id: string readonly id: string,
readonly count: number readonly count: number,
readonly isGameStarted: boolean readonly isGameStarted: boolean,
} }
type User = { type User = {
readonly username: string readonly username: string,
readonly password: string readonly password: string,
readonly colour?: import("../game/colour").Colour readonly colour?: import("../game/colour").Colour
} }
type Api<T = ApiRequest> = (path: string, data?: ApiRequest & T) => Promise<Response> type Api<T = ApiRequest> = (path: string, data?: ApiRequest & T) => Promise<Response>;
type ApiRequest = { type ApiRequest = {
headers?: HeadersInit headers?: HeadersInit,
body?: any body?: any
} }
type JoinGameData = { type JoinGameData = {
readonly username: string readonly username: string,
readonly gameId: GUID readonly gameId: GUID,
} }
type CreateGameData = { type CreateGameData = {
readonly player: PlayerProps readonly player: PlayerProps,
readonly spawns: DirectionalPosition[] readonly spawns: DirectionalPosition[],
} }

View File

@ -1,10 +1,10 @@
import Player from "../game/player" import Player from "../game/player";
import { CharacterType, Ghost } from "../game/character" import {CharacterType, Ghost} from "../game/character";
import { getCharacterSpawns } from "../game/map" import {getCharacterSpawns} from "../game/map";
import { TileType } from "../game/tileType" import {TileType} from "../game/tileType";
import { getDefaultStore } from "jotai" import {getDefaultStore} from "jotai";
import { currentPlayerNameAtom, diceAtom, ghostsAtom, playersAtom, rollDiceButtonAtom, selectedMapAtom } from "./state" import {currentPlayerNameAtom, diceAtom, ghostsAtom, playersAtom, rollDiceButtonAtom, selectedMapAtom} from "./state";
import { Colour } from "../game/colour" import {Colour} from "../game/colour";
export enum GameAction { export enum GameAction {
error, error,
@ -17,112 +17,107 @@ export enum GameAction {
// TODO add updatePellets // TODO add updatePellets
} }
const store = getDefaultStore() const store = getDefaultStore();
const map = store.get(selectedMapAtom) const map = store.get(selectedMapAtom);
const ghostsProps: CharacterProps[] = [{ colour: Colour.purple }, { colour: Colour.purple }] const ghostsProps: CharacterProps[] = [
let spawns = getCharacterSpawns(map).filter(spawn => spawn.type === CharacterType.ghost) {colour: Colour.purple},
{colour: Colour.purple},
];
let spawns = getCharacterSpawns(map).filter(spawn => spawn.type === CharacterType.ghost);
ghostsProps.forEach(ghost => { ghostsProps.forEach(ghost => {
ghost.spawnPosition = spawns.pop()?.position ghost.spawnPosition = spawns.pop()?.position;
})
const ghosts = ghostsProps.map(props => new Ghost(props))
store.set(ghostsAtom, ghosts) });
const ghosts = ghostsProps.map(props => new Ghost(props));
store.set(ghostsAtom, ghosts);
export const doAction: MessageEventFunction<string> = (event): void => { export const doAction: MessageEventFunction<string> = (event): void => {
const message: ActionMessage = JSON.parse(event.data) const message: ActionMessage = JSON.parse(event.data);
console.debug("Received message:", message) console.debug("Received message:", message);
switch (message.action as GameAction) { switch (message.action as GameAction) {
case GameAction.error: case GameAction.error:
console.error("Error:", message.data) console.error("Error:", message.data);
break break;
case GameAction.rollDice: case GameAction.rollDice:
setDice(message.data) setDice(message.data);
break break;
case GameAction.moveCharacter: case GameAction.moveCharacter:
moveCharacter(message.data) moveCharacter(message.data);
break break;
case GameAction.joinGame: case GameAction.joinGame:
joinGame(message.data) joinGame(message.data);
break break;
case GameAction.ready: case GameAction.ready:
ready(message.data) ready(message.data);
break break;
case GameAction.nextPlayer: case GameAction.nextPlayer:
nextPlayer(message.data) nextPlayer(message.data);
break break;
case GameAction.disconnect: case GameAction.disconnect:
updatePlayers(message.data) updatePlayers(message.data);
break break;
} }
} };
function setDice(data?: number[]): void { function setDice(data?: number[]): void {
store.set(diceAtom, data) store.set(diceAtom, data);
} }
type MoveCharacterData = { type MoveCharacterData = { dice: number[], players: PlayerProps[], ghosts: CharacterProps[], eatenPellets: Position[] };
dice: number[]
players: PlayerProps[]
ghosts: CharacterProps[]
eatenPellets: Position[]
}
function moveCharacter(data?: MoveCharacterData): void { function moveCharacter(data?: MoveCharacterData): void {
store.set(diceAtom, data?.dice) store.set(diceAtom, data?.dice);
updatePlayers(data?.players) updatePlayers(data?.players);
updateGhosts(data) updateGhosts(data);
removeEatenPellets(data) removeEatenPellets(data);
} }
function updatePlayers(updatedPlayers?: PlayerProps[]): void { function updatePlayers(updatedPlayers?: PlayerProps[]): void {
if (updatedPlayers) { if (updatedPlayers) {
const newList: Player[] = updatedPlayers.map(p => new Player(p)) const newList: Player[] = updatedPlayers.map(p => new Player(p));
store.set(playersAtom, newList) store.set(playersAtom, newList);
} }
} }
function updateGhosts(data?: MoveCharacterData): void { function updateGhosts(data?: MoveCharacterData): void {
const updatedGhosts = data?.ghosts const updatedGhosts = data?.ghosts;
if (updatedGhosts) { if (updatedGhosts) {
const newList: Ghost[] = updatedGhosts.map(g => new Ghost(g)) const newList: Ghost[] = updatedGhosts.map(g => new Ghost(g));
store.set(ghostsAtom, newList) store.set(ghostsAtom, newList);
} }
} }
function removeEatenPellets(data?: MoveCharacterData): void { function removeEatenPellets(data?: MoveCharacterData): void {
const pellets = data?.eatenPellets const pellets = data?.eatenPellets;
for (const pellet of pellets ?? []) { for (const pellet of pellets ?? []) {
map[pellet.y][pellet.x] = TileType.empty map[pellet.y][pellet.x] = TileType.empty;
} }
} }
function joinGame(data?: PlayerProps[]): void { function joinGame(data?: PlayerProps[]): void { // TODO missing data when refreshing page
// TODO missing data when refreshing page const playerProps = data ?? [];
const playerProps = data ?? [] spawns = getCharacterSpawns(map).filter(spawn => spawn.type === CharacterType.pacMan);
spawns = getCharacterSpawns(map).filter(spawn => spawn.type === CharacterType.pacMan) store.set(playersAtom, playerProps.map(p => new Player(p)));
store.set(
playersAtom,
playerProps.map(p => new Player(p)),
)
} }
type ReadyData = { allReady: boolean; players: PlayerProps[] } type ReadyData = { allReady: boolean, players: PlayerProps[] };
function ready(data?: ReadyData): void { function ready(data?: ReadyData): void {
if (data) { if (data) {
const players = data.players.map(p => new Player(p)) const players = data.players.map(p => new Player(p));
store.set(playersAtom, players) store.set(playersAtom, players);
if (data.allReady) { if (data.allReady) {
store.set(currentPlayerNameAtom, data.players[0].username) store.set(currentPlayerNameAtom, data.players[0].username);
} }
} }
} }
function nextPlayer(currentPlayerName?: string): void { function nextPlayer(currentPlayerName?: string): void {
store.set(currentPlayerNameAtom, currentPlayerName) store.set(currentPlayerNameAtom, currentPlayerName);
store.set(rollDiceButtonAtom, true) store.set(rollDiceButtonAtom, true);
} }

View File

@ -1,28 +1,12 @@
/** export const getData: Api = async (path, {headers} = {}) => {
* getData is an asynchronous function that makes an API request to retrieve data. if (import.meta.env.MODE === "test") return Promise.resolve(new Response(JSON.stringify([])));
* If the mode is test, it returns a promise that resolves to an empty array.
*
* @param path - The path of the API endpoint.
* @param headers - The headers to be included in the request.
* @returns - A promise that resolves to the response from the API.
*/
export const getData: Api = async (path, { headers } = {}) => {
if (import.meta.env.MODE === "test") return Promise.resolve(new Response(JSON.stringify([])))
return await fetch(import.meta.env.VITE_API_HTTP + path, { return await fetch(import.meta.env.VITE_API_HTTP + path, {
method: "GET", method: "GET",
headers: headers, headers: headers
}) });
} }
/** export const postData: Api = async (path, {body, headers} = {}) => {
* Makes a POST request to the API endpoint.
*
* @param path - The path of the endpoint.
* @param body - The payload of the request.
* @param headers - Additional headers for the request.
* @returns - A Promise that resolves to the Response object representing the server's response.
*/
export const postData: Api = async (path, { body, headers } = {}) => {
return await fetch(import.meta.env.VITE_API_HTTP + path, { return await fetch(import.meta.env.VITE_API_HTTP + path, {
method: "POST", method: "POST",
headers: { headers: {

View File

@ -1,75 +1,74 @@
import Player from "../game/player" import Player from "../game/player";
import { atom } from "jotai" import {atom} from "jotai";
import { Ghost } from "../game/character" import {Ghost} from "../game/character";
import { customMap } from "../game/map" import {customMap} from "../game/map";
const playerStorage = "player" const playerStorage = "player";
/** /**
* All players in the game. * All players in the game.
*/ */
export const playersAtom = atom<Player[]>([]) export const playersAtom = atom<Player[]>([]);
/** /**
* All player characters (Pac-Man) in the game. * All player characters (Pac-Man) in the game.
*/ */
export const playerCharactersAtom = atom(get => get(playersAtom).map(player => player.pacMan)) export const playerCharactersAtom = atom(get => get(playersAtom).map(player => player.pacMan));
/** /**
* All ghosts in the game. * All ghosts in the game.
*/ */
export const ghostsAtom = atom<Ghost[]>([]) export const ghostsAtom = atom<Ghost[]>([]);
/** /**
* All characters in the game. * All characters in the game.
*/ */
export const allCharactersAtom = atom(get => [...get(playerCharactersAtom), ...get(ghostsAtom)]) export const allCharactersAtom = atom(get => [...get(playerCharactersAtom), ...get(ghostsAtom)]);
/** /**
* The player that is currently logged in. * The player that is currently logged in.
*/ */
const playerAtom = atom<Player | undefined>(undefined) const playerAtom = atom<Player | undefined>(undefined);
/** /**
* Gets a getter and setter to get or set the player that is currently logged in. * Gets a getter and setter to get or set the player that is currently logged in.
* Returns A tuple containing a getter and setter to get or set the player that is currently logged in. * Returns A tuple containing a getter and setter to get or set the player that is currently logged in.
*/ */
export const thisPlayerAtom = atom( export const thisPlayerAtom = atom(get => {
get => { const atomValue = get(playerAtom);
const atomValue = get(playerAtom) if (!atomValue) {
if (!atomValue) { const item = sessionStorage.getItem(playerStorage);
const item = sessionStorage.getItem(playerStorage) if (item) {
if (item) { const playerProps = JSON.parse(item) as PlayerProps;
const playerProps = JSON.parse(item) as PlayerProps return new Player(playerProps);
return new Player(playerProps)
}
} }
return atomValue }
}, return atomValue;
(_get, set, player: Player | undefined) => { }, (_get, set, player: Player | undefined) => {
if (player) sessionStorage.setItem(playerStorage, JSON.stringify(player)) if (player)
else sessionStorage.removeItem(playerStorage) sessionStorage.setItem(playerStorage, JSON.stringify(player));
set(playerAtom, player) else
}, sessionStorage.removeItem(playerStorage);
) set(playerAtom, player);
});
/** /**
* All dice that have been rolled. * All dice that have been rolled.
*/ */
export const diceAtom = atom<number[] | undefined>(undefined) export const diceAtom = atom<number[] | undefined>(undefined);
/** /**
* The dice that have been selected by the player. * The dice that have been selected by the player.
*/ */
export const selectedDiceAtom = atom<SelectedDice | undefined>(undefined) export const selectedDiceAtom = atom<SelectedDice | undefined>(undefined);
/** /**
* The name of the player whose turn it is. * The name of the player whose turn it is.
*/ */
export const currentPlayerNameAtom = atom<string | undefined>(undefined) export const currentPlayerNameAtom = atom<string | undefined>(undefined);
/** /**
* The player whose turn it is. * The player whose turn it is.
*/ */
export const currentPlayerAtom = atom<Player | undefined>(get => { export const currentPlayerAtom = atom<Player | undefined>(get => {
const currentPlayerName = get(currentPlayerNameAtom) const currentPlayerName = get(currentPlayerNameAtom);
return get(playersAtom).find(player => player.username === currentPlayerName) return get(playersAtom).find(player => player.username === currentPlayerName);
}) });
/** /**
* Whether the roll dice button should be enabled. * Whether the roll dice button should be enabled.
*/ */
export const rollDiceButtonAtom = atom(true) export const rollDiceButtonAtom = atom(true);
/** /**
* The map that is currently selected. * The map that is currently selected.
*/ */
export const selectedMapAtom = atom(customMap) export const selectedMapAtom = atom(customMap);

View File

@ -5,14 +5,14 @@
* @returns A promise that resolves when the predicate is true. * @returns A promise that resolves when the predicate is true.
*/ */
export function wait(predicate: Predicate<void>, timeout: number = 50): Promise<void> { export function wait(predicate: Predicate<void>, timeout: number = 50): Promise<void> {
return new Promise<void>(resolve => { return new Promise<void>((resolve) => {
const f = () => { const f = () => {
if (predicate()) { if (predicate()) {
return resolve() return resolve();
} }
setTimeout(f, timeout) setTimeout(f, timeout);
} };
f() f();
}) });
} }

View File

@ -1,11 +1,11 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
interface ImportMetaEnv { interface ImportMetaEnv {
readonly VITE_API_URI: string readonly VITE_API_URI: string,
readonly VITE_API_HTTP: string readonly VITE_API_HTTP: string,
readonly VITE_API_WS: string readonly VITE_API_WS: string,
} }
interface ImportMeta { interface ImportMeta {
readonly env: ImportMetaEnv readonly env: ImportMetaEnv;
} }

View File

@ -1,9 +1,9 @@
import { wait } from "../utils/utils" import {wait} from "../utils/utils";
interface IWebSocket { interface IWebSocket {
onOpen?: VoidFunction onOpen?: VoidFunction,
onReceive?: MessageEventFunction onReceive?: MessageEventFunction,
onClose?: VoidFunction onClose?: VoidFunction,
onError?: VoidFunction onError?: VoidFunction
} }
@ -11,59 +11,59 @@ interface IWebSocket {
* WebSocketService class provides a WebSocket client interface for easy communication with a WebSocket server. * WebSocketService class provides a WebSocket client interface for easy communication with a WebSocket server.
*/ */
export default class WebSocketService { export default class WebSocketService {
private ws?: WebSocket private ws?: WebSocket;
private readonly _url: string private readonly _url: string;
constructor(url: string, { onOpen, onReceive, onClose, onError }: IWebSocket = {}) { constructor(url: string, {onOpen, onReceive, onClose, onError}: IWebSocket = {}) {
this._url = url this._url = url;
this._onOpen = onOpen this._onOpen = onOpen;
this._onReceive = onReceive this._onReceive = onReceive;
this._onClose = onClose this._onClose = onClose;
this._onError = onError this._onError = onError;
} }
private _onOpen?: VoidFunction private _onOpen?: VoidFunction;
set onOpen(onOpen: VoidFunction) { set onOpen(onOpen: VoidFunction) {
this._onOpen = onOpen this._onOpen = onOpen;
if (!this.ws) return if (!this.ws) return;
this.ws.onopen = onOpen this.ws.onopen = onOpen;
} }
private _onReceive?: MessageEventFunction private _onReceive?: MessageEventFunction;
set onReceive(onReceive: MessageEventFunction) { set onReceive(onReceive: MessageEventFunction) {
this._onReceive = onReceive this._onReceive = onReceive;
if (!this.ws) return if (!this.ws) return;
this.ws.onmessage = onReceive this.ws.onmessage = onReceive;
} }
private _onClose?: VoidFunction private _onClose?: VoidFunction;
set onClose(onClose: VoidFunction) { set onClose(onClose: VoidFunction) {
this._onClose = onClose this._onClose = onClose;
if (!this.ws) return if (!this.ws) return;
this.ws.onclose = onClose this.ws.onclose = onClose;
} }
private _onError?: VoidFunction private _onError?: VoidFunction;
set onError(onError: VoidFunction) { set onError(onError: VoidFunction) {
this._onError = onError this._onError = onError;
if (!this.ws) return if (!this.ws) return;
this.ws.onerror = onError this.ws.onerror = onError;
} }
/** /**
* Opens a WebSocket connection with the specified URL and sets the event callbacks. * Opens a WebSocket connection with the specified URL and sets the event callbacks.
*/ */
public open(): void { public open(): void {
if (typeof WebSocket === "undefined" || this.isConnecting()) return if (typeof WebSocket === "undefined" || this.isConnecting()) return;
this.ws = new WebSocket(this._url) this.ws = new WebSocket(this._url);
if (this._onOpen) this.ws.onopen = this._onOpen if (this._onOpen) this.ws.onopen = this._onOpen;
if (this._onReceive) this.ws.onmessage = this._onReceive if (this._onReceive) this.ws.onmessage = this._onReceive;
if (this._onClose) this.ws.onclose = this._onClose if (this._onClose) this.ws.onclose = this._onClose;
if (this._onError) this.ws.onerror = this._onError if (this._onError) this.ws.onerror = this._onError;
} }
/** /**
@ -72,8 +72,8 @@ export default class WebSocketService {
* @returns {Promise<void>} - A promise that resolves when the "isOpen" condition is met. * @returns {Promise<void>} - A promise that resolves when the "isOpen" condition is met.
*/ */
public async waitForOpen(): Promise<void> { public async waitForOpen(): Promise<void> {
await wait(() => this.isOpen()) await wait(() => this.isOpen());
if (this._onOpen) this.onOpen = this._onOpen if (this._onOpen) this.onOpen = this._onOpen;
} }
/** /**
@ -83,16 +83,16 @@ export default class WebSocketService {
*/ */
public send(data: ActionMessage | string): void { public send(data: ActionMessage | string): void {
if (typeof data !== "string") { if (typeof data !== "string") {
data = JSON.stringify(data) data = JSON.stringify(data);
} }
this.ws?.send(data) this.ws?.send(data);
} }
/** /**
* Closes the WebSocket connection. * Closes the WebSocket connection.
*/ */
public close(): void { public close(): void {
this.ws?.close() this.ws?.close();
} }
/** /**
@ -100,7 +100,7 @@ export default class WebSocketService {
* @returns {boolean} Returns true if the WebSocket is open, otherwise false. * @returns {boolean} Returns true if the WebSocket is open, otherwise false.
*/ */
public isOpen(): boolean { public isOpen(): boolean {
return this.ws?.readyState === WebSocket?.OPEN return this.ws?.readyState === WebSocket?.OPEN;
} }
/** /**
@ -109,7 +109,7 @@ export default class WebSocketService {
* @returns {boolean} - Returns 'true' if the WebSocket is connecting, otherwise 'false'. * @returns {boolean} - Returns 'true' if the WebSocket is connecting, otherwise 'false'.
*/ */
public isConnecting(): boolean { public isConnecting(): boolean {
return this.ws?.readyState === WebSocket?.CONNECTING return this.ws?.readyState === WebSocket?.CONNECTING;
} }
/** /**
@ -118,6 +118,6 @@ export default class WebSocketService {
* @returns {boolean} Returns true if the WebSocket connection is closed, false otherwise. * @returns {boolean} Returns true if the WebSocket connection is closed, false otherwise.
*/ */
public isClosed(): boolean { public isClosed(): boolean {
return this.ws?.readyState === WebSocket?.CLOSED return this.ws?.readyState === WebSocket?.CLOSED;
} }
} }

View File

@ -1,6 +1,5 @@
using System.Net.WebSockets; using System.Net.WebSockets;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using pacMan.DTOs;
using pacMan.Exceptions; using pacMan.Exceptions;
using pacMan.GameStuff; using pacMan.GameStuff;
using pacMan.GameStuff.Items; using pacMan.GameStuff.Items;
@ -9,43 +8,37 @@ using pacMan.Utils;
namespace pacMan.Controllers; namespace pacMan.Controllers;
/// <summary>
/// Controls the game logic and handles requests related to games.
/// </summary>
[ApiController] [ApiController]
[Route("api/[controller]")] [Route("api/[controller]")]
public class GameController(ILogger<GameController> logger, IGameService webSocketService, IActionService actionService) public class GameController : GenericController
: GenericController(logger, webSocketService)
{ {
[HttpGet("[action]")] private readonly IActionService _actionService;
public override Task Connect() => base.Connect(); private readonly GameService _gameService;
/// <summary> public GameController(ILogger<GameController> logger, GameService webSocketService, IActionService actionService) :
/// Retrieves all games from the WebSocketService. base(logger, webSocketService)
/// </summary>
/// <returns>
/// An IEnumerable of Game objects representing all the games.
/// </returns>
[HttpGet("[action]")]
public IEnumerable<Game> All()
{ {
Logger.LogDebug("Returning all games"); _gameService = webSocketService;
return webSocketService.Games; _actionService = actionService;
} }
/// <summary> [HttpGet("connect")]
/// Adds a player to a game. public override async Task Accept() => await base.Accept();
/// </summary>
/// <param name="gameId">The unique identifier of the game.</param> [HttpGet("all")]
/// <param name="player">The player to be joined.</param> public IEnumerable<Game> GetAllGames()
/// <returns>An IActionResult representing the result of the operation.</returns>
[HttpPost("[action]/{gameId:guid}")]
public IActionResult Join(Guid gameId, [FromBody] Player player) // TODO what if player is in a game already?
{ {
Logger.LogDebug("Joining game {}", gameId); Logger.Log(LogLevel.Debug, "Returning all games");
return _gameService.Games;
}
[HttpPost("join/{gameId:guid}")]
public IActionResult JoinGame(Guid gameId, [FromBody] Player player) // TODO what if player is in a game already?
{
Logger.Log(LogLevel.Debug, "Joining game {}", gameId);
try try
{ {
webSocketService.JoinById(gameId, player); _gameService.JoinById(gameId, player);
return Ok("Game joined successfully"); return Ok("Game joined successfully");
} }
catch (GameNotFoundException e) catch (GameNotFoundException e)
@ -58,112 +51,65 @@ public class GameController(ILogger<GameController> logger, IGameService webSock
} }
} }
/// <summary> [HttpGet("exists/{gameId:guid}")]
/// Checks if a game with the specified ID exists. public IActionResult GameExists(Guid gameId)
/// </summary>
/// <param name="gameId">The ID of the game to check.</param>
/// <returns>
/// Returns an <see cref="IActionResult" /> representing the result of the operation.
/// If a game with the specified ID exists, returns an <see cref="OkResult" />.
/// If a game with the specified ID doesn't exist, returns a <see cref="NotFoundResult" />.
/// </returns>
[HttpGet("[action]/{gameId:guid}")]
public IActionResult Exists(Guid gameId)
{ {
Logger.LogDebug("Checking if game {} exists", gameId); Logger.Log(LogLevel.Debug, "Checking if game {} exists", gameId);
return webSocketService.Games.Any(game => game.Id == gameId) ? Ok() : NotFound(); return _gameService.Games.Any(game => game.Id == gameId) ? Ok() : NotFound();
} }
/// <summary> [HttpPost("create")]
/// Creates a new game and adds the specified player to it. public IActionResult CreateGame([FromBody] CreateGameData data)
/// </summary>
/// <param name="data">The data required to create the game.</param>
/// <returns>
/// Returns an <see cref="IActionResult" /> representing the result of the operation.
/// If the game is successfully created, returns a <see cref="CreatedResult" /> with the game details and a location
/// URL.
/// If there is an error during creation, returns a <see cref="BadRequestObjectResult" /> with the error message.
/// </returns>
[HttpPost("[action]")]
public IActionResult Create([FromBody] CreateGameData data)
{ {
Logger.LogDebug("Creating game"); Logger.Log(LogLevel.Debug, "Creating game");
try try
{ {
var game = webSocketService.CreateAndJoin(data.Player, data.Spawns); var game = _gameService.CreateAndJoin(data.Player, data.Spawns);
return Created($"/{game.Id}", game); return Created($"/{game.Id}", game);
} }
catch (Exception e) catch (Exception e)
{ {
return BadRequest(e.Message); return BadRequest(e.Message); // TODO not necessary?
} }
} }
protected override Task Echo() protected override Task Echo()
{ {
actionService.WebSocket = WebSocket ?? throw new NullReferenceException("WebSocket is null"); _actionService.WebSocket = WebSocket ?? throw new NullReferenceException("WebSocket is null");
return base.Echo(); return base.Echo();
} }
/// <summary>
/// Runs the given WebSocketReceiveResult and byte array data to perform an action.
/// </summary>
/// <param name="result">The WebSocketReceiveResult object containing information about the received data.</param>
/// <param name="data">The byte array data received from the WebSocket.</param>
/// <returns>
/// Returns an ArraySegment object representing the action response in byte array format.
/// </returns>
protected override ArraySegment<byte> Run(WebSocketReceiveResult result, byte[] data) protected override ArraySegment<byte> Run(WebSocketReceiveResult result, byte[] data)
{ {
var stringResult = data.GetString(result.Count); var stringResult = data.GetString(result.Count);
Logger.LogInformation("Received: {}", stringResult); Logger.Log(LogLevel.Information, "Received: {}", stringResult);
var action = ActionMessage.FromJson(stringResult); var action = ActionMessage.FromJson(stringResult);
try try
{ {
DoAction(action); _actionService.DoAction(action);
} }
catch (Exception e) catch (Exception e)
{ {
Logger.LogError("{}", e.Message); Logger.Log(LogLevel.Error, "{}", e.Message);
action = new ActionMessage { Action = GameAction.Error, Data = e.Message }; action = new ActionMessage { Action = GameAction.Error, Data = e.Message };
} }
return action.ToArraySegment(); return action.ToArraySegment();
} }
/// <summary> protected override async void Send(ArraySegment<byte> segment)
/// Sends the specified data segment.
/// </summary>
/// <param name="segment">The data segment to send.</param>
protected override void Send(ArraySegment<byte> segment)
{ {
if (actionService.Game is not null) if (_actionService.Game is not null)
actionService.SendToAll(segment); _actionService.SendToAll(segment);
else if (WebSocket is not null) else if (WebSocket is not null)
webSocketService.Send(WebSocket, segment); await _gameService.Send(WebSocket, segment);
} }
protected override ArraySegment<byte>? Disconnect() => protected override ArraySegment<byte>? Disconnect() =>
new ActionMessage { Action = GameAction.Disconnect, Data = actionService.Disconnect() } new ActionMessage { Action = GameAction.Disconnect, Data = _actionService.Disconnect() }
.ToArraySegment(); .ToArraySegment();
protected override void SendDisconnectMessage(ArraySegment<byte> segment) => actionService.SendToAll(segment); protected override void SendDisconnectMessage(ArraySegment<byte> segment) => _actionService.SendToAll(segment);
/// <summary>
/// Performs the specified action based on the given message.
/// </summary>
/// <param name="message">The action message containing the action to be performed.</param>
public void DoAction(ActionMessage message) =>
message.Data = message.Action switch
{
GameAction.RollDice => actionService.RollDice(),
GameAction.MoveCharacter => actionService.HandleMoveCharacter(message.Data),
GameAction.JoinGame => actionService.FindGame(message.Data),
GameAction.Ready => actionService.Ready(),
GameAction.NextPlayer => actionService.FindNextPlayer(),
GameAction.Disconnect => actionService.LeaveGame(),
_ => message.Data
};
} }

View File

@ -1,42 +1,29 @@
using System.Net.WebSockets; using System.Net.WebSockets;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using pacMan.Services; using pacMan.Interfaces;
namespace pacMan.Controllers; namespace pacMan.Controllers;
/// <summary> public abstract class GenericController : ControllerBase
/// Represents a generic controller for handling WebSocket connections.
/// </summary>
public abstract class GenericController(ILogger<GenericController> logger, IWebSocketService webSocketService)
: ControllerBase
{ {
/// <summary>
/// Buffer size used for processing data.
/// </summary>
private const int BufferSize = 1024 * 4; private const int BufferSize = 1024 * 4;
private readonly IWebSocketService _webSocketService;
protected readonly ILogger<GenericController> Logger = logger; protected readonly ILogger<GenericController> Logger;
protected WebSocket? WebSocket; protected WebSocket? WebSocket;
/// <summary> protected GenericController(ILogger<GenericController> logger, IWebSocketService webSocketService)
/// Establishes a WebSocket connection with the client. {
/// </summary> Logger = logger;
/// <remarks> _webSocketService = webSocketService;
/// This method checks if the HTTP request is a WebSocket request. If it is, it accepts the WebSocket connection, logs Logger.Log(LogLevel.Debug, "WebSocket Controller created");
/// the connection establishment, and sets the WebSocket property to }
/// the accepted WebSocket instance.
/// After the connection is established, the method calls the Echo method to start echoing messages. public virtual async Task Accept()
/// If the request is not a WebSocket request, it sets the HTTP response status code to 400 (BadRequest).
/// </remarks>
/// <returns>
/// The task representing the asynchronous operation.
/// </returns>
public virtual async Task Connect()
{ {
if (HttpContext.WebSockets.IsWebSocketRequest) if (HttpContext.WebSockets.IsWebSocketRequest)
{ {
using var webSocket = await HttpContext.WebSockets.AcceptWebSocketAsync(); using var webSocket = await HttpContext.WebSockets.AcceptWebSocketAsync();
Logger.LogInformation("WebSocket connection established to {}", HttpContext.Connection.Id); Logger.Log(LogLevel.Information, "WebSocket connection established to {}", HttpContext.Connection.Id);
WebSocket = webSocket; WebSocket = webSocket;
await Echo(); await Echo();
} }
@ -46,21 +33,16 @@ public abstract class GenericController(ILogger<GenericController> logger, IWebS
} }
} }
/// <summary>
/// An asynchronous method that reads data from the WebSocket connection,
/// processes it, and sends back the processed data.
/// </summary>
/// <returns>A Task representing the asynchronous operation.</returns>
protected virtual async Task Echo() protected virtual async Task Echo()
{ {
if (WebSocket is null) return; if (WebSocket == null) return;
try try
{ {
WebSocketReceiveResult? result; WebSocketReceiveResult? result;
do do
{ {
var buffer = new byte[BufferSize]; var buffer = new byte[BufferSize];
result = await webSocketService.Receive(WebSocket, buffer); result = await _webSocketService.Receive(WebSocket, buffer);
if (result.CloseStatus.HasValue) break; if (result.CloseStatus.HasValue) break;
@ -68,27 +50,23 @@ public abstract class GenericController(ILogger<GenericController> logger, IWebS
Send(segment); Send(segment);
} while (true); } while (true);
var disconnectSegment = Disconnect(); var disconnectSegment = Disconnect();
if (disconnectSegment is not null) if (disconnectSegment != null)
SendDisconnectMessage((ArraySegment<byte>)disconnectSegment); SendDisconnectMessage((ArraySegment<byte>)disconnectSegment);
await webSocketService.Close(WebSocket, result.CloseStatus.Value, result.CloseStatusDescription); await _webSocketService.Close(WebSocket, result.CloseStatus.Value, result.CloseStatusDescription);
} }
catch (WebSocketException e) catch (WebSocketException e)
{ {
Logger.LogError("{}", e.Message); Logger.Log(LogLevel.Error, "{}", e.Message);
} }
} }
/// <summary> protected virtual async void Send(ArraySegment<byte> segment)
/// Sends the specified byte segment using the WebSocket connection.
/// If the WebSocket connection is null, the method does nothing.
/// </summary>
/// <param name="segment">The byte segment to send.</param>
protected virtual void Send(ArraySegment<byte> segment)
{ {
if (WebSocket is null) return; if (WebSocket == null) return;
webSocketService.Send(WebSocket, segment); await _webSocketService.Send(WebSocket, segment);
} }
protected abstract ArraySegment<byte> Run(WebSocketReceiveResult result, byte[] data); protected abstract ArraySegment<byte> Run(WebSocketReceiveResult result, byte[] data);

View File

@ -6,22 +6,21 @@ using pacMan.GameStuff.Items;
namespace pacMan.Controllers; namespace pacMan.Controllers;
[ApiController] [ApiController]
[Route("api/[controller]/[action]")] [Route("api/[controller]")]
public class PlayerController(UserService userService) : ControllerBase public class PlayerController : ControllerBase
{ {
/// <summary> private readonly UserService _userService;
/// Logs in a user.
/// </summary> public PlayerController(UserService userService) => _userService = userService;
/// <param name="user">The user object containing the username and password.</param>
/// <returns>Returns an IActionResult indicating the login result.</returns> [HttpPost("login")]
[HttpPost]
public async Task<IActionResult> Login([FromBody] User user) public async Task<IActionResult> Login([FromBody] User user)
{ {
var result = await userService.Login(user.Username, user.Password); var result = await _userService.Login(user.Username, user.Password);
if (result is null) return Unauthorized("Invalid username or password"); if (result is null) return Unauthorized("Invalid username or password");
return Ok((Player)result); return Ok((Player)result);
} }
[HttpPost] [HttpPost("register")]
public Task<IActionResult> Register([FromBody] User user) => throw new NotSupportedException(); public async Task<IActionResult> Register([FromBody] User user) => throw new NotSupportedException();
} }

View File

@ -6,11 +6,12 @@ namespace pacMan.Controllers;
[ApiController] [ApiController]
[Route("api/[controller]")] [Route("api/[controller]")]
public class WsController(ILogger<WsController> logger, IWebSocketService gameService) : public class WsController : GenericController
GenericController(logger, gameService)
{ {
public WsController(ILogger<WsController> logger, GameService gameService) : base(logger, gameService) { }
[HttpGet] [HttpGet]
public override async Task Connect() => await base.Connect(); public override async Task Accept() => await base.Accept();
protected override ArraySegment<byte> Run(WebSocketReceiveResult result, byte[] data) protected override ArraySegment<byte> Run(WebSocketReceiveResult result, byte[] data)
{ {

View File

@ -1,40 +0,0 @@
using System.Text.Json.Serialization;
using pacMan.GameStuff;
using pacMan.GameStuff.Items;
namespace pacMan.DTOs;
public readonly record struct JoinGameData(
[property: JsonInclude, JsonPropertyName("username"), JsonRequired]
string Username,
[property: JsonInclude, JsonPropertyName("gameId"), JsonRequired]
Guid GameId
)
{
public void Deconstruct(out string username, out Guid gameId) => (username, gameId) = (Username, GameId);
}
public readonly record struct CreateGameData(
[property: JsonInclude, JsonPropertyName("player"), JsonRequired]
Player Player,
[property: JsonInclude, JsonPropertyName("spawns"), JsonRequired]
Queue<DirectionalPosition> Spawns
);
public readonly record struct ReadyData(
[property: JsonInclude, JsonPropertyName("allReady"), JsonRequired]
bool AllReady,
[property: JsonInclude, JsonPropertyName("players"), JsonRequired]
IEnumerable<Player> Players
);
public readonly record struct MovePlayerData(
[property: JsonInclude, JsonPropertyName("players"), JsonRequired]
List<Player> Players,
[property: JsonInclude, JsonPropertyName("ghosts"), JsonRequired]
List<Character> Ghosts,
[property: JsonInclude, JsonPropertyName("dice"), JsonRequired]
List<int> Dice,
[property: JsonInclude, JsonPropertyName("eatenPellets"), JsonRequired]
List<Position> EatenPellets
);

View File

@ -1,9 +1,9 @@
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base FROM mcr.microsoft.com/dotnet/aspnet:7.0 AS base
WORKDIR /app WORKDIR /app
EXPOSE 80 EXPOSE 80
EXPOSE 443 EXPOSE 443
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build FROM mcr.microsoft.com/dotnet/sdk:7.0 AS build
RUN curl -fsSL https://deb.nodesource.com/setup_18.x | bash - \ RUN curl -fsSL https://deb.nodesource.com/setup_18.x | bash - \
&& apt-get install -y \ && apt-get install -y \

View File

@ -1,5 +0,0 @@
namespace pacMan.Exceptions;
public class GameNotFoundException(string message = "Game not found") : Exception(message);
public class GameNotPlayableException(string message = "Game is not allowed to be played") : Exception(message);

View File

@ -0,0 +1,6 @@
namespace pacMan.Exceptions;
public class GameNotFoundException : Exception
{
public GameNotFoundException(string message = "Game not found") : base(message) { }
}

View File

@ -0,0 +1,6 @@
namespace pacMan.Exceptions;
public class GameNotPlayableException : Exception
{
public GameNotPlayableException(string message = "Game is not allowed to be played") : base(message) { }
}

View File

@ -1,3 +0,0 @@
namespace pacMan.Exceptions;
public class PlayerNotFoundException(string? message = "Player not found") : Exception(message);

View File

@ -0,0 +1,6 @@
namespace pacMan.Exceptions;
public class PlayerNotFoundException : Exception
{
public PlayerNotFoundException(string? message = "Player not found") : base(message) { }
}

View File

@ -3,9 +3,6 @@ using System.Text.Json.Serialization;
namespace pacMan.GameStuff; namespace pacMan.GameStuff;
/// <summary>
/// Represents various actions that can be performed in a game.
/// </summary>
public enum GameAction public enum GameAction
{ {
Error, Error,
@ -17,23 +14,13 @@ public enum GameAction
Disconnect Disconnect
} }
/// <summary>
/// Represents an action message with optional data of type <typeparamref name="T" />.
/// Every Action may have a different type of data, or no data at all.
/// </summary>
/// <typeparam name="T">The type of the data.</typeparam>
public class ActionMessage<T> public class ActionMessage<T>
{ {
[JsonPropertyName("action")] public GameAction Action { get; init; } [JsonPropertyName("action")] public GameAction Action { get; init; }
[JsonPropertyName("data")] public T? Data { get; set; } [JsonPropertyName("data")] public T? Data { get; set; }
/// <summary>
/// Parses a JSON string into an ActionMessage object. With dynamic data.
/// </summary>
/// <param name="json">The JSON string to deserialize.</param>
/// <returns>An ActionMessage object populated with the deserialized data.</returns>
public static ActionMessage FromJson(string json) => JsonSerializer.Deserialize<ActionMessage>(json)!; public static ActionMessage FromJson(string json) => JsonSerializer.Deserialize<ActionMessage>(json)!;
} }
public class ActionMessage : ActionMessage<dynamic>; public class ActionMessage : ActionMessage<dynamic> { }

View File

@ -8,7 +8,6 @@ public class Character : IEquatable<Character>
[JsonPropertyName("position")] public MovePath? Position { get; set; } [JsonPropertyName("position")] public MovePath? Position { get; set; }
// TODO isEdible
[JsonInclude] [JsonInclude]
[JsonPropertyName("isEatable")] [JsonPropertyName("isEatable")]
public bool IsEatable { get; set; } = true; public bool IsEatable { get; set; } = true;
@ -32,12 +31,9 @@ public class Character : IEquatable<Character>
return obj.GetType() == GetType() && Equals((Character)obj); return obj.GetType() == GetType() && Equals((Character)obj);
} }
public override int GetHashCode() => HashCode.Combine(Colour, Type); public override int GetHashCode() => HashCode.Combine(Colour, Position, IsEatable, SpawnPosition, (int?)Type);
} }
/// <summary>
/// Represents the types of characters in a game.
/// </summary>
public enum CharacterType public enum CharacterType
{ {
PacMan, PacMan,

View File

@ -4,17 +4,10 @@ namespace pacMan.GameStuff.Items;
public class Box : IEquatable<Box> public class Box : IEquatable<Box>
{ {
[JsonInclude] [JsonPropertyName("pellets")] public int Pellets { get; init; }
[JsonPropertyName("pellets")] [JsonPropertyName("powerPellets")] public int PowerPellet { get; init; }
public int Pellets { get; init; }
[JsonInclude] [JsonPropertyName("colour")] public required string Colour { get; init; }
[JsonPropertyName("powerPellets")]
public int PowerPellet { get; init; }
[JsonInclude]
[JsonPropertyName("colour")]
public required string Colour { get; init; }
public bool Equals(Box? other) public bool Equals(Box? other)
{ {

View File

@ -6,14 +6,7 @@ public class Dice
{ {
private readonly Random _random = new(); private readonly Random _random = new();
/// <summary> [JsonInclude] public int Value { get; private set; }
/// Represents the value of the previous roll.
/// </summary>
[JsonInclude]
public int Value { get; private set; }
/// <summary>
/// Rolls a dice by generating a random number between 1 and 6 and assigns it to the 'Value' property of the dice.
/// </summary>
public void Roll() => Value = _random.Next(1, 7); public void Roll() => Value = _random.Next(1, 7);
} }

View File

@ -2,24 +2,18 @@ using System.Text.Json.Serialization;
namespace pacMan.GameStuff.Items; namespace pacMan.GameStuff.Items;
/// <summary>
/// Represents a cup containing multiple dice.
/// </summary>
public class DiceCup public class DiceCup
{ {
private readonly List<Dice> _dices = [new Dice(), new Dice()]; private readonly List<Dice> _dices;
/// <summary> public DiceCup() =>
/// Gets a list of integer values representing the values of the dices. _dices = new List<Dice>
/// </summary> {
/// <value> new(),
/// A list of integer values representing the values of the dices. new()
/// </value> };
[JsonInclude]
public List<int> Values => _dices.Select(dice => dice.Value).ToList(); [JsonInclude] public List<int> Values => _dices.Select(dice => dice.Value).ToList();
/// <summary>
/// Rolls all the dice in the list.
/// </summary>
public void Roll() => _dices.ForEach(dice => dice.Roll()); public void Roll() => _dices.ForEach(dice => dice.Roll());
} }

View File

@ -3,9 +3,6 @@ using DAL.Database.Models;
namespace pacMan.GameStuff.Items; namespace pacMan.GameStuff.Items;
/// <summary>
/// Represents the various states of a 'Player'.
/// </summary>
public enum State public enum State
{ {
WaitingForPlayers, WaitingForPlayers,
@ -14,9 +11,6 @@ public enum State
Disconnected Disconnected
} }
/// <summary>
/// Represents a player in the game.
/// </summary>
public class Player : IEquatable<Player>, ICloneable public class Player : IEquatable<Player>, ICloneable
{ {
[JsonPropertyName("username")] public required string Username { get; init; } [JsonPropertyName("username")] public required string Username { get; init; }

View File

@ -2,9 +2,6 @@ using System.Text.Json.Serialization;
namespace pacMan.GameStuff; namespace pacMan.GameStuff;
/// <summary>
/// Represents a move path consisting of a sequence of positions and a target end position with a specified direction.
/// </summary>
public class MovePath : IEquatable<MovePath> public class MovePath : IEquatable<MovePath>
{ {
[JsonInclude] [JsonInclude]
@ -22,11 +19,6 @@ public class MovePath : IEquatable<MovePath>
return Equals(Path, other.Path) && End.Equals(other.End) && Direction == other.Direction; return Equals(Path, other.Path) && End.Equals(other.End) && Direction == other.Direction;
} }
/// <summary>
/// Converts a DirectionalPosition object to a MovePath object.
/// </summary>
/// <param name="path">The DirectionalPosition object to convert.</param>
/// <returns>A MovePath object with the same End and Direction as the DirectionalPosition object.</returns>
public static implicit operator MovePath(DirectionalPosition path) => public static implicit operator MovePath(DirectionalPosition path) =>
new() new()
{ {
@ -41,12 +33,9 @@ public class MovePath : IEquatable<MovePath>
return obj.GetType() == GetType() && Equals((MovePath)obj); return obj.GetType() == GetType() && Equals((MovePath)obj);
} }
public override int GetHashCode() => HashCode.Combine(End, (int)Direction); public override int GetHashCode() => HashCode.Combine(Path, End, (int)Direction);
} }
/// <summary>
/// Represents a position with x and y coordinates.
/// </summary>
public class Position : IEquatable<Position> public class Position : IEquatable<Position>
{ {
[JsonPropertyName("x")] public int X { get; init; } [JsonPropertyName("x")] public int X { get; init; }
@ -70,9 +59,6 @@ public class Position : IEquatable<Position>
public override int GetHashCode() => HashCode.Combine(X, Y); public override int GetHashCode() => HashCode.Combine(X, Y);
} }
/// <summary>
/// Enum representing the possible directions: Left, Up, Right, and Down.
/// </summary>
public enum Direction public enum Direction
{ {
Left, Left,
@ -81,9 +67,6 @@ public enum Direction
Down Down
} }
/// <summary>
/// Represents a directional position with a coordinate and a direction.
/// </summary>
public class DirectionalPosition : IEquatable<DirectionalPosition> public class DirectionalPosition : IEquatable<DirectionalPosition>
{ {
[JsonPropertyName("at")] public required Position At { get; init; } [JsonPropertyName("at")] public required Position At { get; init; }
@ -97,11 +80,6 @@ public class DirectionalPosition : IEquatable<DirectionalPosition>
return At.Equals(other.At) && Direction == other.Direction; return At.Equals(other.At) && Direction == other.Direction;
} }
/// <summary>
/// Converts a MovePath object to a DirectionalPosition object.
/// </summary>
/// <param name="path">The MovePath object to convert.</param>
/// <returns>A DirectionalPosition object representing the converted MovePath object.</returns>
public static explicit operator DirectionalPosition(MovePath path) => public static explicit operator DirectionalPosition(MovePath path) =>
new() new()
{ {

View File

@ -1,9 +1,6 @@
namespace pacMan.GameStuff; namespace pacMan.GameStuff;
/// <summary> public class Rules
/// The Rules class holds constant values related to the game rules.
/// </summary>
public static class Rules
{ {
public const int MinPlayers = 2; public const int MinPlayers = 2;
public const int MaxPlayers = 4; public const int MaxPlayers = 4;

View File

@ -0,0 +1,10 @@
using System.Net.WebSockets;
namespace pacMan.Interfaces;
public interface IWebSocketService
{
Task Send(WebSocket webSocket, ArraySegment<byte> segment);
Task<WebSocketReceiveResult> Receive(WebSocket webSocket, byte[] buffer);
Task Close(WebSocket webSocket, WebSocketCloseStatus closeStatus, string? closeStatusDescription);
}

View File

@ -1,4 +1,5 @@
using DAL.Database.Service; using DAL.Database.Service;
using pacMan.Interfaces;
using pacMan.Services; using pacMan.Services;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@ -7,7 +8,6 @@ var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllersWithViews(); builder.Services.AddControllersWithViews();
builder.Services builder.Services
.AddSingleton<IGameService, GameService>()
.AddSingleton<IWebSocketService, WebSocketService>() .AddSingleton<IWebSocketService, WebSocketService>()
.AddSingleton<GameService>() .AddSingleton<GameService>()
.AddScoped<UserService>() .AddScoped<UserService>()

View File

@ -1,7 +1,8 @@
using System.Net.WebSockets; using System.Net.WebSockets;
using System.Text.Json; using System.Text.Json;
using pacMan.DTOs; using System.Text.Json.Serialization;
using pacMan.Exceptions; using pacMan.Exceptions;
using pacMan.GameStuff;
using pacMan.GameStuff.Items; using pacMan.GameStuff.Items;
namespace pacMan.Services; namespace pacMan.Services;
@ -10,10 +11,11 @@ public interface IActionService
{ {
Player Player { set; } Player Player { set; }
Game? Game { get; set; } Game? Game { get; set; }
WebSocket WebSocket { set; } WebSocket? WebSocket { set; }
void DoAction(ActionMessage message);
List<int> RollDice(); List<int> RollDice();
List<Player> FindGame(JsonElement? jsonElement); List<Player> FindGame(JsonElement? jsonElement);
MovePlayerData HandleMoveCharacter(JsonElement? jsonElement); object? HandleMoveCharacter(JsonElement? jsonElement);
ReadyData Ready(); ReadyData Ready();
string FindNextPlayer(); string FindNextPlayer();
List<Player> LeaveGame(); List<Player> LeaveGame();
@ -21,65 +23,68 @@ public interface IActionService
List<Player>? Disconnect(); List<Player>? Disconnect();
} }
/// <summary> public class ActionService : IActionService
/// Provides various actions that can be performed in a game
/// </summary>
public class ActionService(ILogger<ActionService> logger, IGameService gameService) : IActionService
{ {
public WebSocket WebSocket { private get; set; } = null!; private readonly GameService _gameService;
private readonly ILogger<ActionService> _logger;
public ActionService(ILogger<ActionService> logger, GameService gameService)
{
_logger = logger;
_gameService = gameService;
}
public WebSocket? WebSocket { private get; set; }
public Game? Game { get; set; } public Game? Game { get; set; }
public Player? Player { get; set; } public Player? Player { get; set; }
/// <summary> public void DoAction(ActionMessage message)
/// Rolls the dice and returns the result. If the game is null, an empty list is returned. {
/// </summary> message.Data = message.Action switch
/// <returns>A list of integers representing the values rolled on the dice.</returns> {
GameAction.RollDice => RollDice(),
GameAction.MoveCharacter => HandleMoveCharacter(message.Data),
GameAction.JoinGame => FindGame(message.Data),
GameAction.Ready => Ready(),
GameAction.NextPlayer => FindNextPlayer(),
GameAction.Disconnect => LeaveGame(),
_ => message.Data
};
}
public List<int> RollDice() public List<int> RollDice()
{ {
Game?.DiceCup.Roll(); Game?.DiceCup.Roll();
var rolls = Game?.DiceCup.Values ?? []; var rolls = Game?.DiceCup.Values ?? new List<int>();
logger.LogInformation("Rolled [{}]", string.Join(", ", rolls)); _logger.Log(LogLevel.Information, "Rolled [{}]", string.Join(", ", rolls));
return rolls; return rolls;
} }
/// <summary> public object? HandleMoveCharacter(JsonElement? jsonElement)
/// Handles the movement of the character based on the provided JSON element.
/// </summary>
/// <param name="jsonElement">The JSON element containing the data to move the character.</param>
/// <returns>The MovePlayerData object representing the updated character movement information.</returns>
public MovePlayerData HandleMoveCharacter(JsonElement? jsonElement)
{ {
var data = jsonElement?.Deserialize<MovePlayerData>() ?? throw new NullReferenceException("Data is null"); if (Game != null && jsonElement.HasValue)
if (Game is not null)
{ {
Game.Ghosts = data.Ghosts; Game.Ghosts = jsonElement.Value.GetProperty("ghosts").Deserialize<List<Character>>() ??
Game.Players = data.Players; throw new NullReferenceException("Ghosts is null");
Game.Players = jsonElement.Value.GetProperty("players").Deserialize<List<Player>>() ??
throw new NullReferenceException("Players is null");
} }
return data; return jsonElement;
} }
/// <summary>
/// Finds a game based on the given JSON element.
/// </summary>
/// <param name="jsonElement">The JSON data containing the username and gameId.</param>
/// <returns>The list of players in the found game.</returns>
/// <exception cref="NullReferenceException">Thrown when the JSON data is null.</exception>
/// <exception cref="GameNotFoundException">Thrown when the game with the given gameId does not exist.</exception>
/// <exception cref="PlayerNotFoundException">Thrown when the player with the given username is not found in the game.</exception>
public List<Player> FindGame(JsonElement? jsonElement) public List<Player> FindGame(JsonElement? jsonElement)
{ {
var (username, gameId) = var data = jsonElement?.Deserialize<JoinGameData>() ?? throw new NullReferenceException("Data is null");
jsonElement?.Deserialize<JoinGameData>() ?? throw new NullReferenceException("Data is null");
var game = gameService.FindGameById(gameId) ?? var game = _gameService.Games.FirstOrDefault(game => game.Id == data.GameId) ??
throw new GameNotFoundException($"Game was not found, id \"{gameId}\" does not exist"); throw new GameNotFoundException($"Game was not found, id \"{data.GameId}\" does not exist");
var player = game.FindPlayerByUsername(username) ?? var player = game.Players.Find(p => p.Username == data.Username)
throw new PlayerNotFoundException($"Player \"{username}\" was not found in game"); ?? throw new PlayerNotFoundException($"Player \"{data.Username}\" was not found in game");
player.State = game.IsGameStarted ? State.InGame : State.WaitingForPlayers; // TODO doesn't work anymore player.State = game.IsGameStarted ? State.InGame : State.WaitingForPlayers; // TODO doesn't work anymore
Player = player; Player = player;
@ -89,18 +94,10 @@ public class ActionService(ILogger<ActionService> logger, IGameService gameServi
return Game.Players; return Game.Players;
} }
/// <summary>
/// Prepares the game and returns relevant data.
/// </summary>
/// <exception cref="PlayerNotFoundException">Thrown when the player is not found.</exception>
/// <exception cref="GameNotFoundException">Thrown when the game is not found.</exception>
/// <returns>A <see cref="ReadyData" /> object containing information about game readiness.</returns>
public ReadyData Ready() public ReadyData Ready()
{ {
if (Player is null) if (Player == null || Game == null)
throw new PlayerNotFoundException("Player not found, please create a new player"); throw new PlayerNotFoundException("Player not found, please create a new player");
if (Game is null)
throw new GameNotFoundException();
var players = Game.SetReady(Player.Username).ToArray(); var players = Game.SetReady(Player.Username).ToArray();
// TODO roll to start // TODO roll to start
@ -110,55 +107,61 @@ public class ActionService(ILogger<ActionService> logger, IGameService gameServi
return new ReadyData { AllReady = allReady, Players = players }; return new ReadyData { AllReady = allReady, Players = players };
} }
/// <summary>
/// Finds the next player in the game.
/// </summary>
/// <returns>
/// The username of the next player in the game, if available.
/// </returns>
/// <exception cref="GameNotFoundException">
/// Thrown if the game is not found.
/// </exception>
public string FindNextPlayer() => Game?.NextPlayer().Username ?? throw new GameNotFoundException(); public string FindNextPlayer() => Game?.NextPlayer().Username ?? throw new GameNotFoundException();
/// <summary>
/// Removes the player from the game.
/// </summary>
/// <exception cref="NullReferenceException">Throws if the game or player is null.</exception>
/// <returns>A list of remaining players in the game.</returns>
public List<Player> LeaveGame() public List<Player> LeaveGame()
{ {
if (Game is null) throw new NullReferenceException("Game is null"); if (Game == null || Player == null) throw new NullReferenceException("Game or Player is null");
if (Player is null) throw new NullReferenceException("Player is null");
Game.RemovePlayer(Player.Username); Game.RemovePlayer(Player.Username);
return Game.Players; return Game.Players;
} }
/// <summary>
/// Disconnects the player from the game.
/// </summary>
/// <returns>
/// Returns the list of players in the game after disconnecting the player.
/// Returns null if the player is already disconnected or is not connected to a game.
/// </returns>
public List<Player>? Disconnect() public List<Player>? Disconnect()
{ {
if (Player is null) return null; if (Player == null) return null;
Player.State = State.Disconnected; Player.State = State.Disconnected;
if (Game is not null) Game.Connections -= SendSegment; if (Game != null) Game.Connections -= SendSegment;
return Game?.Players; return Game?.Players;
} }
/// <summary>
/// Sends a given byte segment to all players in the game.
/// </summary>
/// <param name="segment">The byte segment to send.</param>
public void SendToAll(ArraySegment<byte> segment) => Game?.SendToAll(segment); public void SendToAll(ArraySegment<byte> segment) => Game?.SendToAll(segment);
/// <summary> private async Task SendSegment(ArraySegment<byte> segment)
/// Sends an array segment of bytes through the WebSocket connection. {
/// </summary> if (WebSocket != null) await _gameService.Send(WebSocket, segment);
/// <param name="segment">The array segment of bytes to send.</param> else await Task.FromCanceled(new CancellationToken(true));
/// <returns>A task that represents the asynchronous send operation.</returns> }
private Task SendSegment(ArraySegment<byte> segment) => gameService.Send(WebSocket, segment); }
public struct JoinGameData
{
[JsonInclude]
[JsonPropertyName("username")]
public required string Username { get; init; }
[JsonInclude]
[JsonPropertyName("gameId")]
public required Guid GameId { get; init; }
}
public struct CreateGameData
{
[JsonInclude]
[JsonPropertyName("player")]
public required Player Player { get; init; }
[JsonInclude]
[JsonPropertyName("spawns")]
public required Queue<DirectionalPosition> Spawns { get; init; }
}
public struct ReadyData
{
[JsonInclude]
[JsonPropertyName("allReady")]
public required bool AllReady { get; init; }
[JsonInclude]
[JsonPropertyName("players")]
public required IEnumerable<Player> Players { get; set; }
} }

View File

@ -5,26 +5,20 @@ using pacMan.GameStuff.Items;
namespace pacMan.Services; namespace pacMan.Services;
/// <summary> public class Game
/// Represents a game instance.
/// </summary>
public class Game(Queue<DirectionalPosition> spawns)
{ {
private readonly Random _random = new(); private readonly Random _random = new();
private int _currentPlayerIndex; private int _currentPlayerIndex;
private List<Player> _players = []; private List<Player> _players = new();
public Game(Queue<DirectionalPosition> spawns) => Spawns = spawns;
[JsonInclude] public Guid Id { get; } = Guid.NewGuid(); [JsonInclude] public Guid Id { get; } = Guid.NewGuid();
/// <summary>
/// Gets or sets the list of players.
/// When setting, the mutable values of the players are updated instead of replacing the list.
/// </summary>
[JsonIgnore] [JsonIgnore]
public List<Player> Players public List<Player> Players
{ {
get => _players; get => _players;
// TODO what if different length?
set set
{ {
if (_players.Count > 0) if (_players.Count > 0)
@ -40,36 +34,17 @@ public class Game(Queue<DirectionalPosition> spawns)
} }
} }
[JsonIgnore] public List<Character> Ghosts { get; set; } = []; // TODO include [JsonIgnore] public List<Character> Ghosts { get; set; } = new(); // TODO include
/// <summary> [JsonIgnore] private Queue<DirectionalPosition> Spawns { get; }
/// The spawn locations on the map.
/// </summary>
/// <value>
/// A Queue of DirectionalPositions representing the spawn locations on the map.
/// </value>
[JsonIgnore]
private Queue<DirectionalPosition> Spawns { get; } = spawns;
[JsonIgnore] public DiceCup DiceCup { get; } = new(); // TODO include [JsonIgnore] public DiceCup DiceCup { get; } = new(); // TODO include
[JsonInclude] public int Count => Players.Count; [JsonInclude] public int Count => Players.Count;
// TODO edge-case when game has started but all players have disconnected, Disconnected property? // TODO edge-case when game has started but all players have disconnected, Disconnected property?
/// <summary> [JsonInclude] public bool IsGameStarted => Count > 0 && Players.Any(player => player.State is State.InGame);
/// Whether the game has started.
/// </summary>
/// <remarks>
/// The game is considered started if the count is greater than zero and at least one player is in the "InGame" state.
/// </remarks>
[JsonInclude]
public bool IsGameStarted => Count > 0 && Players.Any(player => player.State is State.InGame);
/// <summary>
/// Gets the next player in the game.
/// </summary>
/// <returns>The next player.</returns>
/// <exception cref="PlayerNotFoundException">Thrown when there are no players in the game.</exception>
public Player NextPlayer() public Player NextPlayer()
{ {
try try
@ -78,7 +53,7 @@ public class Game(Queue<DirectionalPosition> spawns)
} }
catch (DivideByZeroException) catch (DivideByZeroException)
{ {
throw new PlayerNotFoundException("There are no players in the game."); throw new InvalidOperationException("There are no players in the game.");
} }
return Players[_currentPlayerIndex]; return Players[_currentPlayerIndex];
@ -86,22 +61,8 @@ public class Game(Queue<DirectionalPosition> spawns)
public void Shuffle() => Players.Sort((_, _) => _random.Next(-1, 2)); public void Shuffle() => Players.Sort((_, _) => _random.Next(-1, 2));
/// <summary>
/// An event that is invoked when a message is to be sent to all connections.
/// Each player in the game should be listening to this event.
/// </summary>
/// <remarks>
/// The event handler is of type <see cref="Func{T, TResult}" /> which accepts an <see cref="ArraySegment{T}" /> of
/// bytes and returns a <see cref="Task" />.
/// This event is typically used to perform some action when something happens.
/// </remarks>
public event Func<ArraySegment<byte>, Task>? Connections; public event Func<ArraySegment<byte>, Task>? Connections;
/// <summary>
/// Adds a player to the game.
/// </summary>
/// <param name="player">The player to be added.</param>
/// <exception cref="GameNotPlayableException">Thrown when the game is already full or has already started.</exception>
public void AddPlayer(Player player) public void AddPlayer(Player player)
{ {
if (Players.Count >= Rules.MaxPlayers) if (Players.Count >= Rules.MaxPlayers)
@ -125,10 +86,6 @@ public class Game(Queue<DirectionalPosition> spawns)
return removedPlayer; return removedPlayer;
} }
/// <summary>
/// Sets the spawn position and current position of the specified player's PacMan character.
/// </summary>
/// <param name="player">The player whose PacMan character's spawn and current positions will be set.</param>
private void SetSpawn(Player player) private void SetSpawn(Player player)
{ {
if (player.PacMan.SpawnPosition is not null) return; if (player.PacMan.SpawnPosition is not null) return;
@ -137,17 +94,8 @@ public class Game(Queue<DirectionalPosition> spawns)
player.PacMan.Position = spawn; player.PacMan.Position = spawn;
} }
/// <summary>
/// Sends the specified byte segment to all connected clients.
/// </summary>
/// <param name="segment">The byte segment to send.</param>
public void SendToAll(ArraySegment<byte> segment) => Connections?.Invoke(segment); public void SendToAll(ArraySegment<byte> segment) => Connections?.Invoke(segment);
/// <summary>
/// Sets the state of the player with the specified username to Ready.
/// </summary>
/// <param name="username">The username of the player.</param>
/// <returns>All players</returns>
public IEnumerable<Player> SetReady(string username) public IEnumerable<Player> SetReady(string username)
{ {
var player = Players.FirstOrDefault(p => p.Username == username); var player = Players.FirstOrDefault(p => p.Username == username);
@ -157,25 +105,11 @@ public class Game(Queue<DirectionalPosition> spawns)
return Players; return Players;
} }
/// <summary>
/// Sets all players to the "InGame" state if they are currently in the "Ready" state.
/// </summary>
/// <returns>
/// Returns true if all players were successfully set to the "InGame" state, false otherwise.
/// </returns>
public bool SetAllInGame() public bool SetAllInGame()
{ {
if (Players.Any(player => player.State is not State.Ready)) return false; if (Players.Any(player => player.State != State.Ready)) return false;
foreach (var player in Players) player.State = State.InGame; foreach (var player in Players) player.State = State.InGame;
return true; return true;
} }
/// <summary>
/// Finds a player by their username.
/// </summary>
/// <param name="username">The username of the player to find.</param>
/// <returns>The found Player object if a player with the given username is found; otherwise, null.</returns>
public Player? FindPlayerByUsername(string username) =>
Players.FirstOrDefault(player => player.Username == username);
} }

View File

@ -4,27 +4,20 @@ using pacMan.GameStuff.Items;
namespace pacMan.Services; namespace pacMan.Services;
public interface IGameService : IWebSocketService
{
SynchronizedCollection<Game> Games { get; }
Game JoinById(Guid id, Player player);
Game CreateAndJoin(Player player, Queue<DirectionalPosition> spawns);
Game? FindGameById(Guid id);
Game? FindGameByUsername(string username);
}
/// <summary> /// <summary>
/// The GameService class provides functionality for managing games in a WebSocket environment. It inherits from the /// The GameService class provides functionality for managing games in a WebSocket environment. It inherits from the
/// WebSocketService class. /// WebSocketService class.
/// </summary> /// </summary>
public class GameService(ILogger<WebSocketService> logger) : WebSocketService(logger), IGameService public class GameService : WebSocketService
{ {
public GameService(ILogger<GameService> logger) : base(logger) { }
/// <summary> /// <summary>
/// A thread-safe collection (SynchronizedCollection) of "Game" objects. Utilized for managing multiple game instances /// A thread-safe collection (SynchronizedCollection) of "Game" objects. Utilized for managing multiple game instances
/// simultaneously. /// simultaneously.
/// It represents all the current games being managed by GameService. /// It represents all the current games being managed by GameService.
/// </summary> /// </summary>
public SynchronizedCollection<Game> Games { get; } = []; public SynchronizedCollection<Game> Games { get; } = new();
/// <summary> /// <summary>
/// This method tries to find a game with the specified id, add a player to it and return the updated game. /// This method tries to find a game with the specified id, add a player to it and return the updated game.
@ -64,20 +57,8 @@ public class GameService(ILogger<WebSocketService> logger) : WebSocketService(lo
return game; return game;
} }
/// <summary> public Game? FindGameByUsername(string username)
/// Finds a game by its ID. {
/// </summary> return Games.FirstOrDefault(game => game.Players.Exists(player => player.Username == username));
/// <param name="id">The ID of the game.</param> }
/// <returns>The game with the specified ID, or null if no game was found.</returns>
public Game? FindGameById(Guid id) => Games.FirstOrDefault(game => game.Id == id);
/// <summary>
/// Finds a game by the given username.
/// </summary>
/// <param name="username">The username to search for.</param>
/// <returns>
/// The found game, if any. Returns null if no game is found.
/// </returns>
public Game? FindGameByUsername(string username) =>
Games.FirstOrDefault(game => game.Players.Exists(player => player.Username == username));
} }

View File

@ -1,33 +1,20 @@
using System.Net.WebSockets; using System.Net.WebSockets;
using pacMan.Interfaces;
using pacMan.Utils; using pacMan.Utils;
namespace pacMan.Services; namespace pacMan.Services;
public interface IWebSocketService
{
Task Send(WebSocket webSocket, ArraySegment<byte> segment);
Task<WebSocketReceiveResult> Receive(WebSocket webSocket, byte[] buffer);
Task Close(WebSocket webSocket, WebSocketCloseStatus closeStatus, string? closeStatusDescription);
}
/// <summary> public class WebSocketService : IWebSocketService
/// WebSocketService class provides methods to send, receive and close a WebSocket connection.
/// </summary>
public class WebSocketService(ILogger<WebSocketService> logger) : IWebSocketService
{ {
/// <summary> protected readonly ILogger<WebSocketService> Logger;
/// Sends the specified byte array as a text message through the WebSocket connection.
/// </summary> public WebSocketService(ILogger<WebSocketService> logger)
/// <param name="webSocket">The WebSocket connection.</param> {
/// <param name="segment">The byte array to send.</param> Logger = logger;
/// <returns> logger.Log(LogLevel.Debug, "WebSocket Service created");
/// A task representing the asynchronous operation of sending the message. }
/// </returns>
/// <remarks>
/// This method sends the specified byte array as a text message through the WebSocket connection.
/// It uses the WebSocket.SendAsync method to send the message asynchronously.
/// After sending the message, it logs a debug message using the logger provided.
/// </remarks>
public async Task Send(WebSocket webSocket, ArraySegment<byte> segment) public async Task Send(WebSocket webSocket, ArraySegment<byte> segment)
{ {
await webSocket.SendAsync( await webSocket.SendAsync(
@ -36,34 +23,18 @@ public class WebSocketService(ILogger<WebSocketService> logger) : IWebSocketServ
true, true,
CancellationToken.None); CancellationToken.None);
logger.LogDebug("Message sent through WebSocket"); Logger.Log(LogLevel.Debug, "Message sent to WebSocket");
} }
/// <summary>
/// Receives data from a websocket and logs a debug message.
/// </summary>
/// <param name="webSocket">The websocket to receive data from.</param>
/// <param name="buffer">The buffer to store the received data.</param>
/// <returns>
/// A task representing the asynchronous operation. The result contains the <see cref="WebSocketReceiveResult" />
/// which contains information about the received data.
/// </returns>
public async Task<WebSocketReceiveResult> Receive(WebSocket webSocket, byte[] buffer) public async Task<WebSocketReceiveResult> Receive(WebSocket webSocket, byte[] buffer)
{ {
var result = await webSocket.ReceiveAsync(new ArraySegment<byte>(buffer), CancellationToken.None); var result = await webSocket.ReceiveAsync(new ArraySegment<byte>(buffer), CancellationToken.None);
logger.LogDebug( Logger.Log(LogLevel.Debug,
"Message \"{}\" received from WebSocket", "Message \"{}\" received from WebSocket",
buffer.GetString(result.Count)); buffer.GetString(result.Count));
return result; return result;
} }
/// <summary>
/// Closes the WebSocket connection with the specified close status and description.
/// </summary>
/// <param name="webSocket">The WebSocket connection to close.</param>
/// <param name="closeStatus">The status code indicating the reason for the close.</param>
/// <param name="closeStatusDescription">The optional description explaining the reason for the close.</param>
/// <returns>A task representing the asynchronous operation.</returns>
public async Task Close(WebSocket webSocket, WebSocketCloseStatus closeStatus, string? closeStatusDescription) public async Task Close(WebSocket webSocket, WebSocketCloseStatus closeStatus, string? closeStatusDescription)
{ {
await webSocket.CloseAsync( await webSocket.CloseAsync(
@ -71,6 +42,6 @@ public class WebSocketService(ILogger<WebSocketService> logger) : IWebSocketServ
closeStatusDescription, closeStatusDescription,
CancellationToken.None); CancellationToken.None);
logger.LogInformation("WebSocket connection closed"); Logger.Log(LogLevel.Information, "WebSocket connection closed");
} }
} }

View File

@ -6,24 +6,13 @@ namespace pacMan.Utils;
public static partial class Extensions public static partial class Extensions
{ {
/// <summary>
/// Converts the specified byte array into a string using UTF-8 encoding and then removes any invalid characters.
/// </summary>
/// <param name="bytes">The byte array to convert.</param>
/// <param name="length">The number of bytes to decode.</param>
/// <returns>The decoded string without any invalid characters.</returns>
public static string GetString(this byte[] bytes, int length) public static string GetString(this byte[] bytes, int length)
{ {
var s = Encoding.UTF8.GetString(bytes, 0, length); var s = Encoding.UTF8.GetString(bytes, 0, length);
// Removes invalid characters from the string // Removes invalid characters from the string
return InvalidCharacters().Replace(s, string.Empty); return InvalidCharacters().Replace(s, "");
} }
/// <summary>
/// Converts an object to an <see cref="ArraySegment{T}" /> of bytes.
/// </summary>
/// <param name="obj">The object to convert.</param>
/// <returns>An <see cref="ArraySegment{T}" /> of bytes representing the serialized object in UTF-8 encoding.</returns>
public static ArraySegment<byte> ToArraySegment(this object obj) public static ArraySegment<byte> ToArraySegment(this object obj)
{ {
var json = JsonSerializer.Serialize(obj); var json = JsonSerializer.Serialize(obj);
@ -31,10 +20,6 @@ public static partial class Extensions
return new ArraySegment<byte>(bytes, 0, json.Length); return new ArraySegment<byte>(bytes, 0, json.Length);
} }
/// <summary>
/// Retrieves a regular expression pattern that matches invalid characters.
/// </summary>
/// <returns>A regular expression pattern for matching invalid characters.</returns>
[GeneratedRegex("\\p{C}+")] [GeneratedRegex("\\p{C}+")]
private static partial Regex InvalidCharacters(); private static partial Regex InvalidCharacters();
} }

View File

@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net7.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<TypeScriptCompileBlocked>true</TypeScriptCompileBlocked> <TypeScriptCompileBlocked>true</TypeScriptCompileBlocked>
<TypeScriptToolsVersion>Latest</TypeScriptToolsVersion> <TypeScriptToolsVersion>Latest</TypeScriptToolsVersion>
@ -13,71 +13,70 @@
<RootNamespace>pacMan</RootNamespace> <RootNamespace>pacMan</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS> <DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<LangVersion>13</LangVersion>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.SpaProxy" Version="8.0.0"/> <PackageReference Include="Microsoft.AspNetCore.SpaProxy" Version="7.0.10" />
<PackageReference Include="Microsoft.TypeScript.MSBuild" Version="5.3.3"> <PackageReference Include="Microsoft.TypeScript.MSBuild" Version="5.2.2">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="System.ServiceModel.Primitives" Version="8.0.0" /> <PackageReference Include="System.ServiceModel.Primitives" Version="6.1.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<!-- Don't publish the SPA source files, but do show them in the project files list --> <!-- Don't publish the SPA source files, but do show them in the project files list -->
<Content Remove="$(SpaRoot)**"/> <Content Remove="$(SpaRoot)**" />
<Content Include="..\.dockerignore"> <Content Include="..\.dockerignore">
<Link>.dockerignore</Link> <Link>.dockerignore</Link>
</Content> </Content>
<None Remove="$(SpaRoot)**"/> <None Remove="$(SpaRoot)**" />
<None Include="$(SpaRoot)**" Exclude="$(SpaRoot)node_modules\**"/> <None Include="$(SpaRoot)**" Exclude="$(SpaRoot)node_modules\**" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<TypeScriptCompile Remove="ClientApp\src\components\Counter.tsx"/> <TypeScriptCompile Remove="ClientApp\src\components\Counter.tsx" />
<TypeScriptCompile Remove="ClientApp\src\components\FetchData.tsx"/> <TypeScriptCompile Remove="ClientApp\src\components\FetchData.tsx" />
<TypeScriptCompile Remove="ClientApp\src\components\Home.tsx"/> <TypeScriptCompile Remove="ClientApp\src\components\Home.tsx" />
<TypeScriptCompile Remove="ClientApp\src\pages\FetchData.tsx"/> <TypeScriptCompile Remove="ClientApp\src\pages\FetchData.tsx" />
<TypeScriptCompile Remove="ClientApp\src\classes\tileMap.ts"/> <TypeScriptCompile Remove="ClientApp\src\classes\tileMap.ts" />
<TypeScriptCompile Remove="ClientApp\src\game\tileMap.ts"/> <TypeScriptCompile Remove="ClientApp\src\game\tileMap.ts" />
<TypeScriptCompile Remove="ClientApp\src\components\gameCanvas.tsx"/> <TypeScriptCompile Remove="ClientApp\src\components\gameCanvas.tsx" />
<TypeScriptCompile Remove="ClientApp\src\game\game.ts"/> <TypeScriptCompile Remove="ClientApp\src\game\game.ts" />
<TypeScriptCompile Remove="ClientApp\src\App.test.tsx"/> <TypeScriptCompile Remove="ClientApp\src\App.test.tsx" />
<TypeScriptCompile Remove="ClientApp\src\game\playerStats.tsx"/> <TypeScriptCompile Remove="ClientApp\src\game\playerStats.tsx" />
<TypeScriptCompile Remove="ClientApp\src\websockets\actions.ts"/> <TypeScriptCompile Remove="ClientApp\src\websockets\actions.ts" />
<TypeScriptCompile Remove="ClientApp\src\utils\colours.ts"/> <TypeScriptCompile Remove="ClientApp\src\utils\colours.ts" />
<TypeScriptCompile Remove="ClientApp\src\utils\dom.ts"/> <TypeScriptCompile Remove="ClientApp\src\utils\dom.ts" />
<TypeScriptCompile Remove="ClientApp\src\game\pellet.ts"/> <TypeScriptCompile Remove="ClientApp\src\game\pellet.ts" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Folder Include="ClientApp\tests\utils\"/> <Folder Include="ClientApp\tests\utils\" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\DataAccessLayer\DataAccessLayer.csproj"/> <ProjectReference Include="..\DataAccessLayer\DataAccessLayer.csproj" />
</ItemGroup> </ItemGroup>
<Target Name="DebugEnsureNodeEnv" BeforeTargets="Build" Condition=" '$(Configuration)' == 'Debug' And !Exists('$(SpaRoot)node_modules') "> <Target Name="DebugEnsureNodeEnv" BeforeTargets="Build" Condition=" '$(Configuration)' == 'Debug' And !Exists('$(SpaRoot)node_modules') ">
<!-- Ensure Node.js is installed --> <!-- Ensure Node.js is installed -->
<Exec Command="node --version" ContinueOnError="true"> <Exec Command="node --version" ContinueOnError="true">
<Output TaskParameter="ExitCode" PropertyName="ErrorCode"/> <Output TaskParameter="ExitCode" PropertyName="ErrorCode" />
</Exec> </Exec>
<Error Condition="'$(ErrorCode)' != '0'" Text="Node.js is required to build and run this project. To continue, please install Node.js from https://nodejs.org/, and then restart your command prompt or IDE."/> <Error Condition="'$(ErrorCode)' != '0'" Text="Node.js is required to build and run this project. To continue, please install Node.js from https://nodejs.org/, and then restart your command prompt or IDE." />
<Message Importance="high" Text="Restoring dependencies using 'pnpm'. This may take several minutes..."/> <Message Importance="high" Text="Restoring dependencies using 'pnpm'. This may take several minutes..." />
<Exec WorkingDirectory="$(SpaRoot)" Command="pnpm install"/> <Exec WorkingDirectory="$(SpaRoot)" Command="pnpm install" />
</Target> </Target>
<Target Name="PublishRunWebpack" AfterTargets="ComputeFilesToPublish"> <Target Name="PublishRunWebpack" AfterTargets="ComputeFilesToPublish">
<!-- As part of publishing, ensure the JS resources are freshly built in production mode --> <!-- As part of publishing, ensure the JS resources are freshly built in production mode -->
<Exec WorkingDirectory="$(SpaRoot)" Command="pnpm install"/> <Exec WorkingDirectory="$(SpaRoot)" Command="pnpm install" />
<Exec WorkingDirectory="$(SpaRoot)" Command="pnpm build"/> <Exec WorkingDirectory="$(SpaRoot)" Command="pnpm build" />
<!-- Include the newly-built files in the publish output --> <!-- Include the newly-built files in the publish output -->
<ItemGroup> <ItemGroup>
<DistFiles Include="$(SpaRoot)build\**"/> <DistFiles Include="$(SpaRoot)build\**" />
<ResolvedFileToPublish Include="@(DistFiles->'%(FullPath)')" Exclude="@(ResolvedFileToPublish)"> <ResolvedFileToPublish Include="@(DistFiles->'%(FullPath)')" Exclude="@(ResolvedFileToPublish)">
<RelativePath>wwwroot\%(RecursiveDir)%(FileName)%(Extension)</RelativePath> <RelativePath>wwwroot\%(RecursiveDir)%(FileName)%(Extension)</RelativePath>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory> <CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>