added Team-Support
Features: - Beliebig viele Teams - Editierbare Team-Namen - "Zufällig verteilen" Button - Warnung bei ungleichen Team-Größen - Validierung bei Required-Modus
This commit is contained in:
parent
3f62b3c0e0
commit
caeb1a8e8b
|
|
@ -1,3 +1,4 @@
|
|||
using Koogle.Domain.Enums;
|
||||
using Koogle.Domain.Interfaces;
|
||||
|
||||
namespace Koogle.Application.Games.DeathBox;
|
||||
|
|
@ -29,4 +30,13 @@ public class DeathBoxGameDefinition : IGameDefinition
|
|||
|
||||
/// <inheritdoc />
|
||||
public Type GameModelType => typeof(DeathBoxGameModel);
|
||||
|
||||
/// <inheritdoc />
|
||||
public TeamMode TeamMode => TeamMode.None;
|
||||
|
||||
/// <inheritdoc />
|
||||
public int MinTeams => 2;
|
||||
|
||||
/// <inheritdoc />
|
||||
public int? MaxTeams => null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using Koogle.Application.Games.Shit;
|
||||
using Koogle.Domain.Enums;
|
||||
using Koogle.Domain.Interfaces;
|
||||
|
||||
namespace Koogle.Application.Games.FoxHunt
|
||||
|
|
@ -28,5 +29,14 @@ namespace Koogle.Application.Games.FoxHunt
|
|||
|
||||
/// <inheritdoc />
|
||||
public Type GameModelType => typeof(FoxHuntGameModel);
|
||||
|
||||
/// <inheritdoc />
|
||||
public TeamMode TeamMode => TeamMode.None;
|
||||
|
||||
/// <inheritdoc />
|
||||
public int MinTeams => 2;
|
||||
|
||||
/// <inheritdoc />
|
||||
public int? MaxTeams => null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
namespace Koogle.Application.Games;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a team in a game with assigned players.
|
||||
/// </summary>
|
||||
public record GameTeam
|
||||
{
|
||||
/// <summary>
|
||||
/// Team name (editable by user).
|
||||
/// </summary>
|
||||
public string Name { get; init; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Player IDs assigned to this team.
|
||||
/// </summary>
|
||||
public IReadOnlyList<Guid> PlayerIds { get; init; } = [];
|
||||
}
|
||||
|
|
@ -33,6 +33,12 @@ public interface IGameSetupModel
|
|||
/// Game type identifier.
|
||||
/// </summary>
|
||||
string GameType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Team assignments for team-based games.
|
||||
/// Null when game has no teams.
|
||||
/// </summary>
|
||||
IReadOnlyList<GameTeam>? Teams { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -51,4 +57,7 @@ public abstract record GameSetupModelBase : IGameSetupModel
|
|||
|
||||
/// <inheritdoc />
|
||||
public abstract string GameType { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<GameTeam>? Teams { get; init; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using Koogle.Domain.Enums;
|
||||
using Koogle.Domain.Interfaces;
|
||||
|
||||
namespace Koogle.Application.Games.Shit;
|
||||
|
|
@ -28,4 +29,13 @@ public class ShitGameDefinition : IGameDefinition
|
|||
|
||||
/// <inheritdoc />
|
||||
public Type GameModelType => typeof(ShitGameModel);
|
||||
|
||||
/// <inheritdoc />
|
||||
public TeamMode TeamMode => TeamMode.None;
|
||||
|
||||
/// <inheritdoc />
|
||||
public int MinTeams => 2;
|
||||
|
||||
/// <inheritdoc />
|
||||
public int? MaxTeams => null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using Koogle.Domain.Enums;
|
||||
using Koogle.Domain.Interfaces;
|
||||
|
||||
namespace Koogle.Application.Games.Training;
|
||||
|
|
@ -26,4 +27,13 @@ public class TrainingGameDefinition : IGameDefinition
|
|||
|
||||
/// <inheritdoc />
|
||||
public Type GameModelType => typeof(TrainingGameModel);
|
||||
|
||||
/// <inheritdoc />
|
||||
public TeamMode TeamMode => TeamMode.Optional;
|
||||
|
||||
/// <inheritdoc />
|
||||
public int MinTeams => 2;
|
||||
|
||||
/// <inheritdoc />
|
||||
public int? MaxTeams => null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
namespace Koogle.Domain.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Defines the team mode for a game type.
|
||||
/// </summary>
|
||||
public enum TeamMode
|
||||
{
|
||||
/// <summary>
|
||||
/// No teams - individual play only.
|
||||
/// </summary>
|
||||
None,
|
||||
|
||||
/// <summary>
|
||||
/// Teams are optional - can play with or without teams.
|
||||
/// </summary>
|
||||
Optional,
|
||||
|
||||
/// <summary>
|
||||
/// Teams are required - game cannot start without team assignment.
|
||||
/// </summary>
|
||||
Required
|
||||
}
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
using Koogle.Domain.Enums;
|
||||
|
||||
namespace Koogle.Domain.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -34,4 +36,19 @@ public interface IGameDefinition
|
|||
/// Type of the game-specific model class.
|
||||
/// </summary>
|
||||
Type GameModelType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Team mode for this game type.
|
||||
/// </summary>
|
||||
TeamMode TeamMode { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Minimum number of teams required (when TeamMode != None).
|
||||
/// </summary>
|
||||
int MinTeams { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of teams allowed (null = unlimited).
|
||||
/// </summary>
|
||||
int? MaxTeams { get; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@
|
|||
@using Koogle.Web.Store.GameState
|
||||
@using MudBlazor
|
||||
|
||||
@* Team support *@
|
||||
|
||||
@inject IState<DayState> DayState
|
||||
@inject IDispatcher Dispatcher
|
||||
@inject GameDefinitionRegistry GameRegistry
|
||||
|
|
@ -32,7 +34,20 @@
|
|||
@bind-SelectedParticipantIds="_selectedParticipantIds"
|
||||
MinimumParticipants="1" />
|
||||
</MudExpansionPanel>
|
||||
|
||||
|
||||
@* Step 2b: Team Assignment (only if game supports teams) *@
|
||||
@if (_selectedDefinition?.TeamMode != TeamMode.None)
|
||||
{
|
||||
<MudExpansionPanel Text="2b. Teams"
|
||||
IsExpanded="@(_currentStep == 3)"
|
||||
Disabled="@(_selectedParticipantIds.Count == 0)">
|
||||
<TeamAssignmentSelector AvailableParticipants="@GetSelectedParticipants()"
|
||||
@bind-Teams="_teams"
|
||||
MinTeams="@(_selectedDefinition?.MinTeams ?? 2)"
|
||||
MaxTeams="@_selectedDefinition?.MaxTeams" />
|
||||
</MudExpansionPanel>
|
||||
}
|
||||
|
||||
@* Step 3: Game-specific Setup *@
|
||||
<MudExpansionPanel Text="3. Spieleinstellungen"
|
||||
IsExpanded="@(_currentStep == 2)"
|
||||
|
|
@ -80,6 +95,12 @@
|
|||
<MudText Typo="Typo.body2">
|
||||
<strong>Teilnehmer:</strong> @_selectedParticipantIds.Count Spieler
|
||||
</MudText>
|
||||
@if (_teams.Count > 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2">
|
||||
<strong>Teams:</strong> @_teams.Count (@string.Join(", ", _teams.Select(t => $"{t.Name}: {t.PlayerIds.Count}")))
|
||||
</MudText>
|
||||
}
|
||||
<MudText Typo="Typo.body2">
|
||||
<strong>Modus:</strong> @GetThrowModeLabel()
|
||||
</MudText>
|
||||
|
|
@ -129,6 +150,7 @@
|
|||
private IGameDefinition? _selectedDefinition;
|
||||
private IReadOnlyList<DayParticipantDto> _availableParticipants = [];
|
||||
private IReadOnlyList<Guid> _selectedParticipantIds = [];
|
||||
private IReadOnlyList<GameTeam> _teams = [];
|
||||
// private object? _gameSpecificSetupOptions;
|
||||
// private ThrowMode _throwMode = ThrowMode.Reposition;
|
||||
// private int _throwsPerRound = 3;
|
||||
|
|
@ -153,6 +175,7 @@
|
|||
_selectedDefinition = definition;
|
||||
// _gameSpecificSetupOptions = null;
|
||||
_validationError = null;
|
||||
_teams = []; // Reset teams when game type changes
|
||||
|
||||
if (definition != null)
|
||||
{
|
||||
|
|
@ -160,6 +183,9 @@
|
|||
}
|
||||
}
|
||||
|
||||
private IReadOnlyList<DayParticipantDto> GetSelectedParticipants() =>
|
||||
_availableParticipants.Where(p => _selectedParticipantIds.Contains(p.PersonId)).ToList();
|
||||
|
||||
private Dictionary<string, object> GetSetupParameters()
|
||||
{
|
||||
return new Dictionary<string, object>
|
||||
|
|
@ -183,6 +209,18 @@
|
|||
if (_selectedParticipantIds.Count == 0)
|
||||
return false;
|
||||
|
||||
// Validate teams if required
|
||||
if (_selectedDefinition.TeamMode == TeamMode.Required)
|
||||
{
|
||||
if (_teams.Count < _selectedDefinition.MinTeams)
|
||||
return false;
|
||||
|
||||
// Check all players are assigned
|
||||
var assignedPlayers = _teams.SelectMany(t => t.PlayerIds).ToHashSet();
|
||||
if (!_selectedParticipantIds.All(id => assignedPlayers.Contains(id)))
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,303 @@
|
|||
@using Koogle.Application.DTOs
|
||||
@using Koogle.Application.Games
|
||||
@using Koogle.Domain.Enums
|
||||
@using MudBlazor
|
||||
|
||||
<MudPaper Class="pa-4" Elevation="0">
|
||||
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center" Class="mb-3">
|
||||
<MudText Typo="Typo.subtitle1">Team-Einteilung</MudText>
|
||||
<MudStack Row="true" Spacing="1">
|
||||
<MudButton Size="Size.Small"
|
||||
Variant="Variant.Text"
|
||||
StartIcon="@Icons.Material.Filled.Shuffle"
|
||||
OnClick="RandomizeTeams"
|
||||
Disabled="@(_teams.Count < 2 || _availableParticipants.Count == 0)">
|
||||
Zufällig
|
||||
</MudButton>
|
||||
<MudButton Size="Size.Small"
|
||||
Variant="Variant.Text"
|
||||
StartIcon="@Icons.Material.Filled.Add"
|
||||
OnClick="AddTeam"
|
||||
Disabled="@(MaxTeams.HasValue && _teams.Count >= MaxTeams.Value)">
|
||||
Team
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
|
||||
@if (_teams.Count == 0)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-3">
|
||||
Füge Teams hinzu, um Spieler zuzuweisen.
|
||||
</MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
@* Unbalanced teams warning *@
|
||||
@if (HasUnbalancedTeams())
|
||||
{
|
||||
<MudAlert Severity="Severity.Warning" Dense="true" Class="mb-3">
|
||||
Teams haben unterschiedliche Größen.
|
||||
</MudAlert>
|
||||
}
|
||||
|
||||
<MudGrid Spacing="3">
|
||||
@for (int i = 0; i < _teams.Count; i++)
|
||||
{
|
||||
var teamIndex = i;
|
||||
var team = _teams[teamIndex];
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudCard Elevation="1">
|
||||
<MudCardHeader>
|
||||
<CardHeaderContent>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||
<MudTextField T="string"
|
||||
Value="@team.Name"
|
||||
ValueChanged="@(val => UpdateTeamName(teamIndex, val))"
|
||||
Variant="Variant.Text"
|
||||
Margin="Margin.Dense"
|
||||
Style="max-width: 150px;" />
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||
(@team.PlayerIds.Count)
|
||||
</MudText>
|
||||
</MudStack>
|
||||
</CardHeaderContent>
|
||||
<CardHeaderActions>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete"
|
||||
Size="Size.Small"
|
||||
Color="Color.Error"
|
||||
OnClick="@(() => RemoveTeam(teamIndex))"
|
||||
Disabled="@(_teams.Count <= MinTeams)" />
|
||||
</CardHeaderActions>
|
||||
</MudCardHeader>
|
||||
<MudCardContent Class="pt-0">
|
||||
<MudStack Spacing="1">
|
||||
@foreach (var participant in _availableParticipants)
|
||||
{
|
||||
var isInThisTeam = team.PlayerIds.Contains(participant.PersonId);
|
||||
var isInAnyTeam = IsPlayerInAnyTeam(participant.PersonId);
|
||||
<MudPaper Class="@GetPlayerClass(isInThisTeam)"
|
||||
Style="cursor: pointer;"
|
||||
Elevation="0"
|
||||
@onclick="() => TogglePlayerInTeam(teamIndex, participant.PersonId)">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2" Class="pa-1">
|
||||
<MudCheckBox T="bool"
|
||||
Value="@isInThisTeam"
|
||||
ValueChanged="@((bool _) => TogglePlayerInTeam(teamIndex, participant.PersonId))"
|
||||
Color="Color.Primary"
|
||||
Dense="true"
|
||||
DisableRipple="true" />
|
||||
<MudText Typo="Typo.body2"
|
||||
Style="@(isInAnyTeam && !isInThisTeam ? "opacity: 0.5;" : "")">
|
||||
@participant.PersonName
|
||||
</MudText>
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
}
|
||||
</MudStack>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
}
|
||||
</MudGrid>
|
||||
}
|
||||
|
||||
@* Summary *@
|
||||
@if (_teams.Count > 0)
|
||||
{
|
||||
var unassigned = GetUnassignedPlayers().Count;
|
||||
<MudText Typo="Typo.caption" Color="@(unassigned > 0 ? Color.Warning : Color.Secondary)" Class="mt-3">
|
||||
@if (unassigned > 0)
|
||||
{
|
||||
@($"{unassigned} Spieler nicht zugewiesen")
|
||||
}
|
||||
else
|
||||
{
|
||||
@("Alle Spieler zugewiesen")
|
||||
}
|
||||
</MudText>
|
||||
}
|
||||
</MudPaper>
|
||||
|
||||
@code {
|
||||
/// <summary>
|
||||
/// List of available participants from the day.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public IReadOnlyList<DayParticipantDto> AvailableParticipants { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Current team assignments.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public IReadOnlyList<GameTeam> Teams { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Callback when teams change.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public EventCallback<IReadOnlyList<GameTeam>> TeamsChanged { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Minimum number of teams required.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public int MinTeams { get; set; } = 2;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of teams allowed (null = unlimited).
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public int? MaxTeams { get; set; }
|
||||
|
||||
private List<DayParticipantDto> _availableParticipants = [];
|
||||
private List<MutableTeam> _teams = [];
|
||||
|
||||
private class MutableTeam
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
public HashSet<Guid> PlayerIds { get; set; } = new();
|
||||
}
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
_availableParticipants = AvailableParticipants.ToList();
|
||||
|
||||
// Initialize teams from parameter
|
||||
if (Teams.Count > 0 && _teams.Count == 0)
|
||||
{
|
||||
_teams = Teams.Select(t => new MutableTeam
|
||||
{
|
||||
Name = t.Name,
|
||||
PlayerIds = t.PlayerIds.ToHashSet()
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
// Auto-create minimum teams if none exist
|
||||
if (_teams.Count == 0 && MinTeams > 0)
|
||||
{
|
||||
for (int i = 0; i < MinTeams; i++)
|
||||
{
|
||||
_teams.Add(new MutableTeam { Name = $"Team {i + 1}" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AddTeam()
|
||||
{
|
||||
if (MaxTeams.HasValue && _teams.Count >= MaxTeams.Value)
|
||||
return;
|
||||
|
||||
_teams.Add(new MutableTeam { Name = $"Team {_teams.Count + 1}" });
|
||||
await NotifyTeamsChanged();
|
||||
}
|
||||
|
||||
private async Task RemoveTeam(int index)
|
||||
{
|
||||
if (_teams.Count <= MinTeams)
|
||||
return;
|
||||
|
||||
_teams.RemoveAt(index);
|
||||
await NotifyTeamsChanged();
|
||||
}
|
||||
|
||||
private async Task UpdateTeamName(int index, string name)
|
||||
{
|
||||
_teams[index].Name = name;
|
||||
await NotifyTeamsChanged();
|
||||
}
|
||||
|
||||
private async Task TogglePlayerInTeam(int teamIndex, Guid playerId)
|
||||
{
|
||||
var team = _teams[teamIndex];
|
||||
|
||||
// Remove from other teams first
|
||||
foreach (var t in _teams)
|
||||
{
|
||||
if (t != team)
|
||||
{
|
||||
t.PlayerIds.Remove(playerId);
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle in this team
|
||||
if (team.PlayerIds.Contains(playerId))
|
||||
{
|
||||
team.PlayerIds.Remove(playerId);
|
||||
}
|
||||
else
|
||||
{
|
||||
team.PlayerIds.Add(playerId);
|
||||
}
|
||||
|
||||
await NotifyTeamsChanged();
|
||||
}
|
||||
|
||||
private async Task RandomizeTeams()
|
||||
{
|
||||
if (_teams.Count < 2)
|
||||
return;
|
||||
|
||||
// Clear all teams
|
||||
foreach (var team in _teams)
|
||||
{
|
||||
team.PlayerIds.Clear();
|
||||
}
|
||||
|
||||
// Shuffle participants
|
||||
var shuffled = _availableParticipants.OrderBy(_ => Random.Shared.Next()).ToList();
|
||||
|
||||
// Distribute evenly
|
||||
for (int i = 0; i < shuffled.Count; i++)
|
||||
{
|
||||
var teamIndex = i % _teams.Count;
|
||||
_teams[teamIndex].PlayerIds.Add(shuffled[i].PersonId);
|
||||
}
|
||||
|
||||
await NotifyTeamsChanged();
|
||||
}
|
||||
|
||||
private bool IsPlayerInAnyTeam(Guid playerId) =>
|
||||
_teams.Any(t => t.PlayerIds.Contains(playerId));
|
||||
|
||||
private List<DayParticipantDto> GetUnassignedPlayers() =>
|
||||
_availableParticipants.Where(p => !IsPlayerInAnyTeam(p.PersonId)).ToList();
|
||||
|
||||
private bool HasUnbalancedTeams()
|
||||
{
|
||||
if (_teams.Count < 2)
|
||||
return false;
|
||||
|
||||
var counts = _teams.Select(t => t.PlayerIds.Count).ToList();
|
||||
return counts.Max() - counts.Min() > 1;
|
||||
}
|
||||
|
||||
private async Task NotifyTeamsChanged()
|
||||
{
|
||||
var result = _teams.Select(t => new GameTeam
|
||||
{
|
||||
Name = t.Name,
|
||||
PlayerIds = t.PlayerIds.ToList()
|
||||
}).ToList();
|
||||
|
||||
await TeamsChanged.InvokeAsync(result);
|
||||
}
|
||||
|
||||
private string GetPlayerClass(bool isSelected) =>
|
||||
isSelected
|
||||
? "mud-theme-primary"
|
||||
: "mud-paper-outlined";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current team assignments.
|
||||
/// </summary>
|
||||
public IReadOnlyList<GameTeam> GetTeams() => _teams.Select(t => new GameTeam
|
||||
{
|
||||
Name = t.Name,
|
||||
PlayerIds = t.PlayerIds.ToList()
|
||||
}).ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Validates that all players are assigned to teams.
|
||||
/// </summary>
|
||||
public bool IsValid() => GetUnassignedPlayers().Count == 0 && _teams.Count >= MinTeams;
|
||||
}
|
||||
Loading…
Reference in New Issue