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.
This commit is contained in:
Jonas
2026-03-05 21:58:17 +01:00
parent bec5df7e88
commit 0eed8020b8
10 changed files with 396 additions and 39 deletions
+9 -3
View File
@@ -11,6 +11,12 @@ public class GameHubSocket(IGameManager gameManager) : Hub
public async Task CreateGame(string playerName, Coordinates gFs) 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 player = new Player(playerName, Context.ConnectionId);
var result = _gameManager.CreateGame(gFs, player); 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); var player = new Player(playerName, Context.ConnectionId);
@@ -36,7 +42,7 @@ public class GameHubSocket(IGameManager gameManager) : Hub
return; return;
} }
await Groups.AddToGroupAsync(Context.ConnectionId, gameCode.ToString()); await Groups.AddToGroupAsync(Context.ConnectionId, result);
await Clients.Caller.SendAsync("GameJoined", new await Clients.Caller.SendAsync("GameJoined", new
{ {
@@ -50,7 +56,7 @@ public class GameHubSocket(IGameManager gameManager) : Hub
await _gameManager.RequestGameInformation(gameId, Context.ConnectionId); 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); await _gameManager.Drop(gameId, column, Context.ConnectionId);
} }
+16 -4
View File
@@ -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; [JsonConstructor]
public readonly int Y = y; 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 public enum DropResult
+1 -1
View File
@@ -5,7 +5,7 @@
public string Id { get; set; } public string Id { get; set; }
public List<Player> Players { get; set; } public List<Player> Players { get; set; }
public GameState? State { get; set; } public GameState? State { get; set; }
public int[,] CurrentField { get; set; } public int[][] CurrentField { get; set; }
public int CurrentTurn { get; set; } public int CurrentTurn { get; set; }
} }
} }
+33 -3
View File
@@ -27,7 +27,15 @@ public class GameManager(IGameRepository gameRepository, IHubContext<GameHubSock
{ {
game.StartGame(); game.StartGame();
await hubContext.Clients.Group(game.Id).SendAsync("GameStarted", game.Players); var gameInfoDto = new GameInformationDto
{
Players = game?.Players,
CurrentField = ConvertField(game?.Field.CurrentField),
State = game?.State,
CurrentTurn = game?.CurrentTurn ?? 0
};
await hubContext.Clients.Group(game.Id).SendAsync("GameStarted", gameInfoDto);
} }
return game.Id; return game.Id;
@@ -42,7 +50,7 @@ public class GameManager(IGameRepository gameRepository, IHubContext<GameHubSock
var gameInfoDto = new GameInformationDto var gameInfoDto = new GameInformationDto
{ {
Players = game?.Players, Players = game?.Players,
CurrentField = game?.Field.CurrentField, CurrentField = ConvertField(game?.Field.CurrentField),
State = game?.State, State = game?.State,
CurrentTurn = game?.CurrentTurn ?? 0 CurrentTurn = game?.CurrentTurn ?? 0
}; };
@@ -69,7 +77,7 @@ public class GameManager(IGameRepository gameRepository, IHubContext<GameHubSock
return; return;
} }
await hubContext.Clients.Group(game.Id).SendAsync("FieldUpdated", game.Field.CurrentField); await hubContext.Clients.Group(game.Id).SendAsync("FieldUpdated", ConvertField(game.Field.CurrentField));
var winResult = game.Field.CheckForWin(); var winResult = game.Field.CheckForWin();
if (winResult != 0) if (winResult != 0)
@@ -123,6 +131,28 @@ public class GameManager(IGameRepository gameRepository, IHubContext<GameHubSock
return Task.CompletedTask; return Task.CompletedTask;
} }
private static int[][] ConvertField(int[,]? field)
{
if (field == null)
return Array.Empty<int[]>();
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) private void ScheduleGameDeletion(string gameId, TimeSpan delay)
{ {
_ = Task.Run(async () => _ = Task.Run(async () =>
+76
View File
@@ -0,0 +1,76 @@
<script setup lang="ts">
import type { GameSettings } from '@/scripts/interfaces/GameSettings';
import Slider from './Slider.vue';
let settingsProp = defineModel<GameSettings>('settings', {
required: true,
});
defineEmits(['createGame']);
function gameFieldPreset(x: number, y: number) {
settingsProp.value.fieldSize.x = x;
settingsProp.value.fieldSize.y = y;
}
</script>
<template>
<v-sheet width="100%" class="text-centered pa-2 w-75" rounded>
<h1 class="text-center">Spiel Erstellen</h1>
<v-divider class="mb-4"></v-divider>
<v-row>
<v-col cols="12" md="6">
<h2 class="settingsCat">Spieler</h2>
<v-text-field
v-model="settingsProp.playerName1"
label="Name vom Spieler 1"
required
></v-text-field>
<v-text-field v-if="settingsProp.playerName2 != null"
v-model="settingsProp.playerName2"
label="Name vom Spieler 2"
required
></v-text-field>
</v-col>
<v-col cols="12" md="6">
<h2 class="settingsCat">Spielfeld</h2>
<v-chip-group>
<v-chip text="Klein" @click="gameFieldPreset(4, 5)"></v-chip>
<v-chip text="Standard" @click="gameFieldPreset(7, 6)"></v-chip>
<v-chip text="Groß" @click="gameFieldPreset(12, 10)"></v-chip>
</v-chip-group>
<Slider
v-model="settingsProp.fieldSize.x"
label="Breite"
:min="4"
:max="16"
:step="1"
:width="70"
/>
<Slider
v-model="settingsProp.fieldSize.y"
label="Höhe"
:min="4"
:max="16"
:step="1"
:width="70"
/>
</v-col>
</v-row>
<div class="d-flex align-center justify-space-evenly ma-4 w-100">
<v-btn color="red" @click="$router.push('/')" rounded="xl">
Abbrechen
</v-btn>
<v-btn color="primary" @click="$emit('createGame')" rounded="xl">
Spiel Starten
</v-btn>
</div>
</v-sheet>
</template>
<style scoped>
.settingsCat {
margin-top: 5px;
}
</style>
+55
View File
@@ -0,0 +1,55 @@
<script setup lang="ts">
import { computed } from 'vue'
type Props = {
label?: string
min?: number
max?: number
step?: number
width?: string | number
disabled?: boolean
readonlyValue?: boolean
}
const props = withDefaults(defineProps<Props>(), {
label: '',
min: 0,
max: 100,
step: 1,
width: 70,
disabled: false,
readonlyValue: true,
})
const model = defineModel<number>({ required: true })
const inputWidth = computed(() =>
typeof props.width === 'number' ? `${props.width}px` : props.width
)
</script>
<template>
<v-slider
v-model="model"
:min="min"
:max="max"
:step="step"
ticks="always"
:tick-labels="true"
thumb-label
:label="label"
:disabled="disabled"
>
<template #append>
<v-text-field
:model-value="model"
density="compact"
variant="outlined"
hide-details
:readonly="readonlyValue"
:disabled="disabled"
:style="{ width: inputWidth }"
/>
</template>
</v-slider>
</template>
+40 -13
View File
@@ -1,24 +1,51 @@
<script setup lang="ts"> <script setup lang="ts">
import Field from '@/components/game/Field.vue' import { ref } from 'vue';
import { ref } from 'vue' import GameCreationMenu from '@/components/GameCreationMenu.vue';
import type { FieldSize } from '@/scripts/interfaces/FieldSize.ts' import type { GameSettings } from '@/scripts/interfaces/GameSettings';
import LocalGame from '@/scripts/logic/localMode/LocalGame.ts' import LocalGame from '@/scripts/logic/localMode/LocalGame';
import Field from '@/components/game/Field.vue';
const gameFieldSize: FieldSize = { enum CurrentState {
x: 7, CreatingGame,
y: 6, Game,
} }
const game = ref(new LocalGame(gameFieldSize)) let settings = ref<GameSettings>({
const currentSelection = ref(null) playerName1: 'Spieler 1',
playerName2: 'Spieler 2',
fieldSize: { x: 7, y: 6 },
});
var currentState = ref<CurrentState>(CurrentState.CreatingGame);
const game = ref<LocalGame>(new LocalGame(settings.value));
const gameField = ref<number[][]>([]);
const currentSelectionIndex = ref<number | null>(null);
game.value.onGameStateChanged = (gameState) => {
gameField.value = gameState?.currentField ?? [];
};
async function startGame() {
await game.value.start();
currentState.value = CurrentState.Game;
}
</script> </script>
<template> <template>
<v-container
class="d-flex align-center justify-center fill-height"
v-if="currentState === CurrentState.CreatingGame"
>
<GameCreationMenu v-model:settings="settings" @create-game="startGame()" />
</v-container>
<Field <Field
v-model:current-selection-index="currentSelection" v-else
:gameState="game.field" :game-state="gameField"
@clickOnGameField="" v-model:current-selection-index="currentSelectionIndex"
></Field> @click-on-game-field="game.drop(currentSelectionIndex ?? 1)"
/>
</template> </template>
<style scoped></style> <style scoped></style>
@@ -0,0 +1,8 @@
import type { FieldSize } from './FieldSize';
export interface GameSettings {
fieldSize: FieldSize;
playerName1: string;
playerName2?: string | null;
aiLevel?: number | null;
}
+59 -6
View File
@@ -1,12 +1,65 @@
import type { FieldSize } from '@/scripts/interfaces/FieldSize.ts' import type { GameSettings } from '@/scripts/interfaces/GameSettings';
import buildFieldArray from '@/scripts/utils/BuildFieldArray' import GameConnection, {
type GameIdentifier,
type GameInformationDto,
} from '../signalR/GameConnection';
class LocalGame { class LocalGame {
public field: number[][] public _settings: GameSettings;
public player1: GameConnection;
public player2: GameConnection;
private gameId: string = '';
constructor(fieldSize: FieldSize) { public gameState: GameInformationDto | undefined;
this.field = buildFieldArray(fieldSize); 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 export default LocalGame;
@@ -1,12 +1,102 @@
import type { FieldSize } from '@/scripts/interfaces/FieldSize';
import * as signalR from '@microsoft/signalr'; import * as signalR from '@microsoft/signalr';
class GameConnection{ export interface GameInformationDto {
public connection: signalR.HubConnection id: string | null;
constructor(playerName: string, joinCode: number | undefined) { 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() this.connection = new signalR.HubConnectionBuilder()
.withUrl("/api/gamehub") .withUrl('/api/gamehub')
.withAutomaticReconnect() .withAutomaticReconnect()
.build(); .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);
} }
} }