SinalR Sync part 2:

DayDetails.razor

  1. GameHubService injiziert (Zeile 26)
  2. IAsyncDisposable implementiert statt IDisposable
  3. SignalR-Events abonniert:
    - OnGameStateUpdated → aktualisiert lokalen State via RemoteGameStateUpdatedAction
    - OnThrowRecorded → zeigt Snackbar-Benachrichtigung
    - OnGameStarted → lädt aktives Spiel neu
    - OnGameEnded → lädt abgeschlossene Spiele neu
  4. Hub-Verbindung initialisiert in OnAfterRenderAsync:
    - StartAsync() - verbindet zum Hub
    - JoinDayAsync(DayId) - tritt der Day-Gruppe bei
    - JoinGameAsync() - tritt der Game-Gruppe bei (wenn aktiv)
  5. Automatisches Join/Leave bei Spielwechsel via OnGameStateChanged
  6. Cleanup in DisposeAsync:
    - Unsubscribe von Events
    - LeaveGameAsync/LeaveDayAsync aufrufen
    - Hub-Verbindung disposen

  GameEffects.cs

  BroadcastThrowAsync hinzugefügt (Zeile 451-458) - broadcast Wurf sofort an andere Clients

  GameActions.cs / GameReducers.cs

  RemoteGameStateUpdatedAction und Reducer hinzugefügt für Remote-State-Updates
This commit is contained in:
beo3000 2025-12-29 17:13:17 +01:00
parent 7cbdfe28e6
commit 6f419b3373
4 changed files with 160 additions and 4 deletions

View File

@ -2,7 +2,7 @@
@attribute [Authorize(Policy = "ClubViewer")]
@inherits Fluxor.Blazor.Web.Components.FluxorComponent
@implements IDisposable
@implements IAsyncDisposable
@using Fluxor
@using Koogle.Application.DTOs
@ -12,6 +12,8 @@
@using Koogle.Web.Store.PersonState
@using Koogle.Web.Store.TimerState
@using Koogle.Web.Components.Game
@using Koogle.Web.Hubs
@using Koogle.Web.Services
@using Microsoft.AspNetCore.Authorization
@inject IState<DayState> DayState
@ -22,6 +24,7 @@
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@inject NavigationManager NavigationManager
@inject GameHubService HubService
<PageTitle>Spieltag Details</PageTitle>
@ -875,12 +878,30 @@ else
_activeTabIndex = 1;
}
protected override void OnAfterRender(bool firstRender)
protected override async Task OnAfterRenderAsync(bool firstRender)
{
base.OnAfterRender(firstRender);
await base.OnAfterRenderAsync(firstRender);
if (firstRender)
{
TimerState.StateChanged += OnTimerStateChanged;
GameState.StateChanged += OnGameStateChanged;
// Subscribe to SignalR events
HubService.OnGameStateUpdated += HandleHubGameStateUpdated;
HubService.OnThrowRecorded += HandleHubThrowRecorded;
HubService.OnGameStarted += HandleHubGameStarted;
HubService.OnGameEnded += HandleHubGameEnded;
// Start SignalR connection and join day group
await HubService.StartAsync();
await HubService.JoinDayAsync(DayId);
// If there's an active game, join its group too
if (GameState.Value.ActiveGameId.HasValue)
{
await HubService.JoinGameAsync(GameState.Value.ActiveGameId.Value);
_previousGameId = GameState.Value.ActiveGameId.Value;
}
}
}
@ -894,8 +915,82 @@ else
}
}
public void Dispose()
private Guid? _previousGameId;
private async void OnGameStateChanged(object? sender, EventArgs e)
{
var currentGameId = GameState.Value.ActiveGameId;
// Game started - join game group
if (currentGameId.HasValue && _previousGameId != currentGameId)
{
if (_previousGameId.HasValue)
{
await HubService.LeaveGameAsync(_previousGameId.Value);
}
await HubService.JoinGameAsync(currentGameId.Value);
_previousGameId = currentGameId;
}
// Game ended - leave game group
else if (!currentGameId.HasValue && _previousGameId.HasValue)
{
await HubService.LeaveGameAsync(_previousGameId.Value);
_previousGameId = null;
}
}
// SignalR event handlers
private void HandleHubGameStateUpdated(GameStateSerializationDto state)
{
// Dispatch action to update local state from remote
Dispatcher.Dispatch(new RemoteGameStateUpdatedAction(state));
InvokeAsync(StateHasChanged);
}
private void HandleHubThrowRecorded(Guid gameId, Guid playerId, int pinsKnocked)
{
// Throw was recorded by another client - refresh state
Snackbar.Add($"Wurf empfangen: {pinsKnocked} Pins", Severity.Info);
InvokeAsync(StateHasChanged);
}
private void HandleHubGameStarted(Guid gameId, string gameTypeName)
{
// New game started - reload active game
Dispatcher.Dispatch(new LoadActiveGameAction(DayId));
Snackbar.Add($"Neues Spiel gestartet: {gameTypeName}", Severity.Success);
InvokeAsync(StateHasChanged);
}
private void HandleHubGameEnded(GameResultDto result)
{
// Game ended - reload completed games
Dispatcher.Dispatch(new LoadCompletedGamesAction(DayId));
var message = result.WinnerName != null
? $"Spiel beendet! Gewinner: {result.WinnerName}"
: "Spiel beendet!";
Snackbar.Add(message, Severity.Success);
_activeTabIndex = 0;
InvokeAsync(StateHasChanged);
}
public async ValueTask DisposeAsync()
{
TimerState.StateChanged -= OnTimerStateChanged;
GameState.StateChanged -= OnGameStateChanged;
// Unsubscribe from SignalR events
HubService.OnGameStateUpdated -= HandleHubGameStateUpdated;
HubService.OnThrowRecorded -= HandleHubThrowRecorded;
HubService.OnGameStarted -= HandleHubGameStarted;
HubService.OnGameEnded -= HandleHubGameEnded;
// Leave groups and stop hub connection
if (_previousGameId.HasValue)
{
await HubService.LeaveGameAsync(_previousGameId.Value);
}
await HubService.LeaveDayAsync(DayId);
await HubService.DisposeAsync();
}
}

View File

@ -258,6 +258,11 @@ public record GameStateUpdatedFromHubAction(
/// </summary>
public record GameEndedFromHubAction(GameSummaryDto Summary);
/// <summary>
/// Action dispatched when game state DTO is received from remote SignalR hub.
/// </summary>
public record RemoteGameStateUpdatedAction(Koogle.Application.DTOs.GameStateSerializationDto State);
// Error Actions
/// <summary>

View File

@ -448,6 +448,15 @@ public class GameEffects
dispatcher);
}
// Broadcast throw immediately to other clients (before debounced save)
if (state.ActiveGameId.HasValue)
{
await _hubService.BroadcastThrowAsync(
state.ActiveGameId.Value,
currentPlayerId.Value,
afterThrowState.PinsKnocked);
}
// If game is over, skip save (user must confirm end via UI)
if (isGameOver)
{

View File

@ -1,6 +1,7 @@
using System.Collections.Immutable;
using System.Text.Json;
using Fluxor;
using Koogle.Application.DTOs;
using Koogle.Application.Games;
using Koogle.Domain.Enums;
using Microsoft.Extensions.Logging;
@ -509,6 +510,52 @@ public static class GameReducers
GameModel = action.GameModel
};
/// <summary>
/// Handles RemoteGameStateUpdatedAction - updates state from remote SignalR DTO.
/// </summary>
[ReducerMethod]
public static GameState OnRemoteGameStateUpdated(GameState state, RemoteGameStateUpdatedAction action)
{
var dto = action.State;
var throwPanelAfter = new ThrowPanelState
{
IsStarted = dto.ThrowPanelAfter.IsStarted,
Pin1 = (PinStatus)dto.ThrowPanelAfter.Pins[0],
Pin2 = (PinStatus)dto.ThrowPanelAfter.Pins[1],
Pin3 = (PinStatus)dto.ThrowPanelAfter.Pins[2],
Pin4 = (PinStatus)dto.ThrowPanelAfter.Pins[3],
Pin5 = (PinStatus)dto.ThrowPanelAfter.Pins[4],
Pin6 = (PinStatus)dto.ThrowPanelAfter.Pins[5],
Pin7 = (PinStatus)dto.ThrowPanelAfter.Pins[6],
Pin8 = (PinStatus)dto.ThrowPanelAfter.Pins[7],
Pin9 = (PinStatus)dto.ThrowPanelAfter.Pins[8],
ThrowsPerRound = dto.ThrowPanelAfter.ThrowsPerRound,
ThrowCounterPerRound = dto.ThrowPanelAfter.ThrowCounterPerRound,
ThrowMode = (ThrowMode)dto.ThrowPanelAfter.ThrowMode,
TotalThrowCounter = dto.ThrowPanelAfter.TotalThrowCounter,
BellValue = dto.ThrowPanelAfter.BellValue
};
var participants = new ParticipantsState
{
PlayerIds = dto.Participants.PlayerIds,
CurrentPlayerIndex = dto.Participants.CurrentPlayerIndex,
Mode = (ParticipantsMode)dto.Participants.Mode
};
object? gameModel = dto.GameModel.HasValue
? (object?)dto.GameModel.Value
: null;
return state with
{
ThrowPanelAfter = throwPanelAfter,
Participants = participants,
GameModel = gameModel
};
}
/// <summary>
/// Handles GameEndedFromHubAction - handles game ended from another client.
/// </summary>