From 0eed8020b81ecab39c0d037acb5bb3842b1bf35c Mon Sep 17 00:00:00 2001 From: Jonas <77726472+kobolol@users.noreply.github.com> Date: Thu, 5 Mar 2026 21:58:17 +0100 Subject: [PATCH] Convert field to jagged arrays; add local SignalR UI Change game field representation to jagged arrays (int[][]) for JSON/SignalR compatibility and add conversion helpers in GameManager. Make Coordinates JSON-serializable (constructor and JsonPropertyName attributes). Update GameHubSocket: validate field size, fix JoinGame parameter order/Group join, rename Place->Drop and send proper game id. Add client-side local play support: GameConnection SignalR client, LocalGame orchestration for two local players, and UI components (GameCreationMenu, Slider) plus GameSettings interface. Update LocalMode route to use the new creation UI and start local games. These changes enable reliable serialization over SignalR and a local two-player flow with a creation UI. --- API/Controllers/GameHubSocket.cs | 14 ++- API/Models/Game/GameField.cs | 22 +++- API/Models/Game/GameInformationDto.cs | 2 +- API/Services/GameManager/GameManager.cs | 38 ++++++- GUI/src/components/GameCreationMenu.vue | 76 +++++++++++++ GUI/src/components/Slider.vue | 55 ++++++++++ GUI/src/routes/LocalMode.vue | 55 +++++++--- GUI/src/scripts/interfaces/GameSettings.ts | 8 ++ GUI/src/scripts/logic/localMode/LocalGame.ts | 65 ++++++++++-- .../scripts/logic/signalR/GameConnection.ts | 100 +++++++++++++++++- 10 files changed, 396 insertions(+), 39 deletions(-) create mode 100644 GUI/src/components/GameCreationMenu.vue create mode 100644 GUI/src/components/Slider.vue create mode 100644 GUI/src/scripts/interfaces/GameSettings.ts diff --git a/API/Controllers/GameHubSocket.cs b/API/Controllers/GameHubSocket.cs index 2857139..d284cea 100644 --- a/API/Controllers/GameHubSocket.cs +++ b/API/Controllers/GameHubSocket.cs @@ -11,6 +11,12 @@ public class GameHubSocket(IGameManager gameManager) : Hub public async Task CreateGame(string playerName, Coordinates gFs) { + if (gFs.X <= 0 || gFs.Y <= 0) + { + await Clients.Caller.SendAsync("Error", "Ungültige Spielfeldgröße."); + return; + } + var player = new Player(playerName, Context.ConnectionId); var result = _gameManager.CreateGame(gFs, player); @@ -24,7 +30,7 @@ public class GameHubSocket(IGameManager gameManager) : Hub }); } - public async Task JoinGame(int gameCode, string playerName) + public async Task JoinGame(string playerName, int gameCode) { var player = new Player(playerName, Context.ConnectionId); @@ -36,7 +42,7 @@ public class GameHubSocket(IGameManager gameManager) : Hub return; } - await Groups.AddToGroupAsync(Context.ConnectionId, gameCode.ToString()); + await Groups.AddToGroupAsync(Context.ConnectionId, result); await Clients.Caller.SendAsync("GameJoined", new { @@ -50,7 +56,7 @@ public class GameHubSocket(IGameManager gameManager) : Hub await _gameManager.RequestGameInformation(gameId, Context.ConnectionId); } - public async Task Place(string gameId, int column) + public async Task Drop(string gameId, int column) { await _gameManager.Drop(gameId, column, Context.ConnectionId); } @@ -61,4 +67,4 @@ public class GameHubSocket(IGameManager gameManager) : Hub await base.OnDisconnectedAsync(exception); } -} \ No newline at end of file +} diff --git a/API/Models/Game/GameField.cs b/API/Models/Game/GameField.cs index e4c332b..93be1a7 100644 --- a/API/Models/Game/GameField.cs +++ b/API/Models/Game/GameField.cs @@ -1,9 +1,21 @@ -namespace API.Models.Game; +using System.Text.Json.Serialization; -public readonly struct Coordinates(int x, int y) +namespace API.Models.Game; + +public readonly struct Coordinates { - public readonly int X = x; - public readonly int Y = y; + [JsonConstructor] + public Coordinates(int x, int y) + { + X = x; + Y = y; + } + + [JsonPropertyName("x")] + public int X { get; init; } + + [JsonPropertyName("y")] + public int Y { get; init; } } public enum DropResult @@ -146,4 +158,4 @@ public class GameField(Coordinates gFs) { Array.Copy(CurrentField, BackupField, CurrentField.Length); } -} \ No newline at end of file +} diff --git a/API/Models/Game/GameInformationDto.cs b/API/Models/Game/GameInformationDto.cs index 108a2f3..6e8deea 100644 --- a/API/Models/Game/GameInformationDto.cs +++ b/API/Models/Game/GameInformationDto.cs @@ -5,7 +5,7 @@ public string Id { get; set; } public List Players { get; set; } public GameState? State { get; set; } - public int[,] CurrentField { get; set; } + public int[][] CurrentField { get; set; } public int CurrentTurn { get; set; } } } diff --git a/API/Services/GameManager/GameManager.cs b/API/Services/GameManager/GameManager.cs index 446ed13..472209d 100644 --- a/API/Services/GameManager/GameManager.cs +++ b/API/Services/GameManager/GameManager.cs @@ -27,7 +27,15 @@ public class GameManager(IGameRepository gameRepository, IHubContext(); + + var rows = field.GetLength(0); + var cols = field.GetLength(1); + var convertedField = new int[rows][]; + + for (var y = 0; y < rows; y++) + { + convertedField[y] = new int[cols]; + + for (var x = 0; x < cols; x++) + { + convertedField[y][x] = field[y, x]; + } + } + + return convertedField; + } + private void ScheduleGameDeletion(string gameId, TimeSpan delay) { _ = Task.Run(async () => @@ -138,4 +168,4 @@ public class GameManager(IGameRepository gameRepository, IHubContext +import type { GameSettings } from '@/scripts/interfaces/GameSettings'; +import Slider from './Slider.vue'; + +let settingsProp = defineModel('settings', { + required: true, +}); + +defineEmits(['createGame']); + +function gameFieldPreset(x: number, y: number) { + settingsProp.value.fieldSize.x = x; + settingsProp.value.fieldSize.y = y; +} + + + + + diff --git a/GUI/src/components/Slider.vue b/GUI/src/components/Slider.vue new file mode 100644 index 0000000..39aa63b --- /dev/null +++ b/GUI/src/components/Slider.vue @@ -0,0 +1,55 @@ + + + \ No newline at end of file diff --git a/GUI/src/routes/LocalMode.vue b/GUI/src/routes/LocalMode.vue index 7fac94b..817406c 100644 --- a/GUI/src/routes/LocalMode.vue +++ b/GUI/src/routes/LocalMode.vue @@ -1,24 +1,51 @@ - \ No newline at end of file + diff --git a/GUI/src/scripts/interfaces/GameSettings.ts b/GUI/src/scripts/interfaces/GameSettings.ts new file mode 100644 index 0000000..46b6898 --- /dev/null +++ b/GUI/src/scripts/interfaces/GameSettings.ts @@ -0,0 +1,8 @@ +import type { FieldSize } from './FieldSize'; + +export interface GameSettings { + fieldSize: FieldSize; + playerName1: string; + playerName2?: string | null; + aiLevel?: number | null; +} diff --git a/GUI/src/scripts/logic/localMode/LocalGame.ts b/GUI/src/scripts/logic/localMode/LocalGame.ts index f45fcb2..fde452e 100644 --- a/GUI/src/scripts/logic/localMode/LocalGame.ts +++ b/GUI/src/scripts/logic/localMode/LocalGame.ts @@ -1,12 +1,65 @@ -import type { FieldSize } from '@/scripts/interfaces/FieldSize.ts' -import buildFieldArray from '@/scripts/utils/BuildFieldArray' +import type { GameSettings } from '@/scripts/interfaces/GameSettings'; +import GameConnection, { + type GameIdentifier, + type GameInformationDto, +} from '../signalR/GameConnection'; class LocalGame { - public field: number[][] + public _settings: GameSettings; + public player1: GameConnection; + public player2: GameConnection; + private gameId: string = ''; - constructor(fieldSize: FieldSize) { - this.field = buildFieldArray(fieldSize); + public gameState: GameInformationDto | undefined; + public onGameStateChanged?: (gameState: GameInformationDto | undefined) => void; + + constructor(settings: GameSettings) { + this._settings = settings; + + this.player1 = new GameConnection(settings.playerName1); + this.player2 = new GameConnection(settings.playerName2 ?? 'Player 2'); + + this.player1.onGameStarted = (gameInfo: GameInformationDto) => { + console.log('Game started for player 1', gameInfo); + this.gameStarted(gameInfo); + }; + + this.player1.onFieldUpdated = (currentField: number[][]) => { + if (this.gameState) { + this.gameState.currentField = currentField; + this.onGameStateChanged?.(this.gameState); + } + }; + + this.player1.onGameCreated = (gameIdentifier: GameIdentifier) => { + this.gameId = gameIdentifier.gameId; + this.player2.joinGame(gameIdentifier.gameCode); + }; + + this.player2.onGameJoined = (gameIdentifier: GameIdentifier) => { + this.gameId = gameIdentifier.gameId; + }; + } + + async start() { + await this.player1.connect(); + await this.player2.connect(); + + await this.player1.createGame(this._settings.fieldSize); + } + + async gameStarted(gameInfo: GameInformationDto) { + this.gameState = gameInfo; + this.onGameStateChanged?.(this.gameState); + } + + async drop(index: number) { + if (this.gameState?.currentTurn == 1) { + this.player1.drop(this.gameId, index); + } else { + this.player2.drop(this.gameId, index); + } } } -export default LocalGame \ No newline at end of file +export default LocalGame; diff --git a/GUI/src/scripts/logic/signalR/GameConnection.ts b/GUI/src/scripts/logic/signalR/GameConnection.ts index 98a6d2a..221e6a2 100644 --- a/GUI/src/scripts/logic/signalR/GameConnection.ts +++ b/GUI/src/scripts/logic/signalR/GameConnection.ts @@ -1,13 +1,103 @@ +import type { FieldSize } from '@/scripts/interfaces/FieldSize'; import * as signalR from '@microsoft/signalr'; -class GameConnection{ - public connection: signalR.HubConnection - constructor(playerName: string, joinCode: number | undefined) { +export interface GameInformationDto { + id: string | null; + players: Player[]; + state: number; + currentField: number[][]; + currentTurn: number; +} + +export interface Player { + name: string; + connectionId: string; + playerTag: number; +} + +export interface GameEndedDto { + method: string, + player?: Player | null; +} + +export interface GameIdentifier{ + gameId: string; + gameCode: number; +} + +class GameConnection { + private connection: signalR.HubConnection; + public playerName: string; + + public onGameCreated?: (gameIdentifier: GameIdentifier) => void; + public onGameJoined?: (gameIdentifier: GameIdentifier) => void; + public onGameStarted?: (gameInfo: GameInformationDto) => void; + public onGameInformation?: (gameInfo: GameInformationDto) => void; + public onFieldUpdated?: (currentField: number[][]) => void; + public onGameEnded?: (gameEndedInfo: GameEndedDto) => void; + public onError?: (error: string) => void; + public onGameDestroyed?: () => void; + + constructor(playerName: string) { + this.playerName = playerName; this.connection = new signalR.HubConnectionBuilder() - .withUrl("/api/gamehub") + .withUrl('/api/gamehub') .withAutomaticReconnect() .build(); + + this.connection.on('GameCreated', (payload: GameIdentifier) => { + this.onGameCreated?.(payload); + }); + + this.connection.on('GameJoined', (payload: GameIdentifier) => { + this.onGameJoined?.(payload); + }); + + this.connection.on('GameStarted', (payload: GameInformationDto) => { + this.onGameStarted?.(payload); + }); + + this.connection.on('GameInformation', (payload: GameInformationDto) => { + this.onGameInformation?.(payload); + }); + + this.connection.on('FieldUpdated', (currentField: number[][]) => { + this.onFieldUpdated?.(currentField); + }); + + this.connection.on('GameEnded', (payload: GameEndedDto) => { + this.onGameEnded?.(payload); + }); + + this.connection.on('Error', (error: string) => { + this.onError?.(error); + }); + + this.connection.on('GameDestroyed', () => { + this.onGameDestroyed?.(); + }); + } + + async connect() { + await this.connection.start(); + console.log('Connected'); + } + + async createGame(gFs: FieldSize) { + await this.connection.invoke('CreateGame', this.playerName, gFs); + } + + async joinGame(gameCode: number) { + await this.connection.invoke('JoinGame', this.playerName, gameCode); + } + + async requestGameInformation(gameId: string) { + await this.connection.invoke('RequestGameInformation', gameId); + } + + async drop(gameId: string, column: number){ + await this.connection.invoke('Drop', gameId, column); } } -export default GameConnection; \ No newline at end of file +export default GameConnection;