Architektur-Redesign: SignalR-Multiplayer & MVVM

Umfassende Umstrukturierung der SlipItIn-App für eine moderne, zustandsbasierte und SignalR-gestützte Multiplayer-Architektur. Einführung neuer Services für Auth, API, SignalR, lokalen Speicher und Game-State-Management via Dependency Injection. Komplette Neugestaltung der Views (Login, Registrierung, Lobby, Spielbrett) mit MVVM und CommunityToolkit.Mvvm. Navigation jetzt über MAUI Shell mit expliziten Routen. Game-Logik auf Events/Messaging umgestellt, UI und Logik entkoppelt, Echtzeit-Updates integriert. SignalR für alle Spielaktionen inkl. Wiederverbindung und Offline-Queueing. Lokaler Speicher für Auth, Userdaten, GameState und Aktionen. MainPage entfernt, neue Views implementiert. Projektdatei um NuGet-Pakete und Shared-Projekt erweitert. Backend-Änderungen für direkte Spieler-Zuordnung und korrekte EF Core-Relationen. App ist nun robust, testbar und unterstützt Multiplayer- sowie Offline-Szenarien.
This commit is contained in:
Tim Krampitz
2026-07-26 17:31:26 +02:00
parent 85fb2c9ce7
commit bbc6ad6080
45 changed files with 1544 additions and 75 deletions

View File

@@ -0,0 +1,178 @@
using System.Text.Json;
using Microsoft.AspNetCore.SignalR.Client;
using SlipItIn.Models;
using SlipItIn.Services.Interfaces;
using SlipItIn.Shared.DTOs;
namespace SlipItIn.Services;
public class SignalRService : ISignalRService
{
private readonly IAppConfigurationService _configuration;
private readonly IAuthSessionService _authSession;
private HubConnection? _hubConnection;
public SignalRService(IAppConfigurationService configuration, IAuthSessionService authSession)
{
_configuration = configuration;
_authSession = authSession;
}
public bool IsConnected => _hubConnection?.State == HubConnectionState.Connected;
public event EventHandler<(int GameId, string LobbyCode)>? LobbyCreated;
public event EventHandler<GameStateDto>? GameStateUpdated;
public event EventHandler<int>? GameStarted;
public event EventHandler<PlayerHandDto>? PlayerHandUpdated;
public event EventHandler<SlipChallengeDto>? ChallengeReceived;
public event EventHandler<string>? ErrorReceived;
public event EventHandler<bool>? ConnectionStateChanged;
public async Task<bool> ConnectAsync(string token, CancellationToken cancellationToken = default)
{
if (IsConnected)
return true;
EnsureConnection(token);
if (_hubConnection is null)
return false;
try
{
await _hubConnection.StartAsync(cancellationToken);
ConnectionStateChanged?.Invoke(this, true);
return true;
}
catch (Exception ex)
{
ErrorReceived?.Invoke(this, $"SignalR-Verbindung fehlgeschlagen: {ex.Message}");
return false;
}
}
public async Task DisconnectAsync()
{
if (_hubConnection is null)
return;
await _hubConnection.StopAsync();
ConnectionStateChanged?.Invoke(this, false);
}
public Task CreateLobbyAsync() => InvokeAsync("CreateLobby");
public Task JoinLobbyAsync(string lobbyCode) => InvokeAsync("JoinLobby", lobbyCode);
public Task PlayerReadyAsync(int gameId, int playerId) => InvokeAsync("PlayerReady", gameId, playerId);
public Task StartGameAsync(int gameId) => InvokeAsync("StartGame", gameId);
public Task SubmitSlipAsync(int gameId, int playerId, int cardId)
=> InvokeAsync("SubmitSlip", gameId, playerId, cardId);
public Task ChallengeSlipAsync(int gameId, int challengingPlayerId, int targetCardId)
=> InvokeAsync("ChallengeSlip", gameId, challengingPlayerId, targetCardId);
public Task ResolveChallengeAsync(int challengeId, bool approved)
=> InvokeAsync("ResolveChallenge", challengeId, approved);
public Task RequestGameStateAsync(int gameId) => InvokeAsync("RequestGameState", gameId);
public async Task SendQueuedActionAsync(QueuedGameAction action)
{
if (_hubConnection is null)
return;
object?[] args;
try
{
var doc = JsonDocument.Parse(action.ArgumentsJson);
args = doc.RootElement.ValueKind == JsonValueKind.Array
? doc.RootElement.EnumerateArray().Select(ToObject).ToArray()
: [];
}
catch
{
args = [];
}
await InvokeAsync(action.Method, args);
}
private void EnsureConnection(string token)
{
if (_hubConnection is not null)
return;
_hubConnection = new HubConnectionBuilder()
.WithUrl(_configuration.HubUrl, options =>
{
options.AccessTokenProvider = () => Task.FromResult<string?>(token);
})
.WithAutomaticReconnect([TimeSpan.Zero, TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(30)])
.Build();
_hubConnection.Closed += _ =>
{
ConnectionStateChanged?.Invoke(this, false);
return Task.CompletedTask;
};
_hubConnection.Reconnected += _ =>
{
ConnectionStateChanged?.Invoke(this, true);
return Task.CompletedTask;
};
_hubConnection.On<JsonElement>("LobbyCreated", payload =>
{
var gameId = payload.TryGetProperty("gameId", out var gameIdElement) ? gameIdElement.GetInt32() : 0;
var lobbyCode = payload.TryGetProperty("lobbyCode", out var codeElement) ? codeElement.GetString() ?? string.Empty : string.Empty;
LobbyCreated?.Invoke(this, (gameId, lobbyCode));
});
_hubConnection.On<GameStateDto>("PlayerJoined", dto => GameStateUpdated?.Invoke(this, dto));
_hubConnection.On<GameStateDto>("GameStateUpdated", dto => GameStateUpdated?.Invoke(this, dto));
_hubConnection.On<JsonElement>("GameStarted", payload =>
{
var gameId = payload.TryGetProperty("gameId", out var gameIdElement) ? gameIdElement.GetInt32() : 0;
GameStarted?.Invoke(this, gameId);
});
_hubConnection.On<PlayerHandDto>("PlayerHandUpdated", dto => PlayerHandUpdated?.Invoke(this, dto));
_hubConnection.On<SlipChallengeDto>("ChallengeReceived", dto => ChallengeReceived?.Invoke(this, dto));
_hubConnection.On<JsonElement>("Error", payload =>
{
var message = payload.TryGetProperty("message", out var msgElement)
? msgElement.GetString() ?? "Unbekannter Fehler"
: "Unbekannter Fehler";
ErrorReceived?.Invoke(this, message);
});
}
private async Task InvokeAsync(string method, params object?[] args)
{
if (_hubConnection is null || _hubConnection.State != HubConnectionState.Connected)
throw new InvalidOperationException("Keine aktive SignalR-Verbindung.");
await _hubConnection.InvokeCoreAsync(method, args);
}
private static object? ToObject(JsonElement element)
{
return element.ValueKind switch
{
JsonValueKind.String => element.GetString(),
JsonValueKind.Number when element.TryGetInt32(out var i) => i,
JsonValueKind.Number when element.TryGetInt64(out var l) => l,
JsonValueKind.Number when element.TryGetDouble(out var d) => d,
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.Null => null,
_ => element.GetRawText()
};
}
}